25 Commits

Author SHA1 Message Date
ION606 bf98e2055e final attempt before I give up for today 2025-09-13 22:13:34 -04:00
ION606 2c538a1cf9 why 2025-09-13 21:15:35 -04:00
ION606 1b827c05a6 I will scream 2025-09-13 21:08:45 -04:00
ION606 2649ac6117 ingress dump 2025-09-13 19:00:56 -04:00
ION606 fe0974d162 ingress dump 2025-09-13 18:28:59 -04:00
ION606 45ad5f5901 opened ports 2025-09-13 13:40:32 -04:00
ION606 4e127f663b attempting to add scheduler UI 2025-09-13 13:04:33 -04:00
ION606 7975430489 GOD I AM SO DUMB 2025-09-13 11:56:35 -04:00
ION606 a4952581ec adding kustomization 2025-09-13 11:44:13 -04:00
ION606 469dfcd094 I gave up on airflow 2025-09-13 10:23:14 -04:00
ION606 abd4ee798b attempt to add airflow ini 3 2025-09-13 10:14:48 -04:00
ION606 b3f58e6e4a attempt to add airflow ini 2 2025-09-13 09:55:05 -04:00
ION606 ab7eaa0581 attempt to add airflow ini 2025-09-13 09:30:30 -04:00
ION606 26f4608c93 I have no idea what's going on 2025-09-12 22:38:10 -04:00
ION606 f600e46537 debugging 2025-09-12 22:33:26 -04:00
ION606 8e8655e917 debugging 2025-09-12 22:26:08 -04:00
ION606 e29c03aee7 debugging 2025-09-12 22:25:37 -04:00
ION606 8eb2ade823 what am I even doing anymore? 2025-09-12 22:13:36 -04:00
ION606 7a195de392 no 2025-09-12 21:58:35 -04:00
ION606 ddc95882ee added airflow pipe 2025-09-12 21:52:11 -04:00
ION606 d07892566f value file fix 2025-09-12 14:24:00 -04:00
ION606 678218a33e namespace fix 2025-09-12 14:17:29 -04:00
ION606 d2101345dc more fixes and value files 2025-09-12 13:46:12 -04:00
ION606 899b866142 namespace fix 2025-09-12 13:15:50 -04:00
ION606 e5cae3dc52 initial argo commit 2025-09-12 11:20:18 -04:00
75 changed files with 2405 additions and 6198 deletions
+2 -1
View File
@@ -133,6 +133,7 @@ dist
__pycache__/
.venv/
*.xml
temp.*
bun.lock
tmp/
temp.*
+159 -191
View File
@@ -1,220 +1,188 @@
# ML Stack — Local AI Orchestration Toolkit
# ML Repo — Architecture and External RAG Server Design (for Ollama/Open WebUI)
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.
My openWebUI/searxng configs, plugins, RAG server, as well as a custom program that runs the AI's code in isolated Docker containers
_Last updated: 2025-10-03_
*Last updated: 2025-09-13*
> [!TIP]
> Looking for the compose version of this? See the [compose branch](https://git.ion606.com/ION606/ollama-plus/src/branch/compose/)
---
## A (Few) Notes
## Summary :3
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
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.
---
## Stack At A Glance
| Compose service | Directory / build context | External ports | Primary role |
|-----------------|---------------------------|----------------|--------------|
| `open-webui` | (image: `ghcr.io/open-webui/open-webui:main`) | `4000 -> 8080` | Chat UI, agent orchestration, embedded knowledge base & RAG powered by Postgres |
| `postgres` | | | Persistence for Open WebUI (users, KB, events) |
| `searxng` | `searxng.yml` | `4001 -> 8080` (debug only) | Private SearxNG instance used for live web search tools |
| `coderunner` | `coderunner/` | (internal `8787`) | Bun service that executes pure source code inside sandboxed Docker containers |
| `openwebui_tools` | `tools/` | (internal `1331`) | Python Roku remote API exposed as an OpenAPI tool server |
| `browser` | `browser/` | `7788 -> 7788` | Playwright Chromium UI for autonomous browsing / research |
| `schedules-api` | `scheduler/` | `12253 -> 12253` | Cron-style job scheduler that can open chats, call templates, and upload files |
| `ollama-nextcloud` | `nextcloud/` | `13284 -> 1111` | Nextcloud WebDAV proxy with caching and access controls |
Volumes declared in compose: `open-webui`, `pgdata`, `searxng_data`, `webui_data`, `schedule_data`, and `nextcloud_data`
> [!CAUTION]
> PLEASE I BEG OF YOU REMEMBER TO BACK THESE UP/USE A LOCAL DIRECTORY.
> IF YOU DO NOT AND REMOVE OR PRUNE THE VOLUMES YOU WILL LOSE *ALL* DATA
---
## Service Details
### Open WebUI (`open-webui`)
- Runs the latest `ghcr.io/open-webui/open-webui:main` image with Postgres backing for durable data (`open-webui` and `pgdata` volumes)
- `.env` enables the login form, optional API keys (not currently used), and forwards identifying headers so downstream tools know which user initiated a request
- Depends on the tool containers (`openwebui_tools`, `coderunner`, `schedules-api`, `ollama-nextcloud`) via internal networking; discover their OpenAPI docs from inside the UI to register tools
### Postgres (`postgres`)
> [!IMPORTANT]
> If you plan on exposing ports on this service, please move the inline credentials to the `.env` file
- Standard `postgres:latest` image. Credentials are set inline in compose for local development
- Health-checked with `pg_isready`; the data volume `pgdata` stores Open WebUI metadata
### SearxNG (`searxng`)
- Private SearxNG deployment for agent web search tasks with HTML/JSON outputs enabled
- Mounts `searxng.yml` and persists internal data to `searxng_data`. External port 4001 is exposed only for local debugging and should be removed in production
### Code Runner (`coderunner`)
- Bun-based HTTP server that accepts pure source code plus optional extra files, then runs the workload in a throwaway Docker container pinned to an allow-listed base image per language
- Enforces strict limits (`--network=none`, read-only root FS, tmpfs workdir, CPU/memory caps, dropped capabilities). Supported Languages:
- `python`
- `node`
- `bun`
- `bash`
- `ruby`
- `go`
- `rust`
- `java`
- `c`
- `cpp`
- Exposes `GET /openapi.json` and `POST /execute` inside the internal network (`http://coderunner:8787`). Requires the host Docker socket to spawn child sandboxes; the compose file mounts it read-only with matching group ID.
### Roku Tool Server (`openwebui_tools`)
- Lightweight Python HTTP server that proxies Roku remote commands
- Reads `ROKU_IP` from `.env`; returns helpful errors when the IP is missing or the device is offline
- Serves `GET /roku/openapi.json` for automatic tool registration and handles `GET /roku/{command}` requests. Supported command list matches the enum in `spec/roku.openapi.json` (navigation, inputs, power, volume, remote finder)
### Browser Research UI (`browser`)
- Builds the upstream `browser-use/web-ui` project, installs Chromium plus dependencies, and launches the UI on port 7788
- Runs as an unprivileged user (uid 1000) with dedicated tmpfs directories and a `webui_data` volume for persisted history/state
- Configure resolution, telemetry, and default LLM via `browser/.env` or container environment variables
- The browser-use docs can be found at https://docs.browser-use.com/
### Scheduler API (`schedules-api`)
- Bun/Node cron worker that lets you schedule Open WebUI chats or template-driven jobs using authenticated user tokens
- Persists schedule definitions to `schedule_data` (JSON payload) and can store uploaded supporting files under the same volume
- Reads workflow templates from the bundled `scheduler/templates.json`. To inject custom templates, mount a host file or populate the root-level `templates.json/` directory and update the compose volume mapping
- Key endpoints (documented in `scheduler/openapi.json`):
- `GET /openapi.json`: tool contract.
- `POST /api/schedules`: create or replace a schedule (cron or one-shot ISO timestamp). Validates feature flags, attachments, and template references
- `GET /api/schedules`: list schedules scoped to the calling user (identified via Open WebUI bearer token)
- `DELETE /api/schedules/{name}`: remove a schedule the user owns
- Includes a static UI in `scheduler/public/` for manual interaction. Uses `node-cron` to avoid overlapping executions; failed jobs clean themselves up
### Nextcloud Files Tool (`ollama-nextcloud`)
- Express + WebDAV proxy that exposes a simple JSON API for browsing, downloading, and uploading files stored in Nextcloud
- Environment variables (configured in `.env`):
- `NEXTCLOUD_APP_ID` / `NEXTCLOUD_APP_PASS` / `NEXTCLOUD_WEBDAV_ADDR`: service credentials
- `NEXTCLOUD_ACCESS_DIRS`: JSON array of regex strings that whitelist readable paths (e.g. `["^/Notes", "^/School"]`). When unset, the tool has full access
- Cached downloads are stored under `/tmp` using an embedded SQLite index (`cache.ts`). The server keeps ETags in sync and reuses cached bytes when possible unless `bypasscache` is requested
- Major endpoints (see `nextcloud/openapi.json`):
- `GET /openapi.json`: discovery document for tool registration.
- `POST /file`: fetch a file. Automatically caches and returns metadata + content-type.
- `POST /dir`: list directory contents (shallow or recursive).
- `PUT /file`: upload via multipart form-data (optional recursive dir creation, never overwrites existing files).
### Cloudflared Tunnel Config
- `cloudflared-tunnel-config.yml` maps friendly hostnames to the local services (Ollama, Open WebUI, tool servers). Use it as a blueprint when exposing the stack through Cloudflare Tunnels.
---
## Configuration (`.env`)
```env
ROKU_IP=
WEBUI_URL=
# use built-in login form (username/password)
ENABLE_LOGIN_FORM="true"
# forward identity on outbound model requests (if you're going to use openAI/external LLM)
ENABLE_FORWARD_USER_INFO_HEADERS="true"
# allow user api keys for the scheduler calling OWUIs
ENABLE_API_KEY_AUTH="true"
NEXTCLOUD_APP_ID=
NEXTCLOUD_APP_PASS=
NEXTCLOUD_WEBDAV_ADDR=
NEXTCLOUD_ACCESS_DIRS=
```
---
## Running the Stack
1. Install Docker and Docker
2. Populate `.env` with the correct Roku and Nextcloud settings plus any Open WebUI options
3. Build images (pull base layers and bake GID overrides where needed):
```sh
docker compose build --pull
```
4. Launch everything:
```sh
docker compose up -d
```
5. Open WebUI is available on http://localhost:4000 (use credentials from the UI setup). The supporting services are reachable on the ports listed above or through the internal Docker network
To inspect logs for a specific service:
## Repo map and how each piece fits
```sh
docker compose logs -f coderunner
.
├─ 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
```
Bring the stack down (volumes persist):
### Open WebUI (in `docker-compose.yml`)
```sh
docker compose down
```
* purpose: chat UI + orchestration layer; **includes a built-in knowledge base + RAG** with chunking, embedding, search, and prompt templating.
* notable: backed by Postgres in this compose. exposes `4000:8080`.
* storage: a docker volume `open-webui:` holds app data; Postgres uses `pgdata:`.
### Postgres (in `docker-compose.yml`)
* purpose: persistence for Open WebUI features (users, knowledge, etc.). health-checked with `pg_isready`.
### SearxNG (in `docker-compose.yml` + `searxng.yml`)
* purpose: metasearch engine used by Open WebUI tools/agents for live web lookups.
* config highlights: `use_default_settings: true`, `public_instance: false`, `limiter: false`; formats: `html` and `json`.
### Coderunner service (`coderunner/`)
* **what it is:** a small HTTP server (Bun runtime) that executes pure source code in short-lived, sandboxed containers.
* **why it exists:** lets Open WebUI tools run code safely with tight resource limits (no network, read-only fs, cgroup limits, `--cap-drop=ALL`, `no-new-privileges`).
* **integration contract:** exposes an **OpenAPI schema at `/openapi.json`** and a single POST `/execute` endpoint. Open WebUI can import this as a **tool server**.
* **security posture:** pulls allow-listed base images (gcc, python, node, bun, etc.), mounts only a tmpfs workdir, times out jobs ≈25s, and runs with non-root uid/gid. The container has access to the hosts docker socket *only* to run the sandbox containers.
### Browser-use web-ui (`browser/`)
* purpose: “autonomous” research browser UI (chromium via playwright), reachable on `:7788`.
* built from upstream `browser-use/web-ui` repo, with python deps and browsers installed in the image.
### Cloudflared tunnel (`cloudflared-tunnel-config.yml`)
* maps hostnames (like `mlep.domain.com` for Ollama, `owebui.domain.com` for Open WebUI, and a `tools` host) to the internal services. Useful for private, authenticated access without public inbound ports.
---
## Registering Tool Servers in Open WebUI
## Why I currently **dont** use an external RAG server
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`
Open WebUI ships with pretty good **knowledge / RAG** support: add files/URLs, it chunks + embeds, indexes, retrieves, and automatically **prefixes retrieved context** to the model prompt using a RAG template. For lightweight to mid-sized corpora and single-user/small-team usage, thats often all you need.
These should be fully internal in the docker network. If you expose them consider using a reverse proxy/authentication
**Stay with built-in RAG if most of these are true:**
* total corpus is ≤ \~100k chunks and grows slowly.
* single user or small team (no multi-tenant isolation needed).
* no special retrieval logic (hybrid lexical+semantic, rerankers, metadata filters) beyond what Open WebUI provides.
* tolerance for “UI-managed” knowledge; you dont need programmatic ingestion pipelines or job queues.
## When an external RAG server makes sense
Adopt a decoupled RAG service when you need one or more of:
* **bigger data / throughput**: millions of chunks, higher QPS, horizontal scaling.
* **advanced retrieval**: custom chunkers, hybrid search (bm25 + vector), **reranking**, time-decay, per-tenant filters, embeddings A/B, or multi-modal (image/audio) retrieval.
* **programmatic ingestion**: CI-driven pipelines from git/docs/confluence/S3; delta updates; background jobs.
* **governance / isolation**: strict multi-tenant separation, PII retention controls, audit trails.
* **interoperability**: a clean HTTP API and OpenAPI so other apps (beyond Open WebUI) can reuse your index.
---
## Data, Volumes, and Shared Paths
## External RAG Server — Design and Reference Implementation
- `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
This is a small, dependency-light service designed to run with **Bun** and integrate with both **Ollama** and **Open WebUI**.
> [!IMPORTANT]
> Back up the volumes you care about before upgrading images
### 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:
```
> if you already expose services via cloudflared, add another hostname mapping to the `rag` container (`- hostname: rag.domain.com -> service: http://rag:8788`).
---
## Wiring the RAG server into Open WebUI and Ollama
### 1. Pull models
* `ollama pull nomic-embed-text` (embeddings)
* `ollama pull llama3.1` (chat)
### 2. Expose the OpenAPI to Open WebUI as a **tool server**
* in Open WebUI --> **settings --> tools** --> **add tool server**
* paste the url for the cloudflared hostname
* youll now see tool functions like `listCollections`, `createCollection`, `upsert`, `query`, `chat` available to the assistant
### 3. Usage pattern inside a chat
* to build a knowledge base, call the `createCollection` and `upsert` tools with your documents
* to answer, call `chat` which performs retrieve-then-generate against your chosen collection
---
## FAQ — Built-in vs. External RAG
**Q: will Open WebUIs built-in RAG conflict with this server?**
no — you can use either, or both. Open WebUIs knowledge base is great for ad-hoc use. this service is for programmatic/control-plane needs or when you outgrow the UIs storage/retrieval.
**Q: how do enforce tenant isolation?**
use one collection per tenant and never mix. for stronger guarantees, run separate RAG instances or choose Qdrant with per-collection access control.
**Q: how can use my chunker/reranker?**
yes. place them ahead of `/upsert` and `/query` respectively, or add endpoints like `/rerank` and `/embed` to experiment.
**Q: can this call OpenAI-compatible endpoints instead of native Ollama?**
Ollama exposes an experimental OpenAI-compatible API. you can add a thin client if you already point tools at `/v1/chat/completions`.
---
## License
The repository and reference code are released under Apache-2.0 (see `LICENSE`).
This write-up and reference code are provided under the same **Apache-2.0** terms as the repository.
+37
View File
@@ -0,0 +1,37 @@
apiVersion: argoproj.io/v1alpha1
kind: AppProject
metadata:
name: ai-stack
namespace: argocd
spec:
destinations:
- server: https://kubernetes.default.svc
namespace: ai
- server: https://kubernetes.default.svc
namespace: argo
# # only add if need to deploy into argocd (ehhhhh)
# - server: https://kubernetes.default.svc
# namespace: argocd
sourceRepos: ["*"]
---
apiVersion: argoproj.io/v1alpha1
kind: Application
metadata:
name: ai-stack
namespace: argocd
spec:
project: ai-stack
destination:
server: https://kubernetes.default.svc
namespace: ai
source:
repoURL: https://git.ion606.com/ion606/ollama-plus
targetRevision: argo
path: apps/children
syncPolicy:
automated:
prune: true
selfHeal: true
syncOptions:
- CreateNamespace=true
+18
View File
@@ -0,0 +1,18 @@
apiVersion: argoproj.io/v1alpha1
kind: Application
metadata:
name: argo-templates
namespace: ai
spec:
project: ai-stack
destination:
server: https://kubernetes.default.svc
namespace: argo
source:
repoURL: https://git.ion606.com/ion606/ollama-plus
targetRevision: main
path: apps/argo-templates
syncPolicy:
automated:
prune: true
selfHeal: true
+18
View File
@@ -0,0 +1,18 @@
apiVersion: argoproj.io/v1alpha1
kind: Application
metadata:
name: browser
namespace: ai
spec:
project: ai-stack
destination:
server: https://kubernetes.default.svc
namespace: ai
source:
repoURL: https://git.ion606.com/ion606/ollama-plus.git
targetRevision: main
path: manifests/browser
syncPolicy:
automated:
prune: true
selfHeal: true
+20
View File
@@ -0,0 +1,20 @@
apiVersion: argoproj.io/v1alpha1
kind: Application
metadata:
name: coderunner
namespace: ai
labels:
repo.ion606.com/ollama-plus: "true"
spec:
project: ai-stack
destination:
server: https://kubernetes.default.svc
namespace: ai
source:
repoURL: https://git.ion606.com/ion606/ollama-plus
targetRevision: main
path: manifests/coderunner
syncPolicy:
automated:
prune: true
selfHeal: true
+21
View File
@@ -0,0 +1,21 @@
apiVersion: argoproj.io/v1alpha1
kind: Application
metadata:
name: debug-netshoot
namespace: ai
labels:
repo.ion606.com/ollama-plus: "true"
spec:
project: ai-stack
destination:
server: https://kubernetes.default.svc
namespace: ai
source:
repoURL: https://git.ion606.com/ion606/ollama-plus
targetRevision: main
path: manifests/debug
syncPolicy:
automated:
prune: true
selfHeal: true
+39
View File
@@ -0,0 +1,39 @@
apiVersion: kustomize.config.k8s.io/v1beta1
kind: Kustomization
resources:
- ollama-scheduler.yaml
- coderunner.yaml
- tools.yaml
- rag-server.yaml
- openwebui.yaml
- postgresql.yaml
- searxng.yaml
- browser.yaml
- debug.yaml
- policy.yaml
- policy-argo.yaml
generatorOptions:
disableNameSuffixHash: true
# FINDME: The global branch for my repo
configMapGenerator:
- name: ollama-plus-revs
literals:
- targetRevision=argo
# Inject targetRevision from the ConfigMap into apps (kill me)
replacements:
- source:
kind: ConfigMap
name: ollama-plus-revs
fieldPath: data.targetRevision
targets:
- select:
kind: Application
labelSelector: repo.ion606.com/ollama-plus=true
fieldPaths:
- spec.source.targetRevision
options:
create: true
+20
View File
@@ -0,0 +1,20 @@
apiVersion: argoproj.io/v1alpha1
kind: Application
metadata:
name: ollama-scheduler
namespace: ai
labels:
repo.ion606.com/ollama-plus: "true"
spec:
project: ai-stack
destination:
server: https://kubernetes.default.svc
namespace: argo
source:
repoURL: https://git.ion606.com/ion606/ollama-plus
targetRevision: main
path: manifests/argo-schedules-api
syncPolicy:
automated:
prune: true
selfHeal: true
+23
View File
@@ -0,0 +1,23 @@
apiVersion: argoproj.io/v1alpha1
kind: Application
metadata:
name: openwebui
namespace: ai
annotations:
argocd.argoproj.io/sync-wave: "0"
spec:
project: ai-stack
destination:
server: https://kubernetes.default.svc
namespace: ai
source:
repoURL: https://helm.openwebui.com
chart: open-webui
targetRevision: "*"
helm:
valueFiles:
- apps/values/openwebui.yaml
syncPolicy:
automated:
prune: true
selfHeal: true
+21
View File
@@ -0,0 +1,21 @@
apiVersion: argoproj.io/v1alpha1
kind: Application
metadata:
name: policy-argo
namespace: ai
labels:
repo.ion606.com/ollama-plus: "true"
spec:
project: ai-stack
destination:
server: https://kubernetes.default.svc
namespace: argo
source:
repoURL: https://git.ion606.com/ion606/ollama-plus
targetRevision: main
path: manifests/policy-argo
syncPolicy:
automated:
prune: true
selfHeal: true
+21
View File
@@ -0,0 +1,21 @@
apiVersion: argoproj.io/v1alpha1
kind: Application
metadata:
name: policy
namespace: ai
labels:
repo.ion606.com/ollama-plus: "true"
spec:
project: ai-stack
destination:
server: https://kubernetes.default.svc
namespace: ai
source:
repoURL: https://git.ion606.com/ion606/ollama-plus
targetRevision: main
path: manifests/policy
syncPolicy:
automated:
prune: true
selfHeal: true
+24
View File
@@ -0,0 +1,24 @@
apiVersion: argoproj.io/v1alpha1
kind: Application
metadata:
name: postgresql
namespace: ai
annotations:
argocd.argoproj.io/sync-wave: "-10"
spec:
project: ai-stack
destination:
server: https://kubernetes.default.svc
namespace: ai
source:
repoURL: https://charts.bitnami.com/bitnami
chart: postgresql
targetRevision: "*"
helm:
valueFiles:
- apps/values/postgresql.yaml
syncPolicy:
automated:
prune: true
selfHeal: true
+20
View File
@@ -0,0 +1,20 @@
apiVersion: argoproj.io/v1alpha1
kind: Application
metadata:
name: rag-server
namespace: ai
labels:
repo.ion606.com/ollama-plus: "true"
spec:
project: ai-stack
destination:
server: https://kubernetes.default.svc
namespace: ai
source:
repoURL: https://git.ion606.com/ion606/ollama-plus
targetRevision: main
path: manifests/rag-server
syncPolicy:
automated:
prune: true
selfHeal: true
+21
View File
@@ -0,0 +1,21 @@
apiVersion: argoproj.io/v1alpha1
kind: Application
metadata:
name: searxng
namespace: ai
spec:
project: ai-stack
destination:
server: https://kubernetes.default.svc
namespace: ai
source:
repoURL: https://charts.kubito.dev
chart: searxng
targetRevision: "*"
helm:
valueFiles:
- apps/values/searxng.yaml
syncPolicy:
automated:
prune: true
selfHeal: true
+20
View File
@@ -0,0 +1,20 @@
apiVersion: argoproj.io/v1alpha1
kind: Application
metadata:
name: tools
namespace: ai
labels:
repo.ion606.com/ollama-plus: "true"
spec:
project: ai-stack
destination:
server: https://kubernetes.default.svc
namespace: ai
source:
repoURL: https://git.ion606.com/ion606/ollama-plus
targetRevision: main
path: manifests/tools
syncPolicy:
automated:
prune: true
selfHeal: true
+40
View File
@@ -0,0 +1,40 @@
image:
repository: ghcr.io/open-webui/open-webui
tag: "main"
pullPolicy: IfNotPresent
service:
type: NodePort # use NodePort instead of ClusterIP so openwebui is accessible externally
nodePort: 30400
persistence:
enabled: true
size: 5Gi
ingress:
enabled: false # disable ingress since we'll use NodePort
className: nginx
hosts:
- host: openwebui.local
paths:
- path: /
pathType: Prefix
tls: [] # no https for local/minikube
# NO SECRETS!!!
extraEnvVars:
- name: OLLAMA_BASE_URL
value: "https://mlep.ion606.com"
# postgres === primary db
- name: DATABASE_URL
value: "postgresql://postgres:mypassword@postgresql-primary.ai.svc.cluster.local:5432/openwebui"
# store vectors in pgvector (on the same postgres)
- name: VECTOR_DB
value: "pgvector"
- name: PGVECTOR_DB_URL
value: "postgresql://postgres:mypassword@postgresql-primary.ai.svc.cluster.local:5432/openwebui"
- name: PGVECTOR_CREATE_EXTENSION
value: "true"
# no bish
# - name: WEBUI_URL
# value: "http://openwebui.local"
+24
View File
@@ -0,0 +1,24 @@
# stupid mismatch fix
fullnameOverride: postgresql
architecture: replication
auth:
username: postgres
password: mypassword
database: openwebui
primary:
persistence:
enabled: true
size: 8Gi
service:
type: ClusterIP
readReplicas:
replicaCount: 1
persistence:
enabled: true
size: 8Gi
service:
type: ClusterIP
+21
View File
@@ -0,0 +1,21 @@
image:
repository: searxng/searxng
tag: "latest"
service:
type: NodePort # expose service externally
nodePort: 30081
ingress:
enabled: false
className: nginx
hosts:
- host: searxng.local
paths:
- path: /
pathType: Prefix
# env:
# SEARXNG_SECRET: "please-change-me"
# # helps with URL generation & results links
# BASE_URL: "http://searxng.local/"
+2 -2
View File
@@ -30,8 +30,8 @@ RUN mkdir -p /opt/web-ui/tmp /data && chown -R appuser:appuser /opt/web-ui /data
USER appuser
# copy default env
COPY .env .env
# # copy default env
# COPY .env .env
EXPOSE 7788
HEALTHCHECK --interval=30s --timeout=5s --retries=5 \
+4 -10
View File
@@ -9,25 +9,19 @@ 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
# files
COPY package.json .
COPY index.ts .
COPY openapi.json .
RUN bun i
# your app
COPY index.ts ./index.ts
# 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
+152
View File
@@ -0,0 +1,152 @@
{
"lockfileVersion": 1,
"workspaces": {
"": {
"dependencies": {
"@kubernetes/client-node": "^1.3.0",
},
"devDependencies": {
"@types/node": "^24.3.1",
},
},
},
"packages": {
"@jsep-plugin/assignment": ["@jsep-plugin/assignment@1.3.0", "", { "peerDependencies": { "jsep": "^0.4.0||^1.0.0" } }, "sha512-VVgV+CXrhbMI3aSusQyclHkenWSAm95WaiKrMxRFam3JSUiIaQjoMIw2sEs/OX4XifnqeQUN4DYbJjlA8EfktQ=="],
"@jsep-plugin/regex": ["@jsep-plugin/regex@1.0.4", "", { "peerDependencies": { "jsep": "^0.4.0||^1.0.0" } }, "sha512-q7qL4Mgjs1vByCaTnDFcBnV9HS7GVPJX5vyVoCgZHNSC9rjwIlmbXG5sUuorR5ndfHAIlJ8pVStxvjXHbNvtUg=="],
"@kubernetes/client-node": ["@kubernetes/client-node@1.3.0", "", { "dependencies": { "@types/js-yaml": "^4.0.1", "@types/node": "^22.0.0", "@types/node-fetch": "^2.6.9", "@types/stream-buffers": "^3.0.3", "form-data": "^4.0.0", "hpagent": "^1.2.0", "isomorphic-ws": "^5.0.0", "js-yaml": "^4.1.0", "jsonpath-plus": "^10.3.0", "node-fetch": "^2.6.9", "openid-client": "^6.1.3", "rfc4648": "^1.3.0", "socks-proxy-agent": "^8.0.4", "stream-buffers": "^3.0.2", "tar-fs": "^3.0.8", "ws": "^8.18.2" } }, "sha512-IE0yrIpOT97YS5fg2QpzmPzm8Wmcdf4ueWMn+FiJSI3jgTTQT1u+LUhoYpdfhdHAVxdrNsaBg2C0UXSnOgMoCQ=="],
"@types/js-yaml": ["@types/js-yaml@4.0.9", "", {}, "sha512-k4MGaQl5TGo/iipqb2UDG2UwjXziSWkh0uysQelTlJpX1qGlpUZYm8PnO4DxG1qBomtJUdYJ6qR6xdIah10JLg=="],
"@types/node": ["@types/node@24.3.1", "", { "dependencies": { "undici-types": "~7.10.0" } }, "sha512-3vXmQDXy+woz+gnrTvuvNrPzekOi+Ds0ReMxw0LzBiK3a+1k0kQn9f2NWk+lgD4rJehFUmYy2gMhJ2ZI+7YP9g=="],
"@types/node-fetch": ["@types/node-fetch@2.6.13", "", { "dependencies": { "@types/node": "*", "form-data": "^4.0.4" } }, "sha512-QGpRVpzSaUs30JBSGPjOg4Uveu384erbHBoT1zeONvyCfwQxIkUshLAOqN/k9EjGviPRmWTTe6aH2qySWKTVSw=="],
"@types/stream-buffers": ["@types/stream-buffers@3.0.7", "", { "dependencies": { "@types/node": "*" } }, "sha512-azOCy05sXVXrO+qklf0c/B07H/oHaIuDDAiHPVwlk3A9Ek+ksHyTeMajLZl3r76FxpPpxem//4Te61G1iW3Giw=="],
"agent-base": ["agent-base@7.1.4", "", {}, "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ=="],
"argparse": ["argparse@2.0.1", "", {}, "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q=="],
"asynckit": ["asynckit@0.4.0", "", {}, "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q=="],
"b4a": ["b4a@1.7.1", "", { "peerDependencies": { "react-native-b4a": "*" }, "optionalPeers": ["react-native-b4a"] }, "sha512-ZovbrBV0g6JxK5cGUF1Suby1vLfKjv4RWi8IxoaO/Mon8BDD9I21RxjHFtgQ+kskJqLAVyQZly3uMBui+vhc8Q=="],
"bare-events": ["bare-events@2.6.1", "", {}, "sha512-AuTJkq9XmE6Vk0FJVNq5QxETrSA/vKHarWVBG5l/JbdCL1prJemiyJqUS0jrlXO0MftuPq4m3YVYhoNc5+aE/g=="],
"bare-fs": ["bare-fs@4.4.4", "", { "dependencies": { "bare-events": "^2.5.4", "bare-path": "^3.0.0", "bare-stream": "^2.6.4", "bare-url": "^2.2.2", "fast-fifo": "^1.3.2" }, "peerDependencies": { "bare-buffer": "*" }, "optionalPeers": ["bare-buffer"] }, "sha512-Q8yxM1eLhJfuM7KXVP3zjhBvtMJCYRByoTT+wHXjpdMELv0xICFJX+1w4c7csa+WZEOsq4ItJ4RGwvzid6m/dw=="],
"bare-os": ["bare-os@3.6.2", "", {}, "sha512-T+V1+1srU2qYNBmJCXZkUY5vQ0B4FSlL3QDROnKQYOqeiQR8UbjNHlPa+TIbM4cuidiN9GaTaOZgSEgsvPbh5A=="],
"bare-path": ["bare-path@3.0.0", "", { "dependencies": { "bare-os": "^3.0.1" } }, "sha512-tyfW2cQcB5NN8Saijrhqn0Zh7AnFNsnczRcuWODH0eYAXBsJ5gVxAUuNr7tsHSC6IZ77cA0SitzT+s47kot8Mw=="],
"bare-stream": ["bare-stream@2.7.0", "", { "dependencies": { "streamx": "^2.21.0" }, "peerDependencies": { "bare-buffer": "*", "bare-events": "*" }, "optionalPeers": ["bare-buffer", "bare-events"] }, "sha512-oyXQNicV1y8nc2aKffH+BUHFRXmx6VrPzlnaEvMhram0nPBrKcEdcyBg5r08D0i8VxngHFAiVyn1QKXpSG0B8A=="],
"bare-url": ["bare-url@2.2.2", "", { "dependencies": { "bare-path": "^3.0.0" } }, "sha512-g+ueNGKkrjMazDG3elZO1pNs3HY5+mMmOet1jtKyhOaCnkLzitxf26z7hoAEkDNgdNmnc1KIlt/dw6Po6xZMpA=="],
"call-bind-apply-helpers": ["call-bind-apply-helpers@1.0.2", "", { "dependencies": { "es-errors": "^1.3.0", "function-bind": "^1.1.2" } }, "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ=="],
"combined-stream": ["combined-stream@1.0.8", "", { "dependencies": { "delayed-stream": "~1.0.0" } }, "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg=="],
"debug": ["debug@4.4.1", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-KcKCqiftBJcZr++7ykoDIEwSa3XWowTfNPo92BYxjXiyYEVrUQh2aLyhxBCwww+heortUFxEJYcRzosstTEBYQ=="],
"delayed-stream": ["delayed-stream@1.0.0", "", {}, "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ=="],
"dunder-proto": ["dunder-proto@1.0.1", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.1", "es-errors": "^1.3.0", "gopd": "^1.2.0" } }, "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A=="],
"end-of-stream": ["end-of-stream@1.4.5", "", { "dependencies": { "once": "^1.4.0" } }, "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg=="],
"es-define-property": ["es-define-property@1.0.1", "", {}, "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g=="],
"es-errors": ["es-errors@1.3.0", "", {}, "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw=="],
"es-object-atoms": ["es-object-atoms@1.1.1", "", { "dependencies": { "es-errors": "^1.3.0" } }, "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA=="],
"es-set-tostringtag": ["es-set-tostringtag@2.1.0", "", { "dependencies": { "es-errors": "^1.3.0", "get-intrinsic": "^1.2.6", "has-tostringtag": "^1.0.2", "hasown": "^2.0.2" } }, "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA=="],
"fast-fifo": ["fast-fifo@1.3.2", "", {}, "sha512-/d9sfos4yxzpwkDkuN7k2SqFKtYNmCTzgfEpz82x34IM9/zc8KGxQoXg1liNC/izpRM/MBdt44Nmx41ZWqk+FQ=="],
"form-data": ["form-data@4.0.4", "", { "dependencies": { "asynckit": "^0.4.0", "combined-stream": "^1.0.8", "es-set-tostringtag": "^2.1.0", "hasown": "^2.0.2", "mime-types": "^2.1.12" } }, "sha512-KrGhL9Q4zjj0kiUt5OO4Mr/A/jlI2jDYs5eHBpYHPcBEVSiipAvn2Ko2HnPe20rmcuuvMHNdZFp+4IlGTMF0Ow=="],
"function-bind": ["function-bind@1.1.2", "", {}, "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA=="],
"get-intrinsic": ["get-intrinsic@1.3.0", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.2", "es-define-property": "^1.0.1", "es-errors": "^1.3.0", "es-object-atoms": "^1.1.1", "function-bind": "^1.1.2", "get-proto": "^1.0.1", "gopd": "^1.2.0", "has-symbols": "^1.1.0", "hasown": "^2.0.2", "math-intrinsics": "^1.1.0" } }, "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ=="],
"get-proto": ["get-proto@1.0.1", "", { "dependencies": { "dunder-proto": "^1.0.1", "es-object-atoms": "^1.0.0" } }, "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g=="],
"gopd": ["gopd@1.2.0", "", {}, "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg=="],
"has-symbols": ["has-symbols@1.1.0", "", {}, "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ=="],
"has-tostringtag": ["has-tostringtag@1.0.2", "", { "dependencies": { "has-symbols": "^1.0.3" } }, "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw=="],
"hasown": ["hasown@2.0.2", "", { "dependencies": { "function-bind": "^1.1.2" } }, "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ=="],
"hpagent": ["hpagent@1.2.0", "", {}, "sha512-A91dYTeIB6NoXG+PxTQpCCDDnfHsW9kc06Lvpu1TEe9gnd6ZFeiBoRO9JvzEv6xK7EX97/dUE8g/vBMTqTS3CA=="],
"ip-address": ["ip-address@10.0.1", "", {}, "sha512-NWv9YLW4PoW2B7xtzaS3NCot75m6nK7Icdv0o3lfMceJVRfSoQwqD4wEH5rLwoKJwUiZ/rfpiVBhnaF0FK4HoA=="],
"isomorphic-ws": ["isomorphic-ws@5.0.0", "", { "peerDependencies": { "ws": "*" } }, "sha512-muId7Zzn9ywDsyXgTIafTry2sV3nySZeUDe6YedVd1Hvuuep5AsIlqK+XefWpYTyJG5e503F2xIuT2lcU6rCSw=="],
"jose": ["jose@6.1.0", "", {}, "sha512-TTQJyoEoKcC1lscpVDCSsVgYzUDg/0Bt3WE//WiTPK6uOCQC2KZS4MpugbMWt/zyjkopgZoXhZuCi00gLudfUA=="],
"js-yaml": ["js-yaml@4.1.0", "", { "dependencies": { "argparse": "^2.0.1" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, "sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA=="],
"jsep": ["jsep@1.4.0", "", {}, "sha512-B7qPcEVE3NVkmSJbaYxvv4cHkVW7DQsZz13pUMrfS8z8Q/BuShN+gcTXrUlPiGqM2/t/EEaI030bpxMqY8gMlw=="],
"jsonpath-plus": ["jsonpath-plus@10.3.0", "", { "dependencies": { "@jsep-plugin/assignment": "^1.3.0", "@jsep-plugin/regex": "^1.0.4", "jsep": "^1.4.0" }, "bin": { "jsonpath": "bin/jsonpath-cli.js", "jsonpath-plus": "bin/jsonpath-cli.js" } }, "sha512-8TNmfeTCk2Le33A3vRRwtuworG/L5RrgMvdjhKZxvyShO+mBu2fP50OWUjRLNtvw344DdDarFh9buFAZs5ujeA=="],
"math-intrinsics": ["math-intrinsics@1.1.0", "", {}, "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g=="],
"mime-db": ["mime-db@1.52.0", "", {}, "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg=="],
"mime-types": ["mime-types@2.1.35", "", { "dependencies": { "mime-db": "1.52.0" } }, "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw=="],
"ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="],
"node-fetch": ["node-fetch@2.7.0", "", { "dependencies": { "whatwg-url": "^5.0.0" }, "peerDependencies": { "encoding": "^0.1.0" }, "optionalPeers": ["encoding"] }, "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A=="],
"oauth4webapi": ["oauth4webapi@3.8.1", "", {}, "sha512-olkZDELNycOWQf9LrsELFq8n05LwJgV8UkrS0cburk6FOwf8GvLam+YB+Uj5Qvryee+vwWOfQVeI5Vm0MVg7SA=="],
"once": ["once@1.4.0", "", { "dependencies": { "wrappy": "1" } }, "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w=="],
"openid-client": ["openid-client@6.8.0", "", { "dependencies": { "jose": "^6.1.0", "oauth4webapi": "^3.8.1" } }, "sha512-oG1d1nAVhIIE+JSjLS+7E9wY1QOJpZltkzlJdbZ7kEn7Hp3hqur2TEeQ8gLOHoHkhbRAGZJKoOnEQcLOQJuIyg=="],
"pump": ["pump@3.0.3", "", { "dependencies": { "end-of-stream": "^1.1.0", "once": "^1.3.1" } }, "sha512-todwxLMY7/heScKmntwQG8CXVkWUOdYxIvY2s0VWAAMh/nd8SoYiRaKjlr7+iCs984f2P8zvrfWcDDYVb73NfA=="],
"rfc4648": ["rfc4648@1.5.4", "", {}, "sha512-rRg/6Lb+IGfJqO05HZkN50UtY7K/JhxJag1kP23+zyMfrvoB0B7RWv06MbOzoc79RgCdNTiUaNsTT1AJZ7Z+cg=="],
"smart-buffer": ["smart-buffer@4.2.0", "", {}, "sha512-94hK0Hh8rPqQl2xXc3HsaBoOXKV20MToPkcXvwbISWLEs+64sBq5kFgn2kJDHb1Pry9yrP0dxrCI9RRci7RXKg=="],
"socks": ["socks@2.8.7", "", { "dependencies": { "ip-address": "^10.0.1", "smart-buffer": "^4.2.0" } }, "sha512-HLpt+uLy/pxB+bum/9DzAgiKS8CX1EvbWxI4zlmgGCExImLdiad2iCwXT5Z4c9c3Eq8rP2318mPW2c+QbtjK8A=="],
"socks-proxy-agent": ["socks-proxy-agent@8.0.5", "", { "dependencies": { "agent-base": "^7.1.2", "debug": "^4.3.4", "socks": "^2.8.3" } }, "sha512-HehCEsotFqbPW9sJ8WVYB6UbmIMv7kUUORIF2Nncq4VQvBfNBLibW9YZR5dlYCSUhwcD628pRllm7n+E+YTzJw=="],
"stream-buffers": ["stream-buffers@3.0.3", "", {}, "sha512-pqMqwQCso0PBJt2PQmDO0cFj0lyqmiwOMiMSkVtRokl7e+ZTRYgDHKnuZNbqjiJXgsg4nuqtD/zxuo9KqTp0Yw=="],
"streamx": ["streamx@2.22.1", "", { "dependencies": { "fast-fifo": "^1.3.2", "text-decoder": "^1.1.0" }, "optionalDependencies": { "bare-events": "^2.2.0" } }, "sha512-znKXEBxfatz2GBNK02kRnCXjV+AA4kjZIUxeWSr3UGirZMJfTE9uiwKHobnbgxWyL/JWro8tTq+vOqAK1/qbSA=="],
"tar-fs": ["tar-fs@3.1.0", "", { "dependencies": { "pump": "^3.0.0", "tar-stream": "^3.1.5" }, "optionalDependencies": { "bare-fs": "^4.0.1", "bare-path": "^3.0.0" } }, "sha512-5Mty5y/sOF1YWj1J6GiBodjlDc05CUR8PKXrsnFAiSG0xA+GHeWLovaZPYUDXkH/1iKRf2+M5+OrRgzC7O9b7w=="],
"tar-stream": ["tar-stream@3.1.7", "", { "dependencies": { "b4a": "^1.6.4", "fast-fifo": "^1.2.0", "streamx": "^2.15.0" } }, "sha512-qJj60CXt7IU1Ffyc3NJMjh6EkuCFej46zUqJ4J7pqYlThyd9bO0XBTmcOIhSzZJVWfsLks0+nle/j538YAW9RQ=="],
"text-decoder": ["text-decoder@1.2.3", "", { "dependencies": { "b4a": "^1.6.4" } }, "sha512-3/o9z3X0X0fTupwsYvR03pJ/DjWuqqrfwBgTQzdWDiQSm9KitAyz/9WqsT2JQW7KV2m+bC2ol/zqpW37NHxLaA=="],
"tr46": ["tr46@0.0.3", "", {}, "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw=="],
"undici-types": ["undici-types@7.10.0", "", {}, "sha512-t5Fy/nfn+14LuOc2KNYg75vZqClpAiqscVvMygNnlsHBFpSXdJaYtXMcdNLpl/Qvc3P2cB3s6lOV51nqsFq4ag=="],
"webidl-conversions": ["webidl-conversions@3.0.1", "", {}, "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ=="],
"whatwg-url": ["whatwg-url@5.0.0", "", { "dependencies": { "tr46": "~0.0.3", "webidl-conversions": "^3.0.0" } }, "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw=="],
"wrappy": ["wrappy@1.0.2", "", {}, "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ=="],
"ws": ["ws@8.18.3", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg=="],
"@kubernetes/client-node/@types/node": ["@types/node@22.18.1", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-rzSDyhn4cYznVG+PCzGe1lwuMYJrcBS1fc3JqSa2PvtABwWo+dZ1ij5OVok3tqfpEBCBoaR4d7upFJk73HRJDw=="],
"@kubernetes/client-node/@types/node/undici-types": ["undici-types@6.21.0", "", {}, "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ=="],
}
}
+178 -48
View File
@@ -1,5 +1,6 @@
import http from "node:http";
import { spawn } from "node:child_process";
import * as k8s from "@kubernetes/client-node";
const PORT = Number(process.env.PORT || 8787);
const HOST = "0.0.0.0";
@@ -41,13 +42,95 @@ type fileType = {
content: string
}
// docker binary (or set DOCKER_BIN=podman)
const DOCKER_BIN = process.env.DOCKER_BIN || "docker";
const NS = process.env.NAMESPACE || "ai";
const kc = new k8s.KubeConfig();
kc.loadFromDefault(); // in-cluster uses serviceaccount
const batch = kc.makeApiClient(k8s.BatchV1Api),
core = kc.makeApiClient(k8s.CoreV1Api);
// basic openapi for open webui
const OPENAPI = JSON.parse((await import('fs')).readFileSync('openapi.json'))
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" }
}
}
}
}
}
}
}
}
}
};
function sendJson(res, status, obj) {
function sendJson(res: any, status: number, obj: any) {
const body = JSON.stringify(obj);
res.writeHead(status, { "content-type": "application/json; charset=utf-8" });
res.end(body);
@@ -99,63 +182,110 @@ async function ensureImage(spec: langObj) {
// }
}
async function waitForJobPod(core: k8s.CoreV1Api, jobName: string): Promise<string> {
const labelSelector = `job-name=${jobName}`;
for (; ;) {
const pods = await core.listNamespacedPod({ namespace: NS, labelSelector });
const pod = pods.items.find((p) => p.status?.phase === "Running" || p.status?.phase === "Succeeded" || p.status?.phase === "Failed");
if (pod?.metadata?.name) return pod.metadata.name;
await new Promise((r) => setTimeout(r, 400));
}
};
async function waitForCompletionAndLogs(core: k8s.CoreV1Api, podName: string): Promise<{ status: string; stdout: string; stderr: string; }> {
for (; ;) {
const readReq = {
name: podName,
namespace: NS
},
p = await core.readNamespacedPod(readReq),
phase = p.status?.phase ?? "Pending";
if (phase === "Succeeded" || phase === "Failed") {
const logs = await core.readNamespacedPodLog(readReq);
// stderr is not separated by the api; you can split by stream if needed
return { status: phase, stdout: logs, stderr: "" };
};
await new Promise((r) => setTimeout(r, 500));
}
};
async function runInContainer({ language, code, args = [], files = [] }: {
language: string,
code: string,
args: string[],
files: fileType[]
language: string, code: string, args: string[], files: fileType[]
}) {
if (!LANGS[language]) throw new Error(`language not allowed: ${language}`);
const spec = LANGS[language];
if (!(language in LANGS)) throw new Error(`language not allowed: ${language}`);
const spec = LANGS[language as keyof typeof LANGS];
await ensureImage(spec);
// build the Docker args
const dockerArgs = [
"run", "--rm",
"--network=none", "--read-only",
"--pids-limit=256",
"--cpus=1", "--memory=512m",
"--cap-drop=ALL", "--security-opt", "no-new-privileges",
"--tmpfs", "/work:rw,exec,size=64m",
"-w", "/work",
"--pull=never",
spec.image
];
// inside the container, write the files and run code
// build the same shell script you already use
const script = [
// write the main file using base64
`echo ${JSON.stringify(Buffer.from(code, "utf8").toString("base64"))} | base64 -d > ${spec.filename}`,
// write any extra files using base64
...files.flatMap((f) => [
`mkdir -p "$(dirname "${f.path}")"`,
`echo ${JSON.stringify(Buffer.from(f.content, "utf8").toString("base64"))} | base64 -d > "${f.path}"`,
]),
// run it
`${spec.run.join(' ')} ${args.map((a) => JSON.stringify(a)).join(' ')}`
].join('\n');
dockerArgs.push("sh", "-lc", script);
const uid = crypto.randomUUID().slice(0, 8);
const jobName = `coderun-${uid}`;
const result = await new Promise((resolve) => {
const child = spawn(DOCKER_BIN, dockerArgs, { stdio: ["ignore", "pipe", "pipe"] });
let stdout = "", stderr = "";
// create a short-lived Job with tight security and resource caps
const job: k8s.V1Job = {
apiVersion: "batch/v1",
kind: "Job",
metadata: { name: jobName, namespace: NS },
spec: {
ttlSecondsAfterFinished: 300, // auto-clean once done
backoffLimit: 0, // no retries
activeDeadlineSeconds: 25, // mirrors your 25s timeout
template: {
metadata: { labels: { app: "coderunner-task" } },
spec: {
restartPolicy: "Never",
securityContext: {
runAsNonRoot: true,
seccompProfile: { type: "RuntimeDefault" }
},
containers: [{
name: "task",
image: spec.image,
command: ["sh", "-lc", script],
resources: {
requests: { cpu: "1", memory: "512Mi" },
limits: { cpu: "1", memory: "512Mi" }
},
securityContext: {
allowPrivilegeEscalation: false,
readOnlyRootFilesystem: true,
capabilities: { drop: ["ALL"] }
}
}]
}
}
}
};
const timer = setTimeout(() => {
child.kill("SIGKILL");
resolve({ stdout, stderr: stderr + "\n[killed: timeout]", exitCode: 137, timedOut: true });
}, 25_000);
child.stdout.on("data", (d) => { stdout += d.toString(); });
child.stderr.on("data", (d) => { stderr += d.toString(); });
child.on("close", (code) => {
clearTimeout(timer);
resolve({ stdout, stderr, exitCode: code ?? 1, timedOut: false });
});
await batch.createNamespacedJob({
namespace: NS,
body: job
});
return result;
// wait for pod to complete, then get logs
const podName = await waitForJobPod(core, jobName);
const { status, stdout, stderr } = await waitForCompletionAndLogs(core, podName);
// delete job for hygiene (ttl also cleans it eventually)
try {
await batch.deleteNamespacedJob({ namespace: NS, propagationPolicy: "Background", name: jobName });
} catch (err) { console.error(err); };
return {
stdout,
stderr,
exitCode: status === "Succeeded" ? 0 : 1,
timedOut: status === "Failed"
};
}
const server = http.createServer(async (req, res) => {
@@ -181,7 +311,7 @@ const server = http.createServer(async (req, res) => {
const payload = JSON.parse(body || "{}");
const out = await runInContainer(payload);
sendJson(res, 200, out);
} catch (e) {
} catch (e: any) {
sendJson(res, 400, { error: String(e?.message || e) });
}
});
@@ -190,7 +320,7 @@ const server = http.createServer(async (req, res) => {
res.writeHead(404);
res.end("not found");
} catch (e) {
} catch (e: any) {
sendJson(res, 500, { error: String(e?.message || e) });
}
});
-93
View File
@@ -1,93 +0,0 @@
{
"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"
}
}
}
}
}
}
}
}
}
}
}
+2 -10
View File
@@ -1,16 +1,8 @@
{
"name": "coderunner",
"module": "index.ts",
"type": "module",
"private": true,
"devDependencies": {
"@types/bun": "latest"
},
"peerDependencies": {
"typescript": "^5"
"@types/node": "^24.3.1"
},
"dependencies": {
"@types/node": "^24.6.2",
"http": "^0.0.1-security"
"@kubernetes/client-node": "^1.3.0"
}
}
-178
View File
@@ -1,178 +0,0 @@
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
tools:
container_name: openwebui_tools
build:
context: ./tools
dockerfile: Dockerfile
env_file: .env
restart: on-failure
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
# 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
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
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
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:
schedule_data:
nextcloud_data:
networks:
internal:
driver: bridge
@@ -0,0 +1,59 @@
apiVersion: apps/v1
kind: Deployment
metadata:
name: ollama-scheduler
namespace: argo
spec:
replicas: 1
selector:
matchLabels:
app: ollama-scheduler
template:
metadata:
labels:
app: ollama-scheduler
spec:
serviceAccountName: ollama-scheduler
containers:
- name: ollama-scheduler
image: docker.io/ion606/ollama-scheduler:0.1.0
imagePullPolicy: IfNotPresent
env:
- name: PORT
value: "12253"
- name: NS
value: "argo"
ports:
- name: http
containerPort: 12253
readinessProbe:
tcpSocket:
port: 12253
initialDelaySeconds: 3
periodSeconds: 10
livenessProbe:
tcpSocket:
port: 12253
initialDelaySeconds: 10
periodSeconds: 20
resources:
requests:
cpu: "50m"
memory: "64Mi"
limits:
cpu: "200m"
memory: "256Mi"
---
apiVersion: v1
kind: Service
metadata:
name: ollama-scheduler
namespace: argo
spec:
selector:
app: ollama-scheduler
ports:
- name: http
port: 12253
targetPort: 12253
type: ClusterIP
+18
View File
@@ -0,0 +1,18 @@
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: ollama-scheduler
namespace: argo
spec:
ingressClassName: nginx
rules:
- host: scheduler.local
http:
paths:
- path: /
pathType: Prefix
backend:
service:
name: ollama-scheduler
port:
number: 12253
+37
View File
@@ -0,0 +1,37 @@
apiVersion: v1
kind: ServiceAccount
metadata:
name: ollama-scheduler
namespace: argo
---
apiVersion: rbac.authorization.k8s.io/v1
kind: Role
metadata:
name: ollama-scheduler
namespace: argo
rules:
- apiGroups: ["argoproj.io"]
resources: ["cronworkflows"]
verbs: ["create", "get", "list", "watch", "update", "patch", "delete"]
- apiGroups: ["argoproj.io"]
resources: ["workflows"]
verbs: ["create", "get", "list"]
- apiGroups: ["argoproj.io"]
resources: ["workflowtemplates"]
verbs: ["get", "list"]
---
apiVersion: rbac.authorization.k8s.io/v1
kind: RoleBinding
metadata:
name: ollama-scheduler
namespace: argo
subjects:
- kind: ServiceAccount
name: ollama-scheduler
namespace: argo
roleRef:
apiGroup: rbac.authorization.k8s.io
kind: Role
name: ollama-scheduler
@@ -0,0 +1,17 @@
apiVersion: argoproj.io/v1alpha1
kind: WorkflowTemplate
metadata:
name: hello-template
namespace: argo
spec:
entrypoint: run
arguments:
parameters:
- { name: message, value: "hello from argo" }
templates:
- name: run
container:
image: alpine:3.19
command: ["/bin/sh","-lc"]
args: ["echo \"{{workflow.parameters.message}}\""]
@@ -0,0 +1,27 @@
apiVersion: argoproj.io/v1alpha1
kind: WorkflowTemplate
metadata:
name: ollama-job-template
namespace: argo
spec:
entrypoint: run
arguments:
parameters:
- { name: task, value: "quick-check" }
- { name: prompt, value: "reindex embeddings" }
templates:
- name: run
container:
# Replace with an image that can reach your Ollama endpoint
image: curlimages/curl:8.9.0
command: ["/bin/sh","-lc"]
args:
- >-
echo "task: {{workflow.parameters.task}}";
echo "prompt: {{workflow.parameters.prompt}}";
# Example call (adjust host/port or service DNS):
# curl -s http://ollama.ai.svc.cluster.local:11434/api/generate \
# -H 'content-type: application/json' \
# -d '{"model":"llama3.1","prompt":"{{workflow.parameters.prompt}}"}' | tee /tmp/out.json;
echo done.
+39
View File
@@ -0,0 +1,39 @@
apiVersion: apps/v1
kind: Deployment
metadata: { name: browser, namespace: ai }
spec:
replicas: 1
selector: { matchLabels: { app: browser } }
template:
metadata: { labels: { app: browser } }
spec:
containers:
- name: browser
image: docker.io/ion606/browser:latest
ports: [{ containerPort: 7788 }]
env:
- { name: WEBUI_IP, value: "0.0.0.0" }
- { name: WEBUI_PORT, value: "7788" }
resources:
requests: { cpu: "250m", memory: "256Mi" }
limits: { cpu: "1", memory: "1Gi" } # hard cap
readinessProbe:
httpGet: { path: "/", port: 7788 }
initialDelaySeconds: 5
periodSeconds: 10
livenessProbe:
httpGet: { path: "/", port: 7788 }
initialDelaySeconds: 15
periodSeconds: 20
---
apiVersion: v1
kind: Service
metadata: { name: browser, namespace: ai }
spec:
selector: { app: browser }
ports:
- name: http
port: 7788
targetPort: 7788
nodePort: 30788
type: NodePort
+43
View File
@@ -0,0 +1,43 @@
apiVersion: apps/v1
kind: Deployment
metadata: { name: coderunner, namespace: ai }
spec:
replicas: 1
selector: { matchLabels: { app: coderunner } }
template:
metadata: { labels: { app: coderunner } }
spec:
serviceAccountName: coderunner-sa
containers:
- name: coderunner
image: docker.io/ion606/coderunner:latest
ports: [{ containerPort: 8787 }]
env:
- { name: PORT, value: "8787" }
- name: NAMESPACE
valueFrom:
fieldRef:
fieldPath: metadata.namespace
readinessProbe:
httpGet: { path: "/openapi.json", port: 8787 }
initialDelaySeconds: 5
periodSeconds: 10
livenessProbe:
httpGet: { path: "/openapi.json", port: 8787 }
initialDelaySeconds: 15
periodSeconds: 20
resources:
requests: { cpu: "100m", memory: "128Mi" }
limits: { cpu: "500m", memory: "512Mi" }
---
apiVersion: v1
kind: Service
metadata: { name: coderunner, namespace: ai }
spec:
selector: { app: coderunner }
ports:
- name: http
port: 8787
targetPort: 8787
nodePort: 31787
type: NodePort
+27
View File
@@ -0,0 +1,27 @@
apiVersion: v1
kind: ServiceAccount
metadata: { name: coderunner-sa, namespace: ai }
---
apiVersion: rbac.authorization.k8s.io/v1
kind: Role
metadata: { name: coderunner-job-role, namespace: ai }
rules:
- apiGroups: ["batch"]
resources: ["jobs"]
verbs: ["create", "get", "list", "watch", "delete"]
- apiGroups: [""]
resources: ["pods", "pods/log"]
verbs: ["get", "list", "watch", "delete"]
---
apiVersion: rbac.authorization.k8s.io/v1
kind: RoleBinding
metadata: { name: coderunner-job-rb, namespace: ai }
subjects:
- kind: ServiceAccount
name: coderunner-sa
namespace: ai
roleRef:
apiGroup: rbac.authorization.k8s.io
kind: Role
name: coderunner-job-role
+6
View File
@@ -0,0 +1,6 @@
apiVersion: kustomize.config.k8s.io/v1beta1
kind: Kustomization
resources:
- netshoot.yaml
+26
View File
@@ -0,0 +1,26 @@
apiVersion: apps/v1
kind: Deployment
metadata:
name: netshoot
namespace: ai
spec:
replicas: 1
selector:
matchLabels:
app: netshoot
template:
metadata:
labels:
app: netshoot
spec:
containers:
- name: netshoot
image: nicolaka/netshoot:latest
imagePullPolicy: IfNotPresent
command: ["/bin/sh", "-c", "sleep infinity"]
securityContext:
capabilities:
add: ["NET_ADMIN", "NET_RAW"]
resources:
requests: { cpu: "50m", memory: "64Mi" }
limits: { cpu: "200m", memory: "256Mi" }
+6
View File
@@ -0,0 +1,6 @@
apiVersion: kustomize.config.k8s.io/v1beta1
kind: Kustomization
resources:
- ../policy/allow-ollama-scheduler-ingress.yaml
@@ -0,0 +1,17 @@
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
name: allow-browser-ingress
namespace: ai
spec:
podSelector:
matchLabels:
app: browser
policyTypes: ["Ingress"]
ingress:
- from:
- ipBlock:
cidr: 0.0.0.0/0
ports:
- { protocol: TCP, port: 7788 }
+26
View File
@@ -0,0 +1,26 @@
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
name: allow-common-egress
namespace: ai
spec:
podSelector: {}
policyTypes: ["Egress"]
egress:
# Allow DNS to kube-dns/CoreDNS in kube-system
- to:
- namespaceSelector:
matchLabels:
kubernetes.io/metadata.name: kube-system
ports:
- { protocol: UDP, port: 53 }
- { protocol: TCP, port: 53 }
# Allow PostgreSQL to services/pods in namespace ai on 5432
- to:
- namespaceSelector:
matchLabels:
kubernetes.io/metadata.name: ai
ports:
- { protocol: TCP, port: 5432 }
+20
View File
@@ -0,0 +1,20 @@
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
name: allow-https-egress
namespace: ai
spec:
podSelector: {}
policyTypes: ["Egress"]
egress:
- to:
- ipBlock:
cidr: 0.0.0.0/0
# exclude RFC1918/private ranges so this only permits Internet egress
except:
- 10.0.0.0/8
- 172.16.0.0/12
- 192.168.0.0/16
ports:
- { protocol: TCP, port: 443 }
- { protocol: TCP, port: 80 }
@@ -0,0 +1,17 @@
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
name: allow-ollama-scheduler-ingress
namespace: argo
spec:
podSelector:
matchLabels:
app: ollama-scheduler
policyTypes: ["Ingress"]
ingress:
- from:
- ipBlock:
cidr: 0.0.0.0/0
ports:
- { protocol: TCP, port: 12253 }
@@ -0,0 +1,20 @@
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
name: allow-openwebui-ingress
namespace: ai
spec:
# Select the Open WebUI pods deployed by the Helm release "openwebui"
podSelector:
matchLabels:
app.kubernetes.io/instance: openwebui
policyTypes: ["Ingress"]
ingress:
- from:
- ipBlock:
cidr: 0.0.0.0/0
ports:
# Open WebUI typically listens on 8080 (chart default), sometimes 80
- { protocol: TCP, port: 8080 }
- { protocol: TCP, port: 80 }
+8
View File
@@ -0,0 +1,8 @@
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
name: default-deny-all
namespace: ai
spec:
podSelector: {} # die
policyTypes: ["Ingress", "Egress"]
+9
View File
@@ -0,0 +1,9 @@
apiVersion: kustomize.config.k8s.io/v1beta1
kind: Kustomization
resources:
- default-deny.yaml
- allow-openwebui-ingress.yaml
- allow-browser-ingress.yaml
- allow-common-egress.yaml
- allow-https-egress.yaml
+41
View File
@@ -0,0 +1,41 @@
apiVersion: apps/v1
kind: Deployment
metadata: { name: rag-server, namespace: ai }
spec:
replicas: 1
selector: { matchLabels: { app: rag-server } }
template:
metadata: { labels: { app: rag-server } }
spec:
containers:
- name: rag-server
image: docker.io/ion606/rag-server:latest
ports: [{ containerPort: 8788 }]
env:
- { name: PORT, value: "8788" }
- {
name: OLLAMA_BASE,
value: "http://ollama.ai.svc.cluster.local:11434",
}
- { name: OLLAMA_CHAT_MODEL, value: "llama3.1" }
- { name: OLLAMA_EMBED_MODEL, value: "nomic-embed-text" }
readinessProbe:
httpGet: { path: "/openapi.json", port: 8788 }
livenessProbe:
httpGet: { path: "/", port: 8788 }
initialDelaySeconds: 10
resources:
requests: { cpu: "200m", memory: "256Mi" }
limits: { cpu: "1", memory: "1Gi" }
---
apiVersion: v1
kind: Service
metadata: { name: rag-server, namespace: ai }
spec:
selector: { app: rag-server }
ports:
- name: http
port: 8788
targetPort: 8788
nodePort: 31788
type: NodePort
+37
View File
@@ -0,0 +1,37 @@
apiVersion: apps/v1
kind: Deployment
metadata: { name: tools, namespace: ai }
spec:
replicas: 1
selector: { matchLabels: { app: tools } }
template:
metadata: { labels: { app: tools } }
spec:
containers:
- name: tools
image: docker.io/ion606/tools:latest
ports: [{ containerPort: 1331 }]
env:
- { name: HOST, value: "0.0.0.0" }
- { name: PORT, value: "1331" }
- { name: ROKU_IP, value: "192.0.2.10" }
readinessProbe:
httpGet: { path: "/roku/openapi.json", port: 1331 }
livenessProbe:
httpGet: { path: "/roku/openapi.json", port: 1331 }
initialDelaySeconds: 10
resources:
requests: { cpu: "100m", memory: "128Mi" }
limits: { cpu: "500m", memory: "512Mi" }
---
apiVersion: v1
kind: Service
metadata: { name: tools, namespace: ai }
spec:
selector: { app: tools }
ports:
- name: http
port: 1331
targetPort: 1331
nodePort: 31331
type: NodePort
-5
View File
@@ -1,5 +0,0 @@
node_modules
npm-cache
bun.lock
bun.lockb
*.log
-17
View File
@@ -1,17 +0,0 @@
FROM oven/bun:1 AS base
WORKDIR /app
# prod deps
COPY package.json ./package.json
RUN bun install --ci --production
COPY . .
RUN mkdir -p /app/data && chown -R bun:bun /app/data
USER bun
ENV NODE_ENV=production
CMD ["bun", "run", "server.ts"]
-65
View File
@@ -1,65 +0,0 @@
import { Database } from 'bun:sqlite';
import { createHash } from 'crypto';
import path from 'path';
import fs from 'fs/promises';
type CacheRow = {
path_key: string,
fpath: string,
etag: string,
size: number,
mime: string | null,
cache_path: string,
updated_at: number
};
export const CACHE_DIR = "/tmp",
db = new Database(path.resolve(`${CACHE_DIR}/index.sqlite`));
db.run(`
create table if not exists file_cache (
path_key text primary key,
fpath text not null,
etag text not null,
size integer not null,
mime text,
cache_path text not null,
updated_at integer not null
);
`);
const qGet = db.query<CacheRow, string>('select * from file_cache where path_key = ?;');
const qUpsert = db.query(`
insert into file_cache (path_key, fpath, etag, size, mime, cache_path, updated_at)
values (?1, ?2, ?3, ?4, ?5, ?6, ?7)
on conflict(path_key) do update set
etag=excluded.etag, size=excluded.size, mime=excluded.mime,
cache_path=excluded.cache_path, updated_at=excluded.updated_at;
`);
export const base64url = (buf: Buffer): string =>
buf.toString('base64').replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/g, ''); // no padding
export const pathKey = (addr: string, fpath: string): string => {
// normalize to avoid duplicate keys for equivalent paths
const canonical = new URL(fpath, addr).toString(),
digest = createHash('sha256').update(canonical).digest();
return base64url(digest);
};
export const fanoutPath = (key: string): string =>
path.join(`${CACHE_DIR}/files`, key.slice(0, 2), key.slice(2, 4), key);
export async function ensureDir(p: string): Promise<void> {
await fs.mkdir(path.dirname(p), { recursive: true });
}
export function getRow(key: string): CacheRow | undefined {
return qGet.get(key) as unknown as CacheRow | undefined;
}
export function upsertRow(row: CacheRow): void {
qUpsert.run(row.path_key, row.fpath, row.etag, row.size, row.mime, row.cache_path, row.updated_at);
}
-127
View File
@@ -1,127 +0,0 @@
import { Request } from "express";
import { WebDAVClient } from "webdav";
import { DIRS } from "./server";
import fetch from 'node-fetch'
import { XMLParser } from 'fast-xml-parser';
export async function statOne(fpath: string, client: WebDAVClient) {
const parent = fpath.replace(/\/[^/]+$/, '') || '/',
base = fpath.split('/').filter(Boolean).pop(),
list = await client.getDirectoryContents(parent, { deep: false }).catch(err => {
console.error(`error for "${parent}":`);
console.error(err);
if (err.response?.status === 404) {
throw `Path "${fpath}" not found`;
}
throw err;
}),
entry = Array.isArray(list) ? list.find((e: any) => e.basename === base) : undefined;
if (!entry) throw `Path "${fpath}" not found`;
return { etag: entry.etag as string, size: Number(entry.size), mime: entry.mime as string | undefined };
}
export const toRegExp = (spec: string): RegExp | null => {
// slash-delimited form: /pattern/flags
const m = spec.match(/^\/(.+)\/([a-z]*)$/i);
try {
if (m) {
const [, source, flags] = m;
return new RegExp(source, flags);
}
// raw pattern form: "pattern"
return new RegExp(spec);
} catch {
return null;
}
}
export function checkIfHasPerms(req: Request | string) {
try {
if (typeof req === 'string') {
if (req.includes('..')) return false;
return !DIRS || DIRS.find(r => req.match(r));
}
else if (!req.body) {
return false;
}
const { fpath, obj_type, deep, usecache } = req.body,
o = { fpath, obj_type, deep, usecache };
if (!fpath) return false;
if (!DIRS) return o;
if (fpath.includes('..')) return false;
return DIRS.find(r => fpath.match(r)) ? o : false;
}
catch (err) {
console.error(err);
return false;
}
}
function buildCountPropfindXml() {
return `<?xml version="1.0" encoding="UTF-8"?>
<d:propfind xmlns:d="DAV:"
xmlns:oc="http://owncloud.org/ns"
xmlns:nc="http://nextcloud.org/ns">
<d:prop>
<nc:contained-file-count/>
<nc:contained-folder-count/>
<oc:contained-file-count/>
<oc:contained-folder-count/>
</d:prop>
</d:propfind>`;
}
const authHeaders = (() => {
const u = process.env.NEXTCLOUD_APP_ID!,
p = process.env.NEXTCLOUD_APP_PASS!,
token = Buffer.from(`${u}:${p}`).toString("base64");
return `Basic ${token}`;
})();
export async function getDirectoryFileCount(dirPath: string, client: WebDAVClient) {
const url = client.getFileDownloadLink(dirPath),
xml = buildCountPropfindXml();
const resp = await fetch(url, {
method: "PROPFIND",
headers: {
"Content-Type": "text/xml",
"Depth": "0",
"Authorization": authHeaders
},
body: xml
});
if (!resp.ok) {
throw resp;
}
// parse XML
const parser = new XMLParser({
ignoreAttributes: false,
attributeNamePrefix: "@_",
allowBooleanAttributes: true,
numberParseOptions: {
hex: false,
leadingZeros: false
}
}),
text = await resp.text(),
doc = parser.parse(text),
objs = doc['d:multistatus']['d:response']['d:propstat'],
obj = objs.find((o: any) => o['d:status'].includes("200"))['d:prop'];
return {
file_count: obj['nc:contained-file-count'],
folder: obj['nc:contained-folder-count']
};
}
-564
View File
@@ -1,564 +0,0 @@
{
"openapi": "3.1.0",
"info": {
"title": "Nextcloud Files API (openwebui-friendly)",
"description": "simple file/directory access via nextcloud webdav, with local disk cache.",
"version": "1.0.1"
},
"servers": [
{
"url": "http://ollama-nextcloud:1111",
"description": "local server (use absolute url for tool discovery)"
}
],
"paths": {
"/ping": {
"get": {
"operationId": "nextcloudPing",
"summary": "health / verification probe",
"description": "simple json probe used by tool registrars to verify the server is reachable",
"responses": {
"200": {
"description": "ok",
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"ok": {
"type": "boolean",
"example": true
},
"time": {
"type": "string",
"format": "date-time",
"example": "2025-09-01T12:00:00Z"
}
},
"required": [
"ok"
]
}
}
}
}
}
}
},
"/file": {
"post": {
"operationId": "nextcloudGetFile",
"summary": "Fetch a file (proxied via WebDAV, cached locally)",
"description": "returns the raw file bytes. content-type mirrors the upstream mime when available; otherwise application/octet-stream. also supports an application/json metadata variant for tool registration and LLM-friendly responses.",
"requestBody": {
"required": true,
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"fpath": {
"type": "string",
"description": "absolute path in nextcloud webdav (e.g., /Documents/report.pdf)"
},
"bypasscache": {
"type": "boolean",
"default": false,
"description": "if true, skip the local cache and fetch from upstream"
}
},
"required": [
"fpath"
]
},
"examples": {
"default": {
"value": {
"fpath": "/Documents/report.pdf",
"bypasscache": false
}
}
}
}
}
},
"responses": {
"200": {
"description": "file bytes or metadata",
"headers": {
"content-type": {
"description": "mime type from upstream or application/octet-stream",
"schema": {
"type": "string"
}
}
},
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/FileMeta"
},
"examples": {
"example": {
"value": {
"filename": "/Documents/report.pdf",
"basename": "report.pdf",
"lastmod": "Mon, 01 Sep 2025 12:34:56 GMT",
"size": 2048,
"type": "file",
"etag": "\"a1b2c3d4e5\"",
"mime": "application/pdf",
"cached": true,
"download_url": "http://ollama-nextcloud:1111/file?fpath=/Documents/report.pdf"
}
}
}
},
"application/octet-stream": {
"schema": {
"type": "string",
"format": "binary"
}
},
"*/*": {
"schema": {
"type": "string",
"format": "binary"
}
}
}
},
"401": {
"$ref": "#/components/responses/Unauthorized"
},
"404": {
"$ref": "#/components/responses/NotFound"
},
"500": {
"$ref": "#/components/responses/ServerError"
}
}
},
"put": {
"operationId": "nextcloudUploadFile",
"summary": "Upload a file (application/json, base64-encoded)",
"description": "Uploads a file into a target directory in nextcloud via webdav. the server will not overwrite existing files. the body must be application/json and include `fdir` and `file.buffer` (base64).",
"requestBody": {
"required": true,
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/NewFileMeta"
},
"examples": {
"json-base64": {
"value": {
"fdir": "/Documents",
"createnewdirs": false,
"file": {
"filename": "file",
"originalname": "photo.jpg",
"mimetype": "image/jpeg",
"size": 52345,
"buffer": "/9j/4AAQSkZJRgABAQAAAQABAAD... (truncated base64)"
}
}
}
}
}
}
},
"responses": {
"200": {
"description": "upload succeeded",
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"ok": {
"type": "boolean",
"example": true
},
"uploaded": {
"$ref": "#/components/schemas/DirEntry"
}
},
"required": [
"ok",
"uploaded"
]
}
},
"text/plain": {
"schema": {
"type": "string"
},
"example": "file uploaded successfully to https://nextcloud.example.com/remote.php/dav/files/user/Documents/example.txt"
}
}
},
"400": {
"description": "missing file upload field or invalid base64",
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"ok": {
"type": "boolean",
"example": false
},
"error": {
"type": "string",
"example": "missing file or invalid buffer"
}
}
}
},
"text/plain": {
"schema": {
"type": "string"
},
"example": "missing file"
}
}
},
"401": {
"$ref": "#/components/responses/Unauthorized"
},
"503": {
"description": "file exists and overwrite is not permitted",
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"ok": {
"type": "boolean",
"example": false
},
"error": {
"type": "string",
"example": "file already exists, you do not have permissions to overwrite files"
}
}
}
},
"text/plain": {
"schema": {
"type": "string"
},
"example": "file already exists, you do not have permissions to overwrite files"
}
}
},
"500": {
"$ref": "#/components/responses/ServerError"
}
}
}
},
"/dir": {
"post": {
"operationId": "nextcloudListDirectory",
"summary": "List a directory",
"description": "Lists directory entries from nextcloud webdav. Supports shallow or deep listing.",
"requestBody": {
"required": true,
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"fpath": {
"type": "string",
"description": "directory path in nextcloud webdav (e.g., /Documents)"
},
"deep": {
"type": "boolean",
"default": false,
"description": "whether to recurse into subdirectories"
},
"startInd": {
"type": "number",
"default": 0,
"description": "start listing from this index (for pagination)"
},
"limit": {
"type": "number",
"default": 250,
"description": "maximum number of entries to return"
}
},
"required": [
"fpath"
]
},
"examples": {
"default": {
"value": {
"fpath": "/Documents",
"deep": false
}
}
}
}
}
},
"responses": {
"200": {
"description": "directory listing",
"content": {
"application/json": {
"schema": {
"type": "array",
"items": {
"$ref": "#/components/schemas/DirEntry"
}
}
}
}
},
"401": {
"$ref": "#/components/responses/Unauthorized"
},
"404": {
"$ref": "#/components/responses/NotFound"
},
"500": {
"$ref": "#/components/responses/ServerError"
}
}
}
},
"/openapi.json": {
"get": {
"operationId": "nextcloudGetOpenapi",
"summary": "Serve OpenAPI schema",
"description": "Serves this specification (used by open webui when registering a tool server).",
"responses": {
"200": {
"description": "openapi document",
"content": {
"application/json": {
"schema": {
"type": "object",
"additionalProperties": true
}
}
}
}
}
}
}
},
"components": {
"schemas": {
"DirEntry": {
"type": "object",
"description": "Entry returned by webdav client",
"properties": {
"filename": {
"type": "string",
"example": "/Documents/report.pdf"
},
"basename": {
"type": "string",
"example": "report.pdf"
},
"lastmod": {
"type": "string",
"example": "Mon, 01 Sep 2025 12:34:56 GMT"
},
"size": {
"type": "integer",
"format": "int64",
"example": 2048
},
"type": {
"type": "string",
"enum": [
"file",
"directory"
]
},
"etag": {
"type": "string",
"example": "\"a1b2c3d4e5\""
},
"mime": {
"type": "string",
"nullable": true,
"example": "application/pdf"
}
},
"required": [
"basename",
"type"
]
},
"FileMeta": {
"type": "object",
"description": "Metadata about a file (json-friendly alternative to raw bytes)",
"properties": {
"filename": {
"type": "string"
},
"basename": {
"type": "string"
},
"lastmod": {
"type": "string"
},
"size": {
"type": "integer",
"format": "int64"
},
"type": {
"type": "string",
"enum": [
"file",
"directory"
]
},
"etag": {
"type": "string"
},
"mime": {
"type": "string",
"nullable": true
},
"cached": {
"type": "boolean",
"description": "whether the file was served from local cache"
},
"download_url": {
"type": "string",
"format": "uri",
"description": "url to download raw bytes (may be the same endpoint with different accept header)"
}
},
"required": [
"basename",
"type"
]
},
"NewFileMeta": {
"type": "object",
"properties": {
"fdir": {
"type": "string",
"description": "destination directory path (e.g., /Documents)"
},
"createnewdirs": {
"type": "boolean",
"default": false,
"description": "if true and the directory does not exist, create it recursively before uploading"
},
"file": {
"type": "object",
"description": "uploaded file metadata + contents (base64 if image or misc formats otherwise)",
"properties": {
"filename": {
"type": "string",
"description": "name of the form field associated with this file or client-side identifier"
},
"originalname": {
"type": "string",
"default": "",
"description": "name of the file on the uploader's computer"
},
"mimetype": {
"type": "string",
"default": "text/plain",
"description": "value of the Content-Type for this file, e.g. image/jpeg"
},
"size": {
"type": "integer",
"format": "int64",
"default": 0,
"description": "size of the file in bytes"
},
"buffer": {
"type": "string",
"format": "byte",
"contentEncoding": "any",
"contentMediaType": "application/octet-stream",
"description": "the contents of the file"
}
},
"required": [
"filename",
"mimetype",
"buffer"
]
}
},
"required": [
"fdir",
"file"
]
}
},
"responses": {
"Unauthorized": {
"description": "not authorized to access the requested path (middleware denied or missing parameters)",
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"ok": {
"type": "boolean",
"example": false
},
"error": {
"type": "string"
}
}
}
}
}
},
"NotFound": {
"description": "path not found upstream",
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"ok": {
"type": "boolean",
"example": false
},
"error": {
"type": "string"
}
}
}
}
}
},
"ServerError": {
"description": "unexpected server error",
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"ok": {
"type": "boolean",
"example": false
},
"error": {
"type": "string"
}
}
}
}
}
}
}
}
}
-18
View File
@@ -1,18 +0,0 @@
{
"name": "nextcloud",
"private": true,
"devDependencies": {
"@types/bun": "latest",
"@types/xmldom": "^0.1.34"
},
"peerDependencies": {
"typescript": "^5"
},
"dependencies": {
"@types/cors": "^2.8.19",
"@types/express": "^5.0.3",
"cors": "^2.8.5",
"express": "^5.1.0",
"webdav": "^5.8.0"
}
}
-292
View File
@@ -1,292 +0,0 @@
import express from 'express';
import fs from 'fs/promises';
import fsSync from 'fs';
import { randomUUID } from 'crypto';
import { createClient } from 'webdav';
import { pathKey, fanoutPath, ensureDir, getRow, upsertRow, CACHE_DIR } from './cache.ts';
import { checkIfHasPerms, getDirectoryFileCount, statOne, toRegExp } from './helpers.ts';
import path from 'path';
type statRetType = {
"filename": string,
"basename": string,
"lastmod": string,
"size": number,
"type": "file" | "directory",
"etag": string,
"mime"?: string
};
type ReqFileType = {
filename: string; // Name of the form field associated with this file.
originalname?: string; // Name of the file on the uploader's computer.
mimetype: string; // Value of the Content-Type header for this file.
size?: number; // Size of the file in bytes.
buffer: Buffer; // A Buffer containing the file content.
localFilename: string; // the temporary ID assigned to the file
};
declare global {
namespace Express {
interface Request {
fobj?: {
fpath: any,
deep?: boolean,
bypasscache?: boolean,
startInd?: number,
limit?: number
},
file: ReqFileType
}
}
}
const {
NEXTCLOUD_APP_ID: APP_ID,
NEXTCLOUD_APP_PASS: APP_PASS,
NEXTCLOUD_WEBDAV_ADDR: ADDR,
NEXTCLOUD_ACCESS_DIRS: _DIRS,
PORT: PORT_RAW
} = process.env,
PORT = PORT_RAW || 1111;
if (!(ADDR && APP_ID && APP_PASS)) {
throw new Error("VARIABLES NOT FOUND IN ENV");
}
// const ADDR_URL = new URL(ADDR);
const app = express();
app.use(express.json());
app.use(express.text());
app.use((await import('cors')).default());
app.use((req, res, next) => {
if (req.path === '/openapi.json') {
return res.sendFile('openapi.json', { root: '.' });
}
// GET/PUT has independant handlers
else if (['GET', 'PUT'].includes(req.method)) return next();
const pth = checkIfHasPerms(req);
if (!pth && req.body?.fpath) {
return res.status(401).send(`Not allowed to access "${req.body.fpath}"`);
}
else if (!pth) {
return res.status(404).send("Unknown path");
}
else if (typeof pth === 'string') {
console.warn(`somehow got string out of checkIfHasPerms:\npth: ${pth}\nreq.body: ${req.body}`);
return res.sendStatus(400);
}
req.fobj = pth;
next();
});
export const DIRS: string[] = (() => {
try {
if (!_DIRS) return null;
return JSON.parse(_DIRS).map(toRegExp)
}
catch (err) { console.error(err); }
return null;
})();
if (!DIRS) {
console.warn("NEXTCLOUD_ACCESS_DIRS not specified, tool has full access to all files");
}
const client = createClient(process.env.NEXTCLOUD_WEBDAV_ADDR!, {
username: process.env.NEXTCLOUD_APP_ID!,
password: process.env.NEXTCLOUD_APP_PASS!
});
app.post('/file', async (req, res) => {
if (!req.fobj) return res.sendStatus(401);
const { fpath, bypasscache } = req.fobj;
try {
// read current etag/size from webdav
const { etag, size, mime } = await statOne(fpath, client).catch((err) => {
if (typeof err === 'string') {
console.error(err);
if (!res.headersSent) res.sendStatus(404).send(err);
return { etag: undefined, size: -1, mime: -1 };
}
throw err;
});
if (!etag) return;
// lookup mapping
const key = pathKey(process.env.NEXTCLOUD_WEBDAV_ADDR!, fpath),
cache_path = fanoutPath(key),
row = bypasscache ? null : getRow(key);
// cached file
if (row && row.etag === etag) {
const data = await fs.readFile(row.cache_path);
res.setHeader('content-type', row.mime ?? 'application/octet-stream');
return res.send(data);
}
// TODO: If-None-Match here if client lib exposes raw headers
const buf = await client.getFileContents(fpath).catch(console.error);
if (!buf) return res.sendStatus(500);
await ensureDir(cache_path);
await fs.writeFile(cache_path, buf as any);
upsertRow({
path_key: key,
fpath,
etag,
size,
mime: mime ?? null,
cache_path,
updated_at: Date.now()
});
res.setHeader('content-type', mime ?? 'application/octet-stream');
res.send(buf);
} catch (err) {
console.error(err);
if (!res.headersSent) res.sendStatus(500);
}
});
app.post('/dir', async (req, res) => {
try {
if (!req.fobj) return res.sendStatus(401);
const { fpath, deep } = req.fobj,
{ startInd = 0, limit = 250 } = req.body;
if (!fpath) return res.sendStatus(400);
if (!(await client.exists(fpath))) {
return res.status(404).send("Directory not found");
}
const getFilesInDir = async () => {
for (let i = 0; i < 3; i++) {
try {
return await client.getDirectoryContents(fpath, {
deep
});
}
catch (err: any) {
if (err.response?.status === 500) {
await new Promise(resolve => setTimeout(resolve, 2000));
continue;
}
console.error(`error for "${fpath}":`);
throw err;
}
}
const counts = await getDirectoryFileCount('/Photos', client);
let err = "failed to find dir contents (perhaps the server returned 500 too many times?)";
err += `\n"${fpath}" contains the following, consider listing directories individually (instead of using 'deep' if too many are present: ${JSON.stringify(counts)}`;
res.status(500).send(err);
}
const ret = await getFilesInDir();
if (!ret) return;
const filesInDir = Array.isArray(ret) ? ret : ret.data;
res.json(filesInDir.slice(startInd, limit));
}
catch (err) {
console.error(err);
res.sendStatus(500);
}
});
// openwebui doesn't really help with `multerInstance.single('file'),`
const ensureMockFile = async (req: express.Request, res: express.Response, next: express.NextFunction) => {
try {
const { file, fdir } = req.body as { fdir: any, file: { filename: any, buffer: any, mimetype: any } };
if (!file) return res.status(400).send("missing file");
if (typeof fdir !== 'string') {
return res.status(400).send("Missing fdir in request");
}
if (typeof file.filename !== 'string') {
return res.status(400).send("Invalid filename");
}
if (typeof file.buffer === 'undefined') {
return res.status(400).send("Invalid file upload payload");
}
if (!checkIfHasPerms(fdir)) return res.sendStatus(401);
const fname = `${randomUUID().replaceAll('-', '')}_${file.filename}`,
fpath = path.join(CACHE_DIR, fname);
await fs.writeFile(fpath, file.buffer).catch((err) => {
console.error(err);
res.sendStatus(500);
});
if (res.headersSent) return;
req.file = { ...file, localFilename: fname, mimetype: req.body.mimetype || '' };
next();
}
catch (err) {
console.error(err);
if (!res.headersSent) res.sendStatus(500);
}
};
app.put('/file', ensureMockFile, async (req, res) => {
const file = req.file;
if (!file) return;
const { fdir, createnewdirs } = req.body as {
fdir: string,
createnewdirs: boolean
};
if (!fdir) return (res.headersSent || res.status(400).send("missing fdir in body"));
if (createnewdirs && !(await client.exists(fdir))) {
await client.createDirectory(fdir, {
recursive: true
});
}
const fname = file.filename || file.originalname,
fpath = path.join(fdir, fname!);
if (await client.exists(fpath)) {
return res.status(503).send('file already exists, you do not have permissions to overwrite files');
}
const sstr = fsSync.createReadStream(path.join(CACHE_DIR, file.localFilename)).pipe(client.createWriteStream(fpath, {
overwrite: false
}, (r) => {
if (res.headersSent) return;
res.send(`file uploaded successfully to ${r.url}`);
}));
sstr.on('error', (err) => {
console.error(err);
if (res.headersSent) return;
res.status(500).send("failed to upload file");
});
});
app.get('/ping', (_, res) => res.sendStatus(200));
app.listen(PORT, (err) => {
if (err) throw err;
console.log(`app listening on port ${PORT}`);
});
+12
View File
@@ -0,0 +1,12 @@
# syntax=docker/dockerfile:1
FROM oven/bun:1.2.2-alpine
WORKDIR /app
COPY index.ts ./index.ts
ENV PORT=8788
EXPOSE 8788
CMD ["bun","run","index.ts"]
+289
View File
@@ -0,0 +1,289 @@
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}`);
-1
View File
@@ -4,4 +4,3 @@ bun.lock
bun.lockb
.DS_Store
*.log
templates.json
+4 -9
View File
@@ -1,19 +1,14 @@
FROM oven/bun:1 AS base
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
COPY server.mjs ./server.mjs
COPY public ./public
USER bun
EXPOSE 12253
ENV NODE_ENV=production
CMD ["bun", "run", "server.ts"]
CMD ["bun", "run", "server.mjs"]
-324
View File
@@ -1,324 +0,0 @@
import crypto from 'node:crypto';
import { base } from './resolve-user';
import fs from 'fs';
export type schedInp = {
name: string
when: {
cron?: string
start?: string
}
oneShot?: boolean
template?: { name: string }
parameters: object
prompt: string
userId: string
model: string,
tools: string[]
features?: Record<string, boolean>
chatId?: string
files?: { fname: string, fkey?: string, content?: string }[]
}
export type ollamaInp = {
name: string
displayName: string
userId: string
schedule: string
startAt?: string
oneShot: boolean
template: any //idk
parameters: object
prompt: string
model: string
tools: string[]
features: Record<string, boolean>
cookie: string
chatId?: string
files?: { fname: string, fkey: string }[]
}
async function makeRequest(cookie: string, path: string, throwOnErr = false) {
if (!cookie) return [];
const r = await fetch(`${base}/${path}`, {
method: "GET",
headers: {
'Authorization': `Bearer ${cookie}`,
"content-type": "application/json",
"accept": "application/json",
}
});
if (throwOnErr && !r.ok) throw r;
return await r.text().then(data => {
try { return JSON.parse(data); }
catch (_) { return data; }
}).catch(async err => {
console.error(err);
return [];
});
}
// NO FOREWARD SLASH NO IDK WHY IG /v1/ DOES IT FUCK ME MAN
export const getModels = (cookie: string) => makeRequest(cookie, 'api/models').then((r) => (r.data || []));
export const getTools = (cookie: string) => makeRequest(cookie, 'api/v1/tools/');
export const authCall = (cookie: string) => makeRequest(cookie, 'api/v1/auths/', true);
const buildPrompt = async (def: ollamaInp) => {
let content = `Please complete this request given the following information:\n`;
content += `request: ${def.prompt}\nExtra Context:\n\`\`\`json\n${JSON.stringify(def.parameters || {})}\n\`\`\``;
if (def.files) {
const files = await Promise.all(def.files.map(async f => {
const fpath = `/app/data/files/${f.fkey}`,
r = await fetch(`https://${base}/api/v1/files`, {
headers: {
'Content-Type': 'multipart/form-data',
'filename': f.fname
},
method: 'POST',
body: await fs.readFileSync(fpath)
}).catch(err => {
console.error(err);
return { data: err, ok: false };
});
fs.rm(fpath, { recursive: false }, (err: any) => {
if (err) console.error(err);
});
return {
ok: r.ok,
data: r.ok ? await (r as Response).json() : (r as any).data
};
}));
if (files.find(v => !v.ok)) {
throw new Error(`File upload failed for file arr: ${JSON.stringify(files)}`)
}
return {
content, files: files.map(({ data: f }) => ({
id: f.id,
type: 'file',
url: `/api/v1/files/${f.id}`,
file: f,
name: f.filename,
status: 'uploaded', // trust, trust
size: f.meta.size,
error: '',
itemId: crypto.randomUUID()
}))
};
}
return { content };
};
const getSystemSettings = async (cookie: string) => {
const r = await makeRequest(cookie, 'api/v1/users/user/settings/'),
{ notifications, system } = r?.length || {};
return { notifications, system };
};
const showErr = async (r: any) => {
console.error('=========================================');
console.error(r);
console.error(await r.text());
console.error('=========================================');
}
// duplicate? Maybe?
const toFeatureFlags = (f: Record<string, boolean> = {}) => ({
image_generation: !!f.image_generation,
code_interpreter: !!f.code_interpreter,
web_search: !!f.web_search,
memory: !!f.memory
});
const splitThink = (raw: string, startPos: number = 0) => {
const open = raw.indexOf('<think>', startPos),
close = raw.indexOf('</think>', startPos);
if (close >= 0) {
const start = open >= 0 ? open + '<think>'.length : 0;
return {
thinking: raw.slice(start, close).trim(),
answer: raw.slice(close + '</think>'.length).trim()
};
}
return { thinking: '', answer: raw.trim(), endPos: close };
};
async function newChat(def: ollamaInp, sysMsg?: string) {
const userId = crypto.randomUUID(),
assistantId = crypto.randomUUID(), // will be reused in completions
now = Date.now();
const userMsg = {
id: userId,
parentId: null,
childrenIds: [assistantId],
role: 'user',
...(await buildPrompt(def)),
timestamp: now,
models: [def.model]
};
const assistantPlaceholder = {
id: assistantId,
parentId: userId,
childrenIds: [],
role: 'assistant',
content: '',
model: def.model,
modelName: def.model,
modelIdx: 0,
timestamp: now
};
const reqObj = {
user_id: def.userId,
title: 'New Chat',
chat: {
id: '',
title: 'New Chat',
models: [def.model],
system: sysMsg || '',
params: {}, // model params???
history: {
messages: {
[userMsg.id]: userMsg,
[assistantPlaceholder.id]: assistantPlaceholder
},
currentId: assistantId // needed for openwebUI stuff
},
messages: [userMsg, assistantPlaceholder],
tags: []
},
share_id: null,
archived: false,
pinned: false,
meta: {},
folder_id: null
};
const r = await fetch(`${base}/api/v1/chats/new`, {
method: 'POST',
headers: {
Authorization: `Bearer ${def.cookie}`,
'Content-Type': 'application/json'
},
body: JSON.stringify(reqObj)
});
if (!r.ok) {
await showErr(r);
throw new Error('Failed to create new chat!');
}
const created = await r.json();
return { chatId: created.id as string, assistantId };
}
export async function callNewChat(def: ollamaInp) {
const { cookie } = def;
if (!cookie) throw new Error('Cookie not found!');
const sysSettings = await getSystemSettings(cookie),
{ name: username } = await makeRequest(cookie, 'api/v1/auths/'),
models = await getModels(cookie),
model = models.find((m: any) => m.id === def.model);
if (!model) throw new Error(`Model ${def.model} not found!`);
const { chatId, assistantId } = def.chatId
? { chatId: def.chatId, assistantId: crypto.randomUUID() } // TODO: if passing an existing chat, insert a placeholder there first (same shape as above)
: await newChat(def, sysSettings.system);
// mock socket ID
const sessionId = crypto.randomUUID(),
completeReq = {
chat_id: chatId,
id: assistantId,
stream: false,
model: def.model,
messages: [
{ role: 'system', content: sysSettings.system || '' },
{ role: 'user', ...(await buildPrompt(def)) }
],
features: toFeatureFlags(def.features),
variables: {
'{{USER_NAME}}': username,
'{{USER_LANGUAGE}}': 'en-US'
},
session_id: sessionId,
background_tasks: { title_generation: true, follow_up_generation: true }
};
// run completion
let r = await fetch(`${base}/api/chat/completions`, {
method: 'POST',
headers: {
Authorization: `Bearer ${cookie}`,
'Content-Type': 'application/json'
},
body: JSON.stringify(completeReq)
});
if (!r.ok) return showErr(r);
// bc stream:false, read the whole json here bc ui still wants a finalizer call
const txt = await r.text();
let content = txt;
try { const j = JSON.parse(txt); content = j?.choices?.[0]?.message?.content ?? txt; } catch { }
const thinkRes = splitThink(content),
answerArr = [(thinkRes.thinking ? `<details id="__DETAIL_${0}__"/>\n` : '') + thinkRes.answer];
let counter = thinkRes.thinking ? 1 : 0,
{ thinking, endPos } = thinkRes;
while (thinking && counter < 10) {
const { thinking: newThink, answer: newAnswer, endPos: newEndPos } = splitThink(content, endPos);
thinking = newThink;
if (thinking) {
answerArr.push(`<details id="__DETAIL_${counter}__"/>\n` + newAnswer);
endPos = newEndPos;
}
else break;
counter++;
}
const answer = answerArr.join('\n')
// fetch current chat, replace the assistant placeholder content with `answer`
const chat = await (await fetch(`${base}/api/v1/chats/${chatId}`, {
headers: { Authorization: `Bearer ${def.cookie}` }
})).json();
chat.chat.history.messages[assistantId].content = answer;
const idx = chat.chat.messages.findIndex((m: any) => m.id === assistantId);
if (idx >= 0) chat.chat.messages[idx].content = answer;
console.log(JSON.stringify(chat));
r = await fetch(`${base}/api/v1/chats/${chatId}`, {
method: 'POST',
headers: { Authorization: `Bearer ${def.cookie}`, 'Content-Type': 'application/json' },
body: JSON.stringify({ chat: chat.chat })
});
// now finalize
r = await fetch(`${base}/api/chat/completed`, {
method: 'POST',
headers: { Authorization: `Bearer ${def.cookie}`, 'Content-Type': 'application/json' },
body: JSON.stringify({ chat_id: chatId, id: assistantId, session_id: sessionId, model: def.model })
});
console.log('completed:', await r.text());
}
-97
View File
@@ -1,97 +0,0 @@
import http from 'http'
import { readBodyJson } from '../server';
import { getModels, getTools } from './ollamaCalls';
export const base = 'http://open-webui:8080';
type Me = {
id: string;
email: string;
name: string;
role: string;
profile_image_url: string;
token: string;
token_type: string;
expires_at: string | null;
permissions: {
workspace: {
models: boolean;
knowledge: boolean;
prompts: boolean;
tools: boolean;
};
features: {
direct_tool_servers: boolean;
web_search: boolean;
image_generation: boolean;
code_interpreter: boolean;
notes: boolean;
};
};
};
export default async function loginUser(req: http.IncomingMessage, res: http.ServerResponse) {
const { email, password } = await readBodyJson(req);
if (!email || !password) {
res.writeHead(400).end(JSON.stringify({ error: "email or password not sent" }));
return;
}
// login with username/password
const loginRes = await fetch(`${base}/api/v1/auths/signin`, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ email, password }),
});
if (!loginRes.ok) {
console.error("error logging in", await loginRes.text());
res.writeHead(401).end();
return
}
const upstreamCookie = loginRes.headers.get("set-cookie"),
user = await loginRes.json();
// forward Set-Cookie to the browser so it stores the cookie
const outHeaders: Record<string, string | string[]> = {
"content-type": "application/json",
};
if (upstreamCookie) {
outHeaders["Set-Cookie"] = upstreamCookie;
}
res.writeHead(200, outHeaders).end(JSON.stringify({ user }));
}
export async function getUser(req: http.IncomingMessage, res: http.ServerResponse) {
if (!req.headers.cookie) {
return res.writeHead(401).end("Not logged in");
}
const cookies = Object.fromEntries(req.headers.cookie.split(';').map(c => c.split('=').map(o => o.trim())));
if (!('token' in cookies)) {
return res.writeHead(401).end("Not logged in");
}
const uRes = await fetch(`${base}/api/v1/auths/`, {
method: "GET",
headers: {
"content-type": "application/json",
'Authorization': `Bearer ${cookies['token']}`
},
});
if (!uRes.ok) {
console.error("Error getting user", await uRes.text());
return res.writeHead(401).end();
}
const uObj = await uRes.json();
uObj.models = await getModels(cookies['token']);
uObj.tools = await getTools(cookies['token']);
res.writeHead(200).end(JSON.stringify(uObj));
}
-357
View File
@@ -1,357 +0,0 @@
{
"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"
}
}
}
}
+1 -6
View File
@@ -8,12 +8,7 @@
"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"
"@types/node": "^24.3.3"
}
}
-212
View File
@@ -1,212 +0,0 @@
:root {
--fc-surface: #0f2219;
--fc-surface-2: #103220;
--fc-text: #e8f9f0;
--fc-muted: #7dc9a5;
--fc-accent: #2dd282;
--fc-accent-soft: rgba(45, 210, 130, 0.18);
--fc-border: rgba(125, 201, 165, 0.45);
--fc-border-strong: rgba(45, 210, 130, 0.65);
--fc-overlay: rgba(4, 18, 12, 0.72);
}
.feature-section {
margin-top: 12px;
display: grid;
gap: 6px;
}
.feature-section-header {
display: flex;
flex-direction: column;
gap: 2px;
}
.feature-section-header h3 {
margin: 0;
font-size: 1rem;
}
.feature-pill-group {
display: flex;
flex-wrap: wrap;
gap: 8px;
padding: 6px 0;
min-height: 40px;
}
.feature-pill {
display: inline-flex;
align-items: center;
gap: 4px;
border-radius: 999px;
border: 1px solid var(--fc-border);
background: var(--fc-accent-soft);
color: inherit;
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.07);
transition: background 0.2s ease, border-color 0.2s ease, box-shadow 0.2s ease;
}
.feature-pill--selected {
background: color-mix(in srgb, var(--fc-accent), transparent 68%);
border-color: var(--fc-border-strong);
box-shadow: 0 2px 6px rgba(21, 94, 50, 0.22);
}
.feature-pill-toggle {
appearance: none;
border: none;
background: transparent;
color: inherit;
font: inherit;
padding: 6px 12px;
border-radius: 999px;
cursor: pointer;
flex: 1 1 auto;
text-align: left;
}
.feature-pill-toggle:focus-visible,
.feature-pill-info:focus-visible {
outline: 2px solid color-mix(in srgb, var(--fc-accent), white 25%);
outline-offset: 2px;
}
.feature-pill-info {
appearance: none;
border: none;
background: transparent;
color: var(--fc-muted);
width: 28px;
height: 28px;
border-radius: 50%;
display: inline-flex;
align-items: center;
justify-content: center;
cursor: pointer;
margin-right: 4px;
}
.feature-pill-info:hover {
color: var(--fc-text);
background: rgba(45, 210, 130, 0.16);
}
.feature-pill-info span {
display: inline-block;
font-size: 0.9rem;
font-weight: 700;
}
/* Modal styles */
.fc-overlay {
position: fixed;
inset: 0;
display: grid;
place-items: center;
padding: clamp(12px, 2vw, 24px);
background: var(--fc-overlay);
z-index: 9999;
}
.fc-dialog {
width: min(520px, 100%);
max-height: 90dvh;
overflow: auto;
border-radius: 12px;
background: linear-gradient(180deg, var(--fc-surface), var(--fc-surface-2));
color: var(--fc-text);
box-shadow: 0 12px 30px rgba(0, 0, 0, 0.45);
border: 1px solid rgba(125, 201, 165, 0.35);
}
.fc-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 16px 20px;
border-bottom: 1px solid rgba(125, 201, 165, 0.25);
position: sticky;
top: 0;
backdrop-filter: blur(6px);
background:
linear-gradient(to bottom, color-mix(in srgb, var(--fc-surface), transparent 30%), color-mix(in srgb, var(--fc-surface-2), transparent 35%)),
conic-gradient(from 0.35turn at 12% -30%, color-mix(in srgb, var(--fc-accent), transparent 80%), transparent 40%);
z-index: 1;
}
.fc-titlewrap {
display: flex;
align-items: center;
gap: 10px;
}
.fc-title {
margin: 0;
font-size: 1.1rem;
font-weight: 700;
}
.fc-tag {
display: inline-flex;
align-items: center;
justify-content: center;
padding: 4px 10px;
border-radius: 999px;
font-size: 0.75rem;
font-weight: 700;
letter-spacing: 0.04em;
text-transform: uppercase;
color: #06361d;
background: color-mix(in srgb, var(--fc-accent), white 38%);
border: 1px solid color-mix(in srgb, var(--fc-accent), black 10%);
}
.fc-close {
appearance: none;
border: 1px solid transparent;
background: transparent;
color: var(--fc-muted);
font-size: 1.6rem;
line-height: 1;
border-radius: 8px;
padding: 4px 8px;
cursor: pointer;
}
.fc-close:hover {
color: var(--fc-text);
border-color: rgba(125, 201, 165, 0.4);
background: rgba(45, 210, 130, 0.16);
}
.fc-body {
padding: 20px;
display: grid;
gap: 18px;
}
.fc-desc {
margin: 0;
color: var(--fc-text);
font-size: 0.95rem;
line-height: 1.4;
}
.fc-details {
display: grid;
grid-template-columns: max-content 1fr;
gap: 6px 12px;
margin: 0;
}
.fc-details dt {
font-weight: 600;
color: var(--fc-muted);
}
.fc-details dd {
margin: 0;
font-weight: 500;
}
-258
View File
@@ -1,258 +0,0 @@
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 };
}
+166 -228
View File
@@ -1,155 +1,75 @@
<!doctype html>
<!DOCTYPE html>
<html lang="en">
<head>
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Schedules • Task & Workflow Manager</title>
<title>Schedules UI</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>
</head>
<body>
<h1>Manage Your Tasks and Follow-Ups!</h1>
<body>
<!-- app shell header -->
<header class="app-header">
<div class="brand">
<div class="logo" aria-hidden="true">⏱️</div>
<div class="titles">
<h1>Schedules</h1>
<p class="subtitle">manage your tasks and followups</p>
</div>
</div>
<!-- theme toggle kept with same ids so your code still works -->
<button id="themeToggle" class="theme-toggle card compact" aria-pressed="false" title="toggle dark / light">
<span id="themeToggleIcon" aria-hidden="true">🌙</span>
<span id="themeToggleLabel">Dark</span>
</button>
</header>
<!-- content grid -->
<main class="content-grid">
<!-- left column: auth + run now -->
<aside class="stack col-left">
<!-- user summary card (shows when logged in) -->
<section class="card user-card" id="userCard" aria-hidden="true">
<header class="card-header">
<h2 style="margin-top: 0px;">Account</h2>
</header>
<div class="user-compact">
<img id="userAvatar" class="avatar" src="" alt="" aria-hidden="true" />
<div class="user-info">
<div id="userName" class="user-name muted">not logged in</div>
<div id="userEmail" class="muted user-email"></div>
<div id="userRole" class="muted user-role"></div>
</div>
</div>
<!-- permissions summary -->
<div id="userPermissions" class="permissions" aria-live="polite" hidden>
<h3 class="small">Permissions</h3>
<ul class="perms-list">
<li>
<span class="perm-key">Workspace</span>
<ul class="nested-perms">
<li>
<span class="perm-val" data-perm="workspace.tools"></span>
<span class="perm-key">Tools</span>
</li>
</ul>
</li>
<li>
<span class="perm-key">Chat</span>
<ul class="nested-perms">
<li>
<span class="perm-val" data-perm="chat.file_upload"></span>
<span class="perm-key">File Upload</span>
</li>
</ul>
</li>
<li>
<span class="perm-key">Features</span>
<ul class="nested-perms">
<li>
<span class="perm-val" data-perm="features.web_search"></span>
<span class="perm-key">Web Search</span>
</li>
<li>
<span class="perm-val" data-perm="features.code_interpreter"></span>
<span class="perm-key">Code Interpreter</span>
</li>
</ul>
</li>
</ul>
</div>
<div class="actions">
<button id="userLogoutBtn" aria-variant="danger">Logout</button>
</div>
</section>
<!-- login card (ids preserved) -->
<!-- login card -->
<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>
<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>
<!-- 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>
<form id="loginForm">
<div class="row">
<div>
<label for="userId">user id (uuid)</label>
<input
id="userId"
name="userId"
type="text"
required
placeholder="e.g. 5a8d1d7e-..." />
</div>
<div>
<label for="displayName">display name (optional)</label>
<input
id="displayName"
name="displayName"
type="text"
placeholder="your name" />
</div>
</div>
<div class="actions" style="margin-top: 0.75rem">
<button type="submit">save & set header</button>
<button type="button" id="logoutBtn">logout</button>
</div>
<p id="authStatus" class="muted"></p>
</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"
style="justify-content: space-between; align-items: center">
<h2 style="margin: 0">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">
</div>
<div
id="listStatus"
class="muted"
style="margin: 0.4rem 0 0.6rem"></div>
<div style="overflow: auto">
<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>
<th>name</th>
<th>schedules</th>
<th>tz</th>
<th>template</th>
<th>entrypoint</th>
<th>one-shot</th>
<th>actions</th>
</tr>
</thead>
<tbody id="schedulesTbody"></tbody>
@@ -159,140 +79,158 @@
<!-- create/update schedule -->
<section class="card">
<header class="card-header">
<h2>create / update schedule</h2>
</header>
<form id="createForm" class="form-stack">
<form id="createForm">
<div class="row">
<div>
<label for="name">name</label>
<input id="name" name="name" type="text" required placeholder="daily-report" />
<input
id="name"
name="name"
type="text"
required
placeholder="daily-report" />
</div>
<div>
<label for="tz">timezone</label>
<input
id="tz"
name="tz"
type="text"
value="America/New_York" />
</div>
</div>
<div class="row">
<div>
<label for="iso"
>run at (iso datetime, or leave empty if using
cron)</label
>
<input id="iso" name="iso" type="datetime-local" />
</div>
<div>
<label for="cron"
>cron (min hour day month *), if not using
iso</label
>
<input
id="cron"
name="cron"
type="text"
placeholder="30 9 * * *" />
</div>
</div>
<div class="row">
<div>
<label for="templateName">workflow template</label>
<input id="templateName" name="templateName" type="text" required
<input
id="templateName"
name="templateName"
type="text"
required
placeholder="report-template" />
</div>
<div>
<label for="entrypoint">entrypoint (optional)</label>
<input
id="entrypoint"
name="entrypoint"
type="text"
placeholder="main" />
</div>
</div>
<div class="row">
<div>
<label for="startAt">start at (optional)</label>
<input id="startAt" name="startAt" type="datetime-local" />
<label
><input id="clusterScope" type="checkbox" />
template is cluster-scoped</label
>
</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>
<label
><input id="oneShot" type="checkbox" /> stop after
first success (one-shot)</label
>
</div>
</div>
<div>
<label for="params">parameters (json object)</label>
<div id="params" name="params" placeholder=''></div>
<textarea
id="params"
name="params"
placeholder='{"report_kind":"summary"}'></textarea>
<div class="actions" style="margin-top: 0.75rem">
<button type="submit">upsert schedule</button>
<button type="button" id="loadTemplatesBtn">
load workflow templates
</button>
</div>
<div
id="createStatus"
class="muted"
style="margin-top: 0.5rem"></div>
</form>
<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">
<details style="margin-top: 0.75rem">
<summary class="muted">available workflow templates</summary>
<ul id="templatesUl" class="muted"></ul>
</details>
</div>
</div>
</section>
<!-- run now -->
<section class="card">
<h2>run now</h2>
<form id="runNowForm">
<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>
<label for="rnName">name (label only)</label>
<input
id="rnName"
type="text"
placeholder="ad-hoc-run" />
</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>
<label for="rnTemplateName">workflow template</label>
<input
id="rnTemplateName"
type="text"
placeholder="report-template"
required />
</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 class="row">
<div>
<hr style="width: 100%;">
<label for="rnEntrypoint">entrypoint (optional)</label>
<input
id="rnEntrypoint"
type="text"
placeholder="main" />
</div>
<div>
<label
><input id="rnClusterScope" type="checkbox" />
template is cluster-scoped</label
>
</div>
</div>
<label for="rnParams">parameters (json object)</label>
<textarea
id="rnParams"
placeholder='{"report_kind":"summary"}'></textarea>
<div class="actions 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 class="actions" style="margin-top: 0.75rem">
<button type="submit">run now</button>
</div>
<div
id="runNowStatus"
class="muted"
style="margin-top: 0.5rem"></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>
<script type="module" src="script.js"></script>
</body>
</html>
-340
View File
@@ -1,340 +0,0 @@
.mc-overlay {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.55);
display: grid;
place-items: center;
padding: clamp(12px, 2vw, 24px);
z-index: 9999;
}
.mc-dialog {
width: min(880px, 100%);
max-height: 90dvh;
overflow: auto;
border-radius: 12px;
background: #0b0b0c;
color: #e8e8ea;
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.5);
outline: none;
}
.mc-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 16px 20px;
border-bottom: 1px solid rgba(255, 255, 255, 0.08);
position: sticky;
top: 0;
backdrop-filter: blur(6px);
background: linear-gradient(to bottom, rgba(11, 11, 12, 0.9), rgba(11, 11, 12, 0.7));
z-index: 1;
}
.mc-titlewrap {
display: flex;
align-items: center;
gap: 12px;
min-width: 0;
}
.mc-avatar {
width: 36px;
height: 36px;
border-radius: 50%;
object-fit: cover;
border: 1px solid rgba(255, 255, 255, 0.1);
background: #1a1a1d;
}
.mc-avatar--placeholder {
background: linear-gradient(135deg, #2a2a2e, #1e1e22);
}
.mc-title {
font-size: 1.1rem;
font-weight: 600;
margin: 0;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.mc-subtitle {
font-size: 0.85rem;
color: #b8b8bf;
}
.mc-close {
appearance: none;
border: none;
background: transparent;
color: #bfbfc7;
font-size: 1.6rem;
line-height: 1;
border-radius: 8px;
padding: 4px 8px;
cursor: pointer;
}
.mc-close:hover {
color: #ffffff;
background: rgba(255, 255, 255, 0.06);
}
.mc-body {
padding: 20px;
display: grid;
gap: 18px;
}
.mc-desc {
margin: 0;
color: #d8d8de;
}
.mc-badges {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.mc-badge {
font-size: 0.78rem;
padding: 6px 10px;
border-radius: 999px;
background: rgba(255, 255, 255, 0.06);
border: 1px solid rgba(255, 255, 255, 0.08);
color: #e8e8ea;
}
.mc-section {
display: grid;
gap: 10px;
}
.mc-section-title {
font-size: 0.95rem;
font-weight: 600;
color: #f0f0f3;
margin: 0;
}
.mc-capabilities {
list-style: none;
display: grid;
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
gap: 8px 14px;
padding: 0;
margin: 0;
}
.mc-cap {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 10px 12px;
border-radius: 8px;
background: rgba(255, 255, 255, 0.04);
border: 1px solid rgba(255, 255, 255, 0.06);
}
.mc-cap-label {
color: #d8d8de;
font-size: 0.92rem;
}
.mc-cap-state {
font-size: 0.82rem;
padding: 4px 8px;
border-radius: 999px;
border: 1px solid rgba(255, 255, 255, 0.12);
background: rgba(255, 255, 255, 0.06);
}
.mc-cap-state.is-yes {
background: rgba(16, 185, 129, 0.22);
border-color: rgba(16, 185, 129, 0.35);
}
.mc-cap-state.is-no {
background: rgba(239, 68, 68, 0.22);
border-color: rgba(239, 68, 68, 0.35);
}
.mc-table {
width: 100%;
border-collapse: collapse;
overflow: hidden;
border-radius: 10px;
background: rgba(255, 255, 255, 0.03);
border: 1px solid rgba(255, 255, 255, 0.06);
}
.mc-table th,
.mc-table td {
text-align: left;
padding: 10px 12px;
border-bottom: 1px solid rgba(255, 255, 255, 0.06);
vertical-align: top;
}
.mc-table th {
width: 30%;
color: #bfc0c7;
font-weight: 500;
}
.mc-links {
list-style: none;
padding: 0;
margin: 0;
display: grid;
gap: 6px;
}
.mc-links a {
color: #a6c8ff;
text-decoration: none;
word-break: break-all;
}
.mc-links a:hover {
text-decoration: underline;
}
/* disclosure (advanced details) */
.mc-disclosure {
border: 1px solid rgba(255, 255, 255, 0.08);
border-radius: 10px;
background: rgba(255, 255, 255, 0.03);
overflow: hidden;
}
.mc-disclosure+.mc-disclosure {
margin-top: 8px;
}
.mc-disclosure-summary {
cursor: pointer;
list-style: none;
user-select: none;
padding: 12px 14px;
font-weight: 600;
color: #f0f0f3;
}
.mc-disclosure[open] .mc-disclosure-summary {
border-bottom: 1px solid rgba(255, 255, 255, 0.08);
}
.mc-disclosure-body {
padding: 12px 14px;
display: grid;
gap: 16px;
}
/* nested disclosure for raw json */
.mc-disclosure--nested {
border: 1px dashed rgba(255, 255, 255, 0.14);
background: rgba(255, 255, 255, 0.02);
}
.mc-disclosure--nested .mc-disclosure-summary {
font-weight: 500;
color: #d8d8de;
}
.mc-pre {
margin: 0;
padding: 12px;
overflow: auto;
max-height: 40dvh;
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, "Liberation Mono", monospace;
font-size: 0.82rem;
line-height: 1.4;
border-radius: 8px;
background: #0f0f12;
border: 1px solid rgba(255, 255, 255, 0.08);
}
/* responsive adjustments */
@media (max-width: 520px) {
.mc-header {
padding: 14px 16px;
}
.mc-body {
padding: 16px;
}
.mc-title {
font-size: 1rem;
}
}
.selected-model-card {
margin-top: 0.5rem;
width: 100%;
display: grid;
gap: 0.25rem;
padding: 0.75rem 1rem;
border-radius: var(--radius-md);
border: 1px solid color-mix(in srgb, var(--border), transparent 30%);
background: linear-gradient(180deg,
color-mix(in srgb, var(--accent) 12%, transparent),
color-mix(in srgb, var(--card) 92%, transparent));
color: inherit;
font: inherit;
text-align: left;
cursor: pointer;
transition: border-color 0.2s ease, box-shadow 0.2s ease, transform 0.2s ease, background 0.2s ease;
outline: none;
position: relative;
}
.selected-model-card:hover {
border-color: color-mix(in srgb, var(--accent) 45%, var(--border));
box-shadow: 0 6px 20px rgba(15, 27, 51, 0.35);
transform: translateY(-1px);
}
.selected-model-card:focus-visible {
outline: 2px solid color-mix(in srgb, var(--accent) 65%, white 20%);
outline-offset: 2px;
box-shadow: 0 0 0 2px color-mix(in srgb, var(--accent) 25%, transparent);
}
.selected-model-title {
font-weight: 600;
font-size: 0.95rem;
letter-spacing: -0.01em;
}
.selected-model-meta {
font-size: 0.8rem;
color: var(--muted);
}
.selected-model-desc {
font-size: 0.78rem;
color: color-mix(in srgb, var(--muted) 85%, var(--text));
opacity: 0.92;
}
.selected-model-hint {
margin-top: 0.1rem;
font-size: 0.75rem;
color: color-mix(in srgb, var(--accent) 70%, var(--text));
display: inline-flex;
align-items: center;
gap: 0.25rem;
}
.selected-model-hint::after {
content: '↗';
font-size: 0.75rem;
}
-441
View File
@@ -1,441 +0,0 @@
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);
}
+101 -459
View File
@@ -1,19 +1,8 @@
const storedMe = (() => {
try {
const raw = localStorage.getItem("me") ?? localStorage.getItem("user");
return raw ? JSON.parse(raw) : null;
} catch {
return null;
}
})();
const state = {
me: storedMe,
userId: localStorage.getItem("userId") || "",
displayName: localStorage.getItem("displayName") || "",
};
/**
* @returns {HTMLElement}
*/
const $ = (sel) => document.querySelector(sel),
setText = (sel, v) => {
const el = $(sel);
@@ -25,314 +14,36 @@ const authStatusEl = $("#authStatus"),
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 } });
templatesUl = $("#templatesUl");
// 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';
function paintAuth() {
$("#userId").value = state.userId || "";
$("#displayName").value = state.displayName || "";
if (state.userId) {
authStatusEl.textContent = `logged in as ${state.displayName ? state.displayName + " · " : ""
}${state.userId}`;
} 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
// wrap fetch to always attach x-user-id
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)) {
if (!state.userId)
throw new Error(
"no user id set — use the login form first"
);
headers.set("x-user-id", state.userId); // custom header
if (
!headers.has("content-type") &&
options.body &&
!(options.body instanceof FormData)
) {
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
@@ -350,117 +61,67 @@ async function apiFetch(url, options = {}) {
function renderSchedules(items = []) {
schedulesTbody.innerHTML = "";
items.forEach((it) => {
const tr = document.createElement("tr"),
tRef = it.templateRef
const tr = document.createElement("tr");
const 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(it.timezone || "")}</td>
<td>${escapeHtml(tRef)}</td>
<td>${escapeHtml(it.prompt || "")}</td>
<td>${escapeHtml(it.entrypoint || "")}</td>
<td>${it.oneShot ? "yes" : "no"}</td>
<td class="actions">
<button data-del="${encodeURIComponent(it.name)}">delete</button>
</td>
`;
schedulesTbody.appendChild(tr);
});
}
// tiny escape helper
function escapeHtml(s = "") {
return String(s).replaceAll("&", "&amp;").replaceAll("<", "&lt;").replaceAll(">", "&gt;");
return String(s)
.replaceAll("&", "&amp;")
.replaceAll("<", "&lt;")
.replaceAll(">", "&gt;");
}
// wire up events
$("#loginForm").addEventListener("submit", async (e) => {
$("#loginForm").addEventListener("submit", (e) => {
e.preventDefault();
const username = $("#username").value.trim(),
password = $("#password").value;
if (!username || !password) {
authStatusEl.textContent = "please enter username and password";
const userId = $("#userId").value.trim(),
displayName = $("#displayName").value.trim();
if (!userId) {
authStatusEl.textContent = "please enter a user id";
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;
state.userId = userId;
state.displayName = displayName;
localStorage.setItem("userId", state.userId);
localStorage.setItem("displayName", state.displayName);
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;
$("#logoutBtn").addEventListener("click", () => {
localStorage.removeItem("userId");
localStorage.removeItem("displayName");
state.userId = "";
state.displayName = "";
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();
const res = await apiFetch("/api/schedules");
const data = await res.json();
renderSchedules(data.items || []);
listStatusEl.textContent = `loaded ${Array.isArray(data.items) ? data.items.length : 0} schedule(s)`;
listStatusEl.textContent = `loaded ${Array.isArray(data.items) ? data.items.length : 0
} schedule(s)`;
} catch (e) {
listStatusEl.textContent = `error: ${e.message}`;
}
@@ -476,7 +137,7 @@ schedulesTbody.addEventListener("click", async (e) => {
try {
target.disabled = true;
const res = await apiFetch(`/api/schedules/${name}`, {
const res = await apiFetch(`/schedules/${name}`, {
method: "DELETE",
});
@@ -499,36 +160,17 @@ $("#createForm").addEventListener("submit", async (e) => {
try {
createStatusEl.textContent = "saving...";
const name = $("#name").value.trim(),
startAt = startAtInput?.value || "",
tz = $("#tz").value.trim() || "America/New_York",
iso = $("#iso").value
? new Date($("#iso").value).toISOString()
: "",
cron = $("#cron").value.trim(),
templateName = $("#templateName").value.trim(),
prompt = $("#prompt").value.trim(),
entrypoint = $("#entrypoint").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");
paramsRaw = $("#params").value.trim();
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 {
@@ -538,62 +180,64 @@ $("#createForm").addEventListener("submit", async (e) => {
}
}
let features = {};
if (featuresInput && featuresInput.value) {
try {
const parsed = JSON.parse(featuresInput.value);
if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
features = Object.fromEntries(Object.entries(parsed).map(([k, v]) => [k, Boolean(v)]));
}
} catch {
throw new Error("features selection must be valid json");
}
}
const attachments = filesInput?.files ? Array.from(filesInput.files) : [];
const filesPayload = await Promise.all(attachments.map(async (file) => ({
fname: file.name,
content: await readFileAsDataUrl(file)
})));
const when = oneShot ? { start: startAt } : { cron };
if (!oneShot && startAt) when.start = startAt;
const payload = {
name,
when,
when: cron ? { cron } : { iso },
tz,
oneShot,
template: { name: templateName, clusterScope },
parameters,
prompt,
model,
tools,
features
entrypoint: entrypoint || undefined,
};
if (filesPayload.length) payload.files = filesPayload;
await apiFetch("/api/schedules", {
await apiFetch("/schedules", {
method: "POST",
body: JSON.stringify(payload),
});
createStatusEl.textContent = "saved ✅";
$("#refreshBtn").click();
// clear the form
$("#createForm").reset();
togCron({ target: { checked: $("#oneShot").checked } });
renderSelectedFiles();
if (cronError) {
cronError.style.display = 'none';
cronError.textContent = '';
}
} catch (err) {
console.error(err);
createStatusEl.textContent = `error: ${err.message}`;
}
});
// run now
$("#runNowForm").addEventListener("submit", async (e) => {
e.preventDefault();
try {
runNowStatusEl.textContent = "starting...";
const name = $("#rnName").value.trim() || "ad-hoc",
templateName = $("#rnTemplateName").value.trim(),
entrypoint = $("#rnEntrypoint").value.trim(),
clusterScope = $("#rnClusterScope").checked,
paramsRaw = $("#rnParams").value.trim();
let parameters = {};
if (paramsRaw) {
try {
parameters = JSON.parse(paramsRaw);
} catch {
throw new Error("parameters must be valid json");
}
}
const payload = {
name,
template: { name: templateName, clusterScope },
entrypoint: entrypoint || undefined,
parameters,
};
await apiFetch("/run-now", {
method: "POST",
body: JSON.stringify(payload),
});
runNowStatusEl.textContent = "started ✅";
} catch (err) {
runNowStatusEl.textContent = `error: ${err.message}`;
}
});
// load workflow templates for convenience
$("#loadTemplatesBtn").addEventListener("click", async () => {
@@ -610,16 +254,14 @@ $("#loadTemplatesBtn").addEventListener("click", async () => {
templatesUl.appendChild(li);
});
} catch (e) {
templatesUl.innerHTML = `<li class="danger">error: ${escapeHtml(e.message)}</li>`;
templatesUl.innerHTML = `<li class="danger">error: ${escapeHtml(
e.message
)}</li>`;
}
});
// 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);
paintAuth();
// auto-refresh if already logged in
if (state.userId) $("#refreshBtn").click();
+45 -573
View File
@@ -1,260 +1,25 @@
: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%;
color-scheme: light dark;
font-family: system-ui, sans-serif;
}
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;
margin: 2rem;
display: grid;
place-items: center;
background: color-mix(in srgb, var(--accent) 12%, transparent);
gap: 1.5rem;
max-width: 980px;
}
form,
.card {
border: 1px solid #ccc;
padding: 1rem;
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"] {
label {
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);
margin: 0.25rem 0 0.15rem;
}
input[type="text"],
@@ -262,363 +27,70 @@ 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);
padding: 0.5rem;
border-radius: 8px;
border: 1px solid #bbb;
}
textarea {
min-height: 120px;
font-family: ui-monospace, Menlo, "Roboto Mono", SFMono-Regular, monospace;
min-height: 96px;
font-family: ui-monospace, Menlo, monospace;
}
.checkbox label {
font-weight: 500;
display: inline-flex;
gap: .5rem;
align-items: center;
}
/* buttons */
button {
padding: 0.6rem 0.9rem;
padding: 0.55rem 0.9rem;
border-radius: 10px;
border: 1px solid rgba(2, 6, 23, 0.1);
border: 1px solid #888;
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);
font-size: 0.95rem;
}
th,
td {
padding: .75rem .9rem;
border-bottom: 1px dashed var(--border);
vertical-align: middle;
padding: 0.5rem 0.6rem;
border-bottom: 1px solid #ddd;
text-align: left;
}
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;
.row {
display: grid;
gap: 0.75rem;
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.muted {
color: var(--muted);
opacity: .95;
opacity: 0.75;
font-size: 0.92rem;
}
.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);
.danger {
color: #a30000;
}
/* 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;
.ok {
color: #008000;
}
/* 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 {
.actions {
display: flex;
gap: .5rem;
align-items: center;
gap: 0.5rem;
flex-wrap: wrap;
}
.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);
@media (max-width: 800px) {
.row {
grid-template-columns: 1fr;
}
}
/* 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;
code.inline {
padding: 0.15rem 0.3rem;
border: 1px solid #ddd;
border-radius: 8px;
background: #f7f7f7;
}
-382
View File
@@ -1,382 +0,0 @@
:root {
/* core palette tuned for strong contrast on very dark ui (aiming for wcag aa) */
--tc-bg: #120a09;
/* deep near-black with red tint */
--tc-surface: #1a0e0b;
/* card background */
--tc-surface-2: #21110e;
/* subtle layered bg */
--tc-text: #f3e9e7;
/* primary text */
--tc-text-subtle: #913413;
/* secondary text */
--tc-muted: #cbb2aa;
--tc-accent: #ff6b3d;
/* orange/red accent */
--tc-accent-2: #ff3d3d;
/* red accent for states */
--tc-border: rgba(255, 255, 255, 0.10);
--tc-border-2: rgba(255, 255, 255, 0.16);
--tc-overlay: rgba(0, 0, 0, 0.55);
--tc-link: #ffb083;
/* readable on dark bg */
}
.tc-overlay {
position: fixed;
inset: 0;
background: var(--tc-overlay);
display: grid;
place-items: center;
padding: clamp(12px, 2vw, 24px);
z-index: 9999;
}
.tc-dialog {
width: min(880px, 100%);
max-height: 90dvh;
overflow: auto;
border-radius: 12px;
background: linear-gradient(180deg, var(--tc-surface), var(--tc-surface-2));
color: var(--tc-text);
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.6);
outline: none;
border: 1px solid var(--tc-border);
}
.tc-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 16px 20px;
border-bottom: 1px solid var(--tc-border);
position: sticky;
top: 0;
backdrop-filter: blur(6px);
background:
linear-gradient(to bottom, color-mix(in srgb, var(--tc-surface), transparent 20%), color-mix(in srgb, var(--tc-surface-2), transparent 30%)),
conic-gradient(from 0.25turn at 10% -40%, color-mix(in srgb, var(--tc-accent), transparent 85%), transparent 30%);
z-index: 1;
}
.tc-titlewrap {
display: flex;
align-items: center;
gap: 12px;
min-width: 0;
}
.tc-avatar {
width: 36px;
height: 36px;
border-radius: 50%;
object-fit: cover;
border: 1px solid var(--tc-border-2);
background: #2a1512;
}
.tc-avatar--placeholder {
background: linear-gradient(135deg, #3b1e19, #2a1512);
}
.tc-title {
font-size: 1.1rem;
font-weight: 700;
margin: 0;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.tc-subtitle {
font-size: 0.85rem;
color: var(--tc-muted);
}
.tc-close {
appearance: none;
border: 1px solid transparent;
background: transparent;
color: var(--tc-muted);
font-size: 1.6rem;
line-height: 1;
border-radius: 8px;
padding: 4px 8px;
cursor: pointer;
}
.tc-close:hover {
color: var(--tc-text);
border-color: var(--tc-border-2);
background: color-mix(in srgb, var(--tc-accent), transparent 86%);
}
.tc-body {
padding: 20px;
display: grid;
gap: 18px;
}
.tc-desc {
margin: 0;
color: var(--tc-text);
}
.tc-badges {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.tc-badge {
font-size: 0.78rem;
padding: 6px 10px;
border-radius: 999px;
background: color-mix(in srgb, var(--tc-accent), transparent 88%);
border: 1px solid var(--tc-border-2);
color: var(--tc-text);
}
.tc-section {
display: grid;
gap: 10px;
}
.tc-section-title {
font-size: 0.95rem;
font-weight: 700;
color: var(--tc-text);
margin: 0;
}
.tc-manifest {
list-style: none;
display: grid;
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
gap: 8px 14px;
padding: 0;
margin: 0;
}
.tc-manifest-item {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 10px 12px;
border-radius: 8px;
background: rgba(255, 255, 255, 0.04);
border: 1px solid var(--tc-border);
}
.tc-manifest-item a {
color: #4da6ff;
text-decoration: none;
}
.tc-manifest-item a:hover {
color: #80c1ff;
text-decoration: underline;
}
.tc-manifest-label {
color: var(--tc-text-subtle);
font-size: 0.92rem;
}
.tc-manifest-value {
font-size: 0.86rem;
padding: 2px 8px;
border-radius: 999px;
border: 1px solid var(--tc-border-2);
background: rgba(255, 255, 255, 0.06);
overflow-x: scroll;
}
.tc-table {
width: 100%;
border-collapse: collapse;
overflow: hidden;
border-radius: 10px;
background: rgba(255, 255, 255, 0.03);
border: 1px solid var(--tc-border);
}
.tc-table th,
.tc-table td {
text-align: left;
padding: 10px 12px;
border-bottom: 1px solid var(--tc-border);
vertical-align: top;
}
.tool-pill-group {
display: flex;
flex-wrap: wrap;
gap: 8px;
padding: 6px 0;
min-height: 40px;
}
.tool-pill {
display: inline-flex;
align-items: center;
gap: 4px;
border: 1px solid var(--tc-border);
border-radius: 999px;
background: rgba(255, 255, 255, 0.04);
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.08);
transition: background 0.2s ease, border-color 0.2s ease, box-shadow 0.2s ease;
}
.tool-pill--selected {
background: color-mix(in srgb, var(--tc-accent), transparent 70%);
border-color: color-mix(in srgb, var(--tc-accent), transparent 40%);
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.15);
}
.tool-pill-toggle {
appearance: none;
border: none;
background: transparent;
color: inherit;
font: inherit;
padding: 6px 12px;
border-radius: 999px;
cursor: pointer;
flex: 1 1 auto;
text-align: left;
}
.tool-pill-toggle:focus-visible,
.tool-pill-info:focus-visible {
outline: 2px solid color-mix(in srgb, var(--tc-accent), white 35%);
outline-offset: 2px;
}
.tool-pill-info {
appearance: none;
border: none;
background: transparent;
color: var(--tc-muted);
width: 28px;
height: 28px;
border-radius: 50%;
display: inline-flex;
align-items: center;
justify-content: center;
cursor: pointer;
margin-right: 4px;
}
.tool-pill-info:hover {
color: var(--tc-text);
background: rgba(255, 255, 255, 0.08);
}
.tool-pill-info span {
display: inline-block;
font-size: 0.9rem;
font-weight: 700;
}
.tool-pill-empty {
margin: 0;
}
.tc-table th {
width: 30%;
color: var(--tc-muted);
font-weight: 600;
}
.tc-links {
list-style: none;
padding: 0;
margin: 0;
display: grid;
gap: 6px;
}
.tc-links a {
color: var(--tc-link);
text-decoration: none;
word-break: break-all;
}
.tc-links a:hover {
text-decoration: underline;
}
/* disclosure (advanced details) */
.tc-disclosure {
border: 1px solid var(--tc-border);
border-radius: 10px;
background: rgba(255, 255, 255, 0.03);
overflow: hidden;
}
.tc-disclosure+.tc-disclosure {
margin-top: 8px;
}
.tc-disclosure-summary {
cursor: pointer;
list-style: none;
user-select: none;
padding: 12px 14px;
font-weight: 700;
color: var(--tc-text);
}
.tc-disclosure[open] .tc-disclosure-summary {
border-bottom: 1px solid var(--tc-border);
}
.tc-disclosure-body {
padding: 12px 14px;
display: grid;
gap: 16px;
}
/* nested disclosure for raw json */
.tc-disclosure--nested {
border: 1px dashed color-mix(in srgb, var(--tc-accent-2), transparent 65%);
background: rgba(255, 255, 255, 0.02);
}
.tc-disclosure--nested .tc-disclosure-summary {
font-weight: 600;
color: var(--tc-text-subtle);
}
.tc-pre {
margin: 0;
padding: 12px;
overflow: auto;
max-height: 40dvh;
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, "Liberation Mono", monospace;
font-size: 0.82rem;
line-height: 1.4;
border-radius: 8px;
background: #0d0706;
border: 1px solid var(--tc-border);
color: var(--tc-text);
}
/* responsive adjustments */
@media (max-width: 520px) {
.tc-header {
padding: 14px 16px;
}
.tc-body {
padding: 16px;
}
.tc-title {
font-size: 1rem;
}
}
-389
View File
@@ -1,389 +0,0 @@
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;
}
+233
View File
@@ -0,0 +1,233 @@
import http from 'http'
import fs from 'fs'
import path from 'path'
import { fileURLToPath } from 'url'
import { KubeConfig, CustomObjectsApi } from '@kubernetes/client-node'
const GROUP = 'argoproj.io',
VERSION = 'v1alpha1',
CRON_PLURAL = 'cronworkflows',
WF_PLURAL = 'workflows',
NAMESPACE = process.env.NS || 'argo',
// k8s label/annotation keys (must be lowercase dns-labels)
LABEL_USER_KEY = 'openwebui.user-id',
ANNO_DISPLAY_NAME = 'openwebui/display-name';
// load cluster credentials
const kc = new KubeConfig();
try { kc.loadFromCluster() } catch { kc.loadFromDefault() }
const co = kc.makeApiClient(CustomObjectsApi);
// build cron string from an iso timestamp in a tz
const cronFromISO = (iso, tz = 'America/New_York') => {
const dt = new Date(iso),
parts = new Intl.DateTimeFormat('en-US', {
timeZone: tz, year: 'numeric', month: 'numeric', day: 'numeric',
hour: 'numeric', minute: '2-digit', hour12: false
}).formatToParts(dt).reduce((a, p) => (a[p.type] = p.value, a), {}),
m = Number(parts.month), d = Number(parts.day), h = Number(parts.hour), min = Number(parts.minute);
return `${min} ${h} ${d} ${m} *`;
}
// derive a k8s-safe, user-scoped name and preserve a human display name
const scopedName = (name, userId) => {
// keep to dns-1123 by trimming/normalizing a bit; add an 8-char user suffix for uniqueness
const base = String(name).toLowerCase().replace(/[^a-z0-9-]+/g, '-').replace(/^-+|-+$/g, '').slice(0, 40),
suffix = String(userId).toLowerCase().replace(/[^a-z0-9]+/g, '').slice(0, 8) || 'anon';
return `${base}--u-${suffix}`;
}
// ensure we have a user id header
const requireUserId = (req) => {
const userId = String(req.headers['x-user-id'] || '').trim();
if (!userId) throw Object.assign(new Error('missing x-user-id header'), { status: 401 });
return userId;
}
// normalize parameters and force-inject user_id
const buildParams = (parameters = {}, userId) => {
const merged = { ...parameters, user_id: userId },
args = Object.entries(merged).map(([name, value]) => ({ name, value }));
return args.length ? { parameters: args } : undefined;
}
// create or update a cronworkflow that runs a workflowtemplate (scoped to user)
async function upsertCronWorkflow({
name, when, tz = 'America/New_York', oneShot = false,
template = { name: '', clusterScope: false },
parameters = {}, entrypoint, userId
}) {
const schedule = when.cron ?? cronFromISO(when.iso, tz),
nameActual = scopedName(name, userId),
body = {
apiVersion: `${GROUP}/${VERSION}`,
kind: 'CronWorkflow',
metadata: {
name: nameActual,
labels: { [LABEL_USER_KEY]: userId },
annotations: { [ANNO_DISPLAY_NAME]: name },
},
spec: {
timezone: tz,
schedules: [schedule],
concurrencyPolicy: 'Forbid',
...(oneShot ? { stopStrategy: { expression: 'cronworkflow.succeeded >= 1' } } : {}),
workflowSpec: {
...(entrypoint ? { entrypoint } : {}),
arguments: buildParams(parameters, userId),
workflowTemplateRef: {
name: template.name,
...(template.clusterScope ? { clusterScope: true } : {})
}
}
}
};
// try patch, else create
try {
await co.patchNamespacedCustomObject(
GROUP, VERSION, NAMESPACE, CRON_PLURAL, nameActual, body,
undefined, undefined, undefined,
{ headers: { 'content-type': 'application/merge-patch+json' } }
);
} catch {
await co.createNamespacedCustomObject(GROUP, VERSION, NAMESPACE, CRON_PLURAL, body);
}
}
// run immediately (no schedule) by creating a workflow from the same template (scoped to user)
async function runNow({ name, template, parameters = {}, entrypoint, userId }) {
const wf = {
apiVersion: `${GROUP}/${VERSION}`,
kind: 'Workflow',
metadata: {
generateName: `${scopedName(name, userId)}-`,
labels: { [LABEL_USER_KEY]: userId },
annotations: { [ANNO_DISPLAY_NAME]: name },
},
spec: {
...(entrypoint ? { entrypoint } : {}),
arguments: buildParams(parameters, userId),
workflowTemplateRef: {
name: template.name,
...(template.clusterScope ? { clusterScope: true } : {})
}
}
};
await co.createNamespacedCustomObject(GROUP, VERSION, NAMESPACE, WF_PLURAL, wf);
}
const __filename = fileURLToPath(import.meta.url),
__dirname = path.dirname(__filename),
publicDir = path.join(__dirname, 'public');
// tiny json helper
const readJson = (req) => new Promise((resolve, reject) => {
let d = ''; req.on('data', c => d += c);
req.on('end', () => { try { resolve(JSON.parse(d || '{}')) } catch (e) { reject(e) } });
req.on('error', reject);
});
const server = http.createServer(async (req, res) => {
try {
// death
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()
// minimal static ui
if (req.method === 'GET' && (req.url === '/' || req.url === '/index.html')) {
try {
const html = fs.readFileSync(path.join(publicDir, 'index.html'), 'utf8');
res.writeHead(200, { 'content-type': 'text/html; charset=utf-8' }).end(html);
} catch {
res.writeHead(404).end('ui not found');
}
return;
}
// list CronWorkflows for the calling user
if (req.method === 'GET' && req.url === '/api/schedules') {
const userId = requireUserId(req),
list = await co.listNamespacedCustomObject(
GROUP, VERSION, NAMESPACE, CRON_PLURAL,
undefined, undefined, undefined, `${LABEL_USER_KEY}=${userId}` // labelSelector
),
items = (list.body.items || []).map(it => ({
name: it.metadata?.name,
displayName: it.metadata?.annotations?.[ANNO_DISPLAY_NAME] || it.metadata?.name,
userId: it.metadata?.labels?.[LABEL_USER_KEY],
timezone: it.spec?.timezone,
schedules: it.spec?.schedules,
oneShot: Boolean(it.spec?.stopStrategy),
templateRef: it.spec?.workflowSpec?.workflowTemplateRef,
entrypoint: it.spec?.workflowSpec?.entrypoint,
}));
return res.writeHead(200, { 'content-type': 'application/json' }).end(JSON.stringify({ ok: true, items }));
}
// list WorkflowTemplates for UI (shared)
if (req.method === 'GET' && req.url === '/api/workflowtemplates') {
const list = await co.listNamespacedCustomObject(GROUP, VERSION, NAMESPACE, 'workflowtemplates'),
items = (list.body.items || []).map(it => ({ name: it.metadata?.name }));
return res.writeHead(200, { 'content-type': 'application/json' }).end(JSON.stringify({ ok: true, items }));
}
// create/update a user-scoped schedule
if (req.method === 'POST' && req.url === '/schedules') {
const userId = requireUserId(req),
input = await readJson(req);
await upsertCronWorkflow({ ...input, userId });
return res.writeHead(201, { 'content-type': 'application/json' }).end(JSON.stringify({ ok: true }));
}
// run a job now for the calling user
if (req.method === 'POST' && req.url === '/run-now') {
const userId = requireUserId(req),
input = await readJson(req);
await runNow({ ...input, userId });
return res.writeHead(201, { 'content-type': 'application/json' }).end(JSON.stringify({ ok: true }));
}
// delete a schedule owned by the calling user
if (req.method === 'DELETE' && req.url?.startsWith('/schedules/')) {
const userId = requireUserId(req),
name = decodeURIComponent(req.url.split('/').pop());
// guard: verify ownership via label before deletion
const obj = await co.getNamespacedCustomObject(GROUP, VERSION, NAMESPACE, CRON_PLURAL, name),
owner = obj.body?.metadata?.labels?.[LABEL_USER_KEY];
if (owner !== userId) {
res.writeHead(403, { 'content-type': 'application/json' })
.end(JSON.stringify({ ok: false, error: 'forbidden: schedule not owned by this user' }));
return;
}
await co.deleteNamespacedCustomObject(GROUP, VERSION, NAMESPACE, CRON_PLURAL, name);
return res.writeHead(204).end();
}
res.writeHead(404).end('not found');
} catch (e) {
const code = Number(e.status) || 500;
res.writeHead(code, { 'content-type': 'application/json' })
.end(JSON.stringify({ ok: false, error: e.message || String(e) }));
}
});
const port = Number(process.env.PORT) || 12253;
server.listen(port, () => console.log(`schedules api listening on :${port}`));
-408
View File
@@ -1,408 +0,0 @@
import http, { IncomingMessage } from 'http'
import fs from 'fs'
import path from 'path'
import crypto from 'node:crypto'
import { fileURLToPath } from 'url'
// import Docker from 'dockerode'
import cron from 'node-cron'
import loginUser, { getUser } from './helpers/resolve-user'
import { authCall, callNewChat, ollamaInp, schedInp } from './helpers/ollamaCalls'
// KEEP AS STRING
const openApiSpec = fs.readFileSync('./openapi.json', 'utf-8'),
__filename = fileURLToPath(import.meta.url),
__dirname = path.dirname(__filename),
DEFAULT_TZ = "America/New_York",
// folders/files
DATA_DIR = process.env.DATA_DIR || path.join(__dirname, 'data'),
SCHEDULES_FILE = path.join(DATA_DIR, 'schedules.json'),
TEMPLATES_FILE = process.env.TEMPLATES_FILE || path.join(__dirname, 'templates.json'),
FILES_DIR_PREFERRED = process.env.FILES_DIR || '/app/data/files',
FILES_DIR_FALLBACK = path.join(DATA_DIR, 'files');
let FILES_DIR = FILES_DIR_PREFERRED;
// defaults
const PORT = Number(process.env.PORT) || 12253
// connect to docker (via local socket by default)
// const docker = new Docker({ socketPath: process.env.DOCKER_SOCKET || '/var/run/docker.sock' })
// in-memory schedule registry
const tasks = new Map() // key -> { task, def }
export const readBodyJson = (req: http.IncomingMessage): Promise<any> => new Promise((resolve, reject) => {
let d = ''; req.on('data', c => d += c)
req.on('end', () => { try { resolve(JSON.parse(d || '{}')) } catch (e) { reject(e) } })
req.on('error', reject)
});
const ensureDir = (p: string) => { try { fs.mkdirSync(p, { recursive: true }) } catch { } },
readJsonFile = (p: string, fallback: any = null) => { try { return JSON.parse(fs.readFileSync(p, 'utf8')) } catch { return fallback } },
writeJsonFile = (p: string, obj: object) => fs.writeFileSync(p, JSON.stringify(obj, null, 2))
// build cron string from an iso timestamp (treated as America/New_York)
const cronFromISO = (iso?: string) => {
if (!iso) return;
const dt = new Date(iso);
if (Number.isNaN(dt.getTime())) return;
const parts: { year: number, month: number, day: number, hour: number, minute: number } = new Intl.DateTimeFormat('en-US', {
timeZone: DEFAULT_TZ, year: 'numeric', month: 'numeric', day: 'numeric',
hour: 'numeric', minute: '2-digit', hour12: false
}).formatToParts(dt).reduce((a, p) => (a[p.type] = Number(p.value), a), {} as any);
return `${parts.minute} ${parts.hour} ${parts.day} ${parts.month} *`;
}
const normalizeFeatures = (value: unknown): Record<string, boolean> => {
if (!value || typeof value !== 'object' || Array.isArray(value)) return {};
return Object.fromEntries(Object.entries(value as Record<string, unknown>).map(([k, v]) => [k, Boolean(v)]));
};
type IncomingFile = { fname?: string, fkey?: string, content?: string };
const sanitizeBase64 = (raw?: string) => typeof raw === 'string' ? raw.replace(/^data:[^;]+;base64,/, '') : '';
const prepareIncomingFiles = (value: unknown): { fname: string, fkey: string }[] | string => {
if (!value) return [];
if (!Array.isArray(value)) return 'files must be an array';
const saved: { fname: string, fkey: string }[] = [];
for (const entry of value as IncomingFile[]) {
if (!entry || typeof entry !== 'object') return 'invalid file entry';
const fname = typeof entry.fname === 'string' ? entry.fname.trim() : '';
if (!fname) return 'file entries require fname';
if (entry.content && typeof entry.content === 'string') {
const b64 = sanitizeBase64(entry.content);
if (!b64) return `file ${fname} is missing content`;
const fkey = crypto.randomUUID(),
target = path.join(FILES_DIR, fkey);
try {
fs.writeFileSync(target, Buffer.from(b64, 'base64'));
} catch (err) {
console.error('failed to store file', fname, err);
return `failed to store file ${fname}`;
}
saved.push({ fname, fkey });
continue;
}
if (entry.fkey && typeof entry.fkey === 'string' && entry.fkey.trim()) {
saved.push({ fname, fkey: entry.fkey.trim() });
continue;
}
return `file ${fname} is missing content or fkey`;
}
return saved;
};
// derive a docker-safe, user-scoped name and preserve a human display name
const scopedName = (name: string, userId: string) => {
const base = String(name).toLowerCase().replace(/[^a-z0-9-]+/g, '-').replace(/^-+|-+$/g, '').slice(0, 40),
suffix = String(userId).toLowerCase().replace(/[^a-z0-9]+/g, '').slice(0, 8) || 'anon';
return `${base}--u-${suffix}`;
}
const fetchUserID = async (req: IncomingMessage) => {
const authheader = String(req.headers['authorization'] || '').trim();
if (!authheader) throw Object.assign(new Error('Missing authorization header'), { status: 401 });
if (!authheader.startsWith('Bearer')) throw Object.assign(new Error('Invalid token'), { status: 401 });
try {
const token = authheader.split(' ')[1],
user = await authCall(token);
return user.id;
}
catch {
throw Object.assign(new Error('Invalid token'), { status: 401 });
}
}
// load templates (maps "name" -> { image, command?, args?, env? })
const loadTemplates = () => readJsonFile(TEMPLATES_FILE, { items: [] }).items || [];
// persistence of schedules (for restart durability)
ensureDir(DATA_DIR)
ensureDir(FILES_DIR)
if (!fs.existsSync(FILES_DIR)) {
FILES_DIR = FILES_DIR_FALLBACK;
ensureDir(FILES_DIR);
}
const persist = () => {
const defs = [...tasks.values()].map(v => v.def)
writeJsonFile(SCHEDULES_FILE, { items: defs })
};
const restore = () => {
const saved = readJsonFile(SCHEDULES_FILE, { items: [] }).items || [];
for (const def of saved) scheduleOrReplace(def);
};
// schedule management (create/update)
function scheduleOrReplace(defInput: ollamaInp) {
const def: ollamaInp = { ...defInput, features: normalizeFeatures(defInput?.features) };
// stop existing
const key = def.name
if (tasks.has(key)) { try { tasks.get(key).task.stop() } catch { } tasks.delete(key) }
const startTs = def.startAt ? new Date(def.startAt).getTime() : null;
// forbid overlapping runs per schedule (like argo's Forbid)
let running = false
const task = cron.schedule(def.schedule, async () => {
if (running) return;
if (startTs && Date.now() < startTs) return;
running = true
try {
await callNewChat(def);
// one-shot: stop after first success
if (def.oneShot) {
try { task.stop() } catch { }
tasks.delete(key)
persist()
}
} catch (err) {
// TODO: collect errors here
console.error(err);
// this failed, stop the task now
try { task.stop() } catch { }
try { tasks.delete(key) } catch { }
persist()
} finally {
running = false
}
}, { timezone: DEFAULT_TZ })
tasks.set(key, { task, def })
task.start()
persist()
}
// also does validation
const toScheduleDef = ({ name, when, oneShot = false, template = { name: '' }, parameters = {}, prompt, userId, model, tools, features = {}, files = [] }: schedInp): ollamaInp | string => {
const normalizedFeatures = normalizeFeatures(features);
const startAt = when.start?.trim() || '';
const hasStartAt = Boolean(startAt);
const processedFiles = prepareIncomingFiles(files);
if (typeof processedFiles === 'string') return processedFiles;
if (oneShot) {
if (!hasStartAt) return "One-shot schedules require a start date";
const schedule = cronFromISO(startAt);
if (!schedule) return "Invalid start date";
const startDate = new Date(startAt);
if (startDate.getTime() < Date.now()) return "Date can not be in the past";
return {
name: scopedName(name, userId),
displayName: name,
userId,
schedule,
startAt,
oneShot: true,
template,
parameters,
prompt,
model,
tools,
features: normalizedFeatures,
cookie: '',
files: processedFiles
};
}
const schedule = when.cron?.trim();
if (!schedule) return "Cron expression is required";
if (hasStartAt) {
const startDate = new Date(startAt);
if (Number.isNaN(startDate.getTime())) return "Invalid start date";
if (startDate.getTime() < Date.now()) return "Start date can not be in the past";
}
return {
name: scopedName(name, userId),
displayName: name,
userId,
schedule,
startAt: hasStartAt ? startAt : undefined,
oneShot: false,
template,
parameters,
prompt,
model,
tools,
features: normalizedFeatures,
cookie: '',
files: processedFiles
};
}
const publicDir = path.join(__dirname, 'public');
function reqToURL(req: IncomingMessage) {
let pathname = '/';
try {
pathname = new URL(req.url!, `http://${req.headers.host || 'localhost'}`).pathname;
} catch {
pathname = req.url || '/';
}
return pathname;
}
// http server
const server = http.createServer(async (req, res) => {
try {
// very light cors
const origin = req.headers.origin || '*'
res.setHeader('access-control-allow-origin', origin)
res.setHeader('vary', 'origin')
res.setHeader('access-control-allow-headers', 'content-type, x-user-id')
res.setHeader('access-control-allow-methods', 'GET, POST, DELETE, OPTIONS')
if (req.method === 'OPTIONS') return res.writeHead(204).end()
const pathname = reqToURL(req),
allowed = ['/', ...fs.readdirSync(publicDir).map(f => `/${f}`)];
if (req.method === 'GET' && pathname === '/openapi.json') {
return res.writeHead(200, { 'content-type': 'application/json' }).end(openApiSpec);
}
// list schedules for the calling user
if (req.method === 'GET' && pathname === '/api/schedules') {
const userId = await fetchUserID(req),
items = [...tasks.values()]
.map(v => v.def)
.filter(d => d.userId === userId)
.map(d => ({
name: d.name,
displayName: d.displayName,
userId: d.userId,
schedules: [d.schedule],
startAt: d.startAt,
oneShot: d.oneShot,
templateRef: d.template,
prompt: d.prompt,
model: d.model,
tools: d.tools,
features: d.features,
files: d.files
}));
return res.writeHead(200, { 'content-type': 'application/json' }).end(JSON.stringify({ ok: true, items }))
}
// list "workflow templates" => just expose templates.json names
if (req.method === 'GET' && pathname === '/api/workflowtemplates') {
const items = loadTemplates().map((t: { name: string }) => ({ name: t.name }))
return res.writeHead(200, { 'content-type': 'application/json' }).end(JSON.stringify({ ok: true, items }))
}
if (req.method === 'GET' && allowed.includes(pathname)) {
try {
const fileName = pathname === '/' ? 'index.html' : pathname.slice(1),
filePath = path.join(publicDir, fileName),
ext = path.extname(fileName).toLowerCase(),
type = ext === '.js' ? 'application/javascript' : ext === '.css' ? 'text/css' : 'text/html; charset=utf-8',
content = fs.readFileSync(filePath, 'utf8')
return res.writeHead(200, { 'content-type': type }).end(content);
} catch {
return res.writeHead(404).end('ui not found');
}
}
// create/update a user-scoped schedule
if (req.method === 'POST' && pathname === '/api/schedules') {
const userId = await fetchUserID(req),
input = await readBodyJson(req),
def = toScheduleDef({ ...input, userId });
if (typeof def === 'string') {
return res.writeHead(400, { 'content-type': 'application/json' })
.end(JSON.stringify({ error: def, ok: false }));
}
def.cookie = req.headers['authorization'].split(' ')[1];
scheduleOrReplace(def);
return res.writeHead(201, { 'content-type': 'application/json' }).end(JSON.stringify({ ok: true }));
}
if (req.method === 'POST' && pathname.startsWith('/login')) {
return await loginUser(req, res).catch((err) => {
console.error(err);
res.writeHead(500).end();
});
}
if (req.method === 'GET' && pathname.startsWith('/api/me')) {
return await getUser(req, res).catch((err) => {
console.error(err);
res.writeHead(500).end();
});
}
// delete a schedule owned by the calling user
if (req.method === 'DELETE' && pathname.startsWith('/api/schedules/')) {
const paramRaw = pathname.split('/').pop();
if (!paramRaw) {
return res.writeHead(400).end({ error: "missing schedule ID" });
}
const userId = await fetchUserID(req),
nameParam = decodeURIComponent(paramRaw),
// stored names are already scoped; accept either raw or scoped
key = tasks.has(nameParam) ? nameParam : scopedName(nameParam, userId),
existing = tasks.get(key)?.def;
if (!existing) {
return res.writeHead(404, {
'content-type': 'application/json'
}).end(JSON.stringify({
ok: false,
error: 'not found'
}));
}
if (existing.userId !== userId) {
return res.writeHead(403, {
'content-type': 'application/json'
}).end(JSON.stringify({
ok: false,
error: 'forbidden: schedule not owned by this user'
}));
}
try { tasks.get(key).task.stop(); } catch { }
tasks.delete(key);
persist();
return res.writeHead(204).end();
}
res.writeHead(404).end('not found');
} catch (e: any) {
console.error(e);
const code = Number(e.status) || 500
res.writeHead(code, { 'content-type': 'application/json' }).end(JSON.stringify({ ok: false, error: e.message || String(e) }))
}
})
// boot
restore();
server.listen(PORT, "0.0.0.0", () => {
const addr = server!.address();
console.log(`schedules api listening on ${typeof addr === 'string' ? addr : addr?.address}:${PORT}`)
});
-25
View File
@@ -1,25 +0,0 @@
{
"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"
]
}
]
}
+19
View File
@@ -0,0 +1,19 @@
# browser
docker build -t ion606/browser:latest ./browser;
docker push ion606/browser:latest;
# coderunner
docker build -t ion606/coderunner:latest ./coderunner;
docker push ion606/coderunner:latest;
# rag-server
docker build -t ion606/rag-server:latest ./rag-server;
docker push ion606/rag-server:latest;
# tools
docker build -t ion606/tools:latest ./tools;
docker push ion606/tools:latest;
# scheduling
docker build -t ion606/ollama-scheduler:latest ./scheduler;
docker push ion606/ollama-scheduler:latest;
+60
View File
@@ -0,0 +1,60 @@
#!/usr/bin/env bash
set -euo pipefail;
# cluster + ingress addons (nginx + ingress-dns)
# https://kubernetes.io/docs/tasks/access-application-cluster/ingress-minikube/
# https://minikube.sigs.k8s.io/docs/handbook/addons/ingress-dns/
# NOTE: publish ports 80/443 from the node to the host when using Docker driver
minikube start --driver=docker --cni=cilium --ports=80:80,443:443;
minikube addons enable ingress;
minikube addons enable ingress-dns;
# wait for Cilium (if present) and ingress controller to become Ready
kubectl -n kube-system rollout status ds/cilium --timeout=180s || echo "WARN: cilium DaemonSet not found or not Ready yet";
if kubectl -n ingress-nginx get ds/ingress-nginx-controller >/dev/null 2>&1; then
kubectl -n ingress-nginx rollout status ds/ingress-nginx-controller --timeout=180s || true;
else
kubectl -n ingress-nginx rollout status deploy/ingress-nginx-controller --timeout=180s || true;
fi
# namespaces
kubectl create namespace argocd --dry-run=client -o yaml | kubectl apply -f -;
kubectl create namespace ai --dry-run=client -o yaml | kubectl apply -f -;
# argo workflows namespace (for cronworkflows/workflows + templates)
kubectl create namespace argo --dry-run=client -o yaml | kubectl apply -f -;
# install argo cd (stable manifest)
# https://argo-cd.readthedocs.io/en/stable/operator-manual/installation/
kubectl apply -n argocd -f https://raw.githubusercontent.com/argoproj/argo-cd/stable/manifests/install.yaml;
# WAIT for argocd core components to be ready enough to accept apps (slow piece of-)
kubectl rollout status deploy/argocd-server -n argocd --timeout=180s || true;
kubectl rollout status deploy/argocd-repo-server -n argocd --timeout=180s || true;
kubectl rollout status deploy/argocd-application-controller -n argocd --timeout=180s || true;
# bootstrap this repo
# NOTE: creates the child Applications in apps/children/*
kubectl apply -n argocd -f apps/0-project-and-root.yaml;
echo "DEBUG: writing pods to 'tmp/pods.txt'"
mkdir -p tmp || ""
kubectl get pod -o wide --all-namespaces > tmp/pods.txt
# quick ingress test hint
MINIKUBE_IP=$(minikube ip || echo "<minikube-ip>")
echo "";
echo "To test ingress locally (without DNS), run:";
echo " curl -H 'Host: openwebui.local' http://$MINIKUBE_IP/";
echo "If name doesn't resolve on your host, add to /etc/hosts:";
echo " sudo sh -c 'echo \"$MINIKUBE_IP openwebui.local\" >> /etc/hosts'";
# port-forward argocd ui
echo "";
echo "argocd initial admin password (username 'admin'):";
kubectl -n argocd get secret argocd-initial-admin-secret -o jsonpath='{.data.password}' | base64 -d; echo "";
echo "";
echo "port-forwarding argocd ui to https://localhost:8443 (ctrl+c to stop) ...";
# kubectl -n argocd port-forward svc/scheduler-ui 12253:12253
kubectl -n argocd port-forward svc/argocd-server 8443:443