feat: rebuild xiteng.site homelab platform

This commit is contained in:
2026-08-12 09:48:25 +08:00
parent 557b0eca33
commit 5b84988789
128 changed files with 14979 additions and 292 deletions
+48 -3
View File
@@ -4,6 +4,10 @@ gitea/data/
hedgedoc/data/ hedgedoc/data/
uptime-kuma/data/ uptime-kuma/data/
homepage/config/ homepage/config/
site/data/
pocket-id/data/
ai-gateway/data/
hedgedoc2/data/
# 忽略 Docker 相关文件 # 忽略 Docker 相关文件
*.log *.log
@@ -45,8 +49,49 @@ seaweedfs/data/
# 忽略 remark42 数据 # 忽略 remark42 数据
remark42/var/ remark42/var/
cat-bodhi/data/assets-ai/
cat-bodhi/sprite_alpha_seg_pytorch/outputs/
remark42/.env remark42/.env
# 忽略 OpenWebUI 数据和密钥
openwebui/data/ # 忽略 Beszel 数据和 Agent 密钥
openwebui/.env beszel/data/
beszel/socket/
beszel/agent-data/
beszel/.env
# 忽略 ComfyUI 模型、节点和生成结果
comfyui/models/
comfyui/custom_nodes/
comfyui/output/
comfyui/.env
# 忽略 InvokeAI 模型、配置和生成结果
invokeai/data/
invokeai/.env
# 忽略 code-server 配置、本地扩展和本地环境变量
code-server/config/
code-server/local/
code-server/.env
# 已退役 Pocket ID/OAuth2 Proxy 数据与密钥仍保持忽略,避免历史敏感数据误提交
pocket-id/secrets/*
authentik/secrets/*
!authentik/secrets/.gitkeep
ai-gateway/secrets/*
!ai-gateway/secrets/README.md
oauth2-proxy/secrets/*
# 忽略 Chat Provider 代理桥 Unix socket
chat/run/
chat/node_modules/
chat/.next/
chat/dist/
chat/cache/
chat/data/
# 忽略本地诊断输出
/0
chat/data/
+34
View File
@@ -0,0 +1,34 @@
# Repository Guidelines
## Project Structure & Module Organization
This repository manages a Docker Compose homelab. The root `compose.yml` defines the shared `homelab_net` network. Each service lives in its own directory with a local Compose file, for example `traefik/compose.yml`, `authentik/compose.yml`, `gitea/compose.yml`, `seaweedfs/compose.yml`, and `chat/compose.yml`. Runtime state and secrets belong in service-local `data/`, `var/`, `letsencrypt/`, `.env`, or similar untracked paths.
`cat-bodhi/` is the main application code: `index.html`, `game.js`, `styles.css`, `server.mjs`, `assets/`, `data/`, and Python sprite tooling under `tools/` and `sprite_alpha_seg_pytorch/`.
## Build, Test, and Development Commands
- `docker compose -f compose.yml up -d`: create or update the shared network.
- `docker compose -f traefik/compose.yml up -d`: start a service stack; swap the path for other services.
- `docker compose -f <service>/compose.yml config`: validate a service Compose file before deploying it.
- `docker compose -f <service>/compose.yml logs -f`: inspect service logs during troubleshooting.
- `cd cat-bodhi && npm run dev`: run the local game and sprite import server on port `8080`.
- `cd cat-bodhi && npm run dev:ai:proxy`: run the same server with Node environment proxy support.
## Coding Style & Naming Conventions
Use two-space indentation in YAML, HTML, CSS, and JavaScript. Keep Compose service names, directories, and network aliases lowercase with hyphens, matching paths such as `uptime-kuma` and `outpost-seaweedfs`. Prefer explicit image tags over floating versions. Keep Traefik labels grouped with their service and preserve existing label patterns.
For `cat-bodhi`, keep the vanilla HTML/CSS/JS structure. Name JavaScript functions and variables in `camelCase`; use descriptive asset filenames such as `decor-cat-bed.png`.
## Testing Guidelines
There is no centralized test suite. Validate infrastructure edits with `docker compose -f <service>/compose.yml config` and, when practical, restart only the affected service. For `cat-bodhi`, run `npm run dev`, open `http://localhost:8080`, and manually verify the changed workflow. If sprite processing changes, verify `/api/sprite-status` and `/api/sprite-import`.
## Commit & Pull Request Guidelines
Recent history uses short Conventional Commit-style prefixes such as `feat:`, `fix:`, `docs:`, and `refactor:`, sometimes with Chinese descriptions. Keep commits focused on one service or feature. Pull requests should summarize changed services, include validation commands, call out environment or secret changes, and attach screenshots for UI changes.
## Security & Configuration Tips
Do not commit real `.env` files, tunnel tokens, private keys, certificates, or generated service data. When adding a public route, confirm the intended Authentik mode: app-level OAuth/OIDC or Traefik ForwardAuth. Keep exposed domains and Traefik routers consistent with `README.md`.
+151 -46
View File
@@ -7,17 +7,21 @@
``` ```
公网用户 公网用户
├─ Web (443) ──→ Cloudflare Tunnel ──→ cloudflared (Docker, HTTP/2) ├─ Web (443) ──→ Cloudflare Tunnel ──→ Traefik
│ │
└─ SSH (22) ──→ 阿里云 VPS (frps) ──→ frpc (Docker) ──→ Gitea │ ┌─────────────────┼─────────────────┐
│ ▼ ▼ ▼
Traefik v3.7.1 (反向代理) │ xiteng.site OAuth2/OIDC ForwardAuth
│ 唯一公开目录 应用层认证 入口层认证
┌───────────────┼───────────────────┐ │ │
▼ ▼ ▼ │ ├─→ Edge Cache Controller ──→ Traefik + Cloudflare Cache Rule
(有 Authentik SSO) (ForwardAuth) (免认证) │ ▼
Gitea / HedgeDoc SeaweedFS / Gitea SSH │ Site Registry ──→ Docker API
Homepage / OpenWebUI Uptime Kuma │ │ (Label + 容器状态)
│ ├─→ HTTP 探测器
│ └─→ SQLite (生命周期 + 历史 + 可用率)
└─ SSH (22) ──→ 阿里云 VPS (frps) ──→ frpc ──→ Gitea
``` ```
| 入口 | 域名 | 传输 | 延迟 | | 入口 | 域名 | 传输 | 延迟 |
@@ -25,41 +29,58 @@
| Web | `*.xiteng.site` | Cloudflare Tunnel (HTTP/2) → Traefik | ~50ms | | Web | `*.xiteng.site` | Cloudflare Tunnel (HTTP/2) → Traefik | ~50ms |
| Git SSH | `git.xiteng.site` | 阿里云 VPS → frp → Gitea | ~5ms | | Git SSH | `git.xiteng.site` | 阿里云 VPS → frp → Gitea | ~5ms |
## 服务一览 ## 服务与组件目录
[xiteng.site](https://xiteng.site) 是唯一门户。未登录与已登录用户看到相同的服务、基础设施目录及 CPU/MEM/DISK/GPU 实时设备状态;认证只决定能否读取服务数据或执行操作,不用于隐藏组件的存在。
门户不维护硬编码服务清单。Docker 容器通过 `xiteng.site.component.<id>.*` Label 自行声明名称、分组、说明、入口、访问方式和可选的 HTTP 探测。Registry 以组件 ID 为唯一键,统一保存生命周期、检查历史与可用率。Label 规范见 [`site/README.md`](site/README.md)。
服务可另用 `xiteng.site.cache.<id>.*` Label 声明公开静态目录。Edge Cache Controller 将其编译为高优先级 Traefik 路由及一条合并的 Cloudflare Cache Rule;规范和安全边界见 [`edge-cache/README.md`](edge-cache/README.md)。
| 服务 | 地址 | 认证 | 说明 | | 服务 | 地址 | 认证 | 说明 |
|------|------|------|------| |------|------|------|------|
| Homepage | [home.xiteng.site](https://home.xiteng.site) | Authentik OIDC | 导航面板 | | Xiteng Site | [xiteng.site](https://xiteng.site) | 无 | 个人主页、服务目录与基础设施目录 |
| Authentik | [auth.xiteng.site](https://auth.xiteng.site) | 自身 | 统一 SSO (v2026.5.0) | | Authentik | 内部 | Portal 管理 | 隐藏的 OAuth2/OIDC、ForwardAuth 与身份数据引擎 (v2026.5.0) |
| Key Vault | 内网 | Authentik 身份 | 加密 Backend Credential、Provider Registry 与审计 |
| Gitea | [gitea.xiteng.site](https://gitea.xiteng.site) | Authentik OAuth2 | 代码托管 | | Gitea | [gitea.xiteng.site](https://gitea.xiteng.site) | Authentik OAuth2 | 代码托管 |
| HedgeDoc | [notes.xiteng.site](https://notes.xiteng.site) | Authentik OIDC | Markdown 协作 | | HedgeDoc | [notes.xiteng.site](https://notes.xiteng.site) | Authentik OIDC | Markdown 协作 |
| OpenWebUI | [ai.xiteng.site](https://ai.xiteng.site) | Authentik OIDC | AI 对话界面 (v0.9.5) | | Xiteng Chat | [chat.xiteng.site](https://chat.xiteng.site) | Authentik ForwardAuth | assistant-ui 聊天界面,支持双连接 Provider 与跨刷新聊天历史 |
| Code Server | [code.xiteng.site](https://code.xiteng.site) | Authentik ForwardAuth | 浏览器中的 VS Code 工作台 |
| ComfyUI | [comfy.xiteng.site](https://comfy.xiteng.site) | Authentik ForwardAuth | 节点式图像生成工作台 |
| InvokeAI | [invoke.xiteng.site](https://invoke.xiteng.site) | Authentik ForwardAuth | 图像生成、画布编辑与模型管理 |
| SeaweedFS | [file.xiteng.site](https://file.xiteng.site) | Authentik ForwardAuth | 对象存储 (v4.28) | | SeaweedFS | [file.xiteng.site](https://file.xiteng.site) | Authentik ForwardAuth | 对象存储 (v4.28) |
| SeaweedFS S3 | [minio-api.xiteng.site](https://minio-api.xiteng.site) | Access Key | S3 API | | SeaweedFS S3 | [s3.xiteng.site](https://s3.xiteng.site) | Access Key | S3 API |
| Uptime Kuma | [uptime.xiteng.site](https://uptime.xiteng.site) | Authentik ForwardAuth | 服务监控 |
| Remark42 | [remark.xiteng.site](https://remark.xiteng.site) | Authentik OAuth2 | 评论系统 | | Remark42 | [remark.xiteng.site](https://remark.xiteng.site) | Authentik OAuth2 | 评论系统 |
| Traefik | 内网 | — | 反向代理 | | Traefik | 内网 | — | 反向代理 |
> **注意**: SeaweedFS 已替代原 MinIO。域名于 2026-05-27 从 `s3.xiteng.site` 迁移至 `file.xiteng.site`。 > **注意**: SeaweedFS 已替代原 MinIO。域名于 2026-05-27 从 `s3.xiteng.site` 迁移至 `file.xiteng.site`。
## 认证架构 ## 认证与 Key Vault 架构
``` ```
用户请求 → Cloudflare → cloudflared → Traefik 用户请求 → Cloudflare → cloudflared → Traefik → Authentik
│ issuer + sub
┌───────────── ┌──────────────────────────┴─────────────────────────┐
▼ ▼
ForwardAuth OAuth2/OIDC xiteng.site/account Xiteng Chat
(中间件子请求) (应用层重定向) Backend Credential / Provider assistant-ui / AI SDK
│ │ │ ┌───────────┴───────────┐
▼ ▼
Authentik Outpost Authentik Server Key Vault Backend Provider Frontend Provider
(proxy container) (Django) 加密存储 / Registry / Audit Chat 服务端直连 浏览器直接连接
│ IndexedDB Credential
└──────────┬──────────────┘
AI Provider
``` ```
- **OAuth2/OIDC**: Gitea、HedgeDoc、Homepage、OpenWebUI 各自持有 client_id/secret,用户从应用发起登录 - **Authentik**: 隐藏的身份引擎,负责 OAuth2/OIDC、ForwardAuth、用户与策略存储;日常用户、用户组和应用权限管理统一在 `https://xiteng.site/admin`
- **ForwardAuth**: SeaweedFS、Uptime Kuma 由 Traefik 中间件在请求到达前拦截验证 - **Key Vault**: 用 Authentik `(issuer, sub)` 关联用户,以 AES-256-GCM 信封加密保存 Backend Credential,并维护 Provider Registry;只有 `xiteng-chat` 服务端解析接口会短暂取得属于当前用户的明文
- **独立 Outpost**: SeaweedFS 和 Uptime Kuma 各用独立 proxy outpost 容器(避免 cookie domain 冲突) - **Xiteng Chat**: 基于 assistant-ui 与 AI SDKBackend Provider 由 Chat 服务端使用 Key Vault Credential 直连,Frontend Provider 由浏览器使用 IndexedDB 本地 Credential 直连;聊天记录按 Authentik `(issuer, sub)` 隔离并持久化到 `chat/data/chat.db`,不会保存 Credential 明文
- **Portal**: Authentik ForwardAuth 保护 `/admin``/account``/admin` 只允许 `liooil`,并提供用户、用户组、密码恢复、会话注销和 `liuhome` 应用权限矩阵
- **公开目录**: `xiteng.site` 不做登录判断,只展示 Label 明确声明的公开元数据;受控服务在点击后执行 Authentik 或服务自身认证
Authentik 数据库、Key Vault 的 `vault_master_key`、Portal HMAC Key 与 Chat 的 `chat/data/chat.db` 必须分别备份,不能放入 Docker Label 或 Git。
## 目录结构 ## 目录结构
@@ -73,6 +94,10 @@ homelab/
├── cloudflared/ ├── cloudflared/
│ ├── compose.yml │ ├── compose.yml
│ └── config.yml │ └── config.yml
├── edge-cache/
│ ├── compose.yml # Docker Label → Traefik/Cloudflare 缓存控制面
│ ├── controller.mjs # 动态路由、TTL 与 Cache Rule 生成器
│ └── README.md # 静态路径 Label 规范及安全边界
├── frpc/ ├── frpc/
│ ├── compose.yml │ ├── compose.yml
│ └── frpc.toml # 敏感,gitignore │ └── frpc.toml # 敏感,gitignore
@@ -82,15 +107,48 @@ homelab/
├── hedgedoc/ ├── hedgedoc/
│ ├── compose.yml │ ├── compose.yml
│ └── data/ │ └── data/
├── homepage/ ├── site/
│ ├── compose.yml │ ├── compose.yml
── config/ ── index.html # 页面结构,不包含服务清单
├── openwebui/ │ ├── admin.html # 用户、权限、Vault 与生命周期管理页
│ ├── styles.css
│ ├── app.js # 动态渲染组件卡片
│ ├── server.mjs # 公网站点与同源 API
│ ├── registry.mjs # Label 发现、HTTP 探测与生命周期控制面
│ ├── import-kuma.mjs # 一次性旧历史迁移工具
│ ├── metrics.py # 只读主机与 NVIDIA GPU 指标
│ ├── data/ # Registry SQLitegitignore
│ └── README.md # 组件 Label 规范
├── ai-gateway/ # 内部 Key Vault 服务(保留目录名以避免数据路径迁移)
│ ├── compose.yml │ ├── compose.yml
│ ├── .env # OPENWEBUI_SECRET_KEY │ ├── vault.mjs # 信封加密、所有权、Provider Registry 与审计
── data/ # gitignore ── providers.json # 内置 Provider Catalog
│ ├── providers.mjs # Provider 校验与合并
│ ├── server.mjs
│ └── data/ # Vault SQLitegitignore
├── homelab-emergency # Authentik 与 Vault 本机恢复入口
├── chat/
│ ├── app/ # Next.js 页面与流式聊天 API
│ ├── components/ # assistant-ui 线程与页面壳层
│ ├── data/ # 用户聊天历史 SQLitegitignore
│ ├── Dockerfile
│ └── compose.yml # chat.xiteng.site / Authentik ForwardAuth
├── code-server/
│ ├── compose.yml
│ ├── .env # 本地 UID/GID 等环境变量,gitignore
│ ├── config/ # VS Code Server 配置,gitignore
│ └── local/ # 扩展与用户本地数据,gitignore
├── comfyui/
│ ├── compose.yml
│ ├── compose.gpu.yml # 可选 NVIDIA GPU override
│ ├── models/ # gitignore
│ ├── custom_nodes/ # gitignore
│ └── output/ # gitignore
├── invokeai/
│ ├── compose.yml
│ └── data/ # gitignore,模型、配置和生成结果
├── outpost/ ├── outpost/
│ ├── compose.yml # Uptime Kuma outpost │ ├── compose.yml # Portal Admin / ComfyUI / InvokeAI outpost
│ └── ... │ └── ...
├── outpost-seaweedfs/ ├── outpost-seaweedfs/
│ └── compose.yml # SeaweedFS 独立 outpost │ └── compose.yml # SeaweedFS 独立 outpost
@@ -101,12 +159,9 @@ homelab/
│ ├── compose.yml │ ├── compose.yml
│ ├── security.toml # JWT 已全部注释(社区版 UI 不支持 OIDC) │ ├── security.toml # JWT 已全部注释(社区版 UI 不支持 OIDC)
│ └── data/ │ └── data/
── traefik/ ── traefik/
│ ├── compose.yml
│ └── letsencrypt/ # gitignore
└── uptime-kuma/
├── compose.yml ├── compose.yml
└── data/ └── letsencrypt/ # gitignore
``` ```
## 网络 ## 网络
@@ -118,21 +173,71 @@ homelab/
```bash ```bash
# 按依赖顺序启动 # 按依赖顺序启动
docker compose -f compose.yml up -d # 创建网络 docker compose -f compose.yml up -d # 创建网络
docker compose -f edge-cache/compose.yml up -d
docker compose -f traefik/compose.yml up -d docker compose -f traefik/compose.yml up -d
docker compose -f authentik/compose.yml up -d docker compose -f authentik/compose.yml up -d
docker compose -f gitea/compose.yml up -d docker compose -f gitea/compose.yml up -d
docker compose -f hedgedoc/compose.yml up -d docker compose -f hedgedoc/compose.yml up -d
docker compose -f seaweedfs/compose.yml up -d docker compose -f seaweedfs/compose.yml up -d
docker compose -f uptime-kuma/compose.yml up -d ./homelab-emergency init-secrets
docker compose -f homepage/compose.yml up -d ./homelab-emergency identity-bootstrap
docker compose -f openwebui/compose.yml up -d docker compose -f ai-gateway/compose.yml up -d
docker compose -f site/compose.yml up -d
docker compose -f chat/compose.yml up -d --build
docker compose --env-file code-server/.env -f code-server/compose.yml up -d
docker compose -f comfyui/compose.yml up -d
docker compose -f invokeai/compose.yml up -d
docker compose -f remark42/compose.yml up -d docker compose -f remark42/compose.yml up -d
docker compose -f outpost/compose.yml up -d docker compose --env-file .env -f outpost/compose.yml up -d
docker compose -f outpost-seaweedfs/compose.yml up -d docker compose -f outpost-seaweedfs/compose.yml up -d
docker compose -f cloudflared/compose.yml up -d docker compose -f cloudflared/compose.yml up -d
docker compose -f frpc/compose.yml up -d docker compose -f frpc/compose.yml up -d
``` ```
## 最终恢复入口
日常身份管理位于 `https://xiteng.site/admin`。Authentik 原生管理界面已隐藏;根目录的
`homelab-emergency` 提供不依赖 Portal 的恢复能力:
```bash
./homelab-emergency status
./homelab-emergency identity-recovery liooil
./homelab-emergency identity-set-password liooil
./homelab-emergency identity-reset-2fa liooil
./homelab-emergency identity-reset-passkeys liooil
./homelab-emergency identity-bootstrap
./homelab-emergency vault-list
./homelab-emergency vault-audit 100
./homelab-emergency vault-delete <credential-id>
./homelab-emergency vault-backup ai-gateway/data/backups/vault.db
```
`identity-bootstrap` 保证 `liooil` 是唯一人类管理员、`liooil``ziyue` 属于 `liuhome`
并将当前非开放应用的准入用户组统一为 `liuhome``identity-reset-2fa`
`identity-reset-passkeys` 需要交互确认,只删除指定用户的认证器并写入身份审计。应急脚本不输出
Provider Key、TOTP Secret 或 Passkey 凭据明文。
Homepage、Beszel、Uptime Kuma 和 AutoKuma 已退役。它们不再有活动 Compose 定义;现有 `homepage/config/``beszel/data/``uptime-kuma/data/` 仅作为迁移后的回滚数据保留,不会被 Portal 或启动流程读取。
code-server 将当前仓库挂载到 `/home/coder/homelab`,并持久化 VS Code 配置与扩展到
`code-server/config/``code-server/local/`。公网入口必须保持 Authentik ForwardAuth 保护;
code-server 内置密码认证已关闭,避免重复登录。默认不挂载 Docker socket,如需从浏览器终端管理
Docker,应改用更窄的专用运维入口。
ComfyUI 默认配置不要求 Docker GPU runtime,模型、Custom Nodes 和生成结果分别持久化到 `comfyui/models/``comfyui/custom_nodes/``comfyui/output/`。ComfyUI Manager 可安装第三方节点,公网入口必须保持 Authentik ForwardAuth 保护。
启用 NVIDIA GPU 前,先在宿主安装并配置 NVIDIA Container Toolkit,然后用 override 启动:
```bash
sudo pacman -S --needed nvidia-container-toolkit
sudo nvidia-ctk runtime configure --runtime=docker
sudo systemctl restart docker
docker compose -f comfyui/compose.yml -f comfyui/compose.gpu.yml up -d
```
InvokeAI 使用 NVIDIA GPU,模型、配置和生成结果统一持久化到 `invokeai/data/`。公网入口必须保持 Authentik ForwardAuth 保护;首次进入后在 Model Manager 中安装需要的模型。
## 所需外部资源 ## 所需外部资源
| 资源 | 用途 | | 资源 | 用途 |
+22
View File
@@ -0,0 +1,22 @@
# Key Vault and Provider Registry
This internal service is the credential boundary between Authentik identities and Xiteng Chat. It stores
Backend Provider credentials with AES-256-GCM envelope encryption. Every credential has a random data-encryption
key; the master key wraps only those data keys, so master-key rotation is independent of provider-key rotation.
`providers.json` is the built-in Provider Catalog. Administrators can add Custom Providers in SQLite without
editing the built-in file. A provider declares its API protocol, frontend/backend connection type, endpoint,
optional proxy, authentication shape, default-model preference, and a model-discovery type plus URL. The Vault
does not store model lists; Xiteng Chat fetches them dynamically from each configured Provider.
The service is internal-only. `xiteng.site` and `xiteng-chat` sign requests with a dedicated service token after
Authentik checks. The Vault independently treats only the exact username `liooil` as an administrator. Other
identities can access only records whose `(issuer, sub)` pair matches their session.
Normal metadata APIs never return plaintext. The dedicated `/v1/resolve` endpoint is restricted to the exact
service name `xiteng-chat`; it decrypts one active Backend Credential owned by the signed user and returns it to
the Chat server for one provider request. Frontend Provider credentials never enter this service and remain in
the browser's IndexedDB.
Xiteng Chat connects directly: Backend Providers from the Chat server, Frontend Providers from the browser.
Vault audit records cover credential lifecycle, verification, reassignment, resolution, and Custom Provider changes.
+77
View File
@@ -0,0 +1,77 @@
import {existsSync} from "node:fs";
import {loadBuiltinProviders} from "./providers.mjs";
import {breakGlassActor, loadKeyFile, Vault} from "./vault.mjs";
const databasePath = process.env.DATABASE_PATH || "/data/vault.db";
const masterKeyFile = process.env.MASTER_KEY_FILE || "/run/secrets/vault_master_key";
const [command, ...args] = process.argv.slice(2);
function usage() {
console.log(`Usage:
node cli.mjs list [owner-sub]
node cli.mjs audit [limit]
node cli.mjs verify <credential-id>
node cli.mjs delete <credential-id>
node cli.mjs reassign <credential-id> <issuer> <sub> <username>
node cli.mjs rotate-master <new-key-file>
node cli.mjs backup <destination.db>`);
}
if (!command) {
usage();
process.exit(1);
}
const vault = new Vault({
databasePath,
masterKey: loadKeyFile(masterKeyFile),
builtinProviders: loadBuiltinProviders(process.env.PROVIDER_CATALOG_FILE || "/app/providers.json")
});
const actor = breakGlassActor();
try {
if (command === "list") {
const credentials = vault.listCredentials(actor, {all: true});
const filtered = args[0]
? credentials.filter((credential) => credential.owner.sub === args[0])
: credentials;
console.table(filtered.map((credential) => ({
id: credential.id,
owner: credential.owner.username,
sub: credential.owner.sub,
provider: credential.provider,
name: credential.name,
fingerprint: credential.fingerprint,
status: credential.status
})));
} else if (command === "audit") {
console.table(vault.listAudit(actor, {all: true, limit: args[0] || 100}));
} else if (command === "verify" && args[0]) {
console.log(vault.verifyCredential(actor, args[0]));
} else if (command === "delete" && args[0]) {
console.log(vault.deleteCredential(actor, args[0]));
} else if (command === "reassign" && args.length >= 4) {
console.log(vault.reassignCredential(actor, args[0], {
issuer: args[1],
sub: args[2],
username: args[3],
type: "user"
}));
} else if (command === "rotate-master" && args[0]) {
if (!existsSync(args[0])) {
throw new Error(`New key file not found: ${args[0]}`);
}
console.log(vault.rotateMasterKey(actor, loadKeyFile(args[0])));
console.error("Rotation succeeded. Replace the mounted vault_master_key before restarting the service.");
} else if (command === "backup" && args[0]) {
if (existsSync(args[0])) {
throw new Error(`Refusing to overwrite existing backup: ${args[0]}`);
}
console.log(vault.backup(args[0]));
} else {
usage();
process.exitCode = 1;
}
} finally {
vault.close();
}
+50
View File
@@ -0,0 +1,50 @@
services:
ai-gateway:
image: node:24-alpine
container_name: ai-gateway
restart: unless-stopped
command: ["node", "/app/server.mjs"]
environment:
PORT: "8093"
DATABASE_PATH: /data/vault.db
MASTER_KEY_FILE: /run/secrets/vault_master_key
SERVICE_TOKEN_FILE: /run/secrets/portal_gateway_hmac
PROVIDER_CATALOG_FILE: /app/providers.json
ADMIN_USERNAME: liooil
volumes:
- ./vault.mjs:/app/vault.mjs:ro
- ./server.mjs:/app/server.mjs:ro
- ./providers.mjs:/app/providers.mjs:ro
- ./providers.json:/app/providers.json:ro
- ./cli.mjs:/app/cli.mjs:ro
- ./data:/data
- ./secrets:/run/secrets:ro
read_only: true
security_opt:
- no-new-privileges:true
healthcheck:
test: ["CMD", "node", "-e", "fetch('http://127.0.0.1:8093/healthz').then(r => process.exit(r.ok ? 0 : 1)).catch(() => process.exit(1))"]
interval: 15s
timeout: 5s
retries: 3
start_period: 5s
networks:
- homelab_net
labels:
- "traefik.enable=false"
- "xiteng.site.component.ai-gateway.enabled=true"
- "xiteng.site.component.ai-gateway.name=Key Vault"
- "xiteng.site.component.ai-gateway.description=按用户保存 Backend Provider 凭据,并提供内置与 Custom Provider Registry。"
- "xiteng.site.component.ai-gateway.section=infrastructure"
- "xiteng.site.component.ai-gateway.category=身份与访问"
- "xiteng.site.component.ai-gateway.access=internal"
- "xiteng.site.component.ai-gateway.access-label=仅通过 Portal/API 使用"
- "xiteng.site.component.ai-gateway.icon=KV"
- "xiteng.site.component.ai-gateway.accent=blue"
- "xiteng.site.component.ai-gateway.order=220"
- "xiteng.site.component.ai-gateway.monitor.enabled=true"
- "xiteng.site.component.ai-gateway.monitor.url=http://ai-gateway:8093/healthz"
networks:
homelab_net:
external: true
+462
View File
@@ -0,0 +1,462 @@
[
{
"id": "openai",
"name": "OpenAI",
"api": "openai-responses",
"connection": {
"type": "backend",
"baseUrl": "https://api.openai.com/v1",
"proxy": {
"type": "socks5",
"url": "socks5://provider-proxy-bridge:17897"
}
},
"auth": {
"type": "bearer"
},
"defaultModel": "gpt-5.4"
},
{
"id": "anthropic",
"name": "Anthropic",
"api": "anthropic-messages",
"connection": {
"type": "backend",
"baseUrl": "https://api.anthropic.com",
"proxy": {
"type": "socks5",
"url": "socks5://provider-proxy-bridge:17897"
}
},
"auth": {
"type": "header",
"header": "x-api-key"
},
"defaultModel": "claude-sonnet-4-6"
},
{
"id": "google",
"name": "Google Gemini",
"api": "google-generative-ai",
"connection": {
"type": "backend",
"baseUrl": "https://generativelanguage.googleapis.com/v1beta",
"proxy": null
},
"auth": {
"type": "header",
"header": "x-goog-api-key"
},
"defaultModel": "gemini-3.1-pro-preview"
},
{
"id": "openrouter",
"name": "OpenRouter",
"api": "openai-completions",
"connection": {
"type": "backend",
"baseUrl": "https://openrouter.ai/api/v1",
"proxy": null
},
"auth": {
"type": "bearer"
},
"headers": {
"HTTP-Referer": "https://chat.xiteng.site",
"X-Title": "Xiteng Chat"
},
"defaultModel": "anthropic/claude-sonnet-4.6"
},
{
"id": "rust.cat",
"name": "rust.cat",
"api": "openai-responses",
"connection": {
"type": "backend",
"baseUrl": "https://rust.cat/codex/v1",
"proxy": null
},
"auth": {
"type": "bearer"
},
"defaultModel": "gpt-5.3-codex"
},
{
"id": "deepseek",
"name": "DeepSeek",
"api": "openai-completions",
"connection": {
"type": "backend",
"baseUrl": "https://api.deepseek.com",
"proxy": null
},
"auth": {
"type": "bearer"
},
"defaultModel": "deepseek-chat"
},
{
"id": "groq",
"name": "Groq",
"api": "openai-completions",
"connection": {
"type": "backend",
"baseUrl": "https://api.groq.com/openai/v1",
"proxy": null
},
"auth": {
"type": "bearer"
},
"defaultModel": "openai/gpt-oss-120b"
},
{
"id": "mistral",
"name": "Mistral AI",
"api": "openai-completions",
"connection": {
"type": "backend",
"baseUrl": "https://api.mistral.ai/v1",
"proxy": null
},
"auth": {
"type": "bearer"
},
"defaultModel": "mistral-large-latest"
},
{
"id": "xai",
"name": "xAI",
"api": "openai-completions",
"connection": {
"type": "backend",
"baseUrl": "https://api.x.ai/v1",
"proxy": null
},
"auth": {
"type": "bearer"
},
"defaultModel": "grok-4"
},
{
"id": "moonshot",
"name": "Moonshot AI",
"api": "openai-completions",
"connection": {
"type": "backend",
"baseUrl": "https://api.moonshot.cn/v1",
"proxy": null
},
"auth": {
"type": "bearer"
},
"defaultModel": "kimi-k3"
},
{
"id": "siliconflow",
"name": "SiliconFlow",
"api": "openai-completions",
"connection": {
"type": "backend",
"baseUrl": "https://api.siliconflow.com/v1",
"proxy": null
},
"auth": {
"type": "bearer"
},
"defaultModel": "deepseek-ai/DeepSeek-V3.2"
},
{
"id": "siliconflow-cn",
"name": "SiliconFlow CN",
"api": "openai-completions",
"connection": {
"type": "backend",
"baseUrl": "https://api.siliconflow.cn/v1",
"proxy": null
},
"auth": {
"type": "bearer"
},
"defaultModel": "deepseek-ai/DeepSeek-V3.2"
},
{
"id": "minimax",
"name": "MiniMax",
"api": "openai-completions",
"connection": {
"type": "backend",
"baseUrl": "https://api.minimax.io/v1",
"proxy": null
},
"auth": {
"type": "bearer"
},
"defaultModel": "MiniMax-M2.1"
},
{
"id": "zai",
"name": "Z.AI",
"api": "openai-completions",
"connection": {
"type": "backend",
"baseUrl": "https://api.z.ai/api/paas/v4",
"proxy": null
},
"auth": {
"type": "bearer"
},
"defaultModel": "glm-5"
},
{
"id": "qianfan",
"name": "Baidu Qianfan",
"api": "openai-completions",
"connection": {
"type": "backend",
"baseUrl": "https://qianfan.baidubce.com/v2",
"proxy": null
},
"auth": {
"type": "bearer"
},
"defaultModel": "ernie-4.5-8k-preview"
},
{
"id": "dashscope",
"name": "Alibaba DashScope",
"api": "openai-completions",
"connection": {
"type": "backend",
"baseUrl": "https://dashscope.aliyuncs.com/compatible-mode/v1",
"proxy": null
},
"auth": {
"type": "bearer"
},
"defaultModel": "qwen3.5-plus"
},
{
"id": "together",
"name": "Together AI",
"api": "openai-completions",
"connection": {
"type": "backend",
"baseUrl": "https://api.together.xyz/v1",
"proxy": null
},
"auth": {
"type": "bearer"
},
"defaultModel": "meta-llama/Llama-4-Maverick-17B-128E-Instruct-FP8"
},
{
"id": "fireworks",
"name": "Fireworks AI",
"api": "openai-completions",
"connection": {
"type": "backend",
"baseUrl": "https://api.fireworks.ai/inference/v1",
"proxy": null
},
"auth": {
"type": "bearer"
},
"defaultModel": "accounts/fireworks/models/deepseek-v3p2"
},
{
"id": "cerebras",
"name": "Cerebras",
"api": "openai-completions",
"connection": {
"type": "backend",
"baseUrl": "https://api.cerebras.ai/v1",
"proxy": null
},
"auth": {
"type": "bearer"
},
"defaultModel": "gpt-oss-120b"
},
{
"id": "nvidia",
"name": "NVIDIA NIM",
"api": "openai-completions",
"connection": {
"type": "backend",
"baseUrl": "https://integrate.api.nvidia.com/v1",
"proxy": null
},
"auth": {
"type": "bearer"
},
"defaultModel": "nvidia/llama-3.1-nemotron-ultra-253b-v1"
},
{
"id": "huggingface",
"name": "Hugging Face",
"api": "openai-completions",
"connection": {
"type": "backend",
"baseUrl": "https://router.huggingface.co/v1",
"proxy": null
},
"auth": {
"type": "bearer"
},
"defaultModel": "deepseek-ai/DeepSeek-V3.2"
},
{
"id": "novita",
"name": "Novita AI",
"api": "openai-completions",
"connection": {
"type": "backend",
"baseUrl": "https://api.novita.ai/openai",
"proxy": null
},
"auth": {
"type": "bearer"
},
"defaultModel": "deepseek/deepseek-v3.2"
},
{
"id": "aimlapi",
"name": "AIML API",
"api": "openai-completions",
"connection": {
"type": "backend",
"baseUrl": "https://api.aimlapi.com/v1",
"proxy": null
},
"auth": {
"type": "bearer"
},
"defaultModel": "gpt-5.4"
},
{
"id": "venice",
"name": "Venice AI",
"api": "openai-completions",
"connection": {
"type": "backend",
"baseUrl": "https://api.venice.ai/api/v1",
"proxy": null
},
"auth": {
"type": "bearer"
},
"defaultModel": "llama-3.3-70b"
},
{
"id": "nanogpt",
"name": "NanoGPT",
"api": "openai-completions",
"connection": {
"type": "backend",
"baseUrl": "https://nano-gpt.com/api/v1",
"proxy": null
},
"auth": {
"type": "bearer"
},
"defaultModel": "gpt-5.4"
},
{
"id": "vercel-ai-gateway",
"name": "Vercel AI Gateway",
"api": "openai-completions",
"connection": {
"type": "backend",
"baseUrl": "https://ai-gateway.vercel.sh/v1",
"proxy": null
},
"auth": {
"type": "bearer"
},
"defaultModel": "openai/gpt-5.4"
},
{
"id": "cloudflare-ai-gateway",
"name": "Cloudflare AI Gateway",
"api": "openai-completions",
"connection": {
"type": "backend",
"baseUrl": "https://gateway.ai.cloudflare.com/v1",
"proxy": null
},
"auth": {
"type": "bearer"
},
"defaultModel": "openai/gpt-5.4"
},
{
"id": "litellm",
"name": "LiteLLM",
"api": "openai-completions",
"connection": {
"type": "backend",
"baseUrl": "http://litellm:4000/v1",
"proxy": null
},
"auth": {
"type": "bearer"
},
"defaultModel": "gpt-5.4"
},
{
"id": "ollama",
"name": "Ollama",
"api": "openai-completions",
"connection": {
"type": "frontend",
"baseUrl": "http://127.0.0.1:11434/v1",
"proxy": null
},
"auth": {
"type": "none"
},
"defaultModel": "qwen3:8b"
},
{
"id": "lm-studio",
"name": "LM Studio",
"api": "openai-completions",
"connection": {
"type": "frontend",
"baseUrl": "http://127.0.0.1:1234/v1",
"proxy": null
},
"auth": {
"type": "none"
},
"defaultModel": "local-model"
},
{
"id": "llama.cpp",
"name": "llama.cpp",
"api": "openai-completions",
"connection": {
"type": "frontend",
"baseUrl": "http://127.0.0.1:8080/v1",
"proxy": null
},
"auth": {
"type": "none"
},
"defaultModel": "local-model"
},
{
"id": "vllm",
"name": "vLLM",
"api": "openai-completions",
"connection": {
"type": "frontend",
"baseUrl": "http://127.0.0.1:8000/v1",
"proxy": null
},
"auth": {
"type": "none"
},
"defaultModel": "local-model"
}
]
+97
View File
@@ -0,0 +1,97 @@
import {readFileSync} from "node:fs";
const allowedApis = new Set([
"openai-completions",
"openai-responses",
"anthropic-messages",
"google-generative-ai"
]);
function stringValue(value, field, maximum = 200) {
if (typeof value !== "string" || !value.trim()) throw new Error(`${field} is required`);
return value.trim().slice(0, maximum);
}
function optionalUrl(value, field, protocols) {
if (value === null || value === undefined || value === "") return null;
const url = new URL(stringValue(value, field, 2000));
if (!protocols.includes(url.protocol)) throw new Error(`${field} uses an unsupported protocol`);
return url.toString().replace(/\/$/, "");
}
function normalizeHeaders(value) {
if (!value || typeof value !== "object" || Array.isArray(value)) return {};
return Object.fromEntries(Object.entries(value).map(([name, headerValue]) => [
stringValue(name, "header name", 120),
stringValue(headerValue, `header ${name}`, 1000)
]));
}
export function normalizeProvider(input, {builtin = false} = {}) {
if (!input || typeof input !== "object" || Array.isArray(input)) throw new Error("Provider definition is required");
const id = stringValue(input.id, "provider.id", 80).toLowerCase();
if (!/^[a-z0-9][a-z0-9._-]*$/.test(id)) throw new Error("provider.id contains unsupported characters");
const api = stringValue(input.api, "provider.api", 80);
if (!allowedApis.has(api)) throw new Error(`Unsupported provider API: ${api}`);
const connectionType = input.connection?.type === "frontend" ? "frontend" : "backend";
const proxyInput = input.connection?.proxy;
let proxy = null;
if (proxyInput) {
const proxyType = stringValue(proxyInput.type, "provider.connection.proxy.type", 30);
const allowedProxyTypes = connectionType === "frontend" ? ["relay"] : ["http", "https", "socks5"];
if (!allowedProxyTypes.includes(proxyType)) throw new Error(`Unsupported ${connectionType} proxy type: ${proxyType}`);
proxy = {
type: proxyType,
url: optionalUrl(proxyInput.url, "provider.connection.proxy.url", proxyType === "socks5" ? ["socks5:"] : ["http:", "https:"])
};
}
const authType = ["bearer", "header", "none"].includes(input.auth?.type) ? input.auth.type : "bearer";
const auth = {
type: authType,
...(authType === "header" ? {header: stringValue(input.auth?.header, "provider.auth.header", 120)} : {})
};
const defaultModel = typeof input.defaultModel === "string" ? input.defaultModel.trim().slice(0, 300) : "";
if (!builtin && !defaultModel) throw new Error("provider.defaultModel is required");
const baseUrl = optionalUrl(input.connection?.baseUrl, "provider.connection.baseUrl", ["http:", "https:"]);
const inferredDiscoveryType = api === "anthropic-messages"
? "anthropic-models-list"
: api === "google-generative-ai" ? "google-models-list" : "openai-models-list";
const discoveryType = ["openai-models-list", "anthropic-models-list", "google-models-list"].includes(input.discovery?.type)
? input.discovery.type
: inferredDiscoveryType;
const inferredDiscoveryUrl = discoveryType === "anthropic-models-list"
? `${baseUrl.endsWith("/v1") ? baseUrl : `${baseUrl}/v1`}/models?limit=200`
: discoveryType === "google-models-list" ? `${baseUrl}/models?pageSize=200` : `${baseUrl}/models`;
const discovery = {
type: discoveryType,
url: optionalUrl(input.discovery?.url, "provider.discovery.url", ["http:", "https:"]) || inferredDiscoveryUrl
};
return {
id,
name: stringValue(input.name, "provider.name", 160),
api,
connection: {
type: connectionType,
baseUrl,
proxy
},
auth,
headers: normalizeHeaders(input.headers),
defaultModel,
discovery,
builtin
};
}
export function loadBuiltinProviders(path) {
const definitions = JSON.parse(readFileSync(path, "utf8"));
if (!Array.isArray(definitions)) throw new Error("Provider catalog must be an array");
const providers = new Map();
for (const definition of definitions) {
const provider = normalizeProvider(definition, {builtin: true});
if (providers.has(provider.id)) throw new Error(`Duplicate built-in provider: ${provider.id}`);
providers.set(provider.id, provider);
}
return providers;
}
+9
View File
@@ -0,0 +1,9 @@
# Runtime secrets
`../../homelab-emergency init-secrets` creates these untracked files:
- `vault_master_key`: 32-byte base64 key used only to wrap per-credential data keys.
- `portal_gateway_hmac`: 32-byte base64 key used to authenticate Portal-to-Gateway requests.
Back up both outside this repository. Never store either key in `vault.db`, Authentik, Docker labels,
or the Portal UI.
+183
View File
@@ -0,0 +1,183 @@
import {timingSafeEqual} from "node:crypto";
import {readFileSync} from "node:fs";
import http from "node:http";
import {loadBuiltinProviders, normalizeProvider} from "./providers.mjs";
import {loadKeyFile, Vault} from "./vault.mjs";
const port = Number.parseInt(process.env.PORT || "8093", 10);
const databasePath = process.env.DATABASE_PATH || "/data/vault.db";
const masterKeyFile = process.env.MASTER_KEY_FILE || "/run/secrets/vault_master_key";
const serviceTokenFile = process.env.SERVICE_TOKEN_FILE || "/run/secrets/portal_gateway_hmac";
const providerCatalogFile = process.env.PROVIDER_CATALOG_FILE || "/app/providers.json";
const adminUsername = process.env.ADMIN_USERNAME || "liooil";
const serviceToken = readFileSync(serviceTokenFile, "utf8").trim();
const vault = new Vault({
databasePath,
masterKey: loadKeyFile(masterKeyFile),
builtinProviders: loadBuiltinProviders(providerCatalogFile)
});
const securityHeaders = {
"Cache-Control": "no-store",
"Content-Security-Policy": "default-src 'none'; frame-ancestors 'none'",
"X-Content-Type-Options": "nosniff",
"X-Frame-Options": "DENY"
};
function sendJson(response, statusCode, payload) {
const body = JSON.stringify(payload);
response.writeHead(statusCode, {
...securityHeaders,
"Content-Type": "application/json; charset=utf-8",
"Content-Length": Buffer.byteLength(body)
});
response.end(body);
}
async function readBody(request) {
const chunks = [];
let size = 0;
for await (const chunk of request) {
size += chunk.length;
if (size > 1048576) {
const error = new Error("Request body too large");
error.statusCode = 413;
throw error;
}
chunks.push(chunk);
}
return Buffer.concat(chunks);
}
function parseJson(body) {
if (!body.length) return {};
try {
return JSON.parse(body.toString("utf8"));
} catch {
const error = new Error("Invalid JSON body");
error.statusCode = 400;
throw error;
}
}
function safeEqual(left, right) {
const a = Buffer.from(left || "");
const b = Buffer.from(right || "");
return a.length === b.length && a.length > 0 && timingSafeEqual(a, b);
}
function authenticateService(request) {
const authorization = request.headers.authorization || "";
const token = authorization.startsWith("Bearer ") ? authorization.slice(7).trim() : "";
if (!safeEqual(token, serviceToken)) return null;
const issuer = String(request.headers["x-key-vault-actor-issuer"] || "").trim();
const sub = String(request.headers["x-key-vault-actor-sub"] || "").trim();
const username = String(request.headers["x-key-vault-actor-username"] || "").trim();
if (!issuer || !sub || !username) return null;
return {issuer, sub, username, admin: username === adminUsername};
}
const server = http.createServer(async (request, response) => {
try {
if (!request.url) {
sendJson(response, 400, {error: "Bad request"});
return;
}
const url = new URL(request.url, "http://localhost");
const pathname = url.pathname;
if (request.method === "GET" && pathname === "/healthz") {
sendJson(response, 200, {status: "ok", vault: "sealed-at-rest"});
return;
}
const actor = authenticateService(request);
if (!actor) {
sendJson(response, 401, {error: "Trusted service authentication required"});
return;
}
const body = ["POST", "PUT", "PATCH"].includes(request.method || "")
? await readBody(request)
: Buffer.alloc(0);
if (request.method === "GET" && pathname === "/v1/session") {
sendJson(response, 200, {actor});
return;
}
if (request.method === "GET" && pathname === "/v1/providers") {
sendJson(response, 200, {providers: vault.listProviders(actor)});
return;
}
if (request.method === "POST" && pathname === "/v1/providers") {
const provider = normalizeProvider(parseJson(body), {builtin: false});
sendJson(response, 200, {provider: vault.saveCustomProvider(actor, provider)});
return;
}
const providerDelete = pathname.match(/^\/v1\/providers\/([a-z0-9._-]+)\/delete$/);
if (request.method === "POST" && providerDelete) {
sendJson(response, 200, vault.deleteCustomProvider(actor, providerDelete[1]));
return;
}
if (request.method === "GET" && pathname === "/v1/credentials") {
sendJson(response, 200, {
credentials: vault.listCredentials(actor, {all: actor.admin && url.searchParams.get("scope") === "all"})
});
return;
}
if (request.method === "POST" && pathname === "/v1/credentials") {
sendJson(response, 201, {credential: vault.createCredential(actor, parseJson(body))});
return;
}
const credentialAction = pathname.match(/^\/v1\/credentials\/([0-9a-f-]+)\/(replace|verify|delete)$/);
if (request.method === "POST" && credentialAction) {
const [, id, action] = credentialAction;
if (action === "replace") {
sendJson(response, 200, {credential: vault.replaceCredential(actor, id, parseJson(body))});
} else if (action === "verify") {
sendJson(response, 200, vault.verifyCredential(actor, id));
} else {
sendJson(response, 200, vault.deleteCredential(actor, id));
}
return;
}
if (request.method === "POST" && pathname === "/v1/resolve") {
sendJson(response, 200, vault.resolve(actor, parseJson(body)));
return;
}
if (request.method === "GET" && pathname === "/v1/audit") {
sendJson(response, 200, {
events: vault.listAudit(actor, {
all: actor.admin && url.searchParams.get("scope") === "all",
limit: url.searchParams.get("limit")
})
});
return;
}
sendJson(response, 404, {error: "Not found"});
} catch (error) {
console.error("Key Vault request failed", error.message);
sendJson(response, error.statusCode || 400, {error: error.message || "Request failed"});
}
});
server.listen(port, "0.0.0.0", () => {
console.log(`key-vault listening on :${port}`);
});
function shutdown() {
server.close(() => {
vault.close();
process.exit(0);
});
}
process.on("SIGTERM", shutdown);
process.on("SIGINT", shutdown);
+110
View File
@@ -0,0 +1,110 @@
import assert from "node:assert/strict";
import {spawn} from "node:child_process";
import {randomBytes} from "node:crypto";
import {mkdtempSync, rmSync, writeFileSync} from "node:fs";
import http from "node:http";
import {tmpdir} from "node:os";
import {join} from "node:path";
const directory = mkdtempSync(join(tmpdir(), "xiteng-key-vault-api-test-"));
writeFileSync(join(directory, "master-key"), randomBytes(32).toString("base64"));
writeFileSync(join(directory, "service-token"), "integration-service-token");
const portProbe = http.createServer();
await new Promise((resolve) => portProbe.listen(0, "127.0.0.1", resolve));
const port = portProbe.address().port;
await new Promise((resolve) => portProbe.close(resolve));
const service = spawn(process.execPath, ["server.mjs"], {
cwd: new URL(".", import.meta.url),
env: {
...process.env,
PORT: String(port),
DATABASE_PATH: join(directory, "vault.db"),
MASTER_KEY_FILE: join(directory, "master-key"),
SERVICE_TOKEN_FILE: join(directory, "service-token"),
PROVIDER_CATALOG_FILE: new URL("providers.json", import.meta.url).pathname
},
stdio: ["ignore", "pipe", "pipe"]
});
let logs = "";
service.stdout.on("data", (chunk) => { logs += chunk; });
service.stderr.on("data", (chunk) => { logs += chunk; });
function request(pathname, {method = "GET", body} = {}) {
return fetch(`http://127.0.0.1:${port}${pathname}`, {
method,
headers: {
"Authorization": "Bearer integration-service-token",
"Accept": "application/json",
...(body ? {"Content-Type": "application/json"} : {}),
"X-Key-Vault-Actor-Issuer": "https://auth.xiteng.site",
"X-Key-Vault-Actor-Sub": "integration-user",
"X-Key-Vault-Actor-Username": "integration"
},
body: body ? JSON.stringify(body) : undefined
});
}
try {
let ready = false;
for (let attempt = 0; attempt < 50; attempt += 1) {
try {
if ((await fetch(`http://127.0.0.1:${port}/healthz`)).ok) {
ready = true;
break;
}
} catch {}
await new Promise((resolve) => setTimeout(resolve, 50));
}
assert.equal(ready, true, logs || "Key Vault did not start");
const providersResponse = await request("/v1/providers");
assert.equal(providersResponse.status, 200);
const providers = await providersResponse.json();
assert.ok(providers.providers.some((provider) => provider.id === "openai"));
assert.ok(providers.providers.some((provider) => provider.id === "ollama" && provider.credentialState === "local"));
const customResponse = await request("/v1/providers", {
method: "POST",
body: {
id: "integration-provider",
name: "Integration Provider",
api: "openai-completions",
connection: {type: "backend", baseUrl: "https://example.com/v1", proxy: null},
auth: {type: "bearer"},
defaultModel: "test-model",
discovery: {type: "openai-models-list", url: "https://example.com/v1/models"}
}
});
assert.equal(customResponse.status, 200);
const savedCustom = await customResponse.json();
assert.equal(savedCustom.provider.discovery.url, "https://example.com/v1/models");
assert.equal("models" in savedCustom.provider, false);
const createdResponse = await request("/v1/credentials", {
method: "POST",
body: {providerId: "integration-provider", name: "default", secret: {provider: {apiKey: "provider-secret"}}}
});
assert.equal(createdResponse.status, 201);
const resolvedResponse = await request("/v1/resolve", {
method: "POST",
body: {providerId: "integration-provider", credentialName: "default"}
});
assert.equal(resolvedResponse.status, 200);
const resolved = await resolvedResponse.json();
assert.equal(resolved.provider.connection.baseUrl, "https://example.com/v1");
assert.equal(resolved.credential.secret.provider.apiKey, "provider-secret");
const frontendCredential = await request("/v1/credentials", {
method: "POST",
body: {providerId: "ollama", name: "default", secret: "browser-only"}
});
assert.equal(frontendCredential.status, 409);
console.log("Key Vault API tests passed");
} finally {
service.kill("SIGTERM");
await new Promise((resolve) => service.once("exit", resolve));
rmSync(directory, {recursive: true, force: true});
}
+77
View File
@@ -0,0 +1,77 @@
import assert from "node:assert/strict";
import {randomBytes} from "node:crypto";
import {mkdtempSync, rmSync} from "node:fs";
import {tmpdir} from "node:os";
import {join} from "node:path";
import {loadBuiltinProviders, normalizeProvider} from "./providers.mjs";
import {Vault} from "./vault.mjs";
const directory = mkdtempSync(join(tmpdir(), "xiteng-vault-test-"));
const vault = new Vault({
databasePath: join(directory, "vault.db"),
masterKey: randomBytes(32),
builtinProviders: loadBuiltinProviders(new URL("providers.json", import.meta.url))
});
const admin = {issuer: "https://id.xiteng.site", sub: "admin-sub", username: "liooil", admin: true};
const user = {issuer: "https://id.xiteng.site", sub: "user-sub", username: "ziyue", admin: false};
try {
const providers = vault.listProviders(user);
assert.ok(providers.length >= 25);
assert.equal(providers.find((provider) => provider.id === "ollama").connection.type, "frontend");
assert.throws(() => vault.createCredential(user, {
providerId: "ollama",
name: "default",
secret: "must-not-enter-server"
}), /remain in the browser/i);
const custom = normalizeProvider({
id: "team-proxy",
name: "Team Proxy",
api: "openai-completions",
connection: {
type: "backend",
baseUrl: "https://models.example.com/v1",
proxy: {type: "socks5", url: "socks5://proxy.example.com:1080"}
},
auth: {type: "bearer"},
defaultModel: "team-model",
discovery: {type: "openai-models-list", url: "https://models.example.com/v1/catalog"}
});
vault.saveCustomProvider(user, custom);
assert.equal(vault.getProvider(user, "team-proxy").connection.proxy.type, "socks5");
assert.equal(vault.getProvider(user, "team-proxy").discovery.url, "https://models.example.com/v1/catalog");
assert.equal("models" in vault.getProvider(user, "team-proxy"), false);
const credential = vault.createCredential(user, {
providerId: "team-proxy",
name: "default",
secret: {
provider: {apiKey: "secret-one"},
proxy: {username: "proxy-user", password: "proxy-pass"}
}
});
assert.equal(vault.listCredentials(user).length, 1);
assert.equal(vault.listCredentials(admin, {all: true}).length, 1);
assert.equal(vault.verifyCredential(user, credential.id).verified, true);
const resolved = vault.resolve(user, {providerId: "team-proxy", credentialName: "default"});
assert.equal(resolved.provider.id, "team-proxy");
assert.equal(resolved.credential.secret.provider.apiKey, "secret-one");
assert.equal(resolved.credential.secret.proxy.username, "proxy-user");
vault.replaceCredential(user, credential.id, {secret: "secret-two"});
assert.equal(vault.resolve(user, {providerId: "team-proxy"}).credential.secret.provider.apiKey, "secret-two");
assert.throws(() => vault.resolve(admin, {providerId: "team-proxy"}), /provider not found/i);
vault.deleteCredential(user, credential.id);
assert.equal(vault.listCredentials(user).length, 0);
vault.deleteCustomProvider(user, "team-proxy");
assert.equal(vault.getProvider(user, "team-proxy"), null);
assert.ok(vault.listAudit(admin, {all: true}).length >= 7);
console.log("Vault tests passed");
} finally {
vault.close();
rmSync(directory, {recursive: true, force: true});
}
+552
View File
@@ -0,0 +1,552 @@
import {
createCipheriv,
createDecipheriv,
createHash,
randomBytes,
randomUUID
} from "node:crypto";
import {mkdirSync, readFileSync} from "node:fs";
import {DatabaseSync} from "node:sqlite";
import {normalizeProvider} from "./providers.mjs";
function now() {
return new Date().toISOString();
}
function limitedString(value, field, maximum = 120) {
if (typeof value !== "string" || !value.trim()) throw new Error(`${field} is required`);
return value.trim().slice(0, maximum);
}
function secretBuffer(value) {
let normalized;
if (typeof value === "string") {
if (!value) throw new Error("secret is required");
normalized = {provider: {apiKey: value}};
} else if (value && typeof value === "object" && !Array.isArray(value)) {
normalized = value;
} else {
throw new Error("secret is required");
}
const buffer = Buffer.from(JSON.stringify(normalized));
if (buffer.length > 65536) throw new Error("secret is too large");
return buffer;
}
function parseSecret(buffer) {
const text = buffer.toString("utf8");
try {
const parsed = JSON.parse(text);
if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) return parsed;
} catch {}
return {provider: {apiKey: text}};
}
export function loadKeyFile(path) {
const value = readFileSync(path);
const text = value.toString("utf8").trim();
const decoded = /^[A-Fa-f0-9]{64}$/.test(text) ? Buffer.from(text, "hex") : Buffer.from(text, "base64");
if (decoded.length !== 32) {
throw new Error(`Key file ${path} must contain exactly 32 random bytes encoded as base64 or hex`);
}
return decoded;
}
function encrypt(key, plaintext, associatedData) {
const nonce = randomBytes(12);
const cipher = createCipheriv("aes-256-gcm", key, nonce);
cipher.setAAD(Buffer.from(associatedData));
const ciphertext = Buffer.concat([cipher.update(plaintext), cipher.final()]);
return {ciphertext, nonce, tag: cipher.getAuthTag()};
}
function decrypt(key, ciphertext, nonce, tag, associatedData) {
const decipher = createDecipheriv("aes-256-gcm", key, nonce);
decipher.setAAD(Buffer.from(associatedData));
decipher.setAuthTag(tag);
return Buffer.concat([decipher.update(ciphertext), decipher.final()]);
}
function credentialAad(record) {
return `credential:v1:${record.id}:${record.owner_issuer}:${record.owner_sub}:${record.provider}`;
}
function wrappedDekAad(record) {
return `wrapped-dek:v1:${record.id}`;
}
function fingerprint(secret) {
return createHash("sha256").update(secret).digest("hex").slice(0, 16);
}
function publicCredential(row) {
return {
id: row.id,
owner: {
issuer: row.owner_issuer,
sub: row.owner_sub,
username: row.owner_username
},
providerId: row.provider,
name: row.name,
fingerprint: row.fingerprint,
createdAt: row.created_at,
updatedAt: row.updated_at,
lastAccessedAt: row.last_used_at
};
}
export class Vault {
constructor({databasePath, masterKey, builtinProviders}) {
this.masterKey = masterKey;
this.builtinProviders = builtinProviders;
mkdirSync(databasePath.slice(0, databasePath.lastIndexOf("/")) || ".", {recursive: true});
this.database = new DatabaseSync(databasePath);
this.database.exec(`
PRAGMA journal_mode = WAL;
PRAGMA foreign_keys = ON;
PRAGMA busy_timeout = 5000;
CREATE TABLE IF NOT EXISTS vault_credential (
id TEXT PRIMARY KEY,
owner_issuer TEXT NOT NULL,
owner_sub TEXT NOT NULL,
owner_username TEXT NOT NULL,
owner_type TEXT NOT NULL DEFAULT 'user',
provider TEXT NOT NULL,
name TEXT NOT NULL,
ciphertext BLOB NOT NULL,
nonce BLOB NOT NULL,
auth_tag BLOB NOT NULL,
wrapped_dek BLOB NOT NULL,
wrap_nonce BLOB NOT NULL,
wrap_tag BLOB NOT NULL,
key_version INTEGER NOT NULL DEFAULT 1,
fingerprint TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'active',
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL,
last_used_at TEXT,
revoked_at TEXT
);
CREATE INDEX IF NOT EXISTS vault_credential_owner
ON vault_credential(owner_issuer, owner_sub, provider, name);
CREATE TABLE IF NOT EXISTS custom_provider (
id TEXT PRIMARY KEY,
owner_issuer TEXT NOT NULL,
owner_sub TEXT NOT NULL,
owner_username TEXT NOT NULL,
provider_id TEXT NOT NULL,
definition_json TEXT NOT NULL,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL,
UNIQUE(owner_issuer, owner_sub, provider_id)
);
CREATE INDEX IF NOT EXISTS custom_provider_owner
ON custom_provider(owner_issuer, owner_sub, provider_id);
CREATE TABLE IF NOT EXISTS vault_audit_event (
id INTEGER PRIMARY KEY AUTOINCREMENT,
actor_issuer TEXT NOT NULL,
actor_sub TEXT NOT NULL,
actor_username TEXT NOT NULL,
action TEXT NOT NULL,
target_type TEXT NOT NULL,
target_id TEXT,
owner_issuer TEXT,
owner_sub TEXT,
result TEXT NOT NULL,
detail TEXT,
created_at TEXT NOT NULL
);
CREATE INDEX IF NOT EXISTS vault_audit_owner_time
ON vault_audit_event(owner_issuer, owner_sub, created_at DESC);
DROP TABLE IF EXISTS credential_grant;
UPDATE vault_credential SET status = 'active', revoked_at = NULL;
`);
}
close() {
this.database.close();
}
audit(actor, action, targetType, targetId, owner, result = "success", detail = null) {
this.database.prepare(`
INSERT INTO vault_audit_event (
actor_issuer, actor_sub, actor_username, action, target_type, target_id,
owner_issuer, owner_sub, result, detail, created_at
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`).run(
actor.issuer,
actor.sub,
actor.username,
action,
targetType,
targetId,
owner?.issuer || null,
owner?.sub || null,
result,
detail,
now()
);
}
ownerFromInput(actor, input) {
return input.owner && actor.admin
? {
issuer: limitedString(input.owner.issuer, "owner.issuer", 300),
sub: limitedString(input.owner.sub, "owner.sub", 200),
username: limitedString(input.owner.username, "owner.username", 80)
}
: {issuer: actor.issuer, sub: actor.sub, username: actor.username};
}
customProviderDefinition(value) {
return normalizeProvider(JSON.parse(value));
}
listCustomProviders(actor) {
return this.database.prepare(`
SELECT * FROM custom_provider
WHERE owner_issuer = ? AND owner_sub = ?
ORDER BY provider_id COLLATE NOCASE
`).all(actor.issuer, actor.sub).map((row) => ({
...this.customProviderDefinition(row.definition_json),
createdAt: row.created_at,
updatedAt: row.updated_at
}));
}
getProviderForOwner(owner, providerId) {
const id = limitedString(providerId, "providerId", 80).toLowerCase();
const custom = this.database.prepare(`
SELECT definition_json FROM custom_provider
WHERE owner_issuer = ? AND owner_sub = ? AND provider_id = ?
`).get(owner.issuer, owner.sub, id);
return custom ? this.customProviderDefinition(custom.definition_json) : this.builtinProviders.get(id) || null;
}
getProvider(actor, providerId) {
return this.getProviderForOwner(actor, providerId);
}
listProviders(actor) {
const effective = new Map([...this.builtinProviders].map(([id, provider]) => [id, {...provider}]));
for (const provider of this.listCustomProviders(actor)) effective.set(provider.id, provider);
const credentials = this.listCredentials(actor);
return [...effective.values()]
.sort((left, right) => left.name.localeCompare(right.name))
.map((provider) => ({
...provider,
credentials: provider.connection.type === "backend"
? credentials.filter((credential) => credential.providerId === provider.id)
: [],
credentialState: provider.connection.type === "frontend"
? "local"
: credentials.some((credential) => credential.providerId === provider.id) ? "configured" : "missing"
}));
}
saveCustomProvider(actor, provider) {
const timestamp = now();
const existing = this.database.prepare(`
SELECT id, created_at FROM custom_provider
WHERE owner_issuer = ? AND owner_sub = ? AND provider_id = ?
`).get(actor.issuer, actor.sub, provider.id);
const id = existing?.id || randomUUID();
this.database.prepare(`
INSERT INTO custom_provider (
id, owner_issuer, owner_sub, owner_username, provider_id, definition_json, created_at, updated_at
) VALUES (?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(owner_issuer, owner_sub, provider_id) DO UPDATE SET
owner_username = excluded.owner_username,
definition_json = excluded.definition_json,
updated_at = excluded.updated_at
`).run(
id,
actor.issuer,
actor.sub,
actor.username,
provider.id,
JSON.stringify({...provider, builtin: false}),
existing?.created_at || timestamp,
timestamp
);
this.audit(actor, existing ? "provider.update" : "provider.create", "provider", provider.id, actor, "success", provider.connection.type);
return this.getProvider(actor, provider.id);
}
deleteCustomProvider(actor, providerId) {
const id = limitedString(providerId, "providerId", 80).toLowerCase();
const result = this.database.prepare(`
DELETE FROM custom_provider WHERE owner_issuer = ? AND owner_sub = ? AND provider_id = ?
`).run(actor.issuer, actor.sub, id);
if (!result.changes) {
const error = new Error("Custom provider not found");
error.statusCode = 404;
throw error;
}
this.audit(actor, "provider.delete", "provider", id, actor);
return {deleted: true, id};
}
listCredentials(actor, {all = false} = {}) {
const rows = all && actor.admin
? this.database.prepare(`SELECT * FROM vault_credential ORDER BY owner_username, provider, name`).all()
: this.database.prepare(`
SELECT * FROM vault_credential
WHERE owner_issuer = ? AND owner_sub = ?
ORDER BY provider, name
`).all(actor.issuer, actor.sub);
return rows.map(publicCredential);
}
getCredential(id) {
return this.database.prepare("SELECT * FROM vault_credential WHERE id = ?").get(id);
}
assertAccess(actor, row) {
if (!row || (!actor.admin && (row.owner_issuer !== actor.issuer || row.owner_sub !== actor.sub))) {
const error = new Error("Credential not found");
error.statusCode = 404;
throw error;
}
}
createCredential(actor, input) {
const owner = this.ownerFromInput(actor, input);
const providerId = limitedString(input.providerId, "providerId", 80).toLowerCase();
const provider = this.getProviderForOwner(owner, providerId);
if (!provider) {
const error = new Error("Provider not found");
error.statusCode = 404;
throw error;
}
if (provider.connection.type !== "backend") {
const error = new Error("Frontend credentials must remain in the browser");
error.statusCode = 409;
throw error;
}
const name = limitedString(input.name || "default", "name", 120);
const duplicate = this.database.prepare(`
SELECT id FROM vault_credential WHERE owner_issuer = ? AND owner_sub = ? AND provider = ? AND name = ?
`).get(owner.issuer, owner.sub, providerId, name);
if (duplicate) {
const error = new Error("Credential already exists");
error.statusCode = 409;
throw error;
}
const id = randomUUID();
const secret = secretBuffer(input.secret);
const record = {id, owner_issuer: owner.issuer, owner_sub: owner.sub, provider: providerId};
const dek = randomBytes(32);
const encryptedSecret = encrypt(dek, secret, credentialAad(record));
const encryptedDek = encrypt(this.masterKey, dek, wrappedDekAad(record));
const timestamp = now();
this.database.prepare(`
INSERT INTO vault_credential (
id, owner_issuer, owner_sub, owner_username, owner_type, provider, name,
ciphertext, nonce, auth_tag, wrapped_dek, wrap_nonce, wrap_tag,
fingerprint, status, created_at, updated_at
) VALUES (?, ?, ?, ?, 'user', ?, ?, ?, ?, ?, ?, ?, ?, ?, 'active', ?, ?)
`).run(
id, owner.issuer, owner.sub, owner.username, providerId, name,
encryptedSecret.ciphertext, encryptedSecret.nonce, encryptedSecret.tag,
encryptedDek.ciphertext, encryptedDek.nonce, encryptedDek.tag,
fingerprint(secret), timestamp, timestamp
);
secret.fill(0);
dek.fill(0);
this.audit(actor, "credential.create", "credential", id, owner, "success", providerId);
return publicCredential(this.getCredential(id));
}
decryptCredential(row) {
const dek = decrypt(this.masterKey, row.wrapped_dek, row.wrap_nonce, row.wrap_tag, wrappedDekAad(row));
try {
return decrypt(dek, row.ciphertext, row.nonce, row.auth_tag, credentialAad(row));
} finally {
dek.fill(0);
}
}
replaceCredential(actor, id, input) {
const row = this.getCredential(id);
this.assertAccess(actor, row);
const secret = secretBuffer(input.secret);
const dek = randomBytes(32);
const encryptedSecret = encrypt(dek, secret, credentialAad(row));
const encryptedDek = encrypt(this.masterKey, dek, wrappedDekAad(row));
const timestamp = now();
this.database.prepare(`
UPDATE vault_credential
SET ciphertext = ?, nonce = ?, auth_tag = ?, wrapped_dek = ?, wrap_nonce = ?, wrap_tag = ?,
fingerprint = ?, updated_at = ?
WHERE id = ?
`).run(
encryptedSecret.ciphertext, encryptedSecret.nonce, encryptedSecret.tag,
encryptedDek.ciphertext, encryptedDek.nonce, encryptedDek.tag,
fingerprint(secret), timestamp, id
);
secret.fill(0);
dek.fill(0);
this.audit(actor, "credential.replace", "credential", id, {issuer: row.owner_issuer, sub: row.owner_sub});
return publicCredential(this.getCredential(id));
}
verifyCredential(actor, id) {
const row = this.getCredential(id);
this.assertAccess(actor, row);
const plaintext = this.decryptCredential(row);
const verified = fingerprint(plaintext) === row.fingerprint;
plaintext.fill(0);
this.audit(actor, "credential.verify", "credential", id, {issuer: row.owner_issuer, sub: row.owner_sub}, verified ? "success" : "failure");
return {verified, fingerprint: row.fingerprint};
}
deleteCredential(actor, id) {
const row = this.getCredential(id);
this.assertAccess(actor, row);
this.database.prepare("DELETE FROM vault_credential WHERE id = ?").run(id);
this.audit(actor, "credential.delete", "credential", id, {issuer: row.owner_issuer, sub: row.owner_sub});
return {deleted: true, id};
}
reassignCredential(actor, id, ownerInput) {
if (!actor.admin) {
const error = new Error("Administrator required");
error.statusCode = 403;
throw error;
}
const row = this.getCredential(id);
this.assertAccess(actor, row);
const owner = {
issuer: limitedString(ownerInput.issuer, "owner.issuer", 300),
sub: limitedString(ownerInput.sub, "owner.sub", 200),
username: limitedString(ownerInput.username, "owner.username", 80)
};
const provider = this.getProviderForOwner(owner, row.provider);
if (!provider || provider.connection.type !== "backend") {
const error = new Error("Target owner has no matching Backend Provider");
error.statusCode = 409;
throw error;
}
const plaintext = this.decryptCredential(row);
const next = {...row, owner_issuer: owner.issuer, owner_sub: owner.sub};
const dek = randomBytes(32);
const encryptedSecret = encrypt(dek, plaintext, credentialAad(next));
const encryptedDek = encrypt(this.masterKey, dek, wrappedDekAad(next));
this.database.prepare(`
UPDATE vault_credential
SET owner_issuer = ?, owner_sub = ?, owner_username = ?,
ciphertext = ?, nonce = ?, auth_tag = ?, wrapped_dek = ?, wrap_nonce = ?, wrap_tag = ?, updated_at = ?
WHERE id = ?
`).run(
owner.issuer, owner.sub, owner.username,
encryptedSecret.ciphertext, encryptedSecret.nonce, encryptedSecret.tag,
encryptedDek.ciphertext, encryptedDek.nonce, encryptedDek.tag, now(), id
);
plaintext.fill(0);
dek.fill(0);
this.audit(actor, "credential.reassign", "credential", id, owner, "success", `previous owner ${row.owner_username}`);
return publicCredential(this.getCredential(id));
}
resolve(actor, input) {
const providerId = limitedString(input.providerId, "providerId", 80).toLowerCase();
const provider = this.getProvider(actor, providerId);
if (!provider) {
const error = new Error("Provider not found");
error.statusCode = 404;
throw error;
}
if (provider.connection.type !== "backend") {
const error = new Error("Frontend credentials are stored in the browser");
error.statusCode = 409;
throw error;
}
const name = limitedString(input.credentialName || "default", "credentialName", 120);
const row = this.database.prepare(`
SELECT * FROM vault_credential
WHERE owner_issuer = ? AND owner_sub = ? AND provider = ? AND name = ?
`).get(actor.issuer, actor.sub, providerId, name);
if (!row) {
const error = new Error("Credential not found");
error.statusCode = 404;
throw error;
}
const plaintext = this.decryptCredential(row);
const secret = parseSecret(plaintext);
plaintext.fill(0);
this.database.prepare("UPDATE vault_credential SET last_used_at = ? WHERE id = ?").run(now(), row.id);
this.audit(actor, "credential.resolve", "credential", row.id, actor, "success", providerId);
return {provider, credential: {id: row.id, name: row.name, secret}};
}
listAudit(actor, {all = false, limit = 100} = {}) {
const boundedLimit = Math.max(1, Math.min(500, Number.parseInt(limit, 10) || 100));
const rows = all && actor.admin
? this.database.prepare("SELECT * FROM vault_audit_event ORDER BY created_at DESC LIMIT ?").all(boundedLimit)
: this.database.prepare(`
SELECT * FROM vault_audit_event
WHERE owner_issuer = ? AND owner_sub = ?
ORDER BY created_at DESC LIMIT ?
`).all(actor.issuer, actor.sub, boundedLimit);
return rows.map((row) => ({
id: row.id,
actorUsername: row.actor_username,
action: row.action,
targetType: row.target_type,
targetId: row.target_id,
result: row.result,
detail: row.detail,
createdAt: row.created_at
}));
}
rotateMasterKey(actor, newMasterKey) {
if (!actor.admin) throw new Error("Administrator required");
const rows = this.database.prepare("SELECT * FROM vault_credential").all();
this.database.exec("BEGIN IMMEDIATE");
try {
const update = this.database.prepare(`
UPDATE vault_credential
SET wrapped_dek = ?, wrap_nonce = ?, wrap_tag = ?, key_version = key_version + 1, updated_at = ?
WHERE id = ?
`);
for (const row of rows) {
const dek = decrypt(this.masterKey, row.wrapped_dek, row.wrap_nonce, row.wrap_tag, wrappedDekAad(row));
const wrapped = encrypt(newMasterKey, dek, wrappedDekAad(row));
dek.fill(0);
update.run(wrapped.ciphertext, wrapped.nonce, wrapped.tag, now(), row.id);
}
this.database.exec("COMMIT");
} catch (error) {
this.database.exec("ROLLBACK");
throw error;
}
this.masterKey = newMasterKey;
this.audit(actor, "master-key.rotate", "vault", null, null, "success", `${rows.length} DEKs rewrapped`);
return {rewrapped: rows.length};
}
backup(destination) {
const escaped = destination.replaceAll("'", "''");
this.database.exec(`VACUUM INTO '${escaped}'`);
return {destination};
}
}
export function breakGlassActor() {
return {
issuer: "urn:xiteng:break-glass",
sub: "local-emergency-script",
username: "liooil",
admin: true
};
}
+15
View File
@@ -0,0 +1,15 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1600 1000" preserveAspectRatio="xMidYMid slice" role="img" aria-labelledby="title description">
<title id="title">Xiteng login background</title>
<desc id="description">A warm cream grid with simple red, yellow, blue and green geometric shapes.</desc>
<defs>
<pattern id="grid" width="40" height="40" patternUnits="userSpaceOnUse">
<path d="M40 0H0V40" fill="none" stroke="#1c1712" stroke-opacity=".09" stroke-width="2"/>
</pattern>
</defs>
<rect width="1600" height="1000" fill="#f7efe0"/>
<rect width="1600" height="1000" fill="url(#grid)"/>
<rect x="80" y="90" width="210" height="210" rx="32" fill="#f0c94b" stroke="#1c1712" stroke-width="8" transform="rotate(-6 185 195)"/>
<circle cx="1430" cy="170" r="110" fill="#93c9d7" stroke="#1c1712" stroke-width="8"/>
<path d="M1330 775h190v155h-190z" fill="#177a48" stroke="#1c1712" stroke-width="8" transform="rotate(5 1425 852)"/>
<path d="M92 760l160 76-124 112z" fill="#d83b21" stroke="#1c1712" stroke-width="8" stroke-linejoin="round"/>
</svg>

After

Width:  |  Height:  |  Size: 1.1 KiB

+7
View File
@@ -0,0 +1,7 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512" role="img" aria-labelledby="title description">
<title id="title">Xiteng</title>
<desc id="description">A red geometric X on a cream tile with a yellow background.</desc>
<rect width="512" height="512" rx="104" fill="#f0c94b"/>
<rect x="48" y="48" width="416" height="416" rx="80" fill="#fffaf0" stroke="#1c1712" stroke-width="16"/>
<path d="M150 146h70l36 65 36-65h70l-66 110 66 110h-70l-36-65-36 65h-70l66-110z" fill="#d83b21" stroke="#1c1712" stroke-width="14" stroke-linejoin="round"/>
</svg>

After

Width:  |  Height:  |  Size: 569 B

+9
View File
@@ -0,0 +1,9 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 520 112" role="img" aria-labelledby="title description">
<title id="title">Xiteng</title>
<desc id="description">Xiteng wordmark with the red X symbol.</desc>
<rect x="4" y="4" width="104" height="104" rx="22" fill="#f0c94b" stroke="#1c1712" stroke-width="8"/>
<rect x="20" y="20" width="72" height="72" rx="14" fill="#fffaf0" stroke="#1c1712" stroke-width="5"/>
<path d="M34 31h15l7 13 7-13h15L64 56l14 25H63l-7-13-7 13H34l14-25z" fill="#d83b21" stroke="#1c1712" stroke-width="3" stroke-linejoin="round"/>
<text x="132" y="70" fill="#1c1712" font-family="Inter, ui-sans-serif, system-ui, sans-serif" font-size="64" font-weight="850" letter-spacing="-2">Xiteng</text>
<text x="136" y="98" fill="#675f55" font-family="ui-monospace, SFMono-Regular, Menlo, monospace" font-size="15" font-weight="700" letter-spacing="4">HOME SERVICES</text>
</svg>

After

Width:  |  Height:  |  Size: 912 B

+102
View File
@@ -0,0 +1,102 @@
version: 1
metadata:
name: Xiteng - Brand and login flow
labels:
blueprints.goauthentik.io/instantiate: "true"
entries:
- model: authentik_brands.brand
state: present
identifiers:
domain: authentik-default
default: true
attrs:
branding_title: Xiteng
# Versioned repository assets live in Authentik's built-in static tree.
branding_logo: /static/dist/assets/xiteng/xiteng-wordmark-v1.svg
branding_favicon: /static/dist/assets/xiteng/xiteng-favicon-v1.svg
branding_default_flow_background: /static/dist/assets/xiteng/xiteng-background-v1.svg
attributes:
settings:
locale: zh-hans
branding_custom_css: |
:root {
--ak-font-family-sans-serif: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
--ak-font-family-heading: var(--ak-font-family-sans-serif);
--ak-global--background-color: #f7efe0;
--ak-c-login--MaxWidth: 31rem;
--ak-c-login__content-BoxShadow: none;
--ak-c-login__main--BackgroundColor: #fffaf0;
--ak-c-login__main--Color: #1c1712;
--ak-c-login__main--BoxShadow: 8px 8px 0 #1c1712;
--ak-c-login__footer--Color: #675f55;
--pf-global--primary-color--100: #d83b21;
--pf-global--primary-color--200: #a92a18;
--pf-global--Color--100: #1c1712;
--pf-global--Color--200: #675f55;
--pf-global--BorderColor--100: #1c1712;
--pf-global--BorderRadius--sm: 6px;
--pf-c-login__main--BackgroundColor: #fffaf0;
--pf-c-card--BackgroundColor: #fffaf0;
--pf-c-button--m-primary--BackgroundColor: #d83b21;
--pf-c-button--m-primary--hover--BackgroundColor: #bc321d;
--pf-c-button--m-primary--active--BackgroundColor: #a92a18;
--pf-c-button--m-secondary--Color: #1c1712;
--pf-c-button--m-secondary--BorderColor: #1c1712;
}
ak-flow-executor::part(locale-select) {
display: none;
}
.pf-c-login__main,
.pf-c-card {
border: 2px solid #1c1712;
border-radius: 10px;
}
.pf-c-button.pf-m-primary {
border: 2px solid #1c1712;
border-radius: 6px;
box-shadow: 3px 3px 0 #1c1712;
font-weight: 800;
}
.pf-c-button.pf-m-primary:hover {
box-shadow: 1px 1px 0 #1c1712;
transform: translate(2px, 2px);
}
.pf-c-form-control {
border: 2px solid #1c1712;
border-radius: 6px;
}
@media (max-width: 576px) {
:root {
--ak-c-login--MaxWidth: calc(100vw - 2rem);
--ak-c-login__main--BoxShadow: 5px 5px 0 #1c1712;
}
}
- model: authentik_core.application
state: present
identifiers:
slug: xiteng-portal
attrs:
name: Xiteng Portal
meta_launch_url: https://xiteng.site/?focus=authentik#infrastructure
open_in_new_tab: false
meta_description: 返回 xiteng.site 服务目录
meta_publisher: Xiteng
meta_icon: https://xiteng.site/favicon.svg
- model: authentik_flows.flow
state: present
identifiers:
slug: default-authentication-flow
attrs:
name: Xiteng 登录
title: 欢迎回来
designation: authentication
authentication: none
layout: stacked
+39
View File
@@ -0,0 +1,39 @@
version: 1
# Managed ForwardAuth application and outpost assignment for chat.xiteng.site.
metadata:
name: Xiteng Chat ForwardAuth
labels:
blueprints.goauthentik.io/instantiate: "true"
entries:
- id: xiteng-chat-provider
model: authentik_providers_proxy.proxyprovider
state: present
identifiers:
name: Xiteng Chat
attrs:
authorization_flow: !Find [authentik_flows.flow, [slug, default-provider-authorization-implicit-consent]]
invalidation_flow: !Find [authentik_flows.flow, [slug, default-provider-invalidation-flow]]
external_host: https://chat.xiteng.site
mode: forward_single
- model: authentik_core.application
state: present
identifiers:
slug: xiteng-chat
attrs:
name: Xiteng Chat
provider: !KeyOf xiteng-chat-provider
meta_launch_url: https://chat.xiteng.site
open_in_new_tab: true
- model: authentik_outposts.outpost
state: present
identifiers:
name: uptime-kuma-outpost
attrs:
providers:
- !Find [authentik_core.provider, [name, Xiteng Portal Admin]]
- !Find [authentik_core.provider, [name, comfyui-proxy]]
- !Find [authentik_core.provider, [name, invokeai-proxy]]
- !Find [authentik_core.provider, [name, code-server-proxy]]
- !KeyOf xiteng-chat-provider
@@ -0,0 +1,35 @@
version: 1
metadata:
name: Xiteng - Passkey passwordless login
entries:
- model: authentik_stages_identification.identificationstage
state: present
identifiers:
name: default-authentication-identification
attrs:
user_fields:
- email
- username
password_stage: null
captcha_stage: null
webauthn_stage: !Find [authentik_stages_authenticator_validate.authenticatorvalidatestage, [name, default-authentication-mfa-validation]]
case_insensitive_matching: true
show_matched_user: true
pretend_user_exists: true
enable_remember_me: false
enrollment_flow: null
recovery_flow: null
passwordless_flow: null
show_source_labels: false
# Authentik 2026.5 ships the password/MFA skip policies in its default
# authentication blueprint. Remove the temporary local copies if present.
- model: authentik_policies_expression.expressionpolicy
state: absent
identifiers:
name: xiteng-authentication-password-optional
- model: authentik_policies_expression.expressionpolicy
state: absent
identifiers:
name: xiteng-authentication-mfa-after-passkey
+98
View File
@@ -0,0 +1,98 @@
version: 1
metadata:
name: Xiteng password recovery
labels:
blueprints.goauthentik.io/instantiate: "true"
entries:
- id: recovery-flow
model: authentik_flows.flow
state: present
identifiers:
slug: xiteng-recovery
attrs:
name: Xiteng 密码恢复
title: 设置新密码
designation: recovery
authentication: require_unauthenticated
- id: password
model: authentik_stages_prompt.prompt
state: present
identifiers:
name: xiteng-recovery-password
attrs:
field_key: password
label: 新密码
type: password
required: true
order: 0
placeholder: 新密码
placeholder_expression: false
- id: password-repeat
model: authentik_stages_prompt.prompt
state: present
identifiers:
name: xiteng-recovery-password-repeat
attrs:
field_key: password_repeat
label: 再次输入新密码
type: password
required: true
order: 1
placeholder: 再次输入新密码
placeholder_expression: false
- id: password-stage
model: authentik_stages_prompt.promptstage
state: present
identifiers:
name: Xiteng 设置新密码
attrs:
fields:
- !KeyOf password
- !KeyOf password-repeat
validation_policies: []
- id: user-write
model: authentik_stages_user_write.userwritestage
state: present
identifiers:
name: xiteng-recovery-user-write
attrs:
user_creation_mode: never_create
- id: user-login
model: authentik_stages_user_login.userloginstage
state: present
identifiers:
name: xiteng-recovery-user-login
- model: authentik_flows.flowstagebinding
state: present
identifiers:
target: !KeyOf recovery-flow
stage: !KeyOf password-stage
order: 10
- model: authentik_flows.flowstagebinding
state: present
identifiers:
target: !KeyOf recovery-flow
stage: !KeyOf user-write
order: 20
- model: authentik_flows.flowstagebinding
state: present
identifiers:
target: !KeyOf recovery-flow
stage: !KeyOf user-login
order: 30
- model: authentik_brands.brand
state: present
identifiers:
domain: authentik-default
default: true
attrs:
flow_recovery: !KeyOf recovery-flow
+66
View File
@@ -0,0 +1,66 @@
from pathlib import Path
from authentik.core.models import Application, Group, Token, TokenIntents, User
from authentik.policies.models import PolicyBinding
ADMIN_USERNAME = "liooil"
FAMILY_GROUP = "liuhome"
TOKEN_IDENTIFIER = "xiteng-portal-admin"
TOKEN_PATH = Path("/run/secrets/portal_api_token")
MANAGED_APPLICATIONS = [
"xiteng-portal-admin",
"xiteng-portal",
"xiteng-chat",
"code-server",
"comfyui",
"invokeai",
"gitea",
"hedgedoc",
"hedgedoc2",
"minio",
"seaweedfs",
"remark42",
]
liooil = User.objects.get(username=ADMIN_USERNAME)
admin_group = Group.objects.get(name="authentik Admins")
admin_group.users.add(liooil)
User.objects.filter(username="akadmin").delete()
liuhome, _ = Group.objects.get_or_create(
name=FAMILY_GROUP,
defaults={"is_superuser": False},
)
if liuhome.is_superuser:
liuhome.is_superuser = False
liuhome.save(update_fields=["is_superuser"])
for username in ["liooil", "ziyue"]:
liuhome.users.add(User.objects.get(username=username))
for application in Application.objects.filter(slug__in=MANAGED_APPLICATIONS):
PolicyBinding.objects.filter(target=application).delete()
PolicyBinding.objects.create(
target=application,
group=liuhome,
order=0,
enabled=True,
negate=False,
failure_result=False,
)
Token.objects.filter(identifier=TOKEN_IDENTIFIER).delete()
token = Token.objects.create(
identifier=TOKEN_IDENTIFIER,
intent=TokenIntents.INTENT_API,
user=liooil,
description="Xiteng Portal Authentik administration",
expiring=False,
)
TOKEN_PATH.write_text(token.key, encoding="utf-8")
TOKEN_PATH.chmod(0o600)
print(
f"Identity bootstrap complete: administrator={ADMIN_USERNAME}, "
f"group={FAMILY_GROUP}, applications={len(MANAGED_APPLICATIONS)}"
)
+97 -14
View File
@@ -1,6 +1,6 @@
services: services:
authentik: authentik:
image: ghcr.io/goauthentik/server:2026.5.0 image: ghcr.io/goauthentik/server:2026.5.6
container_name: authentik container_name: authentik
restart: unless-stopped restart: unless-stopped
command: server command: server
@@ -18,6 +18,13 @@ services:
volumes: volumes:
- ./media:/media - ./media:/media
- ./custom-templates:/templates - ./custom-templates:/templates
- ./assets:/web/dist/assets/xiteng:ro
- ./blueprints/xiteng-brand.yaml:/blueprints/xiteng-brand.yaml:ro
- ./blueprints/xiteng-chat.yaml:/blueprints/xiteng-chat.yaml:ro
- ./blueprints/xiteng-recovery.yaml:/blueprints/xiteng-recovery.yaml:ro
- ./blueprints/xiteng-passwordless.yaml:/blueprints/xiteng-passwordless.yaml:ro
- ./bootstrap.py:/bootstrap/portal_identity.py:ro
- ./secrets:/run/secrets
labels: labels:
# --- Traefik 路由 --- # --- Traefik 路由 ---
- "traefik.enable=true" - "traefik.enable=true"
@@ -34,37 +41,113 @@ services:
- "traefik.http.routers.authentik-http.rule=Host(`auth.xiteng.site`)" - "traefik.http.routers.authentik-http.rule=Host(`auth.xiteng.site`)"
- "traefik.http.routers.authentik-http.service=authentik" - "traefik.http.routers.authentik-http.service=authentik"
- "traefik.http.routers.authentik-http.entrypoints=web" - "traefik.http.routers.authentik-http.entrypoints=web"
# --- Homepage --- # Label-managed static paths. The shared edge-cache controller creates
- "homepage.group=我的服务" # higher-priority Traefik routes and the matching Cloudflare Cache Rule.
- "homepage.name=Authentik" - "xiteng.site.cache.frontend.enabled=true"
- "homepage.icon=authentik" - "xiteng.site.cache.frontend.routers=authentik,authentik-http"
- "homepage.href=https://auth.xiteng.site" - "xiteng.site.cache.frontend.paths=/static/dist/"
- "homepage.description=统一认证" - "xiteng.site.cache.frontend.edge-ttl=604800"
# --- AutoKuma --- - "xiteng.site.cache.frontend.stale-while-revalidate=86400"
- "kuma.authentik.http.name=Authentik" # --- Xiteng Site dynamic catalog ---
- "kuma.authentik.http.url=http://authentik:9000/-/health/ready/" - "xiteng.site.component.authentik.enabled=true"
- "kuma.authentik.http.interval=60" - "xiteng.site.component.authentik.name=Authentik"
- "kuma.authentik.http.max_retries=3" - "xiteng.site.component.authentik.description=隐藏的 OAuth2/OIDC、ForwardAuth 与身份数据引擎;通过 Portal 管理。"
- "xiteng.site.component.authentik.section=infrastructure"
- "xiteng.site.component.authentik.category=身份与访问"
- "xiteng.site.component.authentik.access=internal"
- "xiteng.site.component.authentik.access-label=后台身份引擎 · 通过 Portal 管理"
- "xiteng.site.component.authentik.icon=AK"
- "xiteng.site.component.authentik.icon-url=https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/authentik.svg"
- "xiteng.site.component.authentik.accent=red"
- "xiteng.site.component.authentik.order=200"
- "xiteng.site.component.authentik.monitor.enabled=true"
- "xiteng.site.component.authentik.monitor.url=http://authentik:9000/-/health/ready/"
networks:
- homelab_net
worker:
image: ghcr.io/goauthentik/server:2026.5.6
container_name: authentik-worker
restart: unless-stopped
command: worker
env_file:
- .env
environment:
AUTHENTIK_REDIS__HOST: redis
AUTHENTIK_POSTGRESQL__HOST: postgres
AUTHENTIK_POSTGRESQL__NAME: authentik
AUTHENTIK_POSTGRESQL__USER: authentik
AUTHENTIK_POSTGRESQL__PASSWORD: ${AUTHENTIK_DB_PASSWORD}
AUTHENTIK_AVATARS: none
AUTHENTIK_LOG_LEVEL: info
user: root
volumes:
- ./media:/media
- ./custom-templates:/templates
- ./blueprints/xiteng-recovery.yaml:/blueprints/xiteng-recovery.yaml:ro
- ./blueprints/xiteng-brand.yaml:/blueprints/xiteng-brand.yaml:ro
- ./blueprints/xiteng-chat.yaml:/blueprints/xiteng-chat.yaml:ro
- ./blueprints/xiteng-passwordless.yaml:/blueprints/xiteng-passwordless.yaml:ro
labels:
- "traefik.enable=false"
- "xiteng.site.component.authentik-worker.enabled=true"
- "xiteng.site.component.authentik-worker.name=Authentik Worker"
- "xiteng.site.component.authentik-worker.description=执行身份系统后台任务并自动应用 Blueprint。"
- "xiteng.site.component.authentik-worker.section=infrastructure"
- "xiteng.site.component.authentik-worker.category=身份与访问"
- "xiteng.site.component.authentik-worker.access=internal"
- "xiteng.site.component.authentik-worker.access-label=后台任务组件"
- "xiteng.site.component.authentik-worker.icon=WK"
- "xiteng.site.component.authentik-worker.icon-url=https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/authentik.svg"
- "xiteng.site.component.authentik-worker.accent=yellow"
- "xiteng.site.component.authentik-worker.order=205"
depends_on:
- postgres
- redis
networks: networks:
- homelab_net - homelab_net
postgres: postgres:
image: postgres:16-alpine image: postgres:16-alpine@sha256:57c72fd2a128e416c7fcc499958864df5301e940bca0a56f58fddf30ffc07777
container_name: authentik-db container_name: authentik-db
restart: unless-stopped restart: unless-stopped
environment: environment:
POSTGRES_USER: authentik POSTGRES_USER: authentik
POSTGRES_PASSWORD: ${AUTHENTIK_DB_PASSWORD} POSTGRES_PASSWORD: ${AUTHENTIK_DB_PASSWORD}
POSTGRES_DB: authentik POSTGRES_DB: authentik
labels:
- "xiteng.site.component.authentik-db.enabled=true"
- "xiteng.site.component.authentik-db.name=Authentik PostgreSQL"
- "xiteng.site.component.authentik-db.description=保存身份、Provider、应用与策略配置。"
- "xiteng.site.component.authentik-db.section=infrastructure"
- "xiteng.site.component.authentik-db.category=数据层"
- "xiteng.site.component.authentik-db.access=internal"
- "xiteng.site.component.authentik-db.access-label=仅容器网络"
- "xiteng.site.component.authentik-db.icon=PG"
- "xiteng.site.component.authentik-db.icon-url=https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/postgresql.svg"
- "xiteng.site.component.authentik-db.accent=blue"
- "xiteng.site.component.authentik-db.order=510"
volumes: volumes:
- ./pgdata:/var/lib/postgresql/data - ./pgdata:/var/lib/postgresql/data
networks: networks:
- homelab_net - homelab_net
redis: redis:
image: redis:alpine image: redis:alpine@sha256:978f0e01593e65eed801f2402944efcd936d43b5027e4908a7897baf88ed6241
container_name: authentik-redis container_name: authentik-redis
restart: unless-stopped restart: unless-stopped
labels:
- "xiteng.site.component.authentik-redis.enabled=true"
- "xiteng.site.component.authentik-redis.name=Authentik Redis"
- "xiteng.site.component.authentik-redis.description=为身份服务提供缓存与任务状态。"
- "xiteng.site.component.authentik-redis.section=infrastructure"
- "xiteng.site.component.authentik-redis.category=数据层"
- "xiteng.site.component.authentik-redis.access=internal"
- "xiteng.site.component.authentik-redis.access-label=仅容器网络"
- "xiteng.site.component.authentik-redis.icon=RD"
- "xiteng.site.component.authentik-redis.icon-url=https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/redis.svg"
- "xiteng.site.component.authentik-redis.accent=red"
- "xiteng.site.component.authentik-redis.order=520"
volumes: volumes:
- ./redis:/data - ./redis:/data
networks: networks:
View File
+5
View File
@@ -0,0 +1,5 @@
node_modules
.git
*.md
start-local-ai.bat
/tmp
+43
View File
@@ -0,0 +1,43 @@
# CAT-BODHI Sprite Segmentation API
# Node.js + Python + PyTorch in one container
#
# Build (CPU, default):
# docker build -t cat-bodhi .
# Build (GPU, requires nvidia-container-toolkit):
# docker build --build-arg TORCH_INDEX=https://download.pytorch.org/whl/cu126 -t cat-bodhi .
FROM python:3.11-slim
ARG NODE_MAJOR=22
ARG TORCH_INDEX=https://download.pytorch.org/whl/cpu
ARG PIP_MIRROR=https://pypi.tuna.tsinghua.edu.cn/simple
# 中科大 Debian 镜像源
RUN sed -i 's|http://deb.debian.org/debian|https://mirrors.ustc.edu.cn/debian|g' /etc/apt/sources.list.d/debian.sources \
&& sed -i 's|http://deb.debian.org/debian-security|https://mirrors.ustc.edu.cn/debian-security|g' /etc/apt/sources.list.d/debian.sources
# 安装 Node.js LTS
RUN apt-get update && apt-get install -y --no-install-recommends \
ca-certificates curl gnupg \
&& mkdir -p /etc/apt/keyrings \
&& curl -fsSL https://deb.nodesource.com/gpgkey/nodesource-repo.gpg.key | gpg --dearmor -o /etc/apt/keyrings/nodesource.gpg \
&& echo "deb [signed-by=/etc/apt/keyrings/nodesource.gpg] https://deb.nodesource.com/node_${NODE_MAJOR}.x nodistro main" > /etc/apt/sources.list.d/nodesource.list \
&& apt-get update && apt-get install -y --no-install-recommends nodejs \
&& rm -rf /var/lib/apt/lists/*
# 安装 PyTorch(从官方 index+ 其他 Python 包(从清华镜像)
RUN pip install --no-cache-dir \
--index-url ${TORCH_INDEX} \
--extra-index-url ${PIP_MIRROR} \
torch torchvision \
opencv-python-headless \
numpy \
Pillow
WORKDIR /app
COPY . .
RUN mkdir -p assets/ai/cats assets/ai/beads
EXPOSE 8080
ENV PORT=8080
CMD ["node", "server.mjs"]
+71
View File
@@ -0,0 +1,71 @@
services:
cat-bodhi:
build:
context: .
# GPU 加速:取消下面注释(需先安装 nvidia-container-toolkit
# args:
# TORCH_INDEX: https://download.pytorch.org/whl/cu126
container_name: cat-bodhi
restart: unless-stopped
env_file:
- .env
environment:
- PORT=8080
- SPRITE_SEG_ROOT=/opt/sprite_alpha_seg_pytorch
- SPRITE_SEG_PYTHON=/usr/local/bin/python
- SPRITE_SEG_CHECKPOINT=/opt/sprite_alpha_seg_pytorch/checkpoints/unet_sprite_ft.pt
- SPRITE_SEG_OUT_DIR=/opt/sprite_alpha_seg_pytorch/outputs/cat_match
volumes:
# 模型文件目录(需要手动放入 infer_spritesheet_hybrid.py + checkpoints/
- ./sprite_alpha_seg_pytorch:/opt/sprite_alpha_seg_pytorch:rw
# AI 生成图片持久化(可选,不挂载则容器重启后丢失)
- ./data/assets-ai:/app/assets/ai:rw
networks:
- homelab_net
# GPU 支持:取消下面注释(需先安装 nvidia-container-toolkit
# deploy:
# resources:
# reservations:
# devices:
# - driver: nvidia
# count: 1
# capabilities: [gpu]
labels:
# ========== Traefik ==========
- "traefik.enable=true"
# HTTPS router (cert management)
- "traefik.http.routers.cat-bodhi.rule=Host(`cat-bodhi.xiteng.site`)"
- "traefik.http.routers.cat-bodhi.entrypoints=websecure"
- "traefik.http.routers.cat-bodhi.tls=true"
- "traefik.http.routers.cat-bodhi.tls.certresolver=cfresolver"
- "traefik.http.services.cat-bodhi.loadbalancer.server.port=8080"
# HTTP router (Cloudflare Tunnel ingress)
- "traefik.http.routers.cat-bodhi-http.rule=Host(`cat-bodhi.xiteng.site`)"
- "traefik.http.routers.cat-bodhi-http.service=cat-bodhi"
- "traefik.http.routers.cat-bodhi-http.entrypoints=web"
- "xiteng.site.cache.cat-bodhi.enabled=true"
- "xiteng.site.cache.cat-bodhi.routers=cat-bodhi,cat-bodhi-http"
- "xiteng.site.cache.cat-bodhi.paths=/assets/,/data/assets-ai/"
- "xiteng.site.cache.cat-bodhi.edge-ttl=604800"
- "xiteng.site.cache.cat-bodhi.stale-while-revalidate=86400"
- "xiteng.site.component.cat-bodhi.enabled=true"
- "xiteng.site.component.cat-bodhi.name=猫猫盘珠日记"
- "xiteng.site.component.cat-bodhi.description=文玩手串与猫咪主题游戏,以及 Sprite 抠图导入工具。"
- "xiteng.site.component.cat-bodhi.section=services"
- "xiteng.site.component.cat-bodhi.category=作品与实验"
- "xiteng.site.component.cat-bodhi.url=https://cat-bodhi.xiteng.site"
- "xiteng.site.component.cat-bodhi.access=public"
- "xiteng.site.component.cat-bodhi.access-label=无需登录"
- "xiteng.site.component.cat-bodhi.icon=猫"
- "xiteng.site.component.cat-bodhi.accent=yellow"
- "xiteng.site.component.cat-bodhi.order=300"
- "xiteng.site.component.cat-bodhi.navigation=new-tab"
- "xiteng.site.component.cat-bodhi.portal-link=embedded"
networks:
homelab_net:
external: true
+7
View File
@@ -0,0 +1,7 @@
node_modules
dist
.next
package-lock.json
npm-debug.log
data
run
+25
View File
@@ -0,0 +1,25 @@
FROM oven/bun:1.3.5-alpine AS dependencies
WORKDIR /app
COPY package.json bun.lock ./
RUN bun install --frozen-lockfile --production
FROM oven/bun:1.3.5-alpine AS builder
WORKDIR /app
COPY package.json bun.lock ./
RUN bun install --frozen-lockfile
COPY . .
RUN bun run build
FROM oven/bun:1.3.5-alpine AS runner
WORKDIR /app
ENV NODE_ENV=production
ENV PORT=3000
ENV STATIC_ROOT=/app/dist
RUN apk add --no-cache su-exec && addgroup -S app && adduser -S app -G app
COPY --from=dependencies --chown=app:app /app/node_modules ./node_modules
COPY --from=builder --chown=app:app /app/dist ./dist
COPY --from=builder --chown=app:app /app/src/server.ts ./src/server.ts
COPY --from=builder --chown=app:app /app/lib ./lib
COPY --chmod=755 entrypoint.sh /entrypoint.sh
EXPOSE 3000
ENTRYPOINT ["/entrypoint.sh"]
+20
View File
@@ -0,0 +1,20 @@
import {cpSync, mkdirSync, readFileSync, rmSync, writeFileSync} from "node:fs";
rmSync("dist", {recursive: true, force: true});
mkdirSync("dist/assets", {recursive: true});
const result = await Bun.build({
entrypoints: ["src/client.ts"],
outdir: "dist/assets",
target: "browser",
minify: true,
sourcemap: "none",
naming: "client.js"
});
if (!result.success) {
for (const log of result.logs) console.error(log);
process.exit(1);
}
const version = Bun.hash(`${readFileSync("src/client.ts", "utf8")}\0${readFileSync("src/styles.css", "utf8")}`).toString(36);
writeFileSync("dist/index.html", readFileSync("src/index.html", "utf8").replaceAll("__ASSET_VERSION__", version));
cpSync("src/styles.css", "dist/styles.css");
cpSync("public", "dist", {recursive: true});
+139
View File
@@ -0,0 +1,139 @@
{
"lockfileVersion": 1,
"configVersion": 0,
"workspaces": {
"": {
"name": "xiteng-chat",
"dependencies": {
"@ai-sdk/anthropic": "4.0.36",
"@ai-sdk/google": "4.0.39",
"@ai-sdk/openai": "4.0.36",
"@ai-sdk/openai-compatible": "3.0.28",
"ai": "7.0.58",
"dompurify": "3.2.6",
"marked": "15.0.12",
"node-fetch": "3.3.2",
"proxy-agent": "8.0.2",
},
"devDependencies": {
"@types/bun": "1.3.5",
"typescript": "5.9.3",
},
},
},
"packages": {
"@ai-sdk/anthropic": ["@ai-sdk/[email protected]", "", { "dependencies": { "@ai-sdk/provider": "4.0.7", "@ai-sdk/provider-utils": "5.0.25" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-Wg5jfray0X4+qkrr73GZ7U7K2JNGx+S+rNY9LorVlXhkhTqnvtNLYWRA1WxSxlCDCw3yjRCJdL9kyc+b7naAog=="],
"@ai-sdk/gateway": ["@ai-sdk/[email protected]", "", { "dependencies": { "@ai-sdk/provider": "4.0.7", "@ai-sdk/provider-utils": "5.0.25", "@vercel/oidc": "3.2.0" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-LIAO6kAG8fpXQb9L0iwPk1FIbXftvqnyC56v5NEAzeWTeL8fUsy/Hx86VPBTWEDFdwbVprjWifJOAqS6AOj3mA=="],
"@ai-sdk/google": ["@ai-sdk/[email protected]", "", { "dependencies": { "@ai-sdk/provider": "4.0.7", "@ai-sdk/provider-utils": "5.0.25" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-+mRx7UBZn9PkJ4J6YXowaRZKMZYa290cknVqqOw/roZaDg186IUOLn9JHNQkvgaj91/MLW1AZNESy1ZD2yXDCg=="],
"@ai-sdk/openai": ["@ai-sdk/[email protected]", "", { "dependencies": { "@ai-sdk/provider": "4.0.7", "@ai-sdk/provider-utils": "5.0.25" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-wHJNArBdjJPXb8GXcA+FslbRt+7qIE1KoHSAVi+CeBFidinVrTVBZKFMJz3mNZMj8YILBMl/VIvTD/oGFjX6/g=="],
"@ai-sdk/openai-compatible": ["@ai-sdk/[email protected]", "", { "dependencies": { "@ai-sdk/provider": "4.0.7", "@ai-sdk/provider-utils": "5.0.25" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-vF/852mCFiASq2fAPE1mA0E0w78NH68/X2Gukuh4TeJrMIp+CJfoR5QLuY2+pTx7UiLmuYWDpCQhWaupuM7+Xg=="],
"@ai-sdk/provider": ["@ai-sdk/[email protected]", "", { "dependencies": { "json-schema": "^0.4.0" } }, "sha512-6or44XprPzKbr8zkmzosowSE0pxkvJcoojBL+mCZvPUt3kvXp3XSNqeVun9golb1acEfSo6yaEBRT18h2VU+1Q=="],
"@ai-sdk/provider-utils": ["@ai-sdk/[email protected]", "", { "dependencies": { "@ai-sdk/provider": "4.0.7", "@standard-schema/spec": "^1.1.0", "@workflow/serde": "4.1.0", "eventsource-parser": "^3.0.8", "undici": "^7.28.0" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-xscPPHCSjCHWrdhai25sbHCJeKNLW/3D1uSpUZa4cEtTKXA8OnPQ3+Rfu1SmM5Ea/Mf8Dfn3cllw9zeMzo/zFA=="],
"@standard-schema/spec": ["@standard-schema/[email protected]", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="],
"@types/bun": ["@types/[email protected]", "", { "dependencies": { "bun-types": "1.3.5" } }, "sha512-RnygCqNrd3srIPEWBd5LFeUYG7plCoH2Yw9WaZGyNmdTEei+gWaHqydbaIRkIkcbXwhBT94q78QljxN0Sk838w=="],
"@types/node": ["@types/[email protected]", "", { "dependencies": { "undici-types": "~7.18.0" } }, "sha512-RpV6r/ij22zRRdyBPcxDeKAzH43phWVKEjL2iksqo1Vz3CuBUrgmPpPhALKiRfU7OMCmeeO9vECBMsV0hMTG8Q=="],
"@types/trusted-types": ["@types/[email protected]", "", {}, "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw=="],
"@vercel/oidc": ["@vercel/[email protected]", "", {}, "sha512-UycprH3T6n3jH0k44NHMa7pnFHGu/N05MjojYr+Mc6I7obkoLIJujSWwin1pCvdy/eOxrI/l3uDLQsmcrOb4ug=="],
"@workflow/serde": ["@workflow/[email protected]", "", {}, "sha512-pav4F2BoirECWR7Nf1TKt+2eETcBj7jj4cBefQ8VXQCA6NPkaKeLfj/zMgi+3zYV5ZIBT4GuUiphsj0/b9hPQQ=="],
"agent-base": ["[email protected]", "", {}, "sha512-TQf59BsZnytt8GdJKLPfUZ54g/iaUL2OWDSFCCvMOhsHduDQxO8xC4PNeyIkVcA5KwL2phPSv0douC0fgWzmnA=="],
"ai": ["[email protected]", "", { "dependencies": { "@ai-sdk/gateway": "4.0.46", "@ai-sdk/provider": "4.0.7", "@ai-sdk/provider-utils": "5.0.25" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-GfgO90CQQ0yYuoxJAUOeQ6tviyYw1BUIDygSZ1q3Ce6kSc93tYmB5eltKY/NxC0YOouAax7JvDqVnYxvIAr04Q=="],
"ast-types": ["[email protected]", "", { "dependencies": { "tslib": "^2.0.1" } }, "sha512-x1FCFnFifvYDDzTaLII71vG5uvDwgtmDTEVWAxrgeiR8VjMONcCXJx7E+USjDtHlwFmt9MysbqgF9b9Vjr6w+w=="],
"basic-ftp": ["[email protected]", "", {}, "sha512-bopVNp6ugyA150DDuZfPFdt1KZ5a94ZDiwX4hMgZDzF+GttD80lEy8kj98kbyhLXnPvhtIo93mdnLIjpCAeeOw=="],
"bun-types": ["[email protected]", "", { "dependencies": { "@types/node": "*" } }, "sha512-inmAYe2PFLs0SUbFOWSVD24sg1jFlMPxOjOSSCYqUgn4Hsc3rDc7dFvfVYjFPNHtov6kgUeulV4SxbuIV/stPw=="],
"data-uri-to-buffer": ["[email protected]", "", {}, "sha512-0R9ikRb668HB7QDxT1vkpuUBtqc53YyAwMwGeUFKRojY/NWKvdZ+9UYtRfGmhqNbRkTSVpMbmyhXipFFv2cb/A=="],
"debug": ["[email protected]", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="],
"degenerator": ["[email protected]", "", { "dependencies": { "ast-types": "^0.13.4", "escodegen": "^2.1.0", "esprima": "^4.0.1" }, "peerDependencies": { "quickjs-wasi": "^2.2.0" } }, "sha512-ABErK0IefDSyHjlPH7WUEenIAX2rPPnrDcDM+TS3z3+zu9TfyKKi07BQM+8rmxpdE2y1v5fjjdoAS/x4D2U60w=="],
"dompurify": ["[email protected]", "", { "optionalDependencies": { "@types/trusted-types": "^2.0.7" } }, "sha512-/2GogDQlohXPZe6D6NOgQvXLPSYBqIWMnZ8zzOhn09REE4eyAzb+Hed3jhoM9OkuaJ8P6ZGTTVWQKAi8ieIzfQ=="],
"escodegen": ["[email protected]", "", { "dependencies": { "esprima": "^4.0.1", "estraverse": "^5.2.0", "esutils": "^2.0.2" }, "optionalDependencies": { "source-map": "~0.6.1" }, "bin": { "escodegen": "bin/escodegen.js", "esgenerate": "bin/esgenerate.js" } }, "sha512-2NlIDTwUWJN0mRPQOdtQBzbUHvdGY2P1VXSyU83Q3xKxM7WHX2Ql8dKq782Q9TgQUNOLEzEYu9bzLNj1q88I5w=="],
"esprima": ["[email protected]", "", { "bin": { "esparse": "bin/esparse.js", "esvalidate": "bin/esvalidate.js" } }, "sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A=="],
"estraverse": ["[email protected]", "", {}, "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA=="],
"esutils": ["[email protected]", "", {}, "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g=="],
"eventsource-parser": ["[email protected]", "", {}, "sha512-kJezFj9YFAMLeORyi7aCLxLbD5/qWMQnoMVlVPyHIll7lgRJCc3JVln9Vgl9nwQi0YkMnhdGTMNn7CkRRAptMg=="],
"fetch-blob": ["[email protected]", "", { "dependencies": { "node-domexception": "^1.0.0", "web-streams-polyfill": "^3.0.3" } }, "sha512-7yAQpD2UMJzLi1Dqv7qFYnPbaPx7ZfFK6PiIxQ4PfkGPyNyl2Ugx+a/umUonmKqjhM4DnfbMvdX6otXq83soQQ=="],
"formdata-polyfill": ["[email protected]", "", { "dependencies": { "fetch-blob": "^3.1.2" } }, "sha512-buewHzMvYL29jdeQTVILecSaZKnt/RJWjoZCF5OW60Z67/GmSLBkOFM7qh1PI3zFNtJbaZL5eQu1vLfazOwj4g=="],
"get-uri": ["[email protected]", "", { "dependencies": { "basic-ftp": "^5.3.1", "data-uri-to-buffer": "8.0.0", "debug": "^4.3.4" } }, "sha512-/5N/P4Lrh0p/mDwlDRi7Y1+P2o/OyzZI3l6Iz1Ov6XXwwm1y3RlZLuo3gVgML99djrEDtV980bBxSuOeHLk8ww=="],
"http-proxy-agent": ["[email protected]", "", { "dependencies": { "agent-base": "9.0.0", "debug": "^4.3.4", "proxy-agent-negotiate": "1.1.0" } }, "sha512-2NxoveTT58mjYT4n3RPTEfCZGLMbidoO8XEieXfpSYxu+PQJ1qpx4ypwH6N+uF9twBPIvRRgvkvW5HUTYWENig=="],
"https-proxy-agent": ["[email protected]", "", { "dependencies": { "agent-base": "9.0.0", "debug": "^4.3.4", "proxy-agent-negotiate": "1.1.0" } }, "sha512-ag87y7cJJ9/3+GxFr8Oy4O5faDsGRGnBGsJj/YjOSsSx/5eadKLYTMPlzuR6obgoCDDm0abAAZitXXQkMOPSpA=="],
"ip-address": ["[email protected]", "", {}, "sha512-R5SnVLJmgYYvf2F2ZgwSBnelz5G4q5AxIC277GDfUaNbrZKNANcBC7RHqYYePlszf4kBolVkJauG0ZjHHFh55g=="],
"json-schema": ["[email protected]", "", {}, "sha512-es94M3nTIfsEPisRafak+HDLfHXnKBhV3vU5eqPcS3flIWqcxJWgXHXiey3YrpaNsanY5ei1VoYEbOzijuq9BA=="],
"lru-cache": ["[email protected]", "", {}, "sha512-jumlc0BIUrS3qJGgIkWZsyfAM7NCWiBcCDhnd+3NNM5KbBmLTgHVfWBcg6W+rLUsIpzpERPsvwUP7CckAQSOoA=="],
"marked": ["[email protected]", "", { "bin": { "marked": "bin/marked.js" } }, "sha512-8dD6FusOQSrpv9Z1rdNMdlSgQOIP880DHqnohobOmYLElGEqAL/JvxvuxZO16r4HtjTlfPRDC1hbvxC9dPN2nA=="],
"ms": ["[email protected]", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="],
"netmask": ["[email protected]", "", {}, "sha512-eonl3sLUha+S1GzTPxychyhnUzKyeQkZ7jLjKrBagJgPla13F+uQ71HgpFefyHgqrjEbCPkDArxYsjY8/+gLKA=="],
"node-domexception": ["[email protected]", "", {}, "sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ=="],
"node-fetch": ["[email protected]", "", { "dependencies": { "data-uri-to-buffer": "^4.0.0", "fetch-blob": "^3.1.4", "formdata-polyfill": "^4.0.10" } }, "sha512-dRB78srN/l6gqWulah9SrxeYnxeddIG30+GOqK/9OlLVyLg3HPnr6SqOWTWOXKRwC2eGYCkZ59NNuSgvSrpgOA=="],
"pac-proxy-agent": ["[email protected]", "", { "dependencies": { "agent-base": "9.0.0", "debug": "^4.3.4", "get-uri": "8.0.1", "http-proxy-agent": "9.1.0", "https-proxy-agent": "9.1.0", "pac-resolver": "9.0.1", "quickjs-wasi": "^2.2.0", "socks-proxy-agent": "10.1.0" } }, "sha512-1aU+1mpj3DrQPfo3gh+3Gap3G5x+axnMx1P/y0ZF2ch7kb2meyOCAH8K2k9d27ROsTE7TnAerzxqF9aon2jqnA=="],
"pac-resolver": ["[email protected]", "", { "dependencies": { "degenerator": "7.0.1", "netmask": "^2.0.2" }, "peerDependencies": { "quickjs-wasi": "^2.2.0" } }, "sha512-lJbS008tmkj08VhoM8Hzuv/VE5tK9MS0OIQ/7+s0lIF+BYhiQWFYzkSpML7lXs9iBu2jfmzBTLzhe9n6BX+dYw=="],
"proxy-agent": ["[email protected]", "", { "dependencies": { "agent-base": "9.0.0", "debug": "^4.3.4", "http-proxy-agent": "9.1.0", "https-proxy-agent": "9.1.0", "lru-cache": "^7.14.1", "pac-proxy-agent": "9.1.0", "proxy-from-env": "^2.0.0", "socks-proxy-agent": "10.1.0" } }, "sha512-idLLRewuemWd7GH/BDJzGiB0dWGfT2SQs3jy6NtZtGWU9uPTTSdeC1/cdbqLwgzhfv027daGFuXX426e2Eg20A=="],
"proxy-agent-negotiate": ["[email protected]", "", { "peerDependencies": { "kerberos": "^2.0.0" }, "optionalPeers": ["kerberos"] }, "sha512-N8IBcM3UgCVzz2L2Lqv8DVntDnnC8/hiV4nEDUPkqq72TPUgYWjQc+bdZlBPZK9LzPAvOY//gAt0S0DApoOXWQ=="],
"proxy-from-env": ["[email protected]", "", {}, "sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA=="],
"quickjs-wasi": ["[email protected]", "", {}, "sha512-zQxXmQMrEoD3S+jQdYsloq4qAuaxKFHZj6hHqOYGwB2iQZH+q9e/lf5zQPXCKOk0WJuAjzRFbO4KwHIp2D05Iw=="],
"smart-buffer": ["[email protected]", "", {}, "sha512-94hK0Hh8rPqQl2xXc3HsaBoOXKV20MToPkcXvwbISWLEs+64sBq5kFgn2kJDHb1Pry9yrP0dxrCI9RRci7RXKg=="],
"socks": ["[email protected]", "", { "dependencies": { "ip-address": "^10.1.1", "smart-buffer": "^4.2.0" } }, "sha512-LJhUYUvItdQ0LkJTmPeaEObWXAqFyfmP85x0tch/ez9cahmhlBBLbIqDFnvBnUJGagb0JbIQrkBs1wJ+yRYpEw=="],
"socks-proxy-agent": ["[email protected]", "", { "dependencies": { "agent-base": "9.0.0", "debug": "^4.3.4", "socks": "^2.8.3" } }, "sha512-WlMj/67cEJ6MDI1OcsnjuYKDNDoyPCCYZ249kuuXPiMDw9F8PXkVaQ7YWu3siTydfQ/4BEZcvGzu+aYvz7dDCQ=="],
"source-map": ["[email protected]", "", {}, "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g=="],
"tslib": ["[email protected]", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="],
"typescript": ["[email protected]", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="],
"undici": ["[email protected]", "", {}, "sha512-IDxfleLmmbSskfWSUATiN1nfn2rDuvnMOqb5CWR92iIfojA0Ud+ulOAAEQ57LPr9rWmsreUyf5lwyao+7GNNVw=="],
"undici-types": ["[email protected]", "", {}, "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w=="],
"web-streams-polyfill": ["[email protected]", "", {}, "sha512-d2JWLCivmZYTSIoge9MsgFCZrt571BikcWGYkjC1khllbTeDlGqZ2D8vD8E/lJa8WGWbb7Plm8/XJYV7IJHZZw=="],
"zod": ["[email protected]", "", {}, "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ=="],
"get-uri/data-uri-to-buffer": ["[email protected]", "", {}, "sha512-6UHfyCux51b8PTGDgveqtz1tvphBku5DrMKKJbFAZAJOI2zsjDpDoYE1+QGj7FOMS4BdTFNJsJiR3zEB0xH0yQ=="],
}
}
+103
View File
@@ -0,0 +1,103 @@
services:
provider-proxy-host:
image: node:24-alpine
container_name: xiteng-provider-proxy-host
restart: unless-stopped
network_mode: host
command: ["node", "/app/proxy-bridge.mjs"]
environment:
BRIDGE_MODE: host
volumes:
- ./proxy-bridge.mjs:/app/proxy-bridge.mjs:ro
- ./run:/run/provider-proxy
security_opt:
- no-new-privileges:true
healthcheck:
test: ["CMD", "node", "-e", "const s=require('net').connect(7897,'127.0.0.1',()=>{s.end();process.exit(0)});s.setTimeout(2000,()=>process.exit(1));s.on('error',()=>process.exit(1))"]
interval: 15s
timeout: 3s
retries: 3
start_period: 5s
provider-proxy-bridge:
image: node:24-alpine
container_name: xiteng-provider-proxy-bridge
restart: unless-stopped
command: ["node", "/app/proxy-bridge.mjs"]
environment:
BRIDGE_MODE: network
volumes:
- ./proxy-bridge.mjs:/app/proxy-bridge.mjs:ro
- ./run:/run/provider-proxy
security_opt:
- no-new-privileges:true
healthcheck:
test: ["CMD", "node", "-e", "const s=require('net').connect(17897,'127.0.0.1',()=>{s.end();process.exit(0)});s.setTimeout(2000,()=>process.exit(1));s.on('error',()=>process.exit(1))"]
interval: 15s
timeout: 3s
retries: 3
start_period: 5s
networks:
- homelab_net
chat:
build:
context: .
image: xiteng-chat:local
container_name: xiteng-chat
restart: unless-stopped
environment:
KEY_VAULT_URL: http://ai-gateway:8093
KEY_VAULT_TOKEN_FILE: /run/secrets/portal_gateway_hmac
CHAT_DATABASE_PATH: /data/chat.db
PORTAL_URL: http://xiteng-site:8080
volumes:
- ../ai-gateway/secrets/portal_gateway_hmac:/run/secrets/portal_gateway_hmac:ro
- ./data:/data
security_opt:
- no-new-privileges:true
healthcheck:
test: ["CMD", "wget", "-q", "-O", "/dev/null", "http://127.0.0.1:3000/api/health"]
interval: 15s
timeout: 5s
retries: 3
start_period: 10s
networks:
- homelab_net
labels:
- "traefik.enable=true"
- "traefik.http.services.xiteng-chat.loadbalancer.server.port=3000"
- "traefik.http.middlewares.xiteng-chat-scheme.headers.customrequestheaders.X-Forwarded-Proto=https"
- "traefik.http.middlewares.xiteng-chat-auth.forwardauth.address=http://authentik-outpost:9000/outpost.goauthentik.io/auth/traefik"
- "traefik.http.middlewares.xiteng-chat-auth.forwardauth.trustForwardHeader=true"
- "traefik.http.middlewares.xiteng-chat-auth.forwardauth.authResponseHeaders=X-authentik-username,X-authentik-email,X-authentik-name,X-authentik-uid"
- "traefik.http.routers.xiteng-chat.rule=Host(`chat.xiteng.site`)"
- "traefik.http.routers.xiteng-chat.entrypoints=websecure"
- "traefik.http.routers.xiteng-chat.tls=true"
- "traefik.http.routers.xiteng-chat.tls.certresolver=cfresolver"
- "traefik.http.routers.xiteng-chat.service=xiteng-chat"
- "traefik.http.routers.xiteng-chat.middlewares=xiteng-chat-scheme,xiteng-chat-auth"
- "traefik.http.routers.xiteng-chat-http.rule=Host(`chat.xiteng.site`)"
- "traefik.http.routers.xiteng-chat-http.entrypoints=web"
- "traefik.http.routers.xiteng-chat-http.service=xiteng-chat"
- "traefik.http.routers.xiteng-chat-http.middlewares=xiteng-chat-scheme,xiteng-chat-auth"
- "xiteng.site.component.chat.enabled=true"
- "xiteng.site.component.chat.name=Xiteng Chat"
- "xiteng.site.component.chat.description=基于 assistant-ui 与个人 Key Vault 的轻量 AI 对话界面。"
- "xiteng.site.component.chat.section=services"
- "xiteng.site.component.chat.category=AI"
- "xiteng.site.component.chat.url=https://chat.xiteng.site"
- "xiteng.site.component.chat.access=sso"
- "xiteng.site.component.chat.access-label=需要登录"
- "xiteng.site.component.chat.icon=AI"
- "xiteng.site.component.chat.icon-url=https://xiteng.site/icons/services/chat.svg"
- "xiteng.site.component.chat.accent=blue"
- "xiteng.site.component.chat.order=190"
- "xiteng.site.component.chat.navigation=new-tab"
- "xiteng.site.component.chat.portal-link=embedded"
- "xiteng.site.component.chat.monitor.enabled=true"
- "xiteng.site.component.chat.monitor.url=http://xiteng-chat:3000/api/health"
networks:
homelab_net:
external: true
+7
View File
@@ -0,0 +1,7 @@
#!/bin/sh
set -eu
install -m 0400 -o app -g app "$KEY_VAULT_TOKEN_FILE" /tmp/key_vault_service_token
export KEY_VAULT_TOKEN_FILE=/tmp/key_vault_service_token
install -d -m 0700 -o app -g app /data
exec su-exec app bun src/server.ts
+130
View File
@@ -0,0 +1,130 @@
import assert from "node:assert/strict";
import {mkdtempSync, rmSync, writeFileSync} from "node:fs";
import http from "node:http";
import {tmpdir} from "node:os";
import path from "node:path";
import {spawn} from "node:child_process";
const root = process.cwd();
const temporaryDirectory = mkdtempSync(path.join(tmpdir(), "xiteng-chat-history-test-"));
const databasePath = path.join(temporaryDirectory, "chat.db");
const tokenPath = path.join(temporaryDirectory, "service-token");
writeFileSync(tokenPath, "history-test-token");
function availablePort() {
return new Promise((resolve, reject) => {
const server = http.createServer();
server.once("error", reject);
server.listen(0, "127.0.0.1", () => {
const port = server.address().port;
server.close(() => resolve(port));
});
});
}
async function waitForServer(url, child) {
const deadline = Date.now() + 30000;
while (Date.now() < deadline) {
if (child.exitCode !== null) throw new Error(`Bun server exited with code ${child.exitCode}`);
try {
if ((await fetch(url)).ok) return;
} catch {}
await new Promise((resolve) => setTimeout(resolve, 100));
}
throw new Error("Timed out waiting for history test server");
}
async function startServer() {
const port = await availablePort();
const child = spawn(process.execPath, [path.join(root, "src/server.ts")], {
cwd: root,
env: {
...process.env,
PORT: String(port),
STATIC_ROOT: path.join(root, "dist"),
CHAT_DATABASE_PATH: databasePath,
KEY_VAULT_URL: "http://127.0.0.1:1",
KEY_VAULT_TOKEN_FILE: tokenPath
},
stdio: "ignore"
});
await waitForServer(`http://127.0.0.1:${port}/api/health`, child);
return {child, origin: `http://127.0.0.1:${port}`};
}
function stopServer(child) {
if (child.exitCode !== null) return Promise.resolve();
return new Promise((resolve) => {
child.once("exit", resolve);
child.kill("SIGTERM");
});
}
async function api(origin, identity, pathname, init = {}) {
const response = await fetch(`${origin}${pathname}`, {
...init,
headers: {
"X-Authentik-Username": identity.username,
"X-Authentik-Uid": identity.sub,
...(init.body ? {"Content-Type": "application/json"} : {}),
...(init.headers || {})
}
});
const payload = response.status === 204 ? null : await response.json();
return {response, payload};
}
const owner = {username: "history-owner", sub: "owner-sub"};
const other = {username: "history-other", sub: "other-sub"};
let server;
try {
server = await startServer();
const initialSettings = {reasoning: "low", showReasoningSummary: false, temperature: null, maxOutputTokens: null};
const createdResult = await api(server.origin, owner, "/api/conversations", {
method: "POST",
body: JSON.stringify({providerId: "openai", model: "gpt-test", generationSettings: initialSettings})
});
assert.equal(createdResult.response.status, 201);
assert.deepEqual(createdResult.payload.conversation.generationSettings, initialSettings);
const conversationId = createdResult.payload.conversation.id;
const messages = [
{id: "user-1", role: "user", parts: [{type: "text", text: "persistent question"}]},
{id: "assistant-1", role: "assistant", parts: [{type: "reasoning", text: "persistent reasoning"}, {type: "text", text: "persistent answer"}], metadata: {custom: {response: {providerId: "openai", model: "gpt-test", durationMs: 2000, outputTokens: 40, tokensPerSecond: 20}}}}
];
const savedResult = await api(server.origin, owner, `/api/conversations/${conversationId}`, {
method: "PUT",
body: JSON.stringify({providerId: "openai", model: "gpt-test", messages})
});
assert.equal(savedResult.response.status, 200);
assert.equal(savedResult.payload.conversation.title, "persistent question");
assert.equal(savedResult.payload.conversation.messageCount, 2);
const updatedSettings = {reasoning: "high", showReasoningSummary: true, temperature: 0.7, maxOutputTokens: 4096};
const settingsResult = await api(server.origin, owner, `/api/conversations/${conversationId}`, {
method: "PATCH",
body: JSON.stringify({providerId: "openai", model: "gpt-test", generationSettings: updatedSettings})
});
assert.equal(settingsResult.response.status, 200);
await stopServer(server.child);
server = await startServer();
const restored = await api(server.origin, owner, `/api/conversations/${conversationId}`);
assert.equal(restored.response.status, 200);
assert.deepEqual(restored.payload.conversation.messages, messages);
assert.deepEqual(restored.payload.conversation.generationSettings, updatedSettings);
const ownerList = await api(server.origin, owner, "/api/conversations");
assert.equal(ownerList.payload.conversations.length, 1);
assert.equal(ownerList.payload.conversations[0].id, conversationId);
const otherList = await api(server.origin, other, "/api/conversations");
assert.deepEqual(otherList.payload.conversations, []);
assert.equal((await api(server.origin, other, `/api/conversations/${conversationId}`)).response.status, 404);
assert.equal((await api(server.origin, other, `/api/conversations/${conversationId}`, {method: "DELETE"})).response.status, 404);
assert.equal((await api(server.origin, owner, `/api/conversations/${conversationId}`, {method: "DELETE"})).response.status, 204);
assert.equal((await api(server.origin, owner, `/api/conversations/${conversationId}`)).response.status, 404);
console.log("Chat history API tests passed");
} finally {
if (server) await stopServer(server.child);
rmSync(temporaryDirectory, {recursive: true, force: true});
}
+23
View File
@@ -0,0 +1,23 @@
import type {ProviderDefinition, ProviderSecret} from "./provider-types";
export function createBrowserProviderFetch(provider: ProviderDefinition, secret: ProviderSecret) {
const proxy = provider.connection.proxy;
if (!proxy) return fetch;
if (proxy.type !== "relay") throw new Error(`Unsupported frontend proxy: ${proxy.type}`);
return async (input: RequestInfo | URL, init: RequestInit = {}) => {
const headers = new Headers(init.headers);
const relayHeaders: Record<string, string> = {"Content-Type": "application/json"};
if (secret.proxy?.token) relayHeaders.Authorization = `Bearer ${secret.proxy.token}`;
return fetch(proxy.url, {
method: "POST",
headers: relayHeaders,
body: JSON.stringify({
url: String(input),
method: init.method || "GET",
headers: Object.fromEntries(headers.entries()),
body: typeof init.body === "string" ? init.body : null
}),
signal: init.signal
});
};
}
+24
View File
@@ -0,0 +1,24 @@
import type {ProviderDefinition, ProviderSecret} from "./provider-types";
function inferredDiscoveryUrl(provider: ProviderDefinition, baseUrl: string) {
if (provider.discovery.type === "anthropic-models-list") return `${baseUrl.endsWith("/v1") ? baseUrl : `${baseUrl}/v1`}/models?limit=200`;
if (provider.discovery.type === "google-models-list") return `${baseUrl}/models?pageSize=200`;
return `${baseUrl}/models`;
}
export function applyBrowserProviderSettings(provider: ProviderDefinition, secret: ProviderSecret) {
const configuredBaseUrl = secret.provider?.baseUrl?.trim();
if (!configuredBaseUrl) return provider;
const url = new URL(configuredBaseUrl);
if (!["http:", "https:"].includes(url.protocol)) throw new Error("Provider Base URL 必须使用 http 或 https");
const baseUrl = configuredBaseUrl.replace(/\/+$/, "");
const originalBaseUrl = provider.connection.baseUrl.replace(/\/+$/, "");
const discoveryUrl = provider.discovery.url.startsWith(originalBaseUrl)
? inferredDiscoveryUrl(provider, baseUrl)
: provider.discovery.url;
return {
...provider,
connection: {...provider.connection, baseUrl},
discovery: {...provider.discovery, url: discoveryUrl}
};
}
+159
View File
@@ -0,0 +1,159 @@
import type {Conversation, ConversationSummary, StoredChatMessage} from "./conversation-types";
import type {GenerationSettings} from "./generation-settings";
import {
cacheConversation,
cacheConversationSummaries,
listPendingConversationChanges,
loadCachedConversation,
loadCachedConversationSummaries,
queueConversationChange,
removeCachedConversation,
removePendingConversationChange
} from "./offline-history";
class ConversationHttpError extends Error {
constructor(message: string, readonly status: number) {
super(message);
}
}
async function conversationRequest<T>(path: string, init?: RequestInit) {
const response = await fetch(path, {
cache: "no-store",
...init,
headers: {
"Accept": "application/json",
...(init?.body ? {"Content-Type": "application/json"} : {}),
...(init?.headers || {})
}
});
if (response.status === 204) return undefined as T;
const payload = await response.json();
if (!response.ok) throw new ConversationHttpError(payload.error || `HTTP ${response.status}`, response.status);
return payload as T;
}
function isNetworkFailure(error: unknown) {
return error instanceof TypeError || (typeof navigator !== "undefined" && !navigator.onLine);
}
export async function listConversationHistory() {
try {
const payload = await conversationRequest<{conversations: ConversationSummary[]}>("/api/conversations");
await cacheConversationSummaries(payload.conversations);
return payload.conversations;
} catch (error) {
if (!isNetworkFailure(error)) throw error;
const cached = await loadCachedConversationSummaries();
if (cached.length === 0) throw error;
return cached;
}
}
export async function createConversationHistory(providerId: string, model: string, generationSettings: GenerationSettings) {
const payload = await conversationRequest<{conversation: Conversation}>("/api/conversations", {
method: "POST",
body: JSON.stringify({providerId, model, generationSettings})
});
await cacheConversation(payload.conversation);
return payload.conversation;
}
export async function getConversationHistory(id: string) {
try {
const payload = await conversationRequest<{conversation: Conversation}>(`/api/conversations/${encodeURIComponent(id)}`);
await cacheConversation(payload.conversation);
return payload.conversation;
} catch (error) {
if (!isNetworkFailure(error)) throw error;
const cached = await loadCachedConversation(id);
if (!cached) throw error;
return cached;
}
}
export async function updateConversationHistory(id: string, providerId: string, model: string, generationSettings: GenerationSettings) {
const body = JSON.stringify({providerId, model, generationSettings});
try {
return await conversationRequest<{updated: true}>(`/api/conversations/${encodeURIComponent(id)}`, {method: "PATCH", body});
} catch (error) {
if (!isNetworkFailure(error)) throw error;
const cached = await loadCachedConversation(id);
if (cached) await cacheConversation({...cached, providerId, model, generationSettings, updatedAt: new Date().toISOString()});
await queueConversationChange({conversationId: id, method: "PATCH", body});
return {updated: true as const};
}
}
export async function saveConversationHistory(id: string, providerId: string, model: string, messages: StoredChatMessage[]) {
const body = JSON.stringify({providerId, model, messages});
try {
const payload = await conversationRequest<{conversation: Conversation}>(`/api/conversations/${encodeURIComponent(id)}`, {method: "PUT", body});
await cacheConversation(payload.conversation);
return payload.conversation;
} catch (error) {
if (!isNetworkFailure(error)) throw error;
const cached = await loadCachedConversation(id);
if (!cached) throw error;
const firstUserText = messages.find((message) => message.role === "user")?.parts
.filter((part) => part.type === "text" && typeof part.text === "string")
.map((part) => String(part.text)).join(" ").replace(/\s+/g, " ").trim();
const updated: Conversation = {
...cached,
title: firstUserText?.slice(0, 48) || cached.title,
providerId,
model,
messages,
messageCount: messages.length,
updatedAt: new Date().toISOString()
};
await cacheConversation(updated);
await queueConversationChange({conversationId: id, method: "PUT", body});
return updated;
}
}
export async function deleteConversationHistory(id: string) {
try {
await conversationRequest<void>(`/api/conversations/${encodeURIComponent(id)}`, {method: "DELETE"});
} catch (error) {
if (!isNetworkFailure(error)) throw error;
await queueConversationChange({conversationId: id, method: "DELETE"});
}
await removeCachedConversation(id);
}
export async function flushPendingConversationChanges() {
const pending = await listPendingConversationChanges();
for (const change of pending) {
try {
await conversationRequest<void>(`/api/conversations/${encodeURIComponent(change.conversationId)}`, {
method: change.method,
body: change.body
});
await removePendingConversationChange(change.cacheKey);
} catch (error) {
if (error instanceof ConversationHttpError && change.method === "DELETE" && error.status === 404) {
await removePendingConversationChange(change.cacheKey);
continue;
}
if (isNetworkFailure(error)) break;
throw error;
}
}
}
export async function synchronizeOfflineConversationHistory(summaries?: ConversationSummary[]) {
const history = summaries || (await conversationRequest<{conversations: ConversationSummary[]}>("/api/conversations")).conversations;
await cacheConversationSummaries(history);
let cursor = 0;
const worker = async () => {
while (cursor < history.length) {
const summary = history[cursor++];
const payload = await conversationRequest<{conversation: Conversation}>(`/api/conversations/${encodeURIComponent(summary.id)}`);
await cacheConversation(payload.conversation);
}
};
await Promise.all(Array.from({length: Math.min(4, history.length)}, () => worker()));
return history;
}
+18
View File
@@ -0,0 +1,18 @@
import {describe, expect, test} from "bun:test";
import {conversationHash, conversationIdFromHash} from "./conversation-hash.ts";
describe("conversation hash routing", () => {
test("round-trips a conversation id", () => {
const id = "f60dbe18-92ca-4a7d-9d5d-242d0ed4d042";
expect(conversationIdFromHash(conversationHash(id))).toBe(id);
});
test("preserves URL-sensitive ids", () => {
expect(conversationIdFromHash(conversationHash("local/id + draft"))).toBe("local/id + draft");
});
test("ignores unrelated or oversized hashes", () => {
expect(conversationIdFromHash("#services")).toBe("");
expect(conversationIdFromHash(`#conversation=${"x".repeat(121)}`)).toBe("");
});
});
+13
View File
@@ -0,0 +1,13 @@
const conversationHashKey = "conversation";
export function conversationIdFromHash(hash: string) {
const input = hash.startsWith("#") ? hash.slice(1) : hash;
const id = new URLSearchParams(input).get(conversationHashKey)?.trim() || "";
return id && id.length <= 120 ? id : "";
}
export function conversationHash(id: string) {
const parameters = new URLSearchParams();
parameters.set(conversationHashKey, id);
return `#${parameters.toString()}`;
}
+31
View File
@@ -0,0 +1,31 @@
import type {GenerationSettings} from "./generation-settings";
export type ResponseMetadata = {
providerId: string;
model: string;
durationMs: number;
outputTokens: number | null;
tokensPerSecond: number | null;
};
export type StoredChatMessage = {
id: string;
role: "system" | "user" | "assistant";
parts: Array<Record<string, unknown> & {type: string}>;
metadata?: {custom?: {response?: ResponseMetadata}};
};
export type ConversationSummary = {
id: string;
title: string;
providerId: string;
model: string;
messageCount: number;
createdAt: string;
updatedAt: string;
};
export type Conversation = ConversationSummary & {
generationSettings: GenerationSettings;
messages: StoredChatMessage[];
};
+235
View File
@@ -0,0 +1,235 @@
import {randomUUID} from "node:crypto";
import {mkdirSync} from "node:fs";
import path from "node:path";
import {Database} from "bun:sqlite";
import {normalizeGenerationSettings, type GenerationSettings} from "./generation-settings";
import type {ChatIdentity} from "./key-vault";
import type {Conversation, ConversationSummary, ResponseMetadata, StoredChatMessage} from "./conversation-types";
const databasePath = process.env.CHAT_DATABASE_PATH || "/data/chat.db";
let database: Database | undefined;
function getDatabase() {
if (database) return database;
mkdirSync(path.dirname(databasePath), {recursive: true});
const opened = new Database(databasePath, {create: true, strict: true});
opened.run(`
PRAGMA journal_mode = WAL;
PRAGMA foreign_keys = ON;
PRAGMA busy_timeout = 5000;
CREATE TABLE IF NOT EXISTS chat_conversation (
id TEXT PRIMARY KEY,
owner_issuer TEXT NOT NULL,
owner_sub TEXT NOT NULL,
title TEXT NOT NULL,
provider_id TEXT NOT NULL,
model TEXT NOT NULL,
settings_json TEXT NOT NULL DEFAULT '{}',
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
);
CREATE INDEX IF NOT EXISTS chat_conversation_owner_updated
ON chat_conversation (owner_issuer, owner_sub, updated_at DESC);
CREATE TABLE IF NOT EXISTS chat_message (
conversation_id TEXT NOT NULL REFERENCES chat_conversation(id) ON DELETE CASCADE,
id TEXT NOT NULL,
ordinal INTEGER NOT NULL,
role TEXT NOT NULL CHECK (role IN ('system', 'user', 'assistant')),
parts_json TEXT NOT NULL,
created_at TEXT NOT NULL,
PRIMARY KEY (conversation_id, id),
UNIQUE (conversation_id, ordinal)
);
`);
const columns = opened.query("PRAGMA table_info(chat_conversation)").all() as Array<{name: string}>;
if (!columns.some((column) => column.name === "settings_json")) {
opened.run("ALTER TABLE chat_conversation ADD COLUMN settings_json TEXT NOT NULL DEFAULT '{}'");
}
database = opened;
return opened;
}
type ConversationRow = {
id: string;
title: string;
provider_id: string;
model: string;
settings_json: string;
message_count: number;
created_at: string;
updated_at: string;
};
type MessageRow = {id: string; role: StoredChatMessage["role"]; parts_json: string};
function now() {
return new Date().toISOString();
}
function requiredString(value: unknown, field: string, maximum: number) {
if (typeof value !== "string" || !value.trim()) throw new Error(`${field} is required`);
return value.trim().slice(0, maximum);
}
function conversationSummary(row: ConversationRow): ConversationSummary {
return {
id: row.id,
title: row.title,
providerId: row.provider_id,
model: row.model,
messageCount: Number(row.message_count),
createdAt: row.created_at,
updatedAt: row.updated_at
};
}
function normalizeMessages(value: unknown): StoredChatMessage[] {
if (!Array.isArray(value) || value.length > 500) throw new Error("messages must contain at most 500 entries");
return value.map((message, index) => {
if (!message || typeof message !== "object" || Array.isArray(message)) throw new Error(`messages[${index}] is invalid`);
const record = message as Record<string, unknown>;
const role = record.role;
if (!["system", "user", "assistant"].includes(String(role))) throw new Error(`messages[${index}].role is invalid`);
if (!Array.isArray(record.parts)) throw new Error(`messages[${index}].parts is required`);
const partsJson = JSON.stringify(record.parts);
if (Buffer.byteLength(partsJson) > 1024 * 1024) throw new Error(`messages[${index}] is too large`);
const metadataJson = JSON.stringify(record.metadata || {});
if (Buffer.byteLength(metadataJson) > 64 * 1024) throw new Error(`messages[${index}].metadata is too large`);
return {
id: typeof record.id === "string" && record.id.trim() ? record.id.trim().slice(0, 160) : randomUUID(),
role: role as StoredChatMessage["role"],
parts: JSON.parse(partsJson) as StoredChatMessage["parts"],
...(record.metadata && typeof record.metadata === "object" && !Array.isArray(record.metadata) ? {metadata: JSON.parse(metadataJson) as StoredChatMessage["metadata"]} : {})
};
});
}
function inferredTitle(messages: StoredChatMessage[]) {
const userMessage = messages.find((message) => message.role === "user");
const text = userMessage?.parts
.filter((part) => part.type === "text" && typeof part.text === "string")
.map((part) => String(part.text))
.join(" ")
.replace(/\s+/g, " ")
.trim();
return text ? text.slice(0, 60) : "新对话";
}
function ownedConversation(identity: ChatIdentity, id: string) {
return getDatabase().query(`
SELECT c.id, c.title, c.provider_id, c.model, c.settings_json, c.created_at, c.updated_at,
(SELECT COUNT(*) FROM chat_message m WHERE m.conversation_id = c.id) AS message_count
FROM chat_conversation c
WHERE c.id = ? AND c.owner_issuer = ? AND c.owner_sub = ?
`).get(id, identity.issuer, identity.sub) as ConversationRow | undefined;
}
export function listConversations(identity: ChatIdentity): ConversationSummary[] {
return (getDatabase().query(`
SELECT c.id, c.title, c.provider_id, c.model, c.settings_json, c.created_at, c.updated_at,
(SELECT COUNT(*) FROM chat_message m WHERE m.conversation_id = c.id) AS message_count
FROM chat_conversation c
WHERE c.owner_issuer = ? AND c.owner_sub = ?
ORDER BY c.updated_at DESC
LIMIT 100
`).all(identity.issuer, identity.sub) as ConversationRow[]).map(conversationSummary);
}
export function createConversation(identity: ChatIdentity, input: {providerId: unknown; model: unknown; generationSettings?: unknown}): Conversation {
const providerId = requiredString(input.providerId, "providerId", 80);
const model = requiredString(input.model, "model", 300);
const generationSettings = normalizeGenerationSettings(input.generationSettings);
const timestamp = now();
const id = randomUUID();
getDatabase().run("BEGIN IMMEDIATE");
try {
getDatabase().query(`
DELETE FROM chat_conversation
WHERE owner_issuer = ? AND owner_sub = ?
AND NOT EXISTS (SELECT 1 FROM chat_message WHERE conversation_id = chat_conversation.id)
`).run(identity.issuer, identity.sub);
getDatabase().query(`
INSERT INTO chat_conversation (id, owner_issuer, owner_sub, title, provider_id, model, settings_json, created_at, updated_at)
VALUES (?, ?, ?, '新对话', ?, ?, ?, ?, ?)
`).run(id, identity.issuer, identity.sub, providerId, model, JSON.stringify(generationSettings), timestamp, timestamp);
getDatabase().run("COMMIT");
} catch (error) {
getDatabase().run("ROLLBACK");
throw error;
}
return {...conversationSummary({id, title: "新对话", provider_id: providerId, model, settings_json: JSON.stringify(generationSettings), message_count: 0, created_at: timestamp, updated_at: timestamp}), generationSettings, messages: []};
}
export function getConversation(identity: ChatIdentity, id: string): Conversation | null {
const row = ownedConversation(identity, id);
if (!row) return null;
const messages = (getDatabase().query(`
SELECT id, role, parts_json FROM chat_message WHERE conversation_id = ? ORDER BY ordinal
`).all(id) as MessageRow[]).map((message) => {
const parsed = JSON.parse(message.parts_json) as StoredChatMessage["parts"];
const metadataPart = parsed.find((part) => part.type === "data-response-metadata" && part.data && typeof part.data === "object");
return {
id: message.id,
role: message.role,
parts: parsed.filter((part) => part.type !== "data-response-metadata"),
...(metadataPart ? {metadata: {custom: {response: metadataPart.data as ResponseMetadata}}} : {})
};
});
return {...conversationSummary(row), generationSettings: normalizeGenerationSettings(JSON.parse(row.settings_json || "{}")), messages};
}
export function updateConversationSettings(identity: ChatIdentity, id: string, input: {providerId: unknown; model: unknown; generationSettings?: unknown}) {
if (!ownedConversation(identity, id)) return false;
const providerId = requiredString(input.providerId, "providerId", 80);
const model = requiredString(input.model, "model", 300);
const generationSettings = normalizeGenerationSettings(input.generationSettings);
getDatabase().query(`
UPDATE chat_conversation SET provider_id = ?, model = ?, settings_json = ?, updated_at = ?
WHERE id = ? AND owner_issuer = ? AND owner_sub = ?
`).run(providerId, model, JSON.stringify(generationSettings), now(), id, identity.issuer, identity.sub);
return true;
}
export function saveConversationMessages(identity: ChatIdentity, id: string, input: {
providerId: unknown;
model: unknown;
messages: unknown;
}) {
const existing = ownedConversation(identity, id);
if (!existing) return null;
const providerId = requiredString(input.providerId, "providerId", 80);
const model = requiredString(input.model, "model", 300);
const messages = normalizeMessages(input.messages);
const timestamp = now();
const title = existing.title === "新对话" ? inferredTitle(messages) : existing.title;
getDatabase().run("BEGIN IMMEDIATE");
try {
getDatabase().query("DELETE FROM chat_message WHERE conversation_id = ?").run(id);
const insert = getDatabase().query(`
INSERT INTO chat_message (conversation_id, id, ordinal, role, parts_json, created_at)
VALUES (?, ?, ?, ?, ?, ?)
`);
messages.forEach((message, ordinal) => {
const parts = message.metadata?.custom?.response
? [...message.parts, {type: "data-response-metadata", data: message.metadata.custom.response}]
: message.parts;
insert.run(id, message.id, ordinal, message.role, JSON.stringify(parts), timestamp);
});
getDatabase().query(`
UPDATE chat_conversation SET title = ?, provider_id = ?, model = ?, updated_at = ?
WHERE id = ? AND owner_issuer = ? AND owner_sub = ?
`).run(title, providerId, model, timestamp, id, identity.issuer, identity.sub);
getDatabase().run("COMMIT");
} catch (error) {
getDatabase().run("ROLLBACK");
throw error;
}
return getConversation(identity, id);
}
export function deleteConversation(identity: ChatIdentity, id: string) {
const result = getDatabase().query(`
DELETE FROM chat_conversation WHERE id = ? AND owner_issuer = ? AND owner_sub = ?
`).run(id, identity.issuer, identity.sub);
return result.changes > 0;
}
+64
View File
@@ -0,0 +1,64 @@
import {describe, expect, test} from "bun:test";
import {generationCallOptions, normalizeGenerationSettings} from "./generation-settings.ts";
function provider(api) {
return {
id: api,
name: api,
api,
connection: {type: "backend", baseUrl: "https://example.com/v1", proxy: null},
auth: {type: "bearer"},
headers: {},
defaultModel: "test-model",
discovery: {type: "openai-models-list", url: "https://example.com/v1/models"},
builtin: false,
credentialState: "configured",
credentials: []
};
}
describe("generation settings", () => {
test("normalizes user-controlled values", () => {
expect(normalizeGenerationSettings({reasoning: "high", showReasoningSummary: true, temperature: 5, maxOutputTokens: 12.8})).toEqual({
reasoning: "high",
showReasoningSummary: true,
temperature: 2,
maxOutputTokens: 12
});
});
test("maps OpenAI reasoning and summary", () => {
expect(generationCallOptions(provider("openai-responses"), {
reasoning: "high",
showReasoningSummary: true,
temperature: null,
maxOutputTokens: 4096
})).toEqual({
reasoning: "high",
maxOutputTokens: 4096,
providerOptions: {openai: {reasoningSummary: "auto"}}
});
});
test("maps Anthropic adaptive thinking", () => {
expect(generationCallOptions(provider("anthropic-messages"), {
reasoning: "medium",
showReasoningSummary: true,
temperature: 0.4,
maxOutputTokens: null
})).toEqual({
reasoning: "medium",
temperature: 0.4,
providerOptions: {anthropic: {thinking: {type: "adaptive", display: "summarized"}}}
});
});
test("keeps auto mode provider-default", () => {
expect(generationCallOptions(provider("openai-completions"), {
reasoning: "auto",
showReasoningSummary: false,
temperature: null,
maxOutputTokens: null
})).toEqual({});
});
});
+55
View File
@@ -0,0 +1,55 @@
import type {ProviderDefinition} from "./provider-types";
type JsonValue = null | string | number | boolean | JsonValue[] | {[key: string]: JsonValue};
export type ReasoningLevel = "auto" | "none" | "low" | "medium" | "high";
export type GenerationSettings = {
reasoning: ReasoningLevel;
showReasoningSummary: boolean;
temperature: number | null;
maxOutputTokens: number | null;
};
export const defaultGenerationSettings: GenerationSettings = {
reasoning: "auto",
showReasoningSummary: false,
temperature: null,
maxOutputTokens: null
};
export function normalizeGenerationSettings(value: unknown): GenerationSettings {
const input = value && typeof value === "object" && !Array.isArray(value) ? value as Record<string, unknown> : {};
const reasoning = ["auto", "none", "low", "medium", "high"].includes(String(input.reasoning))
? input.reasoning as ReasoningLevel
: "auto";
const temperature = typeof input.temperature === "number" && Number.isFinite(input.temperature)
? Math.min(2, Math.max(0, input.temperature))
: null;
const maxOutputTokens = typeof input.maxOutputTokens === "number" && Number.isFinite(input.maxOutputTokens)
? Math.min(1_000_000, Math.max(1, Math.floor(input.maxOutputTokens)))
: null;
return {
reasoning,
showReasoningSummary: input.showReasoningSummary === true,
temperature,
maxOutputTokens
};
}
export function generationCallOptions(provider: ProviderDefinition, settings: GenerationSettings) {
const providerOptions: Record<string, {[key: string]: JsonValue}> = {};
if (provider.api === "openai-responses") {
providerOptions.openai = {reasoningSummary: settings.showReasoningSummary ? "auto" : null};
} else if (provider.api === "anthropic-messages" && settings.showReasoningSummary && settings.reasoning !== "none") {
providerOptions.anthropic = {thinking: {type: "adaptive", display: "summarized"}};
} else if (provider.api === "google-generative-ai" && settings.showReasoningSummary) {
providerOptions.google = {thinkingConfig: {includeThoughts: true}};
}
return {
...(settings.reasoning !== "auto" ? {reasoning: settings.reasoning} : {}),
...(settings.temperature !== null ? {temperature: settings.temperature} : {}),
...(settings.maxOutputTokens !== null ? {maxOutputTokens: settings.maxOutputTokens} : {}),
...(Object.keys(providerOptions).length ? {providerOptions} : {})
};
}
+53
View File
@@ -0,0 +1,53 @@
import {readFileSync} from "node:fs";
export type ChatIdentity = {issuer: string; sub: string; username: string; name: string; email: string};
type HeaderReader = Pick<Headers, "get">;
const keyVaultUrl = process.env.KEY_VAULT_URL || "http://ai-gateway:8093";
const authentikIssuer = process.env.AUTHENTIK_ISSUER || "https://auth.xiteng.site";
const serviceTokenFile = process.env.KEY_VAULT_TOKEN_FILE || "/run/secrets/portal_gateway_hmac";
let serviceToken: string | null = null;
function loadServiceToken() {
if (!serviceToken) serviceToken = readFileSync(/* turbopackIgnore: true */ serviceTokenFile, "utf8").trim();
return serviceToken;
}
export function identityFromHeaders(headers: HeaderReader): ChatIdentity {
const username = headers.get("x-authentik-username")?.trim() || "";
const sub = headers.get("x-authentik-uid")?.trim() || "";
if (!username || !sub) {
const error = new Error("Authenticated user context is required");
Object.assign(error, {statusCode: 401});
throw error;
}
return {
issuer: authentikIssuer,
sub,
username,
name: headers.get("x-authentik-name")?.trim() || username,
email: headers.get("x-authentik-email")?.trim() || ""
};
}
export function keyVaultFetch(
pathname: string,
identity: ChatIdentity,
init: {method?: string; body?: string; headers?: HeadersInit} = {}
) {
const method = init.method || "GET";
return fetch(new URL(pathname, keyVaultUrl), {
method,
headers: {
"Accept": "application/json",
"Authorization": `Bearer ${loadServiceToken()}`,
...init.headers,
"X-Key-Vault-Actor-Issuer": identity.issuer,
"X-Key-Vault-Actor-Sub": identity.sub,
"X-Key-Vault-Actor-Username": identity.username
},
body: init.body,
cache: "no-store",
signal: AbortSignal.timeout(300000)
});
}
+44
View File
@@ -0,0 +1,44 @@
import {describe, expect, test} from "bun:test";
import {applyBrowserProviderSettings} from "./browser-provider-settings.ts";
import {discoverProviderModels} from "./provider-connectivity.ts";
function llamaProvider() {
return {
id: "llama.cpp",
name: "llama.cpp",
api: "openai-completions",
connection: {type: "frontend", baseUrl: "http://127.0.0.1:8080/v1", proxy: null},
auth: {type: "none"},
headers: {},
defaultModel: "local-model",
discovery: {type: "openai-models-list", url: "http://127.0.0.1:8080/v1/models"},
builtin: true,
credentialState: "local",
credentials: []
};
}
describe("llama.cpp discovery", () => {
test("applies a browser-local endpoint override", () => {
const provider = applyBrowserProviderSettings(llamaProvider(), {provider: {baseUrl: "http://192.168.4.20:8081/v1/"}});
expect(provider.connection.baseUrl).toBe("http://192.168.4.20:8081/v1");
expect(provider.discovery.url).toBe("http://192.168.4.20:8081/v1/models");
});
test("falls back to /props when /v1/models is unavailable", async () => {
const requests = [];
const result = await discoverProviderModels(llamaProvider(), {}, async (input) => {
requests.push(String(input));
if (String(input).endsWith("/v1/models")) {
return new Response(JSON.stringify({error: "Not Found"}), {status: 404, headers: {"Content-Type": "application/json"}});
}
return new Response(JSON.stringify({
model_path: "/models/Qwen3.5-9B-Q4_K_M.gguf",
default_generation_settings: {n_ctx: 32768}
}), {status: 200, headers: {"Content-Type": "application/json"}});
});
expect(requests).toEqual(["http://127.0.0.1:8080/v1/models", "http://127.0.0.1:8080/props"]);
expect(result.endpoint).toBe("http://127.0.0.1:8080/props");
expect(result.models).toEqual([{id: "Qwen3.5-9B-Q4_K_M.gguf", name: "Qwen3.5-9B-Q4_K_M.gguf", ownedBy: "llamacpp", contextWindow: 32768}]);
});
});
+66
View File
@@ -0,0 +1,66 @@
import type {ProviderSecret} from "./provider-types";
export type LocalCredential = {
id: string;
providerId: string;
name: string;
secret: ProviderSecret;
createdAt: string;
updatedAt: string;
};
const databaseName = "xiteng-chat-local-vault";
const storeName = "credentials";
function openDatabase() {
return new Promise<IDBDatabase>((resolve, reject) => {
const request = indexedDB.open(databaseName, 1);
request.onupgradeneeded = () => {
const database = request.result;
if (!database.objectStoreNames.contains(storeName)) database.createObjectStore(storeName, {keyPath: "id"});
};
request.onsuccess = () => resolve(request.result);
request.onerror = () => reject(request.error || new Error("Unable to open local credential store"));
});
}
async function transaction<T>(mode: IDBTransactionMode, operation: (store: IDBObjectStore) => IDBRequest<T>) {
const database = await openDatabase();
return new Promise<T>((resolve, reject) => {
const request = operation(database.transaction(storeName, mode).objectStore(storeName));
request.onsuccess = () => resolve(request.result);
request.onerror = () => reject(request.error || new Error("Local credential operation failed"));
}).finally(() => database.close());
}
export function localCredentialId(providerId: string, name = "default") {
return `${providerId}/${name}`;
}
export function getLocalCredential(providerId: string, name = "default") {
return transaction<LocalCredential | undefined>("readonly", (store) => store.get(localCredentialId(providerId, name)));
}
export async function listLocalCredentials() {
return transaction<LocalCredential[]>("readonly", (store) => store.getAll());
}
export async function saveLocalCredential(providerId: string, name: string, secret: ProviderSecret) {
const id = localCredentialId(providerId, name);
const existing = await getLocalCredential(providerId, name);
const timestamp = new Date().toISOString();
const credential: LocalCredential = {
id,
providerId,
name,
secret,
createdAt: existing?.createdAt || timestamp,
updatedAt: timestamp
};
await transaction("readwrite", (store) => store.put(credential));
return credential;
}
export function deleteLocalCredential(providerId: string, name = "default") {
return transaction("readwrite", (store) => store.delete(localCredentialId(providerId, name)));
}
+173
View File
@@ -0,0 +1,173 @@
import type {Conversation, ConversationSummary} from "./conversation-types";
const databaseName = "xiteng-chat-offline";
const databaseVersion = 1;
const activeProfileKey = "xiteng-chat-offline-profile";
type CachedProfile<T = unknown> = {
id: string;
config: T;
summaries: ConversationSummary[];
updatedAt: string;
};
type CachedConversation = Conversation & {cacheKey: string; profileId: string};
export type PendingConversationChange = {
cacheKey: string;
profileId: string;
conversationId: string;
method: "PUT" | "PATCH" | "DELETE";
body?: string;
createdAt: string;
};
function openDatabase() {
return new Promise<IDBDatabase>((resolve, reject) => {
const request = indexedDB.open(databaseName, databaseVersion);
request.onupgradeneeded = () => {
const database = request.result;
if (!database.objectStoreNames.contains("profiles")) database.createObjectStore("profiles", {keyPath: "id"});
if (!database.objectStoreNames.contains("conversations")) {
const conversations = database.createObjectStore("conversations", {keyPath: "cacheKey"});
conversations.createIndex("profileId", "profileId");
}
if (!database.objectStoreNames.contains("pending")) {
const pending = database.createObjectStore("pending", {keyPath: "cacheKey"});
pending.createIndex("profileId", "profileId");
}
};
request.onsuccess = () => resolve(request.result);
request.onerror = () => reject(request.error || new Error("Unable to open offline history"));
});
}
async function transaction<T>(storeName: string, mode: IDBTransactionMode, run: (store: IDBObjectStore) => IDBRequest<T>) {
const database = await openDatabase();
return new Promise<T>((resolve, reject) => {
const current = database.transaction(storeName, mode);
const request = run(current.objectStore(storeName));
request.onsuccess = () => resolve(request.result);
request.onerror = () => reject(request.error || new Error(`Offline ${storeName} operation failed`));
current.oncomplete = () => database.close();
current.onerror = () => reject(current.error || new Error(`Offline ${storeName} transaction failed`));
});
}
function activeProfileId() {
return window.localStorage.getItem(activeProfileKey) || "";
}
function conversationCacheKey(profileId: string, conversationId: string) {
return `${profileId}:${conversationId}`;
}
export function activateOfflineProfile(profileId: string) {
window.localStorage.setItem(activeProfileKey, profileId);
}
export async function cacheChatConfig<T>(profileId: string, config: T) {
activateOfflineProfile(profileId);
const current = await transaction<CachedProfile<T> | undefined>("profiles", "readonly", (store) => store.get(profileId));
const profile: CachedProfile<T> = {
id: profileId,
config,
summaries: current?.summaries || [],
updatedAt: new Date().toISOString()
};
await transaction<IDBValidKey>("profiles", "readwrite", (store) => store.put(profile));
}
export async function loadCachedChatConfig<T>() {
const profileId = activeProfileId();
if (!profileId) return null;
const profile = await transaction<CachedProfile<T> | undefined>("profiles", "readonly", (store) => store.get(profileId));
return profile ? {profileId, config: profile.config, updatedAt: profile.updatedAt} : null;
}
export async function cacheConversationSummaries(summaries: ConversationSummary[]) {
const profileId = activeProfileId();
if (!profileId) return;
const current = await transaction<CachedProfile | undefined>("profiles", "readonly", (store) => store.get(profileId));
if (!current) return;
await transaction<IDBValidKey>("profiles", "readwrite", (store) => store.put({
...current,
summaries,
updatedAt: new Date().toISOString()
}));
}
export async function loadCachedConversationSummaries() {
const profileId = activeProfileId();
if (!profileId) return [];
const profile = await transaction<CachedProfile | undefined>("profiles", "readonly", (store) => store.get(profileId));
return profile?.summaries || [];
}
export async function cacheConversation(conversation: Conversation) {
const profileId = activeProfileId();
if (!profileId) return;
const record: CachedConversation = {
...conversation,
cacheKey: conversationCacheKey(profileId, conversation.id),
profileId
};
await transaction<IDBValidKey>("conversations", "readwrite", (store) => store.put(record));
const summaries = await loadCachedConversationSummaries();
const summary: ConversationSummary = {
id: conversation.id,
title: conversation.title,
providerId: conversation.providerId,
model: conversation.model,
messageCount: conversation.messageCount,
createdAt: conversation.createdAt,
updatedAt: conversation.updatedAt
};
await cacheConversationSummaries([summary, ...summaries.filter((item) => item.id !== conversation.id)].sort((left, right) => right.updatedAt.localeCompare(left.updatedAt)));
}
export async function loadCachedConversation(id: string) {
const profileId = activeProfileId();
if (!profileId) return null;
const record = await transaction<CachedConversation | undefined>("conversations", "readonly", (store) => store.get(conversationCacheKey(profileId, id)));
if (!record) return null;
const {cacheKey: _cacheKey, profileId: _profileId, ...conversation} = record;
return conversation;
}
export async function removeCachedConversation(id: string) {
const profileId = activeProfileId();
if (!profileId) return;
await transaction<undefined>("conversations", "readwrite", (store) => store.delete(conversationCacheKey(profileId, id)));
const summaries = await loadCachedConversationSummaries();
await cacheConversationSummaries(summaries.filter((conversation) => conversation.id !== id));
}
export async function queueConversationChange(change: Omit<PendingConversationChange, "cacheKey" | "profileId" | "createdAt">) {
const profileId = activeProfileId();
if (!profileId) return;
const record: PendingConversationChange = {
...change,
cacheKey: `${profileId}:${change.conversationId}:${change.method}`,
profileId,
createdAt: new Date().toISOString()
};
await transaction<IDBValidKey>("pending", "readwrite", (store) => store.put(record));
}
export async function listPendingConversationChanges() {
const profileId = activeProfileId();
if (!profileId) return [];
const database = await openDatabase();
return new Promise<PendingConversationChange[]>((resolve, reject) => {
const current = database.transaction("pending", "readonly");
const request = current.objectStore("pending").index("profileId").getAll(profileId);
request.onsuccess = () => resolve(request.result.sort((left, right) => left.createdAt.localeCompare(right.createdAt)));
request.onerror = () => reject(request.error || new Error("Unable to read pending history changes"));
current.oncomplete = () => database.close();
});
}
export async function removePendingConversationChange(cacheKey: string) {
await transaction<undefined>("pending", "readwrite", (store) => store.delete(cacheKey));
}
+5
View File
@@ -0,0 +1,5 @@
export type ChatProfile = {
username: string;
name: string;
email: string;
};
+188
View File
@@ -0,0 +1,188 @@
import {applyProviderAuthentication} from "./provider-model";
import type {ProviderDefinition, ProviderModel, ProviderSecret} from "./provider-types";
type ProviderFetch = (input: RequestInfo | URL, init?: RequestInit) => Promise<Response>;
type JsonRecord = Record<string, unknown>;
function accountEndpoints(provider: ProviderDefinition) {
const baseUrl = provider.connection.baseUrl.replace(/\/+$/, "");
const url = new URL(baseUrl);
if (url.hostname === "openrouter.ai") return ["https://openrouter.ai/api/v1/auth/key"];
if (url.hostname === "api.deepseek.com") return [`${url.origin}/user/balance`];
if (url.hostname === "api.moonshot.cn") return [`${baseUrl}/users/me/balance`];
if (url.hostname === "api.openai.com") return [`${baseUrl}/dashboard/billing/credit_grants`];
if (["openai-completions", "openai-responses"].includes(provider.api)) return [`${baseUrl}/dashboard/billing/credit_grants`];
return [];
}
async function responseText(response: Response, maximum = 4 * 1024 * 1024) {
const text = await response.text();
if (text.length > maximum) throw new Error("Provider response is too large");
return text;
}
function jsonRecord(value: unknown): JsonRecord | null {
return value && typeof value === "object" && !Array.isArray(value) ? value as JsonRecord : null;
}
function publicMetadata(value: unknown, depth = 0): unknown {
if (value === null || ["string", "number", "boolean"].includes(typeof value)) return value;
if (depth >= 3) return undefined;
if (Array.isArray(value)) return value.slice(0, 20).map((item) => publicMetadata(item, depth + 1)).filter((item) => item !== undefined);
const record = jsonRecord(value);
if (!record) return undefined;
return Object.fromEntries(Object.entries(record).slice(0, 50).map(([key, item]) => [key, publicMetadata(item, depth + 1)]).filter((entry) => entry[1] !== undefined));
}
function selectedMetadata(record: JsonRecord, pattern: RegExp) {
return Object.fromEntries(Object.entries(record).filter(([key]) => pattern.test(key)).map(([key, value]) => [key, publicMetadata(value)]).filter((entry) => entry[1] !== undefined));
}
export function normalizeDiscoveredModels(payload: unknown): ProviderModel[] {
const root = jsonRecord(payload);
if (!root) return [];
const source = Array.isArray(root.data) ? root.data : Array.isArray(root.models) ? root.models : [];
return source.slice(0, 300).map((value) => {
const model = jsonRecord(value);
if (!model) return null;
const rawId = model.id || model.name || model.model;
if (typeof rawId !== "string" || !rawId.trim()) return null;
const id = rawId.replace(/^models\//, "");
const pricing = selectedMetadata(model, /price|pricing|cost|rate|token/i);
return {
id,
name: typeof model.displayName === "string" ? model.displayName : typeof model.name === "string" ? model.name.replace(/^models\//, "") : id,
...(typeof model.owned_by === "string" ? {ownedBy: model.owned_by} : {}),
...(Number.isFinite(model.context_length) ? {contextWindow: Number(model.context_length)} : {}),
...(Object.keys(pricing).length ? {pricing} : {})
};
}).filter((model): model is ProviderModel => model !== null);
}
function normalizeLlamaCppProps(payload: unknown, fallbackModel: string): ProviderModel[] {
const root = jsonRecord(payload);
if (!root) return [];
const modelPath = typeof root.model_path === "string" ? root.model_path : "";
const modelAlias = typeof root.model_alias === "string" ? root.model_alias : "";
const id = modelAlias.trim() || modelPath.split(/[\\/]/).filter(Boolean).at(-1) || fallbackModel;
const generationSettings = jsonRecord(root.default_generation_settings);
const contextWindow = Number(generationSettings?.n_ctx);
return [{
id,
name: id,
ownedBy: "llamacpp",
...(Number.isFinite(contextWindow) && contextWindow > 0 ? {contextWindow} : {})
}];
}
function responseRateLimits(response: Response) {
return Object.fromEntries([...response.headers.entries()].filter(([name]) => /rate.?limit|retry-after|quota/i.test(name)));
}
async function fetchJson(providerFetch: ProviderFetch, endpoint: string, headers: Headers, timeoutMs = 15000) {
const response = await providerFetch(endpoint, {
method: "GET",
headers,
signal: AbortSignal.timeout(timeoutMs)
});
const text = await responseText(response);
let payload: unknown = null;
try {
payload = text ? JSON.parse(text) : null;
} catch {
payload = {preview: text.slice(0, 1000)};
}
return {response, payload};
}
async function modelProbe(provider: ProviderDefinition, secret: ProviderSecret, providerFetch: ProviderFetch) {
const endpoint = provider.discovery.url;
const headers = applyProviderAuthentication(provider, secret, {
"Accept": "application/json",
...(provider.discovery.type === "anthropic-models-list" ? {"anthropic-version": "2023-06-01"} : {})
});
const startedAt = performance.now();
const initial = await fetchJson(providerFetch, endpoint, headers);
let {response, payload} = initial;
let resolvedEndpoint = endpoint;
let models = response.ok ? normalizeDiscoveredModels(payload) : [];
if (provider.id === "llama.cpp" && models.length === 0) {
const propsEndpoint = `${provider.connection.baseUrl.replace(/\/v1\/?$/, "")}/props`;
try {
const props = await fetchJson(providerFetch, propsEndpoint, headers, 5000);
const propsModels = props.response.ok ? normalizeLlamaCppProps(props.payload, provider.defaultModel) : [];
if (propsModels.length > 0) {
response = props.response;
payload = props.payload;
resolvedEndpoint = propsEndpoint;
models = propsModels;
}
} catch {
// Preserve the primary /v1/models error when the compatibility probe also fails.
}
}
const latencyMs = Math.max(0, Math.round(performance.now() - startedAt));
if (!response.ok) {
const root = jsonRecord(payload);
const detail = root?.preview || root?.error || root?.message;
const error = new Error(typeof detail === "string" ? detail : `Provider HTTP ${response.status}`);
Object.assign(error, {statusCode: response.status});
throw error;
}
return {endpoint: resolvedEndpoint, headers, response, payload, latencyMs, models};
}
export async function discoverProviderModels(
provider: ProviderDefinition,
secret: ProviderSecret,
providerFetch: ProviderFetch = fetch
) {
const probe = await modelProbe(provider, secret, providerFetch);
return {
endpoint: probe.endpoint,
status: probe.response.status,
latencyMs: probe.latencyMs,
models: probe.models
};
}
export async function testProviderConnectivity(
provider: ProviderDefinition,
secret: ProviderSecret,
providerFetch: ProviderFetch = fetch
) {
const probe = await modelProbe(provider, secret, providerFetch);
const {endpoint, headers, response, payload, latencyMs, models} = probe;
const root = jsonRecord(payload) || {};
const account = selectedMetadata(root, /balance|credit|quota|usage|limit|billing|currency/i);
const rateLimits = responseRateLimits(response);
const accountUrls = accountEndpoints(provider);
let accountProbe: unknown = null;
for (const accountUrl of accountUrls) {
try {
const probe = await fetchJson(providerFetch, accountUrl, headers, 5000);
if (!probe.response.ok) continue;
accountProbe = {
endpoint: accountUrl,
status: probe.response.status,
data: publicMetadata(jsonRecord(probe.payload)?.data || probe.payload)
};
break;
} catch {
// Account metadata is best-effort and must not fail a successful model probe.
}
}
return {
ok: true,
status: response.status,
latencyMs,
endpoint,
modelCount: models.length,
models,
account: Object.keys(account).length ? account : null,
accountProbe,
rateLimits: Object.keys(rateLimits).length ? rateLimits : null
};
}
+66
View File
@@ -0,0 +1,66 @@
import type {ProviderDefinition, ProviderSecret} from "./provider-types";
type ProviderFetch = (input: RequestInfo | URL, init?: RequestInit) => Promise<Response>;
export function applyProviderAuthentication(
provider: ProviderDefinition,
secret: ProviderSecret,
inputHeaders?: HeadersInit
) {
const headers = new Headers(inputHeaders);
headers.delete("authorization");
headers.delete("x-api-key");
headers.delete("x-goog-api-key");
for (const [name, value] of Object.entries(provider.headers || {})) headers.set(name, value);
for (const [name, value] of Object.entries(secret.provider?.headers || {})) headers.set(name, value);
const apiKey = secret.provider?.apiKey || "";
if (provider.auth.type === "bearer" && apiKey) headers.set("Authorization", `Bearer ${apiKey}`);
if (provider.auth.type === "header" && provider.auth.header && apiKey) headers.set(provider.auth.header, apiKey);
return headers;
}
export async function createProviderModel(
provider: ProviderDefinition,
secret: ProviderSecret,
modelId: string,
providerFetch: ProviderFetch
) {
const authenticatedFetch: ProviderFetch = (input, init = {}) => providerFetch(input, {
...init,
headers: applyProviderAuthentication(provider, secret, init.headers)
});
// Provider API comes from the runtime Registry; dynamic imports keep unused SDKs out of the initial client chunk.
if (provider.api === "openai-completions") {
const {createOpenAICompatible} = await import("@ai-sdk/openai-compatible");
return createOpenAICompatible({
name: provider.id,
baseURL: provider.connection.baseUrl,
fetch: authenticatedFetch as typeof fetch
}).chatModel(modelId);
}
if (provider.api === "openai-responses") {
const {createOpenAI} = await import("@ai-sdk/openai");
return createOpenAI({
name: provider.id,
baseURL: provider.connection.baseUrl,
apiKey: secret.provider?.apiKey || "browser-managed",
fetch: authenticatedFetch as typeof fetch
}).responses(modelId);
}
if (provider.api === "anthropic-messages") {
const {createAnthropic} = await import("@ai-sdk/anthropic");
return createAnthropic({
name: provider.id,
baseURL: provider.connection.baseUrl,
apiKey: secret.provider?.apiKey || "browser-managed",
fetch: authenticatedFetch as typeof fetch
}).messages(modelId);
}
const {createGoogleGenerativeAI} = await import("@ai-sdk/google");
return createGoogleGenerativeAI({
name: provider.id,
baseURL: provider.connection.baseUrl,
apiKey: secret.provider?.apiKey || "browser-managed",
fetch: authenticatedFetch as typeof fetch
}).chat(modelId);
}
+45
View File
@@ -0,0 +1,45 @@
export type ProviderApi = "openai-completions" | "openai-responses" | "anthropic-messages" | "google-generative-ai";
export type ProviderModel = {
id: string;
name: string;
contextWindow?: number;
maxTokens?: number;
input?: string[];
reasoning?: boolean;
ownedBy?: string;
pricing?: Record<string, unknown>;
};
export type ProviderDiscovery = {
type: "openai-models-list" | "anthropic-models-list" | "google-models-list";
url: string;
};
export type ProviderDefinition = {
id: string;
name: string;
api: ProviderApi;
connection: {
type: "frontend" | "backend";
baseUrl: string;
proxy: null | {type: "relay" | "http" | "https" | "socks5"; url: string};
};
auth: {type: "bearer" | "header" | "none"; header?: string};
headers: Record<string, string>;
defaultModel: string;
discovery: ProviderDiscovery;
builtin: boolean;
credentialState: "configured" | "missing" | "local";
credentials: Array<{id: string; providerId: string; name: string; fingerprint: string}>;
};
export type ProviderSecret = {
provider?: {apiKey?: string; headers?: Record<string, string>; baseUrl?: string};
proxy?: {username?: string; password?: string; token?: string};
};
export type ResolvedBackendProvider = {
provider: ProviderDefinition;
credential: {id: string; name: string; secret: ProviderSecret};
};
+19
View File
@@ -0,0 +1,19 @@
import {describe, expect, test} from "bun:test";
import {responseMetadata} from "./response-metadata.ts";
describe("response metadata", () => {
test("calculates output token throughput", () => {
const metadata = responseMetadata("rust.cat", "gpt-test", performance.now() - 2000, 40);
expect(metadata.providerId).toBe("rust.cat");
expect(metadata.model).toBe("gpt-test");
expect(metadata.durationMs).toBeGreaterThanOrEqual(1900);
expect(metadata.tokensPerSecond).toBeGreaterThanOrEqual(19);
expect(metadata.tokensPerSecond).toBeLessThanOrEqual(21);
});
test("keeps speed unavailable when provider omits token usage", () => {
const metadata = responseMetadata("local", "model", performance.now() - 100, undefined);
expect(metadata.outputTokens).toBeNull();
expect(metadata.tokensPerSecond).toBeNull();
});
});
+12
View File
@@ -0,0 +1,12 @@
import type {ResponseMetadata} from "./conversation-types";
export function responseMetadata(providerId: string, model: string, startedAt: number, outputTokens: number | undefined | null): ResponseMetadata {
const durationMs = Math.max(1, Math.round(performance.now() - startedAt));
const normalizedTokens = typeof outputTokens === "number" && Number.isFinite(outputTokens) && outputTokens >= 0
? Math.round(outputTokens)
: null;
const tokensPerSecond = normalizedTokens === null
? null
: Math.round((normalizedTokens / (durationMs / 1000)) * 10) / 10;
return {providerId, model, durationMs, outputTokens: normalizedTokens, tokensPerSecond};
}
+16
View File
@@ -0,0 +1,16 @@
import nodeFetch from "node-fetch";
import {ProxyAgent} from "proxy-agent";
import type {ProviderDefinition, ProviderSecret} from "./provider-types";
export function createServerProviderFetch(provider: ProviderDefinition, secret: ProviderSecret) {
const proxy = provider.connection.proxy;
if (!proxy) return fetch;
const proxyUrl = new URL(proxy.url);
if (secret.proxy?.username) proxyUrl.username = secret.proxy.username;
if (secret.proxy?.password) proxyUrl.password = secret.proxy.password;
const agent = new ProxyAgent({getProxyForUrl: () => proxyUrl.toString()});
return async (input: RequestInfo | URL, init?: RequestInit) => {
const response = await nodeFetch(String(input), {...init, agent} as never);
return response as unknown as Response;
};
}
+28
View File
@@ -0,0 +1,28 @@
{
"name": "xiteng-chat",
"version": "1.0.0",
"private": true,
"scripts": {
"dev": "bun --hot src/server.ts",
"typecheck": "tsc --noEmit",
"build": "bun run typecheck && bun run build.ts",
"start": "bun src/server.ts",
"test": "bun test",
"test:history": "bun run build && bun history.test.mjs"
},
"dependencies": {
"@ai-sdk/anthropic": "4.0.36",
"@ai-sdk/google": "4.0.39",
"@ai-sdk/openai": "4.0.36",
"@ai-sdk/openai-compatible": "3.0.28",
"ai": "7.0.58",
"dompurify": "3.2.6",
"marked": "15.0.12",
"node-fetch": "3.3.2",
"proxy-agent": "8.0.2"
},
"devDependencies": {
"@types/bun": "1.3.5",
"typescript": "5.9.3"
}
}
+48
View File
@@ -0,0 +1,48 @@
import {mkdirSync, rmSync} from "node:fs";
import net from "node:net";
const mode = process.env.BRIDGE_MODE || "network";
const socketPath = process.env.BRIDGE_SOCKET || "/run/provider-proxy/upstream.sock";
const listenPort = Number.parseInt(process.env.LISTEN_PORT || "17897", 10);
const upstreamHost = process.env.UPSTREAM_HOST || "127.0.0.1";
const upstreamPort = Number.parseInt(process.env.UPSTREAM_PORT || "7897", 10);
if (mode === "host") {
mkdirSync("/run/provider-proxy", {recursive: true});
rmSync(socketPath, {force: true});
}
const server = net.createServer((client) => {
client.pause();
const upstream = mode === "host"
? net.connect({host: upstreamHost, port: upstreamPort})
: net.connect(socketPath);
upstream.once("connect", () => {
client.pipe(upstream);
upstream.pipe(client);
client.resume();
});
const close = () => {
client.destroy();
upstream.destroy();
};
client.on("error", close);
upstream.on("error", close);
});
const listenTarget = mode === "host" ? socketPath : {host: "0.0.0.0", port: listenPort};
server.listen(listenTarget, () => {
const target = mode === "host" ? socketPath : `0.0.0.0:${listenPort}`;
const upstream = mode === "host" ? `${upstreamHost}:${upstreamPort}` : socketPath;
console.log(`provider proxy ${mode} bridge ${target} -> ${upstream}`);
});
function shutdown() {
server.close(() => {
if (mode === "host") rmSync(socketPath, {force: true});
process.exit(0);
});
}
process.on("SIGTERM", shutdown);
process.on("SIGINT", shutdown);
+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64"><rect width="64" height="64" rx="18" fill="#171717"/><path d="M17 20h30v20H31l-9 8v-8h-5z" fill="#fff"/><circle cx="25" cy="30" r="2" fill="#171717"/><circle cx="32" cy="30" r="2" fill="#171717"/><circle cx="39" cy="30" r="2" fill="#171717"/></svg>

After

Width:  |  Height:  |  Size: 309 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 80 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 80 KiB

+18
View File
@@ -0,0 +1,18 @@
{
"name": "Xiteng Chat",
"short_name": "Chat",
"description": "使用个人 Key Vault 凭据的轻量 AI 对话界面",
"start_url": "/",
"scope": "/",
"display": "standalone",
"background_color": "#f4f4f0",
"theme_color": "#171717",
"orientation": "any",
"lang": "zh-CN",
"categories": ["productivity", "utilities"],
"icons": [
{"src": "/icons/icon-192.png", "sizes": "192x192", "type": "image/png"},
{"src": "/icons/icon-512.png", "sizes": "512x512", "type": "image/png"},
{"src": "/icons/icon-maskable-512.png", "sizes": "512x512", "type": "image/png", "purpose": "maskable"}
]
}
+49
View File
@@ -0,0 +1,49 @@
const cacheName = "xiteng-chat-shell-v4";
const shellAssets = [
"/",
"/manifest.webmanifest",
"/favicon.svg",
"/icons/icon-192.png",
"/icons/icon-512.png",
"/icons/icon-maskable-512.png",
"/icons/apple-touch-icon.png"
];
self.addEventListener("install", (event) => {
event.waitUntil(caches.open(cacheName).then(async (cache) => {
for (const asset of shellAssets) {
try {
const response = await fetch(asset, {cache: "reload"});
if (response.ok) await cache.put(asset, response);
} catch {}
}
}).then(() => self.skipWaiting()));
});
self.addEventListener("activate", (event) => {
event.waitUntil(caches.keys()
.then((keys) => Promise.all(keys.filter((key) => key !== cacheName).map((key) => caches.delete(key))))
.then(() => self.clients.claim()));
});
self.addEventListener("fetch", (event) => {
const request = event.request;
if (request.method !== "GET") return;
const url = new URL(request.url);
if (url.origin !== self.location.origin || url.pathname.startsWith("/api/") || url.pathname.startsWith("/outpost.goauthentik.io/")) return;
if (request.mode === "navigate") {
event.respondWith(fetch(request).then((response) => {
if (response.ok) caches.open(cacheName).then((cache) => cache.put("/", response.clone()));
return response;
}).catch(async () => (await caches.match("/")) || Response.error()));
return;
}
if (shellAssets.includes(url.pathname) || /\.(?:js|css|png|svg|ico|webmanifest)$/.test(url.pathname)) {
event.respondWith(caches.match(request).then((cached) => cached || fetch(request).then((response) => {
if (response.ok) caches.open(cacheName).then((cache) => cache.put(request, response.clone()));
return response;
})));
}
});
+757
View File
@@ -0,0 +1,757 @@
import DOMPurify from "dompurify";
import {marked} from "marked";
import {applyBrowserProviderSettings} from "../lib/browser-provider-settings";
import {createBrowserProviderFetch} from "../lib/browser-provider-fetch";
import {
createConversationHistory,
deleteConversationHistory,
flushPendingConversationChanges,
getConversationHistory,
listConversationHistory,
saveConversationHistory,
synchronizeOfflineConversationHistory,
updateConversationHistory
} from "../lib/conversation-client";
import {conversationHash, conversationIdFromHash} from "../lib/conversation-hash";
import type {Conversation, ConversationSummary, ResponseMetadata, StoredChatMessage} from "../lib/conversation-types";
import {defaultGenerationSettings, type GenerationSettings} from "../lib/generation-settings";
import {
activateOfflineProfile,
cacheChatConfig,
loadCachedChatConfig
} from "../lib/offline-history";
import {
deleteLocalCredential,
getLocalCredential,
listLocalCredentials,
saveLocalCredential,
type LocalCredential
} from "../lib/local-credentials";
import type {ChatProfile} from "../lib/profile-types";
import type {ProviderDefinition, ProviderModel, ProviderSecret} from "../lib/provider-types";
import {responseMetadata} from "../lib/response-metadata";
type ChatProvider = ProviderDefinition & {models: ProviderModel[]; modelDiscoveryError?: string};
type ChatConfig = {providers: ChatProvider[]; profile: ChatProfile};
type ServerChatConfig = ChatConfig & {identityKey: string};
type CachedChatBootstrap = {config: ChatConfig; frontendProviders: ChatProvider[]};
type StreamEvent = {type: string; text?: string; error?: string; metadata?: ResponseMetadata};
type HashNavigationMode = "push" | "replace" | "none";
const rootElement = document.querySelector<HTMLDivElement>("#app");
if (!rootElement) throw new Error("Application root is missing");
const root: HTMLDivElement = rootElement;
const state = {
config: null as ChatConfig | null,
frontendProviders: [] as ChatProvider[],
localCredentials: [] as LocalCredential[],
conversations: [] as ConversationSummary[],
conversation: null as Conversation | null,
providerId: "",
model: "",
generationSettings: {...defaultGenerationSettings},
recentModelKeys: [] as string[],
historyOpen: window.matchMedia("(min-width: 681px)").matches,
offline: false,
loading: true,
error: "",
modelQuery: "",
streaming: false,
streamController: null as AbortController | null,
renderFrame: 0,
settingsTimer: 0
};
const icons: Record<string, string> = {
history: '<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M4 5h16M4 12h10M4 19h16"/></svg>',
plus: '<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M12 5v14M5 12h14"/></svg>',
close: '<svg viewBox="0 0 24 24" aria-hidden="true"><path d="m6 6 12 12M18 6 6 18"/></svg>',
trash: '<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M4 7h16M9 7V4h6v3m-8 0 1 13h8l1-13M10 11v5m4-5v5"/></svg>',
down: '<svg viewBox="0 0 24 24" aria-hidden="true"><path d="m6 9 6 6 6-6"/></svg>',
search: '<svg viewBox="0 0 24 24" aria-hidden="true"><circle cx="11" cy="11" r="7"/><path d="m16 16 4 4"/></svg>',
clock: '<svg viewBox="0 0 24 24" aria-hidden="true"><circle cx="12" cy="12" r="9"/><path d="M12 7v5l3 2"/></svg>',
more: '<svg viewBox="0 0 24 24" aria-hidden="true"><circle cx="5" cy="12" r="1"/><circle cx="12" cy="12" r="1"/><circle cx="19" cy="12" r="1"/></svg>',
settings: '<svg viewBox="0 0 24 24" aria-hidden="true"><circle cx="12" cy="12" r="3"/><path d="M19 12a7 7 0 0 0-.1-1l2-1-2-4-2 1a8 8 0 0 0-2-1l-.3-2h-5l-.3 2a8 8 0 0 0-2 1l-2-1-2 4 2 1a7 7 0 0 0 0 2l-2 1 2 4 2-1a8 8 0 0 0 2 1l.3 2h5l.3-2a8 8 0 0 0 2-1l2 1 2-4-2-1a7 7 0 0 0 .1-1Z"/></svg>',
send: '<svg viewBox="0 0 24 24" aria-hidden="true"><path d="m5 12 7-7 7 7M12 5v14"/></svg>',
stop: '<svg viewBox="0 0 24 24" aria-hidden="true"><rect x="7" y="7" width="10" height="10" rx="1"/></svg>',
copy: '<svg viewBox="0 0 24 24" aria-hidden="true"><rect x="8" y="8" width="11" height="11" rx="2"/><path d="M16 8V6a2 2 0 0 0-2-2H6a2 2 0 0 0-2 2v8a2 2 0 0 0 2 2h2"/></svg>',
retry: '<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M20 7v5h-5M4 17v-5h5"/><path d="M18 9a7 7 0 0 0-12-2l-2 3m2 5a7 7 0 0 0 12 2l2-3"/></svg>',
scroll: '<svg viewBox="0 0 24 24" aria-hidden="true"><path d="m6 9 6 6 6-6"/></svg>',
offline: '<svg viewBox="0 0 24 24" aria-hidden="true"><path d="m3 3 18 18M8 8a9 9 0 0 1 12 2M5 12a9 9 0 0 1 2-2m3 6a3 3 0 0 1 4-1m-2 5h.01"/></svg>'
};
function uuid() {
if (typeof crypto.randomUUID === "function") return crypto.randomUUID();
const bytes = crypto.getRandomValues(new Uint8Array(16));
bytes[6] = (bytes[6] & 0x0f) | 0x40;
bytes[8] = (bytes[8] & 0x3f) | 0x80;
const hex = [...bytes].map((byte) => byte.toString(16).padStart(2, "0"));
return `${hex.slice(0, 4).join("")}-${hex.slice(4, 6).join("")}-${hex.slice(6, 8).join("")}-${hex.slice(8, 10).join("")}-${hex.slice(10).join("")}`;
}
function escapeHtml(value: unknown) {
return String(value ?? "").replace(/[&<>"']/g, (character) => ({"&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;"})[character]!);
}
function messagePartText(message: StoredChatMessage, type: "text" | "reasoning") {
return message.parts.filter((part) => part.type === type && typeof part.text === "string").map((part) => String(part.text)).join("");
}
function markdown(value: string) {
return DOMPurify.sanitize(marked.parse(value, {async: false, gfm: true, breaks: false}) as string);
}
function provider() {
return state.config?.providers.find((item) => item.id === state.providerId) || null;
}
function localCredential(providerId = state.providerId) {
return state.localCredentials.find((item) => item.providerId === providerId && item.name === "default")
|| state.localCredentials.find((item) => item.providerId === providerId)
|| null;
}
function updateConversationHash(id: string, mode: Exclude<HashNavigationMode, "none">) {
const hash = conversationHash(id);
if (window.location.hash === hash) return;
const url = `${window.location.pathname}${window.location.search}${hash}`;
window.history[mode === "replace" ? "replaceState" : "pushState"]({}, "", url);
}
function settingsForProvider(item: ChatProvider) {
const saved = window.localStorage.getItem(`xiteng-chat-model:${item.id}`) || "";
const model = item.models.some((candidate) => candidate.id === saved)
? saved
: item.models.some((candidate) => candidate.id === item.defaultModel) ? item.defaultModel : item.models[0]?.id || "";
return {model};
}
function rememberModel(providerId: string, model: string) {
if (!providerId || !model) return;
const key = `${providerId}/${model}`;
state.recentModelKeys = [key, ...state.recentModelKeys.filter((item) => item !== key)].slice(0, 20);
window.localStorage.setItem("xiteng-chat-recent-models", JSON.stringify(state.recentModelKeys));
}
function avatarPlaceholder(profile: ChatProfile) {
const source = String(profile.name || profile.username || "U").trim() || "U";
const parts = source.split(/\s+/).filter(Boolean);
const initials = (parts.length > 1 ? `${parts[0][0]}${parts.at(-1)![0]}` : [...source].slice(0, 2).join("")).toUpperCase();
let hash = 0;
for (const character of String(profile.username || profile.name || initials)) hash = ((hash << 5) - hash + character.codePointAt(0)!) | 0;
const hue = Math.abs(hash) % 360;
const svg = `<svg xmlns="http://www.w3.org/2000/svg" width="256" height="256" viewBox="0 0 256 256"><rect width="256" height="256" rx="32" fill="hsl(${hue} 58% 48%)"/><text x="128" y="145" text-anchor="middle" font-family="system-ui,sans-serif" font-size="82" font-weight="800" fill="white">${escapeHtml(initials)}</text></svg>`;
return `data:image/svg+xml;charset=utf-8,${encodeURIComponent(svg)}`;
}
async function updateAvatar() {
const image = root.querySelector<HTMLImageElement>(".header-avatar");
const profile = state.config?.profile;
if (!image || !profile?.email.trim()) return;
const digest = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(profile.email.trim().toLowerCase()));
const hash = [...new Uint8Array(digest)].map((byte) => byte.toString(16).padStart(2, "0")).join("");
for (const source of [`https://www.gravatar.com/avatar/${hash}?d=404&s=256`, `https://seccdn.libravatar.org/avatar/${hash}?d=404&s=256`]) {
const loaded = await new Promise<boolean>((resolve) => {
const candidate = new Image();
const timer = window.setTimeout(() => resolve(false), 5000);
candidate.onload = () => { window.clearTimeout(timer); resolve(true); };
candidate.onerror = () => { window.clearTimeout(timer); resolve(false); };
candidate.referrerPolicy = "no-referrer";
candidate.src = source;
});
if (loaded && image.isConnected) {
image.src = source;
break;
}
}
}
function renderModelPicker() {
const active = provider();
if (!active || !state.config) return "";
const query = state.modelQuery.trim().toLowerCase();
const choices = state.config.providers.flatMap((item) => item.models.map((model) => ({provider: item, model, key: `${item.id}/${model.id}`})));
const matches = choices.filter((choice) => !query || choice.key.toLowerCase().includes(query) || choice.model.name.toLowerCase().includes(query));
const recent = state.recentModelKeys.map((key) => choices.find((choice) => choice.key === key)).filter((choice): choice is NonNullable<typeof choice> => Boolean(choice)).filter((choice) => matches.includes(choice));
const option = (choice: typeof choices[number]) => `<button class="model-option${choice.provider.id === state.providerId && choice.model.id === state.model ? " active" : ""}" type="button" data-action="choose-model" data-provider="${escapeHtml(choice.provider.id)}" data-model="${escapeHtml(choice.model.id)}"><span><strong>${escapeHtml(choice.key)}</strong>${choice.model.name !== choice.model.id ? `<small>${escapeHtml(choice.model.name)}</small>` : ""}</span><small>${choice.provider.connection.type === "frontend" ? "Frontend" : "Backend"}</small></button>`;
const groups = state.config.providers.map((item) => {
const items = matches.filter((choice) => choice.provider.id === item.id);
return items.length ? `<section class="model-menu-group"><h3>${escapeHtml(item.name)}</h3>${items.map(option).join("")}</section>` : "";
}).join("");
return `<details class="model-picker"><summary aria-label="Provider 和模型"><span>${escapeHtml(state.providerId)}/${escapeHtml(state.model)}</span>${icons.down}</summary><div class="model-menu"><label class="model-search">${icons.search}<input value="${escapeHtml(state.modelQuery)}" data-action="model-search" placeholder="搜索 provider/model"></label>${recent.length ? `<section class="model-menu-group recent-models"><h3>${icons.clock}最近使用</h3>${recent.map(option).join("")}</section>` : ""}${groups || '<p class="model-menu-empty">没有匹配的模型</p>'}</div></details>`;
}
function renderLocalMenu() {
if (!state.frontendProviders.length) return "";
return `<details class="local-key-menu"><summary aria-label="Frontend Provider 设置">${icons.more}</summary><div><strong class="local-key-menu-title">Frontend Provider 设置</strong>${state.frontendProviders.map((item) => {
const configured = state.localCredentials.some((credential) => credential.providerId === item.id);
return `<section class="local-key-entry"><span><strong>${escapeHtml(item.name)}</strong><small>${escapeHtml(item.auth.type === "none" ? item.connection.baseUrl : item.id)}</small>${item.modelDiscoveryError ? `<small class="local-key-error">${escapeHtml(item.modelDiscoveryError)}</small>` : ""}</span><div><button type="button" data-action="configure-local" data-provider="${escapeHtml(item.id)}">${item.auth.type === "none" ? "端点" : configured ? "更新" : "配置"}</button>${configured ? `<button class="dangerous" type="button" data-action="delete-local" data-provider="${escapeHtml(item.id)}">重置</button>` : ""}${item.auth.type === "none" ? `<button type="button" data-action="probe-local" data-provider="${escapeHtml(item.id)}">探测</button>` : ""}</div></section>`;
}).join("")}</div></details>`;
}
function renderGenerationMenu() {
const settings = state.generationSettings;
return `<details class="generation-menu"><summary aria-label="生成参数">${icons.settings}</summary><div><strong>生成参数</strong><label>思考强度<select data-setting="reasoning"><option value="auto"${settings.reasoning === "auto" ? " selected" : ""}>自动</option><option value="none"${settings.reasoning === "none" ? " selected" : ""}>关闭</option><option value="low"${settings.reasoning === "low" ? " selected" : ""}>低</option><option value="medium"${settings.reasoning === "medium" ? " selected" : ""}>中</option><option value="high"${settings.reasoning === "high" ? " selected" : ""}>高</option></select></label><label class="generation-check"><input type="checkbox" data-setting="showReasoningSummary"${settings.showReasoningSummary ? " checked" : ""}>显示思考摘要</label><label>Temperature<input type="number" min="0" max="2" step="0.1" placeholder="自动" data-setting="temperature" value="${settings.temperature ?? ""}"></label><label>最大输出 Tokens<input type="number" min="1" max="1000000" step="1" placeholder="自动" data-setting="maxOutputTokens" value="${settings.maxOutputTokens ?? ""}"></label><button type="button" data-action="reset-settings">恢复默认</button></div></details>`;
}
function renderHistory() {
return `<button class="history-backdrop${state.historyOpen ? " open" : ""}" type="button" aria-label="关闭历史记录" data-action="close-history"></button><aside class="history-sidebar${state.historyOpen ? " open" : ""}" aria-label="聊天历史"><div class="history-heading"><strong>聊天历史</strong><div><button type="button" data-action="new-conversation" aria-label="新对话"${state.offline ? " disabled title=\"联网后可新建对话\"" : ""}>${icons.plus}</button><button class="history-close" type="button" data-action="close-history" aria-label="关闭历史记录">${icons.close}</button></div></div><div class="history-list">${state.conversations.map((item) => `<article class="history-item${item.id === state.conversation?.id ? " active" : ""}"><button class="history-select" type="button" data-action="select-conversation" data-id="${escapeHtml(item.id)}"><strong>${escapeHtml(item.title)}</strong><small>${escapeHtml(item.providerId)} · ${escapeHtml(item.model)}</small></button><button class="history-delete" type="button" data-action="delete-conversation" data-id="${escapeHtml(item.id)}" aria-label="删除 ${escapeHtml(item.title)}">${icons.trash}</button></article>`).join("")}</div></aside>`;
}
function renderMessage(message: StoredChatMessage, index: number) {
if (message.role === "user") {
return `<article class="message user-message"><div class="message-content user-content"><p>${escapeHtml(messagePartText(message, "text"))}</p></div></article>`;
}
if (message.role !== "assistant") return "";
const reasoning = messagePartText(message, "reasoning");
const text = messagePartText(message, "text");
const error = message.parts.find((part) => part.type === "error" && typeof part.text === "string")?.text;
const response = message.metadata?.custom?.response;
const modelLabel = response?.model ? `${response.providerId || state.providerId}/${response.model}` : `${state.providerId}/${state.model}`;
const speed = typeof response?.tokensPerSecond === "number" ? `${response.tokensPerSecond.toFixed(1)} tok/s` : "速度 —";
const detail = response?.durationMs ? `${(response.durationMs / 1000).toFixed(1)}${typeof response.outputTokens === "number" ? ` · ${response.outputTokens} tokens` : ""}` : "历史回复未记录速度";
return `<article class="message assistant-message" data-message-index="${index}"><div class="message-content assistant-content">${reasoning ? `<details class="message-reasoning"><summary>思考过程</summary><div>${escapeHtml(reasoning)}</div></details>` : ""}${text ? `<div class="aui-md">${markdown(text)}</div>` : state.streaming && index === state.conversation!.messages.length - 1 ? '<span class="response-loader"></span>' : ""}${error ? `<div class="message-error">${escapeHtml(error)}</div>` : ""}</div><div class="message-footer"><div class="response-meta" title="${escapeHtml(detail)}"><span>${escapeHtml(modelLabel)}</span><span>${escapeHtml(speed)}</span></div><div class="message-actions"><button class="icon-button" type="button" data-action="copy-message" data-index="${index}" aria-label="复制回答">${icons.copy}</button><button class="icon-button" type="button" data-action="regenerate-message" data-index="${index}" aria-label="重新生成"${state.streaming || state.offline ? " disabled" : ""}>${icons.retry}</button></div></div></article>`;
}
function renderMessagesMarkup() {
const messages = state.conversation?.messages || [];
if (!messages.length) {
const description = provider()?.connection.type === "frontend"
? "Frontend Provider 由当前浏览器直连;对话记录按 Authentik 身份保存在服务端。"
: "Backend Provider 由 Chat 服务端直连;对话记录按 Authentik 身份保存在服务端。";
return `<div class="welcome"><div class="welcome-mark">XT</div><h1>今天想聊什么?</h1><p>${escapeHtml(description)}</p></div>`;
}
return messages.map(renderMessage).join("");
}
function renderThread() {
return `<section class="thread-root"><div class="thread-viewport" id="thread-viewport"><div id="message-list">${renderMessagesMarkup()}</div><div class="thread-footer"><button class="scroll-button" type="button" data-action="scroll-bottom" aria-label="滚动到底部">${icons.scroll}</button><form class="composer" id="composer"><textarea class="composer-input" name="message" placeholder="输入消息,Enter 发送,Shift + Enter 换行" rows="1" aria-label="聊天消息"${state.offline ? " disabled" : ""}></textarea><button class="send-button" type="${state.streaming ? "button" : "submit"}" data-action="${state.streaming ? "stop" : "send"}" aria-label="${state.streaming ? "停止生成" : "发送消息"}"${state.offline ? " disabled" : ""}>${state.streaming ? icons.stop : icons.send}</button></form><p class="composer-note${state.offline ? " offline" : ""}">${state.offline ? "离线模式:可查看本机历史;联网后自动同步。" : "模型可能会出错,请核对重要信息。"}</p></div></div></section>`;
}
function renderApp() {
if (state.error) {
root.innerHTML = `<main class="state-page"><div class="state-card">${renderLocalMenu()}<span class="state-mark">!</span><h1>聊天服务暂时不可用</h1><p>${escapeHtml(state.error)}</p></div></main>`;
return;
}
if (state.loading || !state.config || !provider() || !state.conversation) {
root.innerHTML = '<main class="state-page"><div class="state-card"><span class="loader"></span><p>正在读取聊天历史与 Provider Registry…</p></div></main>';
return;
}
const profile = state.config.profile;
root.innerHTML = `<main class="app-shell with-history${state.historyOpen ? " history-open" : ""}"><header class="app-header"><div class="header-leading"><button class="history-toggle" type="button" data-action="toggle-history" aria-label="聊天历史">${icons.history}</button></div><div class="brand"><span class="brand-mark">XT</span><span>Xiteng Chat</span>${state.offline ? `<span class="offline-badge">${icons.offline}离线历史</span>` : ""}</div><div class="chat-controls">${renderModelPicker()}${renderLocalMenu()}${renderGenerationMenu()}<a class="header-button account-button" href="https://xiteng.site/account" aria-label="打开我的账户"><img class="header-avatar" src="${avatarPlaceholder(profile)}" alt="${escapeHtml(profile.name || profile.username)} 的头像" referrerpolicy="no-referrer"><span>${escapeHtml(profile.name || profile.username)}</span></a></div></header>${renderHistory()}${renderThread()}</main>`;
void updateAvatar();
}
function renderMessages(scroll = false) {
const list = root.querySelector<HTMLElement>("#message-list");
if (!list) {
renderApp();
return;
}
list.innerHTML = renderMessagesMarkup();
const footer = root.querySelector<HTMLElement>(".thread-footer");
if (footer) footer.outerHTML = new DOMParser().parseFromString(renderThread(), "text/html").querySelector(".thread-footer")!.outerHTML;
if (scroll) scrollBottom();
}
function scheduleMessagesRender(scroll = true) {
if (state.renderFrame) return;
state.renderFrame = window.requestAnimationFrame(() => {
state.renderFrame = 0;
renderMessages(scroll);
});
}
function scrollBottom() {
const viewport = root.querySelector<HTMLElement>("#thread-viewport");
if (viewport) viewport.scrollTop = viewport.scrollHeight;
}
function closeHistoryOnMobile() {
if (window.matchMedia("(max-width: 680px)").matches) state.historyOpen = false;
}
function providerHeaders(item: ChatProvider, secret: ProviderSecret, initial?: HeadersInit) {
const headers = new Headers(initial);
for (const [name, value] of Object.entries(item.headers || {})) headers.set(name, value);
for (const [name, value] of Object.entries(secret.provider?.headers || {})) headers.set(name, value);
const apiKey = secret.provider?.apiKey || "";
if (item.auth.type === "bearer" && apiKey) headers.set("Authorization", `Bearer ${apiKey}`);
if (item.auth.type === "header" && item.auth.header && apiKey) headers.set(item.auth.header, apiKey);
return headers;
}
function normalizeModels(payload: unknown): ProviderModel[] {
const root = payload && typeof payload === "object" && !Array.isArray(payload) ? payload as Record<string, unknown> : {};
const source = Array.isArray(root.data) ? root.data : Array.isArray(root.models) ? root.models : [];
return source.slice(0, 300).flatMap((value) => {
if (!value || typeof value !== "object" || Array.isArray(value)) return [];
const model = value as Record<string, unknown>;
const rawId = model.id || model.name || model.model;
if (typeof rawId !== "string" || !rawId.trim()) return [];
const id = rawId.replace(/^models\//, "");
return [{id, name: typeof model.displayName === "string" ? model.displayName : typeof model.name === "string" ? model.name.replace(/^models\//, "") : id}];
});
}
async function discoverFrontendProvider(item: ChatProvider, secret: ProviderSecret) {
const effective = applyBrowserProviderSettings(item, secret) as ChatProvider;
const providerFetch = createBrowserProviderFetch(effective, secret);
const response = await providerFetch(effective.discovery.url, {
headers: providerHeaders(effective, secret, {"Accept": "application/json"}),
signal: AbortSignal.timeout(15000)
});
const payload = await response.json().catch(() => null);
let models = response.ok ? normalizeModels(payload) : [];
if (effective.id === "llama.cpp" && !models.length) {
const propsUrl = `${effective.connection.baseUrl.replace(/\/v1\/?$/, "")}/props`;
const props = await providerFetch(propsUrl, {headers: providerHeaders(effective, secret), signal: AbortSignal.timeout(5000)});
const value = await props.json() as {model_alias?: string; model_path?: string};
const id = value.model_alias?.trim() || value.model_path?.split(/[\\/]/).filter(Boolean).at(-1) || effective.defaultModel;
if (props.ok) models = [{id, name: id}];
}
if (!response.ok && !models.length) throw new Error(`Provider HTTP ${response.status}`);
return {...effective, models, modelDiscoveryError: undefined};
}
async function parseLines(response: Response, onLine: (line: string) => void | Promise<void>) {
if (!response.ok) {
const payload = await response.json().catch(() => null) as {error?: string} | null;
throw new Error(payload?.error || `HTTP ${response.status}`);
}
if (!response.body) throw new Error("Streaming response body is unavailable");
const reader = response.body.getReader();
const decoder = new TextDecoder();
let buffer = "";
while (true) {
const {done, value} = await reader.read();
buffer += decoder.decode(value || new Uint8Array(), {stream: !done});
const lines = buffer.split("\n");
buffer = lines.pop() || "";
for (const line of lines) if (line.trim()) await onLine(line.trim());
if (done) break;
}
if (buffer.trim()) await onLine(buffer.trim());
}
async function streamBackend(messages: StoredChatMessage[], onEvent: (event: StreamEvent) => void, signal: AbortSignal) {
const item = provider()!;
const credential = item.credentials.find((value) => value.name === "default") || item.credentials[0];
if (!credential) throw new Error(`请先在 Key Vault 中配置 ${item.name}`);
const response = await fetch("/api/chat", {
method: "POST",
headers: {"Content-Type": "application/json"},
body: JSON.stringify({conversationId: state.conversation!.id, providerId: item.id, credentialName: credential.name, model: state.model, generationSettings: state.generationSettings, messages}),
signal
});
await parseLines(response, (line) => onEvent(JSON.parse(line) as StreamEvent));
}
async function streamFrontend(messages: StoredChatMessage[], onEvent: (event: StreamEvent) => void, signal: AbortSignal) {
const item = provider()!;
if (item.api !== "openai-completions") throw new Error(`Frontend Provider 暂不支持 ${item.api}`);
const credential = localCredential(item.id);
const secret = credential?.secret || {};
const effective = applyBrowserProviderSettings(item, secret) as ChatProvider;
const providerFetch = createBrowserProviderFetch(effective, secret);
const startedAt = performance.now();
let outputTokens: number | null = null;
const response = await providerFetch(`${effective.connection.baseUrl.replace(/\/+$/, "")}/chat/completions`, {
method: "POST",
headers: providerHeaders(effective, secret, {"Content-Type": "application/json", "Accept": "text/event-stream"}),
body: JSON.stringify({
model: state.model,
messages: messages.filter((message) => message.role !== "system" || messagePartText(message, "text")).map((message) => ({role: message.role, content: messagePartText(message, "text")})),
stream: true,
stream_options: {include_usage: true},
...(state.generationSettings.temperature !== null ? {temperature: state.generationSettings.temperature} : {}),
...(state.generationSettings.maxOutputTokens !== null ? {max_tokens: state.generationSettings.maxOutputTokens} : {}),
...(state.generationSettings.reasoning !== "auto" && state.generationSettings.reasoning !== "none" ? {reasoning_effort: state.generationSettings.reasoning} : {})
}),
signal
});
await parseLines(response, (line) => {
if (!line.startsWith("data:")) return;
const data = line.slice(5).trim();
if (!data || data === "[DONE]") return;
const payload = JSON.parse(data) as {choices?: Array<{delta?: {content?: string; reasoning?: string; reasoning_content?: string}}>; usage?: {completion_tokens?: number; output_tokens?: number}};
const delta = payload.choices?.[0]?.delta;
if (delta?.reasoning_content || delta?.reasoning) onEvent({type: "reasoning-delta", text: delta.reasoning_content || delta.reasoning});
if (delta?.content) onEvent({type: "text-delta", text: delta.content});
const reported = payload.usage?.completion_tokens ?? payload.usage?.output_tokens;
if (typeof reported === "number") outputTokens = reported;
});
onEvent({type: "finish", metadata: responseMetadata(item.id, state.model, startedAt, outputTokens)});
}
async function refreshConversations() {
state.conversations = await listConversationHistory();
}
async function generateAssistant(baseMessages: StoredChatMessage[]) {
const assistant: StoredChatMessage = {id: uuid(), role: "assistant", parts: []};
state.conversation!.messages = [...baseMessages, assistant];
state.streaming = true;
state.streamController = new AbortController();
renderApp();
scrollBottom();
let text = "";
let reasoning = "";
let finished = false;
const onEvent = (event: StreamEvent) => {
if (event.type === "text-delta" && event.text) text += event.text;
if (event.type === "reasoning-delta" && event.text) reasoning += event.text;
assistant.parts = [
...(reasoning ? [{type: "reasoning", text: reasoning}] : []),
...(text ? [{type: "text", text}] : [])
];
if (event.type === "finish" && event.metadata) {
assistant.metadata = {custom: {response: event.metadata}};
finished = true;
}
if (event.type === "error") throw new Error(event.error || "生成失败");
scheduleMessagesRender();
};
try {
if (provider()!.connection.type === "backend") await streamBackend(baseMessages, onEvent, state.streamController.signal);
else await streamFrontend(baseMessages, onEvent, state.streamController.signal);
if (!finished) throw new Error("Provider 未返回完成事件");
state.conversation = await saveConversationHistory(state.conversation!.id, state.providerId, state.model, [...baseMessages, assistant]);
await refreshConversations();
} catch (error) {
if (state.streamController.signal.aborted) {
assistant.parts = [
...(reasoning ? [{type: "reasoning", text: reasoning}] : []),
...(text ? [{type: "text", text}] : []),
{type: "error", text: "已停止生成"}
];
} else {
assistant.parts = [
...(reasoning ? [{type: "reasoning", text: reasoning}] : []),
...(text ? [{type: "text", text}] : []),
{type: "error", text: error instanceof Error ? error.message : "生成失败"}
];
}
} finally {
state.streaming = false;
state.streamController = null;
renderApp();
scrollBottom();
}
}
async function sendMessage(text: string) {
if (state.streaming || state.offline || !state.conversation || !text.trim()) return;
const user: StoredChatMessage = {id: uuid(), role: "user", parts: [{type: "text", text: text.trim()}]};
const messages = [...state.conversation.messages, user];
state.conversation.messages = messages;
renderApp();
scrollBottom();
state.conversation = await saveConversationHistory(state.conversation.id, state.providerId, state.model, messages);
await refreshConversations();
await generateAssistant(messages);
}
async function regenerate(index: number) {
if (state.streaming || state.offline || !state.conversation) return;
const message = state.conversation.messages[index];
if (!message || message.role !== "assistant") return;
const base = state.conversation.messages.slice(0, index);
if (!base.some((item) => item.role === "user")) return;
state.conversation.messages = base;
await saveConversationHistory(state.conversation.id, state.providerId, state.model, base);
await generateAssistant(base);
}
async function selectConversation(id: string, navigation: HashNavigationMode = "push") {
if (!state.config || state.conversation?.id === id) return;
const selected = await getConversationHistory(id);
const selectedProvider = state.config.providers.find((item) => item.id === selected.providerId) || state.config.providers[0];
if (!selectedProvider) return;
state.conversation = selected;
state.providerId = selectedProvider.id;
state.model = selected.model || settingsForProvider(selectedProvider).model;
state.generationSettings = selected.generationSettings;
rememberModel(state.providerId, state.model);
closeHistoryOnMobile();
if (navigation !== "none") updateConversationHash(selected.id, navigation);
renderApp();
}
async function newConversation() {
const item = provider();
if (state.offline || !item || !state.model) return;
const created = await createConversationHistory(item.id, state.model, state.generationSettings);
state.conversation = created;
state.generationSettings = created.generationSettings;
await refreshConversations();
closeHistoryOnMobile();
updateConversationHash(created.id, "push");
renderApp();
}
async function removeConversation(id: string) {
const target = state.conversations.find((item) => item.id === id);
if (!target || !window.confirm(`删除对话“${target.title}”?`)) return;
await deleteConversationHistory(id);
state.conversations = await listConversationHistory();
if (state.conversation?.id === id) {
if (state.conversations[0]) await selectConversation(state.conversations[0].id, "replace");
else if (!state.offline) await newConversation();
else state.error = "离线缓存中已没有聊天记录,请联网后新建对话。";
}
renderApp();
}
function chooseModel(providerId: string, model: string) {
const item = state.config?.providers.find((candidate) => candidate.id === providerId);
if (!item || !model || !state.conversation) return;
state.providerId = item.id;
state.model = model;
state.conversation = {...state.conversation, providerId: item.id, model};
state.modelQuery = "";
window.localStorage.setItem("xiteng-chat-provider", item.id);
window.localStorage.setItem(`xiteng-chat-model:${item.id}`, model);
rememberModel(item.id, model);
scheduleSettingsSave();
renderApp();
}
function scheduleSettingsSave() {
window.clearTimeout(state.settingsTimer);
state.settingsTimer = window.setTimeout(() => {
if (!state.conversation) return;
void updateConversationHistory(state.conversation.id, state.providerId, state.model, state.generationSettings).catch((error) => console.error("Unable to save conversation settings", error));
}, 400);
}
async function configureLocal(providerId: string) {
const item = state.frontendProviders.find((candidate) => candidate.id === providerId);
if (!item) return;
const current = await getLocalCredential(item.id);
if (item.auth.type === "none") {
const baseUrl = window.prompt(`${item.name} Base URL`, current?.secret.provider?.baseUrl || item.connection.baseUrl);
if (baseUrl === null) return;
try {
const parsed = new URL(baseUrl);
if (!["http:", "https:"].includes(parsed.protocol)) throw new Error();
} catch {
window.alert("Base URL 必须是有效的 http 或 https URL");
return;
}
await saveLocalCredential(item.id, "default", {...current?.secret, provider: {...current?.secret.provider, baseUrl: baseUrl.replace(/\/+$/, "")}});
} else {
const apiKey = window.prompt(`${item.name} API Key`, current?.secret.provider?.apiKey || "");
if (apiKey === null) return;
const proxyToken = item.connection.proxy?.type === "relay" ? window.prompt("Relay Token(没有则留空):", current?.secret.proxy?.token || "") || "" : "";
await saveLocalCredential(item.id, "default", {provider: {apiKey}, ...(proxyToken ? {proxy: {token: proxyToken}} : {})});
}
window.location.reload();
}
async function probeLocal(providerId: string) {
const item = state.frontendProviders.find((candidate) => candidate.id === providerId);
if (!item) return;
try {
const detected = await discoverFrontendProvider(item, (await getLocalCredential(item.id))?.secret || {});
state.frontendProviders = state.frontendProviders.map((candidate) => candidate.id === detected.id ? detected : candidate);
if (state.config) state.config.providers = [...state.config.providers.filter((candidate) => candidate.id !== detected.id), detected];
window.alert(`探测成功:发现 ${detected.models.length} 个模型`);
renderApp();
} catch (error) {
window.alert(`探测失败:${error instanceof Error ? error.message : "未知错误"}\n\n请确认浏览器已允许 chat.xiteng.site 的“本地网络访问”权限。`);
}
}
async function initialize() {
let onlineBootstrap = true;
let identityKey = "";
let rawProviders: ChatProvider[] = [];
let profile: ChatProfile = {username: "", name: "", email: ""};
let cached: CachedChatBootstrap | null = null;
try {
const response = await fetch("/api/config", {cache: "no-store"});
const payload = await response.json() as ServerChatConfig & {error?: string};
if (!response.ok) throw new Error(payload.error || `HTTP ${response.status}`);
identityKey = payload.identityKey;
rawProviders = payload.providers;
profile = payload.profile;
activateOfflineProfile(identityKey);
await flushPendingConversationChanges();
} catch (error) {
const stored = await loadCachedChatConfig<CachedChatBootstrap>();
if (!stored) throw error;
onlineBootstrap = false;
identityKey = stored.profileId;
cached = stored.config;
activateOfflineProfile(identityKey);
}
state.localCredentials = await listLocalCredentials();
let configured: ChatConfig;
if (onlineBootstrap) {
const providers = await Promise.all(rawProviders.map(async (item) => {
if (item.connection.type === "backend") return item;
const credential = state.localCredentials.find((value) => value.providerId === item.id && value.name === "default") || state.localCredentials.find((value) => value.providerId === item.id);
if (item.auth.type !== "none" && !credential) return {...item, models: []};
try {
return await discoverFrontendProvider(item, credential?.secret || {});
} catch (error) {
return {...item, models: [], modelDiscoveryError: error instanceof Error ? error.message : "Model discovery failed"};
}
}));
state.frontendProviders = providers.filter((item) => item.connection.type === "frontend");
configured = {
profile,
providers: providers.filter((item) => item.models.length > 0 && (item.connection.type === "backend" ? item.credentials.length > 0 : item.auth.type === "none" || state.localCredentials.some((credential) => credential.providerId === item.id)))
};
await cacheChatConfig(identityKey, {config: configured, frontendProviders: state.frontendProviders});
} else {
configured = cached!.config;
state.frontendProviders = cached!.frontendProviders;
}
state.config = configured;
state.offline = !onlineBootstrap || !navigator.onLine;
state.conversations = await listConversationHistory();
try {
const recent = JSON.parse(window.localStorage.getItem("xiteng-chat-recent-models") || "[]");
if (Array.isArray(recent)) state.recentModelKeys = recent.filter((item) => typeof item === "string").slice(0, 20);
} catch {
window.localStorage.removeItem("xiteng-chat-recent-models");
}
const hashId = conversationIdFromHash(window.location.hash);
const selectedSummary = state.conversations.find((item) => item.id === hashId) || state.conversations[0];
if (selectedSummary) {
const selected = await getConversationHistory(selectedSummary.id);
const selectedProvider = configured.providers.find((item) => item.id === selected.providerId) || configured.providers[0];
if (!selectedProvider) throw new Error("尚未配置可用的 Provider 凭据");
state.conversation = selected;
state.providerId = selectedProvider.id;
state.model = selected.model || settingsForProvider(selectedProvider).model;
state.generationSettings = selected.generationSettings;
rememberModel(state.providerId, state.model);
updateConversationHash(selected.id, "replace");
if (onlineBootstrap) void synchronizeOfflineConversationHistory(state.conversations).catch((error) => console.error("Unable to refresh offline history", error));
} else {
if (!onlineBootstrap) throw new Error("离线缓存中还没有聊天记录,请联网后再试。");
const savedProviderId = window.localStorage.getItem("xiteng-chat-provider") || "";
const selectedProvider = configured.providers.find((item) => item.id === savedProviderId) || configured.providers[0];
if (!selectedProvider) throw new Error("尚未配置可用的 Provider 凭据");
const selection = settingsForProvider(selectedProvider);
state.conversation = await createConversationHistory(selectedProvider.id, selection.model, defaultGenerationSettings);
state.conversations = [state.conversation];
state.providerId = selectedProvider.id;
state.model = selection.model;
state.generationSettings = state.conversation.generationSettings;
rememberModel(state.providerId, state.model);
updateConversationHash(state.conversation.id, "replace");
}
state.loading = false;
renderApp();
}
root.addEventListener("submit", (event) => {
if (!(event.target instanceof HTMLFormElement) || event.target.id !== "composer") return;
event.preventDefault();
const input = event.target.elements.namedItem("message");
if (input instanceof HTMLTextAreaElement) void sendMessage(input.value).catch(showError);
});
root.addEventListener("keydown", (event) => {
if (!(event.target instanceof HTMLTextAreaElement) || event.target.name !== "message") return;
if (event.key === "Enter" && !event.shiftKey) {
event.preventDefault();
void sendMessage(event.target.value).catch(showError);
}
});
root.addEventListener("input", (event) => {
const target = event.target;
if (target instanceof HTMLTextAreaElement && target.name === "message") {
target.style.height = "auto";
target.style.height = `${Math.min(target.scrollHeight, 180)}px`;
}
if (target instanceof HTMLInputElement && target.dataset.action === "model-search") {
state.modelQuery = target.value;
const details = target.closest("details");
renderApp();
const next = root.querySelector<HTMLInputElement>('[data-action="model-search"]');
const nextDetails = next?.closest("details");
if (nextDetails) nextDetails.open = true;
next?.focus();
next?.setSelectionRange(next.value.length, next.value.length);
if (details?.open && nextDetails) nextDetails.open = true;
}
});
root.addEventListener("change", (event) => {
const target = event.target;
if (!(target instanceof HTMLInputElement || target instanceof HTMLSelectElement) || !target.dataset.setting) return;
const key = target.dataset.setting as keyof GenerationSettings;
if (key === "showReasoningSummary" && target instanceof HTMLInputElement) state.generationSettings.showReasoningSummary = target.checked;
if (key === "reasoning") state.generationSettings.reasoning = target.value as GenerationSettings["reasoning"];
if (key === "temperature") state.generationSettings.temperature = target.value === "" ? null : Math.min(2, Math.max(0, Number(target.value)));
if (key === "maxOutputTokens") state.generationSettings.maxOutputTokens = target.value === "" ? null : Math.min(1_000_000, Math.max(1, Math.floor(Number(target.value))));
if (state.conversation) state.conversation.generationSettings = {...state.generationSettings};
scheduleSettingsSave();
});
root.addEventListener("click", (event) => {
const button = (event.target as Element).closest<HTMLElement>("[data-action]");
if (!button) return;
const action = button.dataset.action;
if (action === "toggle-history") { state.historyOpen = !state.historyOpen; renderApp(); }
if (action === "close-history") { state.historyOpen = false; renderApp(); }
if (action === "new-conversation") void newConversation().catch(showError);
if (action === "select-conversation" && button.dataset.id) void selectConversation(button.dataset.id).catch(showError);
if (action === "delete-conversation" && button.dataset.id) void removeConversation(button.dataset.id).catch(showError);
if (action === "choose-model" && button.dataset.provider && button.dataset.model) chooseModel(button.dataset.provider, button.dataset.model);
if (action === "configure-local" && button.dataset.provider) void configureLocal(button.dataset.provider).catch(showError);
if (action === "probe-local" && button.dataset.provider) void probeLocal(button.dataset.provider).catch(showError);
if (action === "delete-local" && button.dataset.provider) {
const item = state.frontendProviders.find((candidate) => candidate.id === button.dataset.provider);
if (item && window.confirm(`删除此浏览器中的 ${item.name} Credential`)) void deleteLocalCredential(item.id).then(() => window.location.reload());
}
if (action === "reset-settings") { state.generationSettings = {...defaultGenerationSettings}; scheduleSettingsSave(); renderApp(); }
if (action === "stop") state.streamController?.abort();
if (action === "scroll-bottom") scrollBottom();
if (action === "copy-message") {
const index = Number(button.dataset.index);
const message = state.conversation?.messages[index];
if (message) void navigator.clipboard.writeText(messagePartText(message, "text")).then(() => {
button.classList.add("copied");
window.setTimeout(() => button.classList.remove("copied"), 1200);
});
}
if (action === "regenerate-message") void regenerate(Number(button.dataset.index)).catch(showError);
});
function showError(error: unknown) {
window.alert(error instanceof Error ? error.message : "操作失败");
}
window.addEventListener("hashchange", () => {
const id = conversationIdFromHash(window.location.hash);
if (id && id !== state.conversation?.id && state.conversations.some((item) => item.id === id)) void selectConversation(id, "none").catch(showError);
});
window.addEventListener("offline", () => { state.offline = true; renderApp(); });
window.addEventListener("online", () => {
void (async () => {
try {
await flushPendingConversationChanges();
await synchronizeOfflineConversationHistory();
window.location.reload();
} catch (error) {
console.error("Unable to synchronize offline history", error);
}
})();
});
window.matchMedia("(min-width: 681px)").addEventListener("change", (event) => { state.historyOpen = event.matches; renderApp(); });
if ("serviceWorker" in navigator) navigator.serviceWorker.register("/sw.js?v=4", {scope: "/"}).catch((error) => console.error("Unable to register service worker", error));
renderApp();
initialize().catch((error) => {
state.loading = false;
state.error = error instanceof Error ? error.message : "配置加载失败";
renderApp();
});
+23
View File
@@ -0,0 +1,23 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
<meta name="theme-color" media="(prefers-color-scheme: light)" content="#f4f4f0">
<meta name="theme-color" media="(prefers-color-scheme: dark)" content="#111210">
<meta name="description" content="使用个人 Key Vault 凭据的轻量 AI 对话界面">
<meta name="apple-mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-title" content="Xiteng Chat">
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
<title>Xiteng Chat</title>
<link rel="manifest" href="/manifest.webmanifest">
<link rel="icon" href="/favicon.svg" type="image/svg+xml">
<link rel="icon" href="/icons/icon-192.png" sizes="192x192" type="image/png">
<link rel="apple-touch-icon" href="/icons/apple-touch-icon.png" sizes="180x180">
<link rel="stylesheet" href="/styles.css?__ASSET_VERSION__">
<script type="module" src="/assets/client.js?__ASSET_VERSION__"></script>
</head>
<body>
<div id="app"></div>
</body>
</html>
+424
View File
@@ -0,0 +1,424 @@
import {createHash, randomUUID} from "node:crypto";
import path from "node:path";
import {convertToModelMessages, streamText, type UIMessage} from "ai";
import {
createConversation,
deleteConversation,
getConversation,
listConversations,
saveConversationMessages,
updateConversationSettings
} from "../lib/conversations";
import {generationCallOptions, normalizeGenerationSettings} from "../lib/generation-settings";
import {identityFromHeaders, keyVaultFetch, type ChatIdentity} from "../lib/key-vault";
import {discoverProviderModels, testProviderConnectivity} from "../lib/provider-connectivity";
import {createProviderModel} from "../lib/provider-model";
import {createServerProviderFetch} from "../lib/server-provider-fetch";
import type {ProviderDefinition, ProviderSecret, ResolvedBackendProvider} from "../lib/provider-types";
import {responseMetadata} from "../lib/response-metadata";
import type {StoredChatMessage} from "../lib/conversation-types";
const port = Number.parseInt(process.env.PORT || "3000", 10);
const portalUrl = process.env.PORTAL_URL || "http://xiteng-site:8080";
const staticRoot = path.resolve(process.env.STATIC_ROOT || "dist");
const encoder = new TextEncoder();
const securityHeaders = {
"Content-Security-Policy": [
"default-src 'self'",
"base-uri 'self'",
"object-src 'none'",
"frame-src 'none'",
"frame-ancestors 'none'",
"form-action 'self'",
"script-src 'self'",
"style-src 'self' 'unsafe-inline'",
"img-src 'self' data: blob: https:",
"font-src 'self' data:",
"manifest-src 'self'",
"worker-src 'self' blob:",
"connect-src 'self' http: https: ws: wss:"
].join("; "),
"Referrer-Policy": "strict-origin-when-cross-origin",
"X-Content-Type-Options": "nosniff",
"X-Frame-Options": "DENY"
};
function json(payload: unknown, status = 200, headers: HeadersInit = {}) {
return Response.json(payload, {status, headers: {...securityHeaders, "Cache-Control": "no-store", ...headers}});
}
function errorStatus(error: unknown, fallback: number) {
return typeof error === "object" && error && "statusCode" in error ? Number(error.statusCode) : fallback;
}
async function accountProfile(identity: ChatIdentity) {
const fallback = {username: identity.username, name: identity.name || identity.username, email: identity.email};
try {
const response = await fetch(new URL("/api/account/identity", portalUrl), {
headers: {
"Accept": "application/json",
"X-Portal-Authenticated": "1",
"X-Authentik-Username": identity.username,
"X-Authentik-Uid": identity.sub,
"X-Authentik-Email": identity.email
},
signal: AbortSignal.timeout(10000)
});
if (!response.ok) return fallback;
const payload = await response.json() as {profile?: {username?: string; name?: string; email?: string}};
return {
username: payload.profile?.username?.trim() || fallback.username,
name: payload.profile?.name?.trim() || fallback.name,
email: payload.profile?.email?.trim() || fallback.email
};
} catch {
return fallback;
}
}
async function discoverBackendProvider(provider: ProviderDefinition, identity: ChatIdentity) {
if (provider.connection.type !== "backend" || !provider.credentials.length) return {...provider, models: []};
const credential = provider.credentials.find((item) => item.name === "default") || provider.credentials[0];
const response = await keyVaultFetch("/v1/resolve", identity, {
method: "POST",
headers: {"Content-Type": "application/json"},
body: JSON.stringify({providerId: provider.id, credentialName: credential.name})
});
const resolved = await response.json() as ResolvedBackendProvider & {error?: string};
if (!response.ok) return {...provider, models: [], modelDiscoveryError: resolved.error || `Key Vault HTTP ${response.status}`};
try {
const discovery = await discoverProviderModels(
resolved.provider,
resolved.credential.secret,
createServerProviderFetch(resolved.provider, resolved.credential.secret)
);
return {...provider, models: discovery.models};
} catch (error) {
return {...provider, models: [], modelDiscoveryError: error instanceof Error ? error.message : "Model discovery failed"};
}
}
async function config(request: Request) {
try {
const identity = identityFromHeaders(request.headers);
const response = await keyVaultFetch("/v1/providers", identity);
const payload = await response.json() as {providers?: ProviderDefinition[]; error?: string};
if (!response.ok) return json({error: payload.error || `Key Vault HTTP ${response.status}`}, response.status);
const [providers, profile] = await Promise.all([
Promise.all((payload.providers || []).map((provider) => discoverBackendProvider(provider, identity))),
accountProfile(identity)
]);
const identityKey = createHash("sha256").update(`${identity.issuer}\0${identity.sub}`).digest("hex").slice(0, 32);
return json({providers, identityKey, profile});
} catch (error) {
return json({error: error instanceof Error ? error.message : "Provider configuration unavailable"}, errorStatus(error, 503));
}
}
async function conversations(request: Request) {
try {
const identity = identityFromHeaders(request.headers);
if (request.method === "GET") return json({conversations: listConversations(identity)});
if (request.method === "POST") return json({conversation: createConversation(identity, await request.json())}, 201);
return json({error: "Method not allowed"}, 405, {Allow: "GET, POST"});
} catch (error) {
return json({error: error instanceof Error ? error.message : "Conversation request failed"}, request.method === "POST" ? 400 : 500);
}
}
async function conversation(request: Request, id: string) {
try {
const identity = identityFromHeaders(request.headers);
if (request.method === "GET") {
const value = getConversation(identity, id);
return value ? json({conversation: value}) : json({error: "Conversation not found"}, 404);
}
if (request.method === "PUT") {
const value = saveConversationMessages(identity, id, await request.json());
return value ? json({conversation: value}) : json({error: "Conversation not found"}, 404);
}
if (request.method === "PATCH") {
const updated = updateConversationSettings(identity, id, await request.json());
return updated ? json({updated: true}) : json({error: "Conversation not found"}, 404);
}
if (request.method === "DELETE") {
return deleteConversation(identity, id)
? new Response(null, {status: 204, headers: securityHeaders})
: json({error: "Conversation not found"}, 404);
}
return json({error: "Method not allowed"}, 405, {Allow: "GET, PUT, PATCH, DELETE"});
} catch (error) {
return json({error: error instanceof Error ? error.message : "Conversation request failed"}, 400);
}
}
function temporaryProvider(value: unknown): ProviderDefinition {
if (!value || typeof value !== "object" || Array.isArray(value)) throw new Error("provider is required");
const input = value as Record<string, unknown>;
const connectionInput = input.connection as Record<string, unknown> | undefined;
const authInput = input.auth as Record<string, unknown> | undefined;
const discoveryInput = input.discovery as Record<string, unknown> | undefined;
const id = String(input.id || "").trim().toLowerCase();
const name = String(input.name || "").trim();
const api = String(input.api || "");
if (!/^[a-z0-9][a-z0-9._-]*$/.test(id)) throw new Error("provider.id is invalid");
if (!name) throw new Error("provider.name is required");
if (!["openai-completions", "openai-responses", "anthropic-messages", "google-generative-ai"].includes(api)) throw new Error("provider.api is invalid");
if (connectionInput?.type !== "backend") throw new Error("Only Backend Provider drafts can be tested by the Chat server");
const baseUrl = new URL(String(connectionInput.baseUrl || ""));
if (!["http:", "https:"].includes(baseUrl.protocol)) throw new Error("provider baseUrl is invalid");
const proxyInput = connectionInput.proxy as Record<string, unknown> | null | undefined;
let proxy: ProviderDefinition["connection"]["proxy"] = null;
if (proxyInput) {
const type = String(proxyInput.type || "") as "http" | "https" | "socks5";
if (!["http", "https", "socks5"].includes(type)) throw new Error("provider proxy type is invalid");
const url = new URL(String(proxyInput.url || ""));
if (type === "socks5" ? url.protocol !== "socks5:" : !["http:", "https:"].includes(url.protocol)) throw new Error("provider proxy URL is invalid");
proxy = {type, url: url.toString().replace(/\/$/, "")};
}
const defaultModel = String(input.defaultModel || "").trim().slice(0, 300);
if (!defaultModel) throw new Error("provider.defaultModel is required");
const authType = ["bearer", "header", "none"].includes(String(authInput?.type)) ? String(authInput?.type) as "bearer" | "header" | "none" : "bearer";
const header = authType === "header" ? String(authInput?.header || "").trim() : "";
if (authType === "header" && !header) throw new Error("provider auth header is required");
const discoveryType = String(discoveryInput?.type || "");
if (!["openai-models-list", "anthropic-models-list", "google-models-list"].includes(discoveryType)) throw new Error("provider.discovery.type is invalid");
const discoveryUrl = new URL(String(discoveryInput?.url || ""));
if (!["http:", "https:"].includes(discoveryUrl.protocol)) throw new Error("provider.discovery.url is invalid");
return {
id,
name,
api: api as ProviderDefinition["api"],
connection: {type: "backend", baseUrl: baseUrl.toString().replace(/\/$/, ""), proxy},
defaultModel,
auth: authType === "header" ? {type: authType, header} : {type: authType},
headers: {},
discovery: {type: discoveryType as ProviderDefinition["discovery"]["type"], url: discoveryUrl.toString()},
builtin: false,
credentialState: "missing",
credentials: []
};
}
async function catalogFor(identity: ChatIdentity) {
const response = await keyVaultFetch("/v1/providers", identity);
const payload = await response.json() as {providers?: ProviderDefinition[]; error?: string};
if (!response.ok) throw Object.assign(new Error(payload.error || `Key Vault HTTP ${response.status}`), {statusCode: response.status});
return payload.providers || [];
}
async function savedSecret(identity: ChatIdentity, provider: ProviderDefinition, credentialName: string) {
if (!provider.credentials.some((credential) => credential.name === credentialName)) return null;
const response = await keyVaultFetch("/v1/resolve", identity, {
method: "POST",
headers: {"Content-Type": "application/json"},
body: JSON.stringify({providerId: provider.id, credentialName})
});
const payload = await response.json() as ResolvedBackendProvider & {error?: string};
if (!response.ok) throw Object.assign(new Error(payload.error || `Key Vault HTTP ${response.status}`), {statusCode: response.status});
return payload.credential.secret;
}
async function providerTest(request: Request) {
try {
const identity = identityFromHeaders(request.headers);
const input = await request.json() as {providerId?: string; credentialName?: string; provider?: unknown; secret?: ProviderSecret};
const credentialName = input.credentialName?.trim() || "default";
let provider: ProviderDefinition;
let secret: ProviderSecret = input.secret || {};
if (input.provider) {
provider = temporaryProvider(input.provider);
if (provider.auth.type !== "none" && !secret.provider?.apiKey) {
const saved = (await catalogFor(identity)).find((item) => item.id === provider.id);
const existingSecret = saved ? await savedSecret(identity, saved, credentialName) : null;
if (!existingSecret) return json({error: "Temporary API Key is required for connectivity testing"}, 409);
secret = existingSecret;
}
} else {
const providerId = input.providerId?.trim();
if (!providerId) return json({error: "providerId is required"}, 400);
const saved = (await catalogFor(identity)).find((item) => item.id === providerId);
if (!saved) return json({error: "Provider not found"}, 404);
provider = saved;
const existingSecret = await savedSecret(identity, provider, credentialName);
if (existingSecret) secret = existingSecret;
else if (provider.auth.type !== "none") return json({error: `Credential ${credentialName} is required for connectivity testing`}, 409);
}
const result = await testProviderConnectivity(provider, secret, createServerProviderFetch(provider, secret));
return json({...result, detected: {id: provider.id, name: provider.name, api: provider.api, auth: provider.auth, connection: provider.connection, discovery: provider.discovery}});
} catch (error) {
return json({error: error instanceof Error ? error.message : "Provider connectivity test failed"}, errorStatus(error, 502));
}
}
function streamEvent(controller: ReadableStreamDefaultController<Uint8Array>, event: unknown) {
controller.enqueue(encoder.encode(`${JSON.stringify(event)}\n`));
}
function cleanMessages(value: unknown): StoredChatMessage[] {
if (!Array.isArray(value)) throw new Error("messages are required");
return value.map((message) => {
if (!message || typeof message !== "object" || Array.isArray(message)) throw new Error("message is invalid");
const record = message as StoredChatMessage;
return {
id: typeof record.id === "string" ? record.id : randomUUID(),
role: record.role,
parts: Array.isArray(record.parts) ? record.parts.filter((part) => part.type === "text" || part.type === "reasoning") : [],
...(record.metadata ? {metadata: record.metadata} : {})
};
});
}
async function chat(request: Request) {
try {
const identity = identityFromHeaders(request.headers);
const input = await request.json() as {
messages?: unknown;
providerId?: string;
credentialName?: string;
model?: string;
conversationId?: string;
generationSettings?: unknown;
};
const messages = cleanMessages(input.messages);
if (!messages.length) return json({error: "messages are required"}, 400);
if (!input.providerId?.trim() || !input.model?.trim() || !input.conversationId?.trim()) {
return json({error: "conversationId, providerId and model are required"}, 400);
}
const conversationId = input.conversationId.trim();
if (!getConversation(identity, conversationId)) return json({error: "Conversation not found"}, 404);
const response = await keyVaultFetch("/v1/resolve", identity, {
method: "POST",
headers: {"Content-Type": "application/json"},
body: JSON.stringify({providerId: input.providerId.trim(), credentialName: input.credentialName?.trim() || "default"})
});
const resolved = await response.json() as ResolvedBackendProvider & {error?: string};
if (!response.ok) return json({error: resolved.error || `Key Vault HTTP ${response.status}`}, response.status);
if (resolved.provider.connection.type !== "backend") return json({error: "Frontend Provider must run in the browser"}, 409);
const providerFetch = createServerProviderFetch(resolved.provider, resolved.credential.secret);
const model = await createProviderModel(resolved.provider, resolved.credential.secret, input.model.trim(), providerFetch);
const generationSettings = normalizeGenerationSettings(input.generationSettings);
const startedAt = performance.now();
const result = streamText({
model,
messages: await convertToModelMessages(messages as UIMessage[]),
abortSignal: request.signal,
...generationCallOptions(resolved.provider, generationSettings)
});
const body = new ReadableStream<Uint8Array>({
async start(controller) {
let text = "";
let reasoning = "";
try {
streamEvent(controller, {type: "start"});
for await (const part of result.fullStream) {
if (part.type === "text-delta") {
text += part.text;
streamEvent(controller, {type: "text-delta", text: part.text});
} else if (part.type === "reasoning-delta") {
reasoning += part.text;
streamEvent(controller, {type: "reasoning-delta", text: part.text});
} else if (part.type === "error") {
throw part.error;
}
}
const usage = await result.usage;
const metadata = responseMetadata(input.providerId!.trim(), input.model!.trim(), startedAt, usage.outputTokens);
const assistant: StoredChatMessage = {
id: randomUUID(),
role: "assistant",
parts: [
...(reasoning ? [{type: "reasoning", text: reasoning}] : []),
...(text ? [{type: "text", text}] : [])
],
metadata: {custom: {response: metadata}}
};
saveConversationMessages(identity, conversationId, {providerId: input.providerId, model: input.model, messages: [...messages, assistant]});
streamEvent(controller, {type: "finish", metadata});
} catch (error) {
console.error("Backend Provider request failed", error instanceof Error ? error.message : error);
streamEvent(controller, {type: "error", error: error instanceof Error ? error.message : "Chat request failed"});
} finally {
controller.close();
}
}
});
return new Response(body, {
headers: {
...securityHeaders,
"Cache-Control": "no-store",
"Content-Type": "application/x-ndjson; charset=utf-8",
"X-Accel-Buffering": "no"
}
});
} catch (error) {
console.error("Chat request failed", error instanceof Error ? error.message : error);
return json({error: error instanceof Error ? error.message : "Chat request failed"}, errorStatus(error, 500));
}
}
const mimeTypes: Record<string, string> = {
".css": "text/css; charset=utf-8",
".html": "text/html; charset=utf-8",
".ico": "image/x-icon",
".js": "text/javascript; charset=utf-8",
".json": "application/json; charset=utf-8",
".png": "image/png",
".svg": "image/svg+xml",
".webmanifest": "application/manifest+json; charset=utf-8",
".webp": "image/webp"
};
async function staticResponse(pathname: string) {
let decoded: string;
try {
decoded = decodeURIComponent(pathname);
} catch {
return json({error: "Invalid path"}, 400);
}
const relative = decoded === "/" ? "index.html" : decoded.replace(/^\/+/, "");
let filePath = path.resolve(staticRoot, relative);
if (!filePath.startsWith(`${staticRoot}${path.sep}`) && filePath !== path.join(staticRoot, "index.html")) return json({error: "Not found"}, 404);
let file = Bun.file(filePath);
if (!(await file.exists()) && !path.extname(relative)) {
filePath = path.join(staticRoot, "index.html");
file = Bun.file(filePath);
}
if (!(await file.exists())) return json({error: "Not found"}, 404);
const extension = path.extname(filePath);
const immutable = /-[A-Za-z0-9_-]{8,}\.(?:js|css)$/.test(path.basename(filePath));
return new Response(file, {
headers: {
...securityHeaders,
"Content-Type": mimeTypes[extension] || "application/octet-stream",
"Cache-Control": extension === ".html" || path.basename(filePath) === "sw.js"
? "no-cache"
: immutable ? "public, max-age=31536000, immutable" : "public, max-age=3600"
}
});
}
const server = Bun.serve({
port,
hostname: "0.0.0.0",
idleTimeout: 255,
async fetch(request, server) {
const url = new URL(request.url);
if (url.pathname === "/api/health" && request.method === "GET") return json({status: "ok"});
if (url.pathname === "/api/config" && request.method === "GET") return config(request);
if (url.pathname === "/api/conversations") return conversations(request);
const conversationMatch = url.pathname.match(/^\/api\/conversations\/([^/]+)$/);
if (conversationMatch) return conversation(request, decodeURIComponent(conversationMatch[1]));
if (url.pathname === "/api/provider-test" && request.method === "POST") return providerTest(request);
if (url.pathname === "/api/chat" && request.method === "POST") {
server.timeout(request, 0);
return chat(request);
}
if (url.pathname.startsWith("/api/")) return json({error: "Not found"}, 404);
if (request.method !== "GET" && request.method !== "HEAD") return json({error: "Method not allowed"}, 405);
return staticResponse(url.pathname);
}
});
console.log(`xiteng-chat Bun server listening on ${server.url}`);
+201
View File
@@ -0,0 +1,201 @@
svg { width: 17px; height: 17px; fill: none; stroke: currentColor; stroke-width: 1.8; stroke-linecap: round; stroke-linejoin: round; }
:root {
color-scheme: light;
--bg: #f7f7f5;
--panel: rgba(255, 255, 255, 0.88);
--text: #171717;
--muted: #6f6f6a;
--line: rgba(23, 23, 23, 0.11);
--soft: #eeeeea;
--accent: #171717;
--accent-text: #ffffff;
--danger: #a3382d;
font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
}
* { box-sizing: border-box; }
html, body { width: 100%; height: 100%; margin: 0; }
body { overflow: hidden; background: var(--bg); color: var(--text); }
button, input, select { font: inherit; }
button, a { -webkit-tap-highlight-color: transparent; }
.app-shell { position: relative; height: 100dvh; background: radial-gradient(circle at 50% -20%, #fff 0, var(--bg) 42%); }
.app-header { position: absolute; inset: 0 0 auto; z-index: 10; display: grid; grid-template-columns: 1fr auto 1fr; height: 64px; align-items: center; padding: 0 22px; border-bottom: 1px solid var(--line); background: var(--panel); backdrop-filter: blur(18px); }
.header-leading { display: flex; align-items: center; gap: 8px; justify-self: start; }
.brand { display: flex; align-items: center; gap: 10px; font-weight: 680; letter-spacing: -0.02em; }
.brand-mark, .welcome-mark { display: grid; place-items: center; color: #fff; background: #171717; font-weight: 800; letter-spacing: -0.08em; }
.brand-mark { width: 30px; height: 30px; border-radius: 10px; font-size: 11px; }
.offline-badge { display: inline-flex; align-items: center; gap: 5px; border: 1px solid rgba(163,56,45,.2); border-radius: 999px; padding: 4px 7px; background: rgba(163,56,45,.07); color: var(--danger); font-size: 10px; font-weight: 650; letter-spacing: 0; white-space: nowrap; }
.chat-controls { display: flex; align-items: center; justify-self: end; gap: 8px; }
.header-button, .local-key-menu > summary, .generation-menu > summary { display: flex; height: 36px; align-items: center; gap: 7px; border: 1px solid var(--line); border-radius: 11px; background: rgba(255,255,255,.72); color: var(--text); text-decoration: none; }
.header-button { padding: 0 11px; cursor: pointer; }
.account-button { padding: 4px 10px 4px 5px; }
.header-avatar { width: 26px; height: 26px; border-radius: 8px; object-fit: cover; background: var(--soft); }
.header-button:hover, .local-key-menu > summary:hover, .generation-menu > summary:hover { background: #fff; border-color: rgba(23,23,23,.2); }
.local-key-menu, .generation-menu { position: relative; }
.local-key-menu > summary, .generation-menu > summary { width: 36px; justify-content: center; cursor: pointer; list-style: none; }
.local-key-menu > summary::-webkit-details-marker, .generation-menu > summary::-webkit-details-marker { display: none; }
.local-key-menu > div { position: absolute; z-index: 31; top: 43px; right: 0; display: grid; width: min(320px, calc(100vw - 24px)); max-height: min(520px, calc(100dvh - 80px)); overflow-y: auto; gap: 4px; border: 1px solid var(--line); border-radius: 12px; padding: 8px; background: var(--panel); box-shadow: 0 14px 36px rgba(0,0,0,.14); }
.local-key-menu-title { padding: 5px 7px 8px; color: var(--muted); font-size: 10px; letter-spacing: .08em; text-transform: uppercase; }
.local-key-entry { display: flex; min-width: 0; align-items: center; justify-content: space-between; gap: 8px; border-radius: 9px; padding: 7px; }
.local-key-entry:hover { background: var(--soft); }
.local-key-entry > span { min-width: 0; text-align: left; }
.local-key-entry > span strong, .local-key-entry > span small { display: block; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.local-key-entry > span small { margin-top: 2px; color: var(--muted); font-size: 9px; }
.local-key-entry > span .local-key-error { max-width: 190px; color: var(--danger); white-space: normal; }
.local-key-entry > div { display: flex; flex: 0 0 auto; gap: 2px; }
.local-key-menu button { border: 0; border-radius: 8px; padding: 8px 9px; background: transparent; color: var(--text); text-align: left; cursor: pointer; }
.local-key-menu button:hover { background: var(--panel); }
.local-key-menu button.dangerous { color: #b42318; }
.generation-menu > div { position: absolute; z-index: 31; top: 43px; right: 0; display: grid; width: min(280px, calc(100vw - 24px)); gap: 10px; border: 1px solid var(--line); border-radius: 12px; padding: 14px; background: var(--panel); box-shadow: 0 14px 36px rgba(0,0,0,.14); }
.generation-menu > div > strong { font-size: 12px; }
.generation-menu label { display: grid; gap: 5px; color: var(--muted); font-size: 10px; }
.generation-menu select, .generation-menu input[type="number"] { min-width: 0; width: 100%; box-sizing: border-box; border: 1px solid var(--line); border-radius: 8px; padding: 8px; background: var(--bg); color: var(--text); }
.generation-menu .generation-check { display: flex; align-items: center; gap: 7px; color: var(--text); }
.generation-menu button { border: 0; border-radius: 8px; padding: 9px; background: var(--soft); color: var(--text); cursor: pointer; }
.model-picker { position: relative; }
.model-picker > summary { display: flex; min-width: 190px; max-width: 290px; height: 36px; align-items: center; justify-content: space-between; gap: 8px; border: 1px solid var(--line); border-radius: 11px; padding: 0 10px; background: rgba(255,255,255,.72); color: var(--text); cursor: pointer; list-style: none; }
.model-picker > summary span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.model-picker > summary::-webkit-details-marker { display: none; }
.model-picker[open] > summary { border-color: rgba(23,23,23,.24); background: #fff; }
.model-picker[open] > summary svg { transform: rotate(180deg); }
.model-menu { position: absolute; z-index: 30; top: 43px; left: 0; width: min(430px, calc(100vw - 24px)); max-height: min(670px, calc(100dvh - 82px)); overflow-y: auto; border: 1px solid var(--line); border-radius: 14px; padding: 8px; background: var(--panel); box-shadow: 0 18px 48px rgba(0,0,0,.15); backdrop-filter: blur(18px); }
.model-search { position: sticky; z-index: 1; top: -8px; display: flex; height: 40px; align-items: center; gap: 8px; margin: -1px -1px 5px; padding: 0 9px; border-bottom: 1px solid var(--line); background: var(--panel); color: var(--muted); }
.model-search input { width: 100%; border: 0; outline: 0; background: transparent; color: var(--text); }
.model-menu-group { display: grid; gap: 2px; padding: 5px 0 8px; }
.model-menu-group + .model-menu-group { border-top: 1px solid var(--line); }
.model-menu-group h3 { display: flex; align-items: center; gap: 5px; margin: 0; padding: 4px 8px; color: var(--muted); font-size: 10px; font-weight: 750; letter-spacing: .08em; text-transform: uppercase; }
.model-option { display: flex; width: 100%; align-items: center; justify-content: space-between; gap: 12px; border: 0; border-radius: 8px; padding: 8px; background: transparent; color: var(--text); text-align: left; cursor: pointer; }
.model-option:hover, .model-option.active { background: var(--soft); }
.model-option > span { min-width: 0; }
.model-option strong, .model-option small { display: block; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.model-option strong { font-size: 12px; font-weight: 620; }
.model-option small { color: var(--muted); font-size: 10px; }
.model-menu-empty { margin: 10px; color: var(--muted); text-align: center; }
.state-card > .model-picker { display: inline-block; margin-bottom: 24px; text-align: left; }
.state-card > .model-picker > summary { min-width: 260px; }
.history-toggle, .history-heading button, .history-delete { display: grid; place-items: center; border: 0; background: transparent; color: inherit; cursor: pointer; }
.history-toggle { width: 32px; height: 32px; border-radius: 9px; }
.history-toggle:hover, .history-heading button:hover, .history-delete:hover { background: var(--soft); }
.history-sidebar { position: absolute; z-index: 9; top: 64px; bottom: 0; left: 0; width: 252px; display: flex; flex-direction: column; transform: translateX(-102%); border-right: 1px solid var(--line); background: var(--panel); backdrop-filter: blur(18px); transition: transform .18s ease; }
.history-sidebar.open { transform: translateX(0); }
.history-heading { display: flex; height: 52px; flex: 0 0 auto; align-items: center; justify-content: space-between; padding: 0 12px 0 16px; border-bottom: 1px solid var(--line); font-size: 13px; }
.history-heading > div { display: flex; gap: 2px; }
.history-heading button { width: 30px; height: 30px; border-radius: 8px; }
.history-close { display: none !important; }
.history-list { min-height: 0; flex: 1; overflow-y: auto; padding: 8px; }
.history-item { display: grid; grid-template-columns: minmax(0, 1fr) 28px; align-items: center; border-radius: 9px; }
.history-item:hover, .history-item.active { background: var(--soft); }
.history-select { min-width: 0; border: 0; padding: 9px 4px 9px 9px; background: transparent; color: var(--text); text-align: left; cursor: pointer; }
.history-select strong, .history-select small { display: block; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.history-select strong { font-size: 12px; font-weight: 650; }
.history-select small { margin-top: 3px; color: var(--muted); font-size: 9px; }
.history-delete { width: 26px; height: 26px; border-radius: 7px; color: var(--muted); opacity: 0; }
.history-item:hover .history-delete, .history-item.active .history-delete { opacity: 1; }
.history-backdrop { display: none; }
.with-history .thread-root { padding-left: 0; transition: padding-left .18s ease; }
.with-history.history-open .thread-root { padding-left: 252px; }
.thread-root { height: 100dvh; padding-top: 64px; }
.thread-viewport { position: relative; display: flex; height: 100%; flex-direction: column; overflow-y: auto; scroll-behavior: smooth; padding: 24px 18px 0; }
#message-list { display: flex; flex: 1 0 auto; flex-direction: column; }
.welcome { display: flex; width: min(680px, 100%); flex: 1; flex-direction: column; justify-content: center; margin: 0 auto; padding: 80px 4px 210px; }
.welcome-mark { width: 48px; height: 48px; border-radius: 16px; font-size: 15px; box-shadow: 0 10px 30px rgba(0,0,0,.12); }
.welcome h1 { margin: 22px 0 8px; font-size: clamp(30px, 5vw, 46px); line-height: 1.08; letter-spacing: -.045em; }
.welcome p { max-width: 560px; margin: 0; color: var(--muted); font-size: 15px; line-height: 1.7; }
.message { width: min(760px, 100%); margin: 0 auto; padding: 14px 0; }
.user-message { display: flex; justify-content: flex-end; }
.message-content { overflow-wrap: anywhere; font-size: 15px; line-height: 1.72; }
.user-content { max-width: min(620px, 86%); border-radius: 16px 16px 5px 16px; padding: 7px 13px; background: var(--soft); line-height: 1.5; white-space: pre-wrap; }
.user-content > p { margin: 0; }
.user-content > p + p { margin-top: 6px; }
.assistant-content { padding: 2px 4px; }
.assistant-content .aui-md { color: var(--text); }
.assistant-content .aui-md > :first-child { margin-top: 0; }
.assistant-content .aui-md > :last-child { margin-bottom: 0; }
.assistant-content pre { overflow-x: auto; border: 1px solid var(--line); border-radius: 12px; padding: 14px; background: #20201f; color: #f6f6f3; }
.assistant-content code { font-family: "SFMono-Regular", Consolas, monospace; font-size: .88em; }
.assistant-content :not(pre) > code { border-radius: 5px; padding: 2px 5px; background: var(--soft); }
.assistant-content a { color: inherit; text-underline-offset: 3px; }
.message-reasoning { margin: 0 0 12px; border-left: 2px solid var(--line); padding-left: 12px; color: var(--muted); }
.message-reasoning > summary { cursor: pointer; font-size: 12px; font-weight: 650; }
.message-reasoning > div { margin-top: 7px; font-size: 12px; line-height: 1.65; white-space: pre-wrap; }
.message-error { margin-top: 10px; border: 1px solid rgba(163,56,45,.2); border-radius: 10px; padding: 10px 12px; background: rgba(163,56,45,.07); color: var(--danger); font-size: 13px; }
.thread-footer { position: sticky; bottom: 0; display: flex; width: min(760px, 100%); flex-direction: column; align-items: center; margin: auto auto 0; padding: 40px 0 16px; background: linear-gradient(to bottom, transparent, var(--bg) 38%); }
.scroll-button { position: absolute; top: 1px; display: grid; width: 34px; height: 34px; place-items: center; border: 1px solid var(--line); border-radius: 50%; background: var(--panel); color: var(--muted); box-shadow: 0 5px 18px rgba(0,0,0,.08); cursor: pointer; }
.message-footer { display: flex; min-height: 30px; align-items: center; justify-content: space-between; gap: 12px; margin: 6px 0 0; }
.response-meta { display: flex; min-width: 0; align-items: center; gap: 7px; color: var(--muted); font-size: 10px; }
.response-meta span:first-child { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.response-meta span + span::before { margin-right: 7px; content: "·"; }
.scroll-button:disabled { visibility: hidden; }
.composer { display: grid; width: 100%; grid-template-columns: 1fr auto; align-items: end; gap: 8px; border: 1px solid rgba(23,23,23,.15); border-radius: 20px; padding: 8px 8px 8px 16px; background: #fff; box-shadow: 0 14px 45px rgba(0,0,0,.09); }
.composer:focus-within { border-color: rgba(23,23,23,.32); box-shadow: 0 14px 45px rgba(0,0,0,.1), 0 0 0 3px rgba(23,23,23,.04); }
.message-actions { display: flex; flex: 0 0 auto; gap: 2px; margin: 0; opacity: 1; visibility: visible; }
.icon-button { display: grid; width: 30px; height: 30px; place-items: center; border: 0; border-radius: 8px; background: transparent; color: var(--muted); cursor: pointer; }
.icon-button:hover { background: var(--soft); color: var(--text); }
.composer-input { max-height: 180px; min-height: 38px; resize: none; border: 0; outline: 0; padding: 9px 0 7px; background: transparent; color: var(--text); line-height: 1.5; }
.composer-input:disabled { cursor: not-allowed; color: var(--muted); }
.composer-input::placeholder { color: #9a9a94; }
.send-button { display: grid; width: 38px; height: 38px; place-items: center; border: 0; border-radius: 13px; background: var(--accent); color: var(--accent-text); cursor: pointer; }
.send-button:disabled { cursor: default; opacity: .35; }
.composer-note { margin: 8px 0 0; color: #90908a; font-size: 11px; }
.composer-note.offline { color: var(--danger); }
.state-page { position: relative; display: grid; min-height: 100dvh; place-items: center; padding: 24px; background: radial-gradient(circle at 50% 10%, #fff, var(--bg) 52%); }
.state-card { width: min(520px, 100%); text-align: center; }
.state-provider-select { display: inline-flex; align-items: center; gap: 8px; margin-bottom: 24px; border: 1px solid var(--line); border-radius: 11px; padding: 8px 10px; color: var(--muted); background: var(--panel); }
.state-provider-select select { border: 0; outline: 0; color: var(--text); background: transparent; }
.state-mark { display: grid; width: 52px; height: 52px; place-items: center; margin: 0 auto 18px; border-radius: 17px; background: #171717; color: #fff; font-weight: 800; }
.state-card h1 { margin: 0 0 10px; font-size: 28px; letter-spacing: -.035em; }
.state-card p { margin: 0 auto; color: var(--muted); line-height: 1.7; }
.state-card code { border-radius: 5px; padding: 2px 5px; background: var(--soft); color: var(--text); }
.primary-link { display: inline-flex; margin-top: 22px; border-radius: 12px; padding: 11px 16px; background: #171717; color: #fff; text-decoration: none; }
.button-link { border: 0; cursor: pointer; }
.loader { display: block; width: 30px; height: 30px; margin: 0 auto 16px; border: 3px solid var(--line); border-top-color: #171717; border-radius: 50%; animation: spin .75s linear infinite; }
@keyframes spin { to { transform: rotate(360deg); } }
.response-loader { display: inline-block; width: 7px; height: 18px; border-radius: 2px; background: currentColor; vertical-align: text-bottom; animation: pulse 1s ease-in-out infinite; }
.icon-button.copied { color: #25824d; }
.icon-button:disabled { cursor: default; opacity: .4; }
@keyframes pulse { 50% { opacity: .25; } }
@media (max-width: 680px) {
.app-header { grid-template-columns: auto auto 1fr; height: 58px; gap: 10px; padding: 0 12px; }
.thread-root { padding-top: 58px; }
.chat-controls { min-width: 0; gap: 5px; }
.chat-controls > * { flex: 0 0 auto; }
.model-picker > summary { min-width: 0; width: min(132px, 34vw); }
.account-button { width: 36px; }
.app-header { overflow: clip; }
.brand > span:nth-child(2), .header-button span { display: none; }
.offline-badge { padding: 4px 6px; font-size: 0; }
.header-button { width: 36px; justify-content: center; padding: 0; }
.provider-control select { max-width: 105px; }
.model-picker > summary { min-width: 132px; max-width: 170px; }
.model-menu { position: fixed; top: 64px; right: 12px; left: 12px; width: auto; max-height: calc(100dvh - 76px); }
.local-key-menu > div { position: fixed; top: 64px; right: 12px; left: 12px; width: auto; max-height: calc(100dvh - 76px); }
.generation-menu > div { position: fixed; top: 64px; right: 12px; left: 12px; width: auto; max-height: calc(100dvh - 76px); overflow-y: auto; }
.history-sidebar { position: fixed; z-index: 40; top: 0; width: min(300px, 86vw); border-right: 1px solid var(--line); }
.history-heading { height: 58px; }
.history-close { display: grid !important; }
.history-backdrop { position: fixed; z-index: 35; inset: 0; display: block; border: 0; background: rgba(0,0,0,.36); opacity: 0; pointer-events: none; transition: opacity .18s ease; }
.history-backdrop.open { opacity: 1; pointer-events: auto; }
.with-history.history-open .thread-root { padding-left: 0; }
.thread-viewport { padding-inline: 12px; }
.welcome { padding-bottom: 170px; }
.user-content { max-width: 92%; }
.thread-footer { padding-bottom: max(10px, env(safe-area-inset-bottom)); }
.composer-note { display: none; }
}
@media (prefers-color-scheme: dark) {
:root { color-scheme: dark; --bg: #111210; --panel: rgba(22,23,21,.88); --text: #f2f2ee; --muted: #a1a19a; --line: rgba(255,255,255,.12); --soft: #242521; --accent: #f0f0ec; --accent-text: #171717; }
.app-shell { background: radial-gradient(circle at 50% -20%, #272824 0, var(--bg) 42%); }
.header-button, .local-key-menu > summary, .local-key-menu > div, .generation-menu > summary, .generation-menu > div, .model-picker > summary, .model-menu, .composer, .history-sidebar { background: rgba(31,32,29,.9); }
.model-picker[open] > summary { border-color: rgba(255,255,255,.24); background: #292a26; }
.header-button:hover, .local-key-menu > summary:hover { background: #292a26; }
.assistant-content pre { background: #080908; }
.state-page { background: radial-gradient(circle at 50% 10%, #272824, var(--bg) 52%); }
.brand-mark, .welcome-mark, .state-mark, .primary-link { background: #efefeb; color: #171717; }
.loader { border-top-color: #efefeb; }
}
+18
View File
@@ -0,0 +1,18 @@
{
"compilerOptions": {
"target": "ES2022",
"lib": ["dom", "dom.iterable", "esnext"],
"types": ["bun"],
"strict": true,
"noEmit": true,
"skipLibCheck": true,
"allowJs": false,
"esModuleInterop": true,
"module": "esnext",
"moduleResolution": "bundler",
"resolveJsonModule": true,
"isolatedModules": true
},
"include": ["src/**/*.ts", "lib/**/*.ts", "build.ts"],
"exclude": ["node_modules", "dist"]
}
+14 -1
View File
@@ -1,12 +1,25 @@
services: services:
cloudflared: cloudflared:
image: cloudflare/cloudflared:2025.5.0 image: cloudflare/cloudflared:2026.7.3
container_name: cloudflared container_name: cloudflared
restart: unless-stopped restart: unless-stopped
command: tunnel --protocol http2 run command: tunnel --protocol http2 run
volumes: volumes:
- ./config.yml:/etc/cloudflared/config.yml:ro - ./config.yml:/etc/cloudflared/config.yml:ro
- ./credentials.json:/etc/cloudflared/credentials.json:ro - ./credentials.json:/etc/cloudflared/credentials.json:ro
labels:
- "xiteng.site.component.cloudflare-tunnel.enabled=true"
- "xiteng.site.component.cloudflare-tunnel.name=Cloudflare Tunnel"
- "xiteng.site.component.cloudflare-tunnel.description=将 xiteng.site 与通配子域的公网流量送入 Traefik。"
- "xiteng.site.component.cloudflare-tunnel.section=infrastructure"
- "xiteng.site.component.cloudflare-tunnel.category=边缘与网络"
- "xiteng.site.component.cloudflare-tunnel.endpoint=*.xiteng.site → Traefik"
- "xiteng.site.component.cloudflare-tunnel.access=internal"
- "xiteng.site.component.cloudflare-tunnel.access-label=基础设施"
- "xiteng.site.component.cloudflare-tunnel.icon=CF"
- "xiteng.site.component.cloudflare-tunnel.icon-url=https://cdn.simpleicons.org/cloudflare"
- "xiteng.site.component.cloudflare-tunnel.accent=yellow"
- "xiteng.site.component.cloudflare-tunnel.order=100"
networks: networks:
- homelab_net - homelab_net
+62
View File
@@ -0,0 +1,62 @@
services:
code-server:
image: codercom/code-server:4.132.0-39
container_name: code-server
restart: unless-stopped
user: "${CODE_SERVER_UID:-1000}:${CODE_SERVER_GID:-1000}"
environment:
DOCKER_USER: ${CODE_SERVER_USER:-coder}
command:
- --bind-addr
- 0.0.0.0:8080
- --auth
- none
- /home/coder/homelab
volumes:
- ./config:/home/coder/.config:rw
- ./local:/home/coder/.local:rw
- ../:/home/coder/homelab:rw
networks:
- homelab_net
labels:
# Traefik
- "traefik.enable=true"
- "traefik.http.routers.code-server.rule=Host(`code.xiteng.site`)"
- "traefik.http.routers.code-server.entrypoints=websecure"
- "traefik.http.routers.code-server.tls=true"
- "traefik.http.routers.code-server.tls.certresolver=cfresolver"
- "traefik.http.routers.code-server.service=code-server"
- "traefik.http.routers.code-server.middlewares=code-server-scheme,code-server-auth"
- "traefik.http.services.code-server.loadbalancer.server.port=8080"
- "traefik.http.routers.code-server-http.rule=Host(`code.xiteng.site`)"
- "traefik.http.routers.code-server-http.entrypoints=web"
- "traefik.http.routers.code-server-http.service=code-server"
- "traefik.http.routers.code-server-http.middlewares=code-server-scheme,code-server-auth"
- "xiteng.site.cache.code-server.enabled=true"
- "xiteng.site.cache.code-server.routers=code-server,code-server-http"
- "xiteng.site.cache.code-server.paths=/assets/,/static/"
- "xiteng.site.cache.code-server.edge-ttl=604800"
- "xiteng.site.cache.code-server.stale-while-revalidate=86400"
# Authentik ForwardAuth
- "traefik.http.middlewares.code-server-scheme.headers.customrequestheaders.X-Forwarded-Proto=https"
- "traefik.http.middlewares.code-server-auth.forwardauth.address=http://authentik-outpost:9000/outpost.goauthentik.io/auth/traefik"
- "traefik.http.middlewares.code-server-auth.forwardauth.trustForwardHeader=true"
- "traefik.http.middlewares.code-server-auth.forwardauth.authResponseHeaders=X-authentik-username,X-authentik-groups,X-authentik-email,X-authentik-name"
- "xiteng.site.component.code-server.enabled=true"
- "xiteng.site.component.code-server.name=Code Server"
- "xiteng.site.component.code-server.description=浏览器中的 VS Code 工作台,用于维护 Homelab 配置与脚本。"
- "xiteng.site.component.code-server.section=services"
- "xiteng.site.component.code-server.category=开发与协作"
- "xiteng.site.component.code-server.url=https://code.xiteng.site"
- "xiteng.site.component.code-server.access=sso"
- "xiteng.site.component.code-server.access-label=需要 Authentik"
- "xiteng.site.component.code-server.icon=CS"
- "xiteng.site.component.code-server.icon-url=https://cdn.simpleicons.org/coder"
- "xiteng.site.component.code-server.accent=blue"
- "xiteng.site.component.code-server.order=130"
- "xiteng.site.component.code-server.monitor.enabled=true"
- "xiteng.site.component.code-server.monitor.url=http://code-server:8080/healthz"
networks:
homelab_net:
external: true
+14
View File
@@ -0,0 +1,14 @@
FROM ghcr.nju.edu.cn/lecode-official/comfyui-docker:0.6.3-comfyui-0.8.2-comfyui-manager-4.0.5-pytorch-2.9.1-cuda-12.8-cudnn-9
USER root
WORKDIR /
RUN rm -rf /opt/comfyui /opt/comfyui-manager \
&& git clone --depth 1 --branch v0.31.0 https://github.com/Comfy-Org/ComfyUI.git /opt/comfyui \
&& git clone --depth 1 --branch 4.2.2 https://github.com/Comfy-Org/ComfyUI-Manager.git /opt/comfyui-manager
RUN /opt/conda/bin/pip install --index-url https://pypi.tuna.tsinghua.edu.cn/simple --requirement /opt/comfyui/requirements.txt \
&& if [ -f /opt/comfyui-manager/requirements.txt ]; then /opt/conda/bin/pip install --index-url https://pypi.tuna.tsinghua.edu.cn/simple --requirement /opt/comfyui-manager/requirements.txt; fi
WORKDIR /opt/comfyui
+10
View File
@@ -0,0 +1,10 @@
services:
comfyui:
command: ["--enable-manager"]
deploy:
resources:
reservations:
devices:
- driver: nvidia
count: all
capabilities: [gpu]
+62
View File
@@ -0,0 +1,62 @@
services:
comfyui:
build:
context: .
image: xiteng-comfyui:0.31.0-manager-4.2.2
container_name: comfyui
restart: unless-stopped
entrypoint: ["/bin/bash", "/entrypoint-patched.sh"]
environment:
USER_ID: ${COMFYUI_USER_ID:-1000}
GROUP_ID: ${COMFYUI_GROUP_ID:-1000}
PYTHONPATH: /opt/comfyui-manager
command: ["--cpu", "--enable-manager"]
volumes:
- ./entrypoint.sh:/entrypoint-patched.sh:ro
- ./models:/opt/comfyui/models:rw
- ./custom_nodes:/opt/comfyui/custom_nodes:rw
- ./output:/opt/comfyui/output:rw
networks:
- homelab_net
labels:
# Traefik
- "traefik.enable=true"
- "traefik.http.routers.comfyui.rule=Host(`comfy.xiteng.site`)"
- "traefik.http.routers.comfyui.entrypoints=websecure"
- "traefik.http.routers.comfyui.tls=true"
- "traefik.http.routers.comfyui.tls.certresolver=cfresolver"
- "traefik.http.routers.comfyui.service=comfyui"
- "traefik.http.routers.comfyui.middlewares=comfyui-scheme,comfyui-auth"
- "traefik.http.services.comfyui.loadbalancer.server.port=8188"
- "traefik.http.routers.comfyui-http.rule=Host(`comfy.xiteng.site`)"
- "traefik.http.routers.comfyui-http.entrypoints=web"
- "traefik.http.routers.comfyui-http.service=comfyui"
- "traefik.http.routers.comfyui-http.middlewares=comfyui-scheme,comfyui-auth"
- "xiteng.site.cache.comfyui.enabled=true"
- "xiteng.site.cache.comfyui.routers=comfyui,comfyui-http"
- "xiteng.site.cache.comfyui.paths=/assets/,/static/"
- "xiteng.site.cache.comfyui.edge-ttl=604800"
- "xiteng.site.cache.comfyui.stale-while-revalidate=86400"
# Authentik ForwardAuth
- "traefik.http.middlewares.comfyui-scheme.headers.customrequestheaders.X-Forwarded-Proto=https"
- "traefik.http.middlewares.comfyui-auth.forwardauth.address=http://authentik-outpost:9000/outpost.goauthentik.io/auth/traefik"
- "traefik.http.middlewares.comfyui-auth.forwardauth.trustForwardHeader=true"
- "traefik.http.middlewares.comfyui-auth.forwardauth.authResponseHeaders=X-authentik-username,X-authentik-groups,X-authentik-email,X-authentik-name"
- "xiteng.site.component.comfyui.enabled=true"
- "xiteng.site.component.comfyui.name=ComfyUI"
- "xiteng.site.component.comfyui.description=节点式图像生成、模型与工作流实验环境。"
- "xiteng.site.component.comfyui.section=services"
- "xiteng.site.component.comfyui.category=AI"
- "xiteng.site.component.comfyui.url=https://comfy.xiteng.site"
- "xiteng.site.component.comfyui.access=sso"
- "xiteng.site.component.comfyui.access-label=需要 Authentik"
- "xiteng.site.component.comfyui.icon=CU"
- "xiteng.site.component.comfyui.icon-url=https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/comfyui.svg"
- "xiteng.site.component.comfyui.accent=red"
- "xiteng.site.component.comfyui.order=210"
- "xiteng.site.component.comfyui.monitor.enabled=true"
- "xiteng.site.component.comfyui.monitor.url=http://comfyui:8188"
networks:
homelab_net:
external: true
+103
View File
@@ -0,0 +1,103 @@
#!/bin/bash
set -e
echo "Creating directories for models..."
MODEL_DIRECTORIES=(
"checkpoints"
"clip"
"clip_vision"
"configs"
"controlnet"
"diffusers"
"diffusion_models"
"embeddings"
"gligen"
"hypernetworks"
"loras"
"photomaker"
"style_models"
"text_encoders"
"unet"
"upscale_models"
"vae"
"vae_approx"
)
for MODEL_DIRECTORY in "${MODEL_DIRECTORIES[@]}"; do
mkdir -p "/opt/comfyui/models/$MODEL_DIRECTORY"
done
echo "Preparing ComfyUI Manager..."
export PYTHONPATH="/opt/comfyui-manager${PYTHONPATH:+:$PYTHONPATH}"
rm --force /opt/comfyui/custom_nodes/ComfyUI-Manager
ln -s /opt/comfyui-manager /opt/comfyui/custom_nodes/ComfyUI-Manager
mkdir -p /opt/comfyui/user/__manager
python - <<'PY'
from configparser import ConfigParser
from pathlib import Path
config_path = Path("/opt/comfyui/user/__manager/config.ini")
config = ConfigParser(strict=False)
config.read(config_path)
if "default" not in config:
config["default"] = {}
defaults = {
"git_exe": "",
"use_uv": "True",
"channel_url": "https://raw.githubusercontent.com/ltdrdata/ComfyUI-Manager/main",
"share_option": "all",
"bypass_ssl": "False",
"file_logging": "True",
"update_policy": "stable-comfyui",
"windows_selector_event_loop_policy": "False",
"model_download_by_agent": "False",
"downgrade_blacklist": "",
"security_level": "normal",
"always_lazy_install": "False",
"verbose": "False",
}
for key, value in defaults.items():
config["default"].setdefault(key, value)
config["default"]["network_mode"] = "offline"
config["default"]["db_mode"] = "local"
with config_path.open("w", encoding="utf-8") as handle:
config.write(handle)
PY
echo "Installing requirements for custom nodes..."
for CUSTOM_NODE_DIRECTORY in /opt/comfyui/custom_nodes/*; do
if [ "$CUSTOM_NODE_DIRECTORY" != "/opt/comfyui/custom_nodes/ComfyUI-Manager" ]; then
if [ -f "$CUSTOM_NODE_DIRECTORY/requirements.txt" ]; then
CUSTOM_NODE_NAME=${CUSTOM_NODE_DIRECTORY##*/}
CUSTOM_NODE_NAME=${CUSTOM_NODE_NAME//[-_]/ }
echo "Installing requirements for $CUSTOM_NODE_NAME..."
pip install --requirement "$CUSTOM_NODE_DIRECTORY/requirements.txt"
fi
fi
done
if [ -z "$USER_ID" ] || [ -z "$GROUP_ID" ]; then
echo "Running container as $USER..."
exec /opt/conda/bin/python main.py \
--port 8188 \
--listen 0.0.0.0 \
--disable-auto-launch \
"$@"
else
echo "Creating non-root user..."
getent group "$GROUP_ID" > /dev/null 2>&1 || groupadd --gid "$GROUP_ID" comfyui-user
id -u "$USER_ID" > /dev/null 2>&1 || useradd --uid "$USER_ID" --gid "$GROUP_ID" --create-home comfyui-user
chown --recursive "$USER_ID:$GROUP_ID" /opt/comfyui
chown --recursive "$USER_ID:$GROUP_ID" /opt/comfyui-manager
export PATH=$PATH:/home/comfyui-user/.local/bin
echo "Running container as comfyui-user ($USER_ID:$GROUP_ID)..."
sudo --set-home --preserve-env=PATH,PYTHONPATH --user "#$USER_ID" \
/opt/conda/bin/python main.py \
--port 8188 \
--listen 0.0.0.0 \
--disable-auto-launch \
"$@"
fi
+1 -3
View File
@@ -2,6 +2,4 @@ networks:
homelab_net: homelab_net:
driver: bridge driver: bridge
services: services: {}
# 这里只定义网络和基础服务,具体服务在各自子目录
# 可扩展如 traefik/nginx-proxy-manager 等
+45
View File
@@ -0,0 +1,45 @@
services:
deck:
image: nginx:alpine@sha256:4a73073bd557c65b759505da037898b61f1be6cbcc3c2c3aeac22d2a470c1752
container_name: deck
restart: unless-stopped
volumes:
- ./index.html:/usr/share/nginx/html/index.html:ro
networks:
- homelab_net
labels:
# Traefik
- "traefik.enable=true"
- "traefik.http.routers.deck.rule=Host(`deck.xiteng.site`)"
- "traefik.http.routers.deck.entrypoints=websecure"
- "traefik.http.routers.deck.tls=true"
- "traefik.http.routers.deck.tls.certresolver=cfresolver"
- "traefik.http.routers.deck.service=deck"
- "traefik.http.services.deck.loadbalancer.server.port=80"
- "traefik.http.routers.deck-http.rule=Host(`deck.xiteng.site`)"
- "traefik.http.routers.deck-http.service=deck"
- "traefik.http.routers.deck-http.entrypoints=web"
- "xiteng.site.cache.deck.enabled=true"
- "xiteng.site.cache.deck.routers=deck,deck-http"
- "xiteng.site.cache.deck.paths=/assets/,/static/,/css/,/js/"
- "xiteng.site.cache.deck.edge-ttl=604800"
- "xiteng.site.cache.deck.stale-while-revalidate=86400"
- "xiteng.site.component.deck.enabled=true"
- "xiteng.site.component.deck.name=Xiteng Deck"
- "xiteng.site.component.deck.description=介绍个人基础设施与 xiteng.site 的公开演示文稿。"
- "xiteng.site.component.deck.section=services"
- "xiteng.site.component.deck.category=作品与实验"
- "xiteng.site.component.deck.url=https://deck.xiteng.site"
- "xiteng.site.component.deck.access=public"
- "xiteng.site.component.deck.access-label=无需登录"
- "xiteng.site.component.deck.icon=DK"
- "xiteng.site.component.deck.accent=red"
- "xiteng.site.component.deck.order=310"
- "xiteng.site.component.deck.navigation=new-tab"
- "xiteng.site.component.deck.portal-link=embedded"
- "xiteng.site.component.deck.monitor.enabled=true"
- "xiteng.site.component.deck.monitor.url=http://deck:80"
networks:
homelab_net:
external: true
+522
View File
@@ -0,0 +1,522 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>xiteng.site — Personal Infrastructure</title>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&family=JetBrains+Mono:wght@400;500;700&display=swap" rel="stylesheet">
<style>
:root {
--bg: #050508;
--surface: #0a0a14;
--card: rgba(255,255,255,0.025);
--card-hover: rgba(255,255,255,0.05);
--text: #e4e4ec;
--text-dim: #8888a0;
--text-muted: #5a5a72;
--accent: #6366f1;
--accent-glow: rgba(99,102,241,0.35);
--green: #22c55e;
--green-glow: rgba(34,197,94,0.3);
--amber: #f59e0b;
--blue: #3b82f6;
--red: #ef4444;
--border: rgba(255,255,255,0.06);
--border-visible: rgba(255,255,255,0.10);
--grid: rgba(255,255,255,0.025);
}
*,*::before,*::after{box-sizing:border-box;margin:0;padding:0}
html{scroll-behavior:smooth;background:var(--bg);-webkit-text-size-adjust:100%}
body{
font-family:'Inter',system-ui,-apple-system,sans-serif;
background:var(--bg);color:var(--text);
overflow-x:hidden;line-height:1.6;
-webkit-font-smoothing:antialiased;
}
/* ── Canvas Background ── */
#gridCanvas{position:fixed;inset:0;z-index:0;pointer-events:none;opacity:0.6}
/* ── Status Bar ── */
.statusbar{
position:fixed;top:0;left:0;right:0;z-index:100;
height:36px;display:flex;align-items:center;justify-content:space-between;
padding:0 20px;font-family:'JetBrains Mono',monospace;
font-size:11px;color:var(--text-muted);
background:rgba(5,5,8,0.85);
backdrop-filter:blur(12px);
border-bottom:1px solid var(--border);
}
.statusbar a{display:inline-flex;align-items:center;color:var(--text-dim);text-decoration:none;font-weight:600}
.statusbar a:hover{color:var(--text)}
.statusbar .left{display:flex;align-items:center;gap:16px}
.statusbar .dot{
width:7px;height:7px;border-radius:50%;background:var(--green);
box-shadow:0 0 6px var(--green-glow);
margin-right:8px;display:inline-block;vertical-align:middle;
animation:pulse-dot 2s ease-in-out infinite;
}
@keyframes pulse-dot{0%,100%{opacity:1}50%{opacity:0.5}}
.statusbar .right{display:flex;gap:16px}
/* ── Layout ── */
.container{position:relative;z-index:1;max-width:1100px;margin:0 auto;padding:0 24px}
section{padding:100px 0;position:relative}
section:first-of-type{padding-top:160px}
/* ── Typography ── */
.label{
font-family:'JetBrains Mono',monospace;
font-size:12px;font-weight:500;color:var(--accent);
text-transform:uppercase;letter-spacing:0.08em;margin-bottom:16px;display:block;
}
h1{
font-size:clamp(40px,6vw,80px);font-weight:700;
line-height:1.05;letter-spacing:-0.03em;margin-bottom:16px;
}
h1 .hl{color:var(--accent)}
h2{
font-size:clamp(28px,3.5vw,48px);font-weight:600;
line-height:1.15;letter-spacing:-0.02em;margin-bottom:12px;
}
.subtitle{
font-size:clamp(15px,1.4vw,20px);font-weight:300;color:var(--text-dim);
max-width:560px;line-height:1.7;
}
/* ── Hero ── */
.hero-grid{
display:grid;grid-template-columns:repeat(3,1fr);gap:12px;
margin-top:48px;max-width:600px;
}
.hero-stat{
background:var(--card);border:1px solid var(--border);
border-radius:8px;padding:16px 18px;
}
.hero-stat .val{
font-family:'JetBrains Mono',monospace;
font-size:22px;font-weight:700;color:var(--text);
}
.hero-stat .val.up{color:var(--green)}
.hero-stat .lbl{font-size:11px;color:var(--text-muted);margin-top:2px;text-transform:uppercase;letter-spacing:0.05em}
.hero-stat .val .unit{font-size:13px;color:var(--text-dim);font-weight:400}
/* ── Terminal Block ── */
.terminal{
background:rgba(0,0,0,0.5);border:1px solid var(--border-visible);
border-radius:10px;overflow:hidden;font-family:'JetBrains Mono',monospace;
font-size:clamp(11px,0.8vw,13px);line-height:1.8;
}
.terminal .bar{
height:28px;background:rgba(255,255,255,0.03);
display:flex;align-items:center;padding:0 12px;gap:6px;
border-bottom:1px solid var(--border);
}
.terminal .bar .tb{width:10px;height:10px;border-radius:50%}
.terminal .bar .tb.r{background:var(--red)}
.terminal .bar .tb.y{background:var(--amber)}
.terminal .bar .tb.g{background:var(--green)}
.terminal .bar .ttl{font-size:11px;color:var(--text-muted);margin-left:8px}
.terminal .body{padding:16px 18px;color:var(--text-dim)}
.terminal .body .prompt{color:var(--green)}
.terminal .body .cmd{color:var(--text)}
.terminal .body .out{color:var(--text-dim)}
.terminal .body .cursor{
display:inline-block;width:8px;height:15px;background:var(--accent);
vertical-align:text-bottom;margin-left:1px;
animation:blink 1s step-end infinite;
}
@keyframes blink{50%{opacity:0}}
/* ── Architecture Diagram ── */
.arch-diagram{
display:flex;align-items:center;gap:0;flex-wrap:wrap;
justify-content:center;padding:20px 0;
}
.arch-node{
background:var(--surface);border:1px solid var(--border-visible);
border-radius:12px;padding:20px 28px;text-align:center;
transition:all 0.3s;position:relative;
}
.arch-node:hover{
border-color:var(--accent);
box-shadow:0 0 24px var(--accent-glow);
transform:translateY(-2px);
}
.arch-node .n{font-size:18px;font-weight:600;color:var(--text)}
.arch-node .d{font-size:11px;color:var(--text-muted);margin-top:3px;font-family:'JetBrains Mono',monospace}
.arch-node.accent{border-color:var(--accent);background:rgba(99,102,241,0.04)}
.arch-node.accent .n{color:var(--accent)}
.arch-arrow{color:var(--text-muted);font-size:18px;padding:0 10px;font-family:'JetBrains Mono',monospace;flex-shrink:0}
.arch-pulse{animation:arch-glow 2.5s ease-in-out infinite}
.arch-pulse:nth-child(2){animation-delay:0.5s}
.arch-pulse:nth-child(3){animation-delay:1s}
@keyframes arch-glow{0%,100%{box-shadow:0 0 0 rgba(99,102,241,0)}50%{box-shadow:0 0 20px var(--accent-glow)}}
/* ── Service Cards ── */
.card-grid{
display:grid;grid-template-columns:repeat(auto-fill,minmax(280px,1fr));
gap:16px;margin-top:40px;
}
.svc-card{
background:var(--card);border:1px solid var(--border);
border-radius:12px;padding:28px;cursor:pointer;
transition:all 0.3s;position:relative;overflow:hidden;
}
.svc-card::before{
content:'';position:absolute;inset:0;border-radius:12px;
background:radial-gradient(600px circle at var(--mx,50%) var(--my,50%),rgba(99,102,241,0.06),transparent 40%);
opacity:0;transition:opacity 0.3s;
}
.svc-card:hover::before{opacity:1}
.svc-card:hover{
border-color:var(--border-visible);
background:var(--card-hover);
transform:translateY(-2px);
}
.svc-card .indicator{
width:8px;height:8px;border-radius:50%;background:var(--green);
box-shadow:0 0 8px var(--green-glow);
display:inline-block;margin-bottom:16px;
animation:pulse-dot 3s ease-in-out infinite;
}
.svc-card .indicator:nth-child(odd){animation-delay:1.5s}
.svc-card h3{font-size:20px;font-weight:600;margin-bottom:4px;letter-spacing:-0.01em}
.svc-card .url{
font-family:'JetBrains Mono',monospace;font-size:11px;
color:var(--text-muted);margin-bottom:12px;
}
.svc-card p{font-size:13px;color:var(--text-dim);line-height:1.6}
.svc-card .tags{margin-top:14px;display:flex;gap:6px;flex-wrap:wrap}
.svc-card .tag{
font-size:10px;font-weight:600;text-transform:uppercase;
letter-spacing:0.04em;padding:3px 8px;border-radius:4px;
color:var(--accent);background:rgba(99,102,241,0.08);
border:1px solid rgba(99,102,241,0.12);
}
/* ── Stack Pills ── */
.stack-section{display:flex;flex-direction:column;gap:20px;margin-top:40px}
.stack-row{display:flex;align-items:flex-start;gap:16px;flex-wrap:wrap}
.stack-label{
font-family:'JetBrains Mono',monospace;font-size:12px;font-weight:700;
color:var(--green);min-width:80px;text-align:right;padding-top:5px;
}
.stack-pills{display:flex;gap:8px;flex-wrap:wrap}
.stack-pill{
padding:6px 14px;border-radius:20px;font-size:13px;font-weight:500;
background:var(--card);border:1px solid var(--border);
color:var(--text-dim);transition:all 0.2s;
}
.stack-pill:hover{border-color:var(--border-visible);color:var(--text)}
.stack-pill.hl{border-color:var(--accent);color:var(--accent);background:rgba(99,102,241,0.06)}
/* ── CTA / Command Prompt ── */
.cmd-prompt{
background:var(--surface);border:1px solid var(--border-visible);
border-radius:12px;padding:20px 24px;display:flex;align-items:center;
gap:12px;font-family:'JetBrains Mono',monospace;font-size:15px;
max-width:560px;margin:0 auto;margin-top:48px;transition:all 0.3s;
}
.cmd-prompt:focus-within{border-color:var(--accent);box-shadow:0 0 20px var(--accent-glow)}
.cmd-prompt .pr{color:var(--green);flex-shrink:0}
.cmd-prompt input{
flex:1;background:none;border:none;outline:none;
color:var(--text);font:inherit;caret-color:var(--accent);
}
.cmd-prompt input::placeholder{color:var(--text-muted)}
/* ── Footer ── */
footer{
text-align:center;padding:60px 24px;border-top:1px solid var(--border);
font-family:'JetBrains Mono',monospace;font-size:12px;color:var(--text-muted);
}
footer a{color:var(--accent);text-decoration:none}
footer a:hover{text-decoration:underline}
/* ── Scroll Reveal ── */
.reveal{opacity:0;transform:translateY(30px);transition:all 0.7s cubic-bezier(0.16,1,0.3,1)}
.reveal.visible{opacity:1;transform:translateY(0)}
/* ── Responsive ── */
@media(max-width:768px){
section{padding:60px 0}
section:first-of-type{padding-top:120px}
.arch-diagram{flex-direction:column;gap:4px}
.arch-arrow{display:none}
.hero-grid{grid-template-columns:repeat(2,1fr)}
.stack-row{flex-direction:column;gap:6px}
.stack-label{text-align:left;min-width:auto}
.card-grid{grid-template-columns:1fr}
.statusbar .right{display:none}
}
@media(max-width:400px){
.hero-grid{grid-template-columns:1fr 1fr;gap:6px}
.hero-stat{padding:10px 12px}
.hero-stat .val{font-size:18px}
}
</style>
</head>
<body>
<canvas id="gridCanvas"></canvas>
<!-- ═══ Status Bar ═══ -->
<header class="statusbar">
<div class="left"><a href="https://xiteng.site/?focus=deck#services">← xiteng.site</a><span><span class="dot"></span> all systems operational</span></div>
<div class="right">UTC+8 · Arch Linux · Docker · <span style="color:var(--green)">UP 42d</span></div>
</header>
<main class="container">
<!-- ═══ Hero ═══ -->
<section>
<div class="reveal">
<span class="label">// Personal Infrastructure</span>
<h1><span class="hl">xiteng</span>.site</h1>
<p class="subtitle">
自建开发基础设施。一台 Linux 机器,Docker Compose 驱动,
Cloudflare Tunnel 接入,Authentik 统一认证。
</p>
</div>
<div class="reveal hero-grid">
<div class="hero-stat"><div class="val up">8</div><div class="lbl">Services</div></div>
<div class="hero-stat"><div class="val">1<span class="unit"> host</span></div><div class="lbl">Arch Linux</div></div>
<div class="hero-stat"><div class="val up">99.9<span class="unit">%</span></div><div class="lbl">Uptime (30d)</div></div>
</div>
<div class="reveal" style="margin-top:40px">
<div class="terminal">
<div class="bar"><div class="tb r"></div><div class="tb y"></div><div class="tb g"></div><span class="ttl">ssh aliyun — bash — 80×24</span></div>
<div class="body">
<span class="prompt">$</span> <span class="cmd">docker compose ps</span><br>
<span class="out">NAME &nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;STATUS<br>
gitea &nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;Up 7 days<br>
hedgedoc &nbsp;&nbsp;&nbsp;&nbsp;Up 7 days<br>
seaweedfs &nbsp;&nbsp;&nbsp;Up 7 days<br>
uptime-kuma &nbsp;Up 7 days<br>
homepage &nbsp;&nbsp;&nbsp;&nbsp;Up 7 days<br>
authentik &nbsp;&nbsp;&nbsp;Up 7 days<br>
deck &nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;Up 5 minutes<br>
cloudflared &nbsp;Up 7 days</span><br>
<span class="prompt">$</span> <span class="cursor"></span>
</div>
</div>
</div>
</section>
<!-- ═══ Architecture ═══ -->
<section>
<div class="reveal"><span class="label">// How It Works</span></div>
<div class="reveal"><h2>Traffic Flow</h2></div>
<div class="reveal arch-diagram">
<div class="arch-node"><div class="n">Internet</div><div class="d">HTTPS</div></div>
<div class="arch-arrow"></div>
<div class="arch-node accent arch-pulse"><div class="n">Cloudflare</div><div class="d">Tunnel · CDN</div></div>
<div class="arch-arrow"></div>
<div class="arch-node arch-pulse"><div class="n">Traefik</div><div class="d">TLS · Router</div></div>
<div class="arch-arrow"></div>
<div class="arch-node accent arch-pulse"><div class="n">Authentik</div><div class="d">SSO · Auth</div></div>
<div class="arch-arrow"></div>
<div class="arch-node"><div class="n">Services</div><div class="d">8 containers</div></div>
</div>
</section>
<!-- ═══ Services ═══ -->
<section>
<div class="reveal"><span class="label">// Services</span></div>
<div class="reveal"><h2>What's Running</h2></div>
<div class="reveal card-grid">
<a href="https://git.xiteng.site" class="svc-card" style="text-decoration:none;color:inherit">
<div class="indicator"></div>
<h3>Gitea</h3>
<div class="url">git.xiteng.site</div>
<p>自建 Git 托管。Pull Request、CI/CD Actions、包仓库、OAuth2 登录。</p>
<div class="tags"><span class="tag">git</span><span class="tag">ci/cd</span><span class="tag">oauth2</span></div>
</a>
<a href="https://notes.xiteng.site" class="svc-card" style="text-decoration:none;color:inherit">
<div class="indicator"></div>
<h3>HedgeDoc</h3>
<div class="url">notes.xiteng.site</div>
<p>实时协作 Markdown。Mermaid 图表、MathJax 公式、OIDC 登录。</p>
<div class="tags"><span class="tag">docs</span><span class="tag">collab</span><span class="tag">oidc</span></div>
</a>
<a href="https://s3.xiteng.site" class="svc-card" style="text-decoration:none;color:inherit">
<div class="indicator"></div>
<h3>SeaweedFS</h3>
<div class="url">s3.xiteng.site</div>
<p>S3 兼容对象存储。60MB 镜像,替代 MinIO。Filer UI + REST API。</p>
<div class="tags"><span class="tag">s3</span><span class="tag">storage</span></div>
</a>
<a href="https://status.xiteng.site" class="svc-card" style="text-decoration:none;color:inherit">
<div class="indicator"></div>
<h3>Uptime Kuma</h3>
<div class="url">status.xiteng.site</div>
<p>健康监控。60s 间隔探测,飞书/邮件告警,公开状态页。</p>
<div class="tags"><span class="tag">monitor</span><span class="tag">alerts</span></div>
</a>
<a href="https://xiteng.site" class="svc-card" style="text-decoration:none;color:inherit">
<div class="indicator"></div>
<h3>Homepage</h3>
<div class="url">xiteng.site</div>
<p>统一仪表盘。Docker 自动发现,书签 & 搜索,OIDC 登录。</p>
<div class="tags"><span class="tag">dashboard</span><span class="tag">oidc</span></div>
</a>
<a href="https://auth.xiteng.site" class="svc-card" style="text-decoration:none;color:inherit">
<div class="indicator"></div>
<h3>Authentik</h3>
<div class="url">auth.xiteng.site</div>
<p>身份认证平台。OAuth2/OIDC ProviderForwardAuth 代理保护。</p>
<div class="tags"><span class="tag">sso</span><span class="tag">security</span></div>
</a>
</div>
</section>
<!-- ═══ Stack ═══ -->
<section>
<div class="reveal"><span class="label">// Stack</span></div>
<div class="reveal"><h2>Technology</h2></div>
<div class="reveal stack-section">
<div class="stack-row">
<div class="stack-label">Infra</div>
<div class="stack-pills">
<span class="stack-pill hl">Docker Compose</span>
<span class="stack-pill">Arch Linux</span>
<span class="stack-pill">Cloudflare Tunnel</span>
<span class="stack-pill">frp Transit</span>
</div>
</div>
<div class="stack-row">
<div class="stack-label">Proxy</div>
<div class="stack-pills">
<span class="stack-pill hl">Traefik v3</span>
<span class="stack-pill">Let's Encrypt</span>
<span class="stack-pill">Cloudflare DNS</span>
</div>
</div>
<div class="stack-row">
<div class="stack-label">Data</div>
<div class="stack-pills">
<span class="stack-pill hl">PostgreSQL</span>
<span class="stack-pill">Redis</span>
<span class="stack-pill">SeaweedFS</span>
</div>
</div>
<div class="stack-row">
<div class="stack-label">Connect</div>
<div class="stack-pills">
<span class="stack-pill hl">Authentik</span>
<span class="stack-pill">Hermes Agent</span>
<span class="stack-pill">Feishu Bot</span>
</div>
</div>
</div>
</section>
<!-- ═══ CTA ═══ -->
<section style="text-align:center">
<div class="reveal">
<span class="label">// Try It</span>
<h2>Open a Terminal</h2>
<p class="subtitle" style="margin:0 auto 0 auto">访问任意服务,Authentik 统一认证。或输入命令探索。</p>
</div>
<div class="reveal cmd-prompt">
<span class="pr">$</span>
<input type="text" id="cmdInput" placeholder="输入 help 查看可用命令…" autocomplete="off" autocorrect="off" autocapitalize="off" spellcheck="false">
</div>
</section>
</main>
<footer>
<a href="https://git.xiteng.site">git.xiteng.site</a> &nbsp;·&nbsp;
<a href="https://notes.xiteng.site">notes</a> &nbsp;·&nbsp;
<a href="https://status.xiteng.site">status</a> &nbsp;·&nbsp;
<a href="https://xiteng.site">home</a> &nbsp;·&nbsp;
Built with Hermes Agent
</footer>
<script>
// ═══ Grid Canvas ═══
(function(){
const c=document.getElementById('gridCanvas'),x=c.getContext('2d');
let W,H,mx=-100,my=-100;
function resize(){W=c.width=window.innerWidth;H=c.height=window.innerHeight}
resize();window.addEventListener('resize',resize);
document.addEventListener('mousemove',e=>{mx=e.clientX;my=e.clientY});
const dots=[];
for(let i=0;i<80;i++)dots.push({x:Math.random()*2000,y:Math.random()*2000,r:1+Math.random()*1.5,vx:(Math.random()-0.5)*0.3,vy:(Math.random()-0.5)*0.3});
function draw(){
x.clearRect(0,0,W,H);
// Grid
x.strokeStyle='rgba(255,255,255,0.018)';x.lineWidth=0.5;
const s=50;
for(let gx=s;gx<W;gx+=s){x.beginPath();x.moveTo(gx,0);x.lineTo(gx,H);x.stroke()}
for(let gy=s;gy<H;gy+=s){x.beginPath();x.moveTo(0,gy);x.lineTo(W,gy);x.stroke()}
// Dots
dots.forEach(d=>{
d.x+=d.vx;d.y+=d.vy;
if(d.x<0)d.x=2000;if(d.x>2000)d.x=0;
if(d.y<0)d.y=2000;if(d.y>2000)d.y=0;
const dx=d.x-mx,dy=d.y-my,dist=Math.sqrt(dx*dx+dy*dy);
const glow=Math.max(0,1-dist/200);
const r=d.r+glow*2;
x.beginPath();x.arc(d.x,d.y,r,0,Math.PI*2);
const a=0.15+glow*0.4;
x.fillStyle=`rgba(99,102,241,${a})`;x.fill();
});
requestAnimationFrame(draw);
}
draw();
})();
// ═══ Scroll Reveal ═══
(function(){
const obs=new IntersectionObserver(entries=>{
entries.forEach(e=>{if(e.isIntersecting)e.target.classList.add('visible')});
},{threshold:0.15});
document.querySelectorAll('.reveal').forEach(el=>obs.observe(el));
})();
// ═══ Card Glow Follow ═══
document.querySelectorAll('.svc-card').forEach(card=>{
card.addEventListener('mousemove',e=>{
const r=card.getBoundingClientRect();
card.style.setProperty('--mx',(e.clientX-r.left)+'px');
card.style.setProperty('--my',(e.clientY-r.top)+'px');
});
});
// ═══ Command Prompt ═══
(function(){
const input=document.getElementById('cmdInput');
const routes={
'git':'https://git.xiteng.site',
'notes':'https://notes.xiteng.site',
's3':'https://s3.xiteng.site',
'status':'https://status.xiteng.site',
'home':'https://xiteng.site',
'auth':'https://auth.xiteng.site',
'deck':'https://deck.xiteng.site',
'file':'https://file.xiteng.site',
};
input.addEventListener('keydown',e=>{
if(e.key!=='Enter')return;
const v=input.value.trim().toLowerCase();
input.value='';
if(v==='help'||v==='?'){
let msg='Available: ';
Object.keys(routes).forEach(k=>msg+=k+' ');
msg+='\nType a service name to navigate, or "help" to see this.';
alert(msg);
}else if(routes[v]){
window.open(routes[v],'_blank');
}else if(v){
alert(`Unknown command: ${v}\nType "help" to see available commands.`);
}
});
})();
</script>
</body>
</html>
+6
View File
@@ -0,0 +1,6 @@
dynamic/*
!dynamic/.gitkeep
state/*
!state/.gitkeep
secrets/*
!secrets/.gitkeep
+66
View File
@@ -0,0 +1,66 @@
# Label-managed edge cache
Services opt paths into the shared edge cache with Docker labels. The controller discovers the
labels, writes higher-priority Traefik routers, and maintains a single Cloudflare Cache Rule that
makes the declared host/path pairs eligible for edge caching.
```yaml
labels:
- "xiteng.site.cache.frontend.enabled=true"
- "xiteng.site.cache.frontend.routers=example,example-http"
- "xiteng.site.cache.frontend.paths=/static/,/assets/"
- "xiteng.site.cache.frontend.edge-ttl=604800"
- "xiteng.site.cache.frontend.browser-ttl=0"
- "xiteng.site.cache.frontend.stale-while-revalidate=86400"
```
The policy id (`frontend`) only needs to be unique inside one Compose service. `routers` refers to
Traefik Docker router names declared on the same service. When omitted, all explicit HTTP routers on
the service are used. Each router must declare its entrypoints and service so it can be referenced
from the file provider. Existing router middlewares are inherited, so request headers and origin-side
authentication are not silently removed.
| Field | Default | Behavior |
|---|---:|---|
| `enabled` | required | Only the exact value `true` enables a policy. |
| `routers` | auto | Comma-separated Traefik router names on the same container. |
| `paths` | required | Comma-separated directory prefixes. Each must start and end with `/`; `/`, traversal and encoded paths are rejected. |
| `hosts` | from `Host()` | Optional exact hostnames for complex router rules; hosts must be inside `xiteng.site`. |
| `edge-ttl` | `604800` | Cloudflare TTL in seconds, bounded to 60 seconds365 days. |
| `browser-ttl` | `0` | When non-zero, also overrides the browser `Cache-Control` TTL. |
| `stale-while-revalidate` | `86400` | Stale revalidation window in seconds. |
The generated router only matches `GET` and `HEAD`, has priority 1000 above its base router, and adds
`Cloudflare-CDN-Cache-Control`. Cloudflare's managed rule makes extensionless files eligible for cache,
respects the TTL header for successful responses, and gives 3xx5xx responses a zero edge TTL.
Declaring a path static is a security decision: every response below the prefix must be public and
independent of users, cookies, authorization headers, and request-specific data. Never label HTML,
API, callback, admin, download-token, or user-content paths. A Cloudflare cache hit is served before
Traefik/ForwardAuth, even though cache misses still inherit the base router's middlewares.
Removing a policy does not revoke copies already held by browsers or edge locations; choose bounded
TTLs and purge Cloudflare explicitly when previously public content must be withdrawn immediately.
## Cloudflare token
Place an untracked API token at `secrets/cloudflare_cache_api_token`. It needs access to the
`xiteng.site` zone and the permissions required to edit Cache Rules. The existing DNS-01 token is
intentionally not reused. The controller remains healthy without this file and reports
`waiting-for-token`; in that state Traefik headers and default-extension caching work, but arbitrary
extensionless URLs are not guaranteed to be cached by Cloudflare.
Create the token in Cloudflare, then install it without exposing it in shell history or chat:
```sh
./edge-cache/configure-token
```
Inspect the generated rule without contacting Cloudflare:
```sh
docker compose -f edge-cache/compose.yml exec controller \
node /app/cloudflare.mjs plan
```
The live controller automatically creates or updates only the rule whose description is
`Xiteng label-managed static paths`; it preserves all unrelated Cache Rules.
+19
View File
@@ -0,0 +1,19 @@
import {readFileSync} from "node:fs";
import {cloudflareRuleFromSpec, syncCloudflareRule} from "./controller.mjs";
const command = process.argv[2] || "plan";
const specPath = process.env.CLOUDFLARE_SPEC_PATH || "/state/cloudflare-cache-rules.json";
const tokenPath = process.env.CLOUDFLARE_API_TOKEN_FILE || "/run/secrets/cloudflare_cache_api_token";
const zoneId = process.env.CLOUDFLARE_ZONE_ID || "";
const spec = JSON.parse(readFileSync(specPath, "utf8"));
if (command === "plan") {
console.log(JSON.stringify(cloudflareRuleFromSpec(spec), null, 2));
} else if (["check", "apply"].includes(command)) {
const token = readFileSync(tokenPath, "utf8").trim();
const result = await syncCloudflareRule(spec, {zoneId, token, dryRun: command === "check"});
console.log(JSON.stringify(result, null, 2));
} else {
console.error("Usage: node cloudflare.mjs [plan|check|apply]");
process.exit(2);
}
+52
View File
@@ -0,0 +1,52 @@
services:
controller:
image: node:24-alpine
container_name: xiteng-edge-cache
restart: unless-stopped
command: ["node", "/app/controller.mjs"]
environment:
PORT: 8094
DOCKER_SOCKET: /var/run/docker.sock
OUTPUT_DIRECTORY: /dynamic
STATE_DIRECTORY: /state
DISCOVERY_INTERVAL_MS: 5000
CLOUDFLARE_SYNC_INTERVAL_MS: 60000
CLOUDFLARE_ZONE_NAME: xiteng.site
CLOUDFLARE_ZONE_ID: c1cce0c846ccc0d2138b9b79c5e91e9f
CLOUDFLARE_API_TOKEN_FILE: /run/secrets/cloudflare_cache_api_token
volumes:
- ./controller.mjs:/app/controller.mjs:ro
- ./cloudflare.mjs:/app/cloudflare.mjs:ro
- /var/run/docker.sock:/var/run/docker.sock:ro
- ./dynamic:/dynamic
- ./state:/state
- ./secrets:/run/secrets:ro
read_only: true
tmpfs:
- /tmp
security_opt:
- no-new-privileges:true
healthcheck:
test: ["CMD", "node", "-e", "fetch('http://127.0.0.1:8094/healthz').then(r => process.exit(r.ok ? 0 : 1)).catch(() => process.exit(1))"]
interval: 15s
timeout: 5s
retries: 3
start_period: 5s
labels:
- "traefik.enable=false"
- "xiteng.site.component.edge-cache.enabled=true"
- "xiteng.site.component.edge-cache.name=Edge Cache Controller"
- "xiteng.site.component.edge-cache.description=将服务声明的静态路径编译为 Traefik 路由与 Cloudflare Cache Rule。"
- "xiteng.site.component.edge-cache.section=infrastructure"
- "xiteng.site.component.edge-cache.category=边缘与网络"
- "xiteng.site.component.edge-cache.access=internal"
- "xiteng.site.component.edge-cache.access-label=仅容器网络"
- "xiteng.site.component.edge-cache.icon=EC"
- "xiteng.site.component.edge-cache.accent=blue"
- "xiteng.site.component.edge-cache.order=115"
networks:
- homelab_net
networks:
homelab_net:
external: true
+29
View File
@@ -0,0 +1,29 @@
#!/bin/sh
set -eu
EDGE_CACHE_DIR=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)
EDGE_CACHE_TOKEN_FILE="$EDGE_CACHE_DIR/secrets/cloudflare_cache_api_token"
EDGE_CACHE_TOKEN_TMP="$EDGE_CACHE_TOKEN_FILE.tmp.$$"
if [ ! -t 0 ]; then
echo "Refusing to read a Cloudflare token from a non-interactive stdin." >&2
exit 1
fi
printf 'Cloudflare Cache Rules API token: ' > /dev/tty
stty -echo < /dev/tty
IFS= read -r EDGE_CACHE_TOKEN < /dev/tty || true
stty echo < /dev/tty
printf '\n' > /dev/tty
if [ "${#EDGE_CACHE_TOKEN}" -lt 20 ]; then
echo "Token is missing or unexpectedly short." >&2
exit 1
fi
umask 077
printf '%s\n' "$EDGE_CACHE_TOKEN" > "$EDGE_CACHE_TOKEN_TMP"
mv "$EDGE_CACHE_TOKEN_TMP" "$EDGE_CACHE_TOKEN_FILE"
unset EDGE_CACHE_TOKEN
echo "Token installed with mode 600. The running controller will sync the managed Cache Rule automatically."
+509
View File
@@ -0,0 +1,509 @@
import http from "node:http";
import {createHash} from "node:crypto";
import {mkdirSync, readFileSync, renameSync, writeFileSync} from "node:fs";
const labelPrefix = "xiteng.site.cache.";
const allowedFields = new Set([
"enabled",
"routers",
"hosts",
"paths",
"edge-ttl",
"browser-ttl",
"stale-while-revalidate"
]);
const managedRuleDescription = "Xiteng label-managed static paths";
function boundedInteger(value, fallback, minimum, maximum) {
const parsed = Number.parseInt(value || "", 10);
return Number.isFinite(parsed) ? Math.max(minimum, Math.min(maximum, parsed)) : fallback;
}
function csv(value) {
return [...new Set(String(value || "").split(",").map((item) => item.trim()).filter(Boolean))];
}
function stableName(...parts) {
const source = parts.join("-").toLowerCase();
const readable = source.replace(/[^a-z0-9-]+/g, "-").replace(/-+/g, "-").replace(/^-|-$/g, "").slice(0, 48);
const digest = createHash("sha256").update(source).digest("hex").slice(0, 10);
return `${readable || "cache"}-${digest}`;
}
function normalizedLabels(labels) {
return Object.fromEntries(Object.entries(labels || {}).map(([key, value]) => [key.toLowerCase(), String(value)]));
}
function normalizePathPrefix(value) {
const path = String(value || "").trim();
if (!path.startsWith("/") || !path.endsWith("/") || path === "/") {
throw new Error(`static path must start and end with / and cannot be root: ${path || "(empty)"}`);
}
if (path.includes("..") || path.includes("//") || !/^\/[A-Za-z0-9._~/-]+\/$/.test(path)) {
throw new Error(`static path contains unsafe characters: ${path}`);
}
return path;
}
function normalizeHost(value, zoneName = "") {
const host = String(value || "").trim().toLowerCase().replace(/\.$/, "");
if (!/^[a-z0-9](?:[a-z0-9.-]*[a-z0-9])?$/.test(host)) {
throw new Error(`invalid hostname: ${value}`);
}
if (zoneName && host !== zoneName && !host.endsWith(`.${zoneName}`)) {
throw new Error(`hostname is outside ${zoneName}: ${host}`);
}
return host;
}
function extractHosts(rule, zoneName) {
const hosts = [];
for (const match of String(rule || "").matchAll(/\bHost\(([^)]*)\)/g)) {
for (const quoted of match[1].matchAll(/`([^`]+)`/g)) {
hosts.push(normalizeHost(quoted[1], zoneName));
}
}
return [...new Set(hosts)];
}
function qualifyReference(value, provider) {
const reference = String(value || "").trim();
return !reference || reference.includes("@") ? reference : `${reference}@${provider}`;
}
export function definitionsFromLabels(rawLabels) {
const labels = normalizedLabels(rawLabels);
const definitions = new Map();
for (const [key, value] of Object.entries(labels)) {
if (!key.startsWith(labelPrefix)) {
continue;
}
const remainder = key.slice(labelPrefix.length);
const separator = remainder.indexOf(".");
const id = remainder.slice(0, separator);
const field = remainder.slice(separator + 1);
if (separator <= 0 || !/^[a-z0-9][a-z0-9-]*$/.test(id) || !allowedFields.has(field)) {
continue;
}
if (!definitions.has(id)) {
definitions.set(id, {});
}
definitions.get(id)[field] = value.trim();
}
return definitions;
}
function routerNames(labels) {
const names = new Set();
for (const key of Object.keys(labels)) {
const match = key.match(/^traefik\.http\.routers\.([a-z0-9-]+)\.rule$/);
if (match) {
names.add(match[1]);
}
}
return [...names].sort();
}
function serviceNames(labels) {
const names = new Set();
for (const key of Object.keys(labels)) {
const match = key.match(/^traefik\.http\.services\.([a-z0-9-]+)\.loadbalancer\.server\.port$/);
if (match) {
names.add(match[1]);
}
}
return [...names];
}
function cloudflareExpression(policies) {
const clauses = [];
for (const policy of policies) {
const hosts = policy.hosts.map((host) => `http.host eq ${JSON.stringify(host)}`);
const paths = policy.paths.map((path) => `starts_with(http.request.uri.path, ${JSON.stringify(path)})`);
clauses.push(`((${hosts.join(" or ")}) and (${paths.join(" or ")}))`);
}
return clauses.length ? `(${clauses.join(" or ")})` : "(http.host eq \"cache-disabled.invalid\")";
}
export function buildConfiguration(containers, {zoneName = "xiteng.site"} = {}) {
const routers = {};
const middlewares = {};
const cloudflarePolicies = [];
const errors = [];
const seenPolicies = new Set();
for (const container of containers || []) {
const labels = normalizedLabels(container.Labels);
if (labels["traefik.enable"] !== "true") {
continue;
}
const project = labels["com.docker.compose.project"] || "docker";
const service = labels["com.docker.compose.service"] || String(container.Names?.[0] || container.Id || "service").replace(/^\//, "");
const definitions = definitionsFromLabels(labels);
for (const [policyId, definition] of definitions) {
if (definition.enabled !== "true") {
continue;
}
const policyKey = `${project}/${service}/${policyId}`;
if (seenPolicies.has(policyKey)) {
continue;
}
seenPolicies.add(policyKey);
try {
const paths = csv(definition.paths).map(normalizePathPrefix).sort();
if (!paths.length) {
throw new Error("at least one static path is required");
}
const selectedRouters = (csv(definition.routers).length ? csv(definition.routers) : routerNames(labels)).sort();
if (!selectedRouters.length) {
throw new Error("no Traefik routers found; set .routers explicitly");
}
const edgeTtl = boundedInteger(definition["edge-ttl"], 604800, 60, 31536000);
const browserTtl = boundedInteger(definition["browser-ttl"], 0, 0, 31536000);
const staleWhileRevalidate = boundedInteger(definition["stale-while-revalidate"], 86400, 0, 604800);
const middlewareName = `xiteng-static-${stableName(policyKey)}`;
const responseHeaders = {
"Cloudflare-CDN-Cache-Control": `public, s-maxage=${edgeTtl}${staleWhileRevalidate ? `, stale-while-revalidate=${staleWhileRevalidate}` : ""}`,
"X-Xiteng-Cache-Policy": policyKey
};
if (browserTtl > 0) {
responseHeaders["Cache-Control"] = `public, max-age=${browserTtl}${staleWhileRevalidate ? `, stale-while-revalidate=${staleWhileRevalidate}` : ""}`;
}
const policyRouters = {};
const discoveredHosts = new Set(csv(definition.hosts).map((host) => normalizeHost(host, zoneName)));
for (const baseRouter of selectedRouters) {
if (!/^[a-z0-9][a-z0-9-]*$/.test(baseRouter)) {
throw new Error(`invalid router name: ${baseRouter}`);
}
const routerPrefix = `traefik.http.routers.${baseRouter}.`;
const baseRule = labels[`${routerPrefix}rule`];
if (!baseRule) {
throw new Error(`router ${baseRouter} has no rule on this service`);
}
for (const host of extractHosts(baseRule, zoneName)) {
discoveredHosts.add(host);
}
const entryPoints = csv(labels[`${routerPrefix}entrypoints`]);
if (!entryPoints.length) {
throw new Error(`router ${baseRouter} must declare entrypoints`);
}
let targetService = labels[`${routerPrefix}service`];
if (!targetService) {
const candidates = serviceNames(labels);
if (candidates.length !== 1) {
throw new Error(`router ${baseRouter} must declare its Traefik service`);
}
[targetService] = candidates;
}
const inheritedMiddlewares = csv(labels[`${routerPrefix}middlewares`]).map((item) => qualifyReference(item, "docker"));
const generatedRouter = {
rule: `(${baseRule}) && (Method(\`GET\`) || Method(\`HEAD\`)) && (${paths.map((path) => `PathPrefix(\`${path}\`)`).join(" || ")})`,
entryPoints,
service: qualifyReference(targetService, "docker"),
// Traefik runs response middleware in reverse order. Put the cache
// middleware first so it is the final writer of cache headers.
middlewares: [middlewareName, ...inheritedMiddlewares],
priority: boundedInteger(labels[`${routerPrefix}priority`], 0, 0, 1000000) + 1000
};
if (labels[`${routerPrefix}tls`] && labels[`${routerPrefix}tls`] !== "false") {
generatedRouter.tls = {};
if (labels[`${routerPrefix}tls.certresolver`]) {
generatedRouter.tls.certResolver = labels[`${routerPrefix}tls.certresolver`];
}
if (labels[`${routerPrefix}tls.options`]) {
generatedRouter.tls.options = qualifyReference(labels[`${routerPrefix}tls.options`], "docker");
}
}
policyRouters[`xiteng-static-${stableName(policyKey, baseRouter)}`] = generatedRouter;
}
if (!discoveredHosts.size) {
throw new Error("no exact Host() value found; set .hosts explicitly");
}
middlewares[middlewareName] = {headers: {customResponseHeaders: responseHeaders}};
Object.assign(routers, policyRouters);
cloudflarePolicies.push({
id: policyKey,
hosts: [...discoveredHosts].sort(),
paths
});
} catch (error) {
errors.push(`${policyKey}: ${error.message}`);
}
}
}
cloudflarePolicies.sort((left, right) => left.id.localeCompare(right.id));
return {
traefik: {http: {routers, middlewares}},
cloudflare: {
version: 1,
zone: zoneName,
description: managedRuleDescription,
expression: cloudflareExpression(cloudflarePolicies),
policies: cloudflarePolicies
},
errors,
policyCount: cloudflarePolicies.length,
routerCount: Object.keys(routers).length
};
}
export function cloudflareRuleFromSpec(spec) {
return {
action: "set_cache_settings",
action_parameters: {
cache: true,
edge_ttl: {
mode: "respect_origin",
status_code_ttl: [
{status_code_range: {to: 199}, value: 0},
{status_code_range: {from: 300, to: 499}, value: 0},
{status_code_range: {from: 500}, value: -1}
]
},
browser_ttl: {mode: "respect_origin"},
cache_key: {cache_deception_armor: true},
serve_stale: {disable_stale_while_updating: false}
},
expression: spec.expression,
description: managedRuleDescription,
enabled: Array.isArray(spec.policies) && spec.policies.length > 0
};
}
async function cloudflareRequest(path, {token, method = "GET", body} = {}) {
const response = await fetch(`https://api.cloudflare.com/client/v4${path}`, {
method,
headers: {
Authorization: `Bearer ${token}`,
"Content-Type": "application/json"
},
body: body ? JSON.stringify(body) : undefined,
signal: AbortSignal.timeout(15000)
});
const payload = await response.json();
if (!response.ok || !payload.success) {
const detail = (payload.errors || []).map((error) => `${error.code}: ${error.message}`).join("; ");
const error = new Error(`Cloudflare API ${response.status}${detail ? ` (${detail})` : ""}`);
error.status = response.status;
throw error;
}
return payload.result;
}
function canonicalizeJson(value) {
if (Array.isArray(value)) {
return value.map(canonicalizeJson);
}
if (value && typeof value === "object") {
return Object.fromEntries(
Object.keys(value).sort().map((key) => [key, canonicalizeJson(value[key])])
);
}
return value;
}
function comparableRule(rule) {
return JSON.stringify(canonicalizeJson({
action: rule.action,
action_parameters: rule.action_parameters,
expression: rule.expression,
description: rule.description,
enabled: rule.enabled !== false
}));
}
export async function syncCloudflareRule(spec, {zoneId, token, dryRun = false} = {}) {
if (!zoneId || !token) {
throw new Error("Cloudflare zone ID and API token are required");
}
const desired = cloudflareRuleFromSpec(spec);
let ruleset;
try {
ruleset = await cloudflareRequest(`/zones/${zoneId}/rulesets/phases/http_request_cache_settings/entrypoint`, {token});
} catch (error) {
if (error.status !== 404) {
throw error;
}
}
const current = ruleset?.rules?.find((rule) => rule.description === managedRuleDescription);
if (current && comparableRule(current) === comparableRule(desired)) {
return {changed: false, action: "unchanged", ruleId: current.id};
}
if (dryRun) {
return {changed: true, action: current ? "update" : "create", desired};
}
if (!ruleset) {
const created = await cloudflareRequest(`/zones/${zoneId}/rulesets`, {
token,
method: "POST",
body: {
name: "Xiteng label-managed cache rules",
description: managedRuleDescription,
kind: "zone",
phase: "http_request_cache_settings",
rules: [desired]
}
});
return {changed: true, action: "create-ruleset", ruleId: created.rules?.[0]?.id};
}
if (!current) {
const created = await cloudflareRequest(`/zones/${zoneId}/rulesets/${ruleset.id}/rules`, {
token,
method: "POST",
body: desired
});
return {changed: true, action: "create", ruleId: created.id};
}
const updated = await cloudflareRequest(`/zones/${zoneId}/rulesets/${ruleset.id}/rules/${current.id}`, {
token,
method: "PATCH",
body: desired
});
return {changed: true, action: "update", ruleId: updated.id};
}
function writeIfChanged(path, value) {
let current = "";
try {
current = readFileSync(path, "utf8");
} catch {
// The first discovery creates the file.
}
if (current === value) {
return false;
}
const temporary = `${path}.tmp`;
writeFileSync(temporary, value, {mode: 0o644});
renameSync(temporary, path);
return true;
}
function dockerGet(socketPath, pathname) {
return new Promise((resolve, reject) => {
const request = http.request({socketPath, path: pathname, method: "GET", headers: {Accept: "application/json"}}, (response) => {
const chunks = [];
response.on("data", (chunk) => chunks.push(chunk));
response.on("end", () => {
if ((response.statusCode || 500) >= 400) {
reject(new Error(`Docker API ${response.statusCode}`));
return;
}
try {
resolve(JSON.parse(Buffer.concat(chunks).toString("utf8")));
} catch (error) {
reject(error);
}
});
});
request.setTimeout(5000, () => request.destroy(new Error("Docker API timeout")));
request.on("error", reject);
request.end();
});
}
function readToken(path) {
try {
return readFileSync(path, "utf8").trim();
} catch {
return "";
}
}
export async function runController() {
const port = boundedInteger(process.env.PORT, 8094, 1, 65535);
const socketPath = process.env.DOCKER_SOCKET || "/var/run/docker.sock";
const outputDirectory = process.env.OUTPUT_DIRECTORY || "/dynamic";
const stateDirectory = process.env.STATE_DIRECTORY || "/state";
const intervalMs = boundedInteger(process.env.DISCOVERY_INTERVAL_MS, 5000, 2000, 60000);
const cloudflareIntervalMs = boundedInteger(process.env.CLOUDFLARE_SYNC_INTERVAL_MS, 60000, 10000, 3600000);
const zoneName = process.env.CLOUDFLARE_ZONE_NAME || "xiteng.site";
const zoneId = process.env.CLOUDFLARE_ZONE_ID || "";
const tokenFile = process.env.CLOUDFLARE_API_TOKEN_FILE || "/run/secrets/cloudflare_cache_api_token";
const traefikPath = `${outputDirectory}/xiteng-static-cache.yml`;
const cloudflarePath = `${stateDirectory}/cloudflare-cache-rules.json`;
const state = {
ready: false,
lastDiscoveryAt: null,
policies: 0,
routers: 0,
errors: [],
cloudflare: {status: "waiting-for-token", lastSyncAt: null, error: null}
};
let discoveryRunning = false;
let lastCloudflareAttempt = 0;
let lastCloudflareSpec = "";
mkdirSync(outputDirectory, {recursive: true});
mkdirSync(stateDirectory, {recursive: true});
async function discover() {
if (discoveryRunning) {
return;
}
discoveryRunning = true;
try {
const containers = await dockerGet(socketPath, "/containers/json?all=0");
const built = buildConfiguration(containers, {zoneName});
const traefikValue = `${JSON.stringify(built.traefik, null, 2)}\n`;
const cloudflareValue = `${JSON.stringify(built.cloudflare, null, 2)}\n`;
const changed = writeIfChanged(traefikPath, traefikValue);
writeIfChanged(cloudflarePath, cloudflareValue);
state.ready = true;
state.lastDiscoveryAt = new Date().toISOString();
state.policies = built.policyCount;
state.routers = built.routerCount;
state.errors = built.errors;
if (changed || built.errors.length) {
console.log(JSON.stringify({event: "cache-config", changed, policies: built.policyCount, routers: built.routerCount, errors: built.errors}));
}
const token = readToken(tokenFile);
const now = Date.now();
if (!token) {
state.cloudflare.status = "waiting-for-token";
state.cloudflare.error = null;
} else if (cloudflareValue !== lastCloudflareSpec || now - lastCloudflareAttempt >= cloudflareIntervalMs) {
lastCloudflareAttempt = now;
try {
const result = await syncCloudflareRule(built.cloudflare, {zoneId, token});
lastCloudflareSpec = cloudflareValue;
state.cloudflare = {status: result.action, lastSyncAt: new Date().toISOString(), error: null};
if (result.changed) {
console.log(JSON.stringify({event: "cloudflare-cache-rule", action: result.action, policies: built.policyCount}));
}
} catch (error) {
state.cloudflare = {status: "error", lastSyncAt: new Date().toISOString(), error: error.message};
console.error(JSON.stringify({event: "cloudflare-cache-rule-error", error: error.message}));
}
}
} catch (error) {
state.errors = [error.message];
console.error(JSON.stringify({event: "cache-controller-error", error: error.message}));
} finally {
discoveryRunning = false;
}
}
const server = http.createServer((request, response) => {
if (request.url === "/healthz") {
const healthy = state.ready && state.errors.length === 0 && state.cloudflare.status !== "error";
response.writeHead(healthy ? 200 : 503, {"Content-Type": "application/json", "Cache-Control": "no-store"});
response.end(JSON.stringify(state));
return;
}
response.writeHead(404, {"Content-Type": "text/plain", "Cache-Control": "no-store"});
response.end("Not found");
});
server.listen(port, "0.0.0.0");
await discover();
setInterval(discover, intervalMs).unref();
}
if (process.argv[1] && new URL(import.meta.url).pathname === process.argv[1]) {
runController().catch((error) => {
console.error(error);
process.exit(1);
});
}
+115
View File
@@ -0,0 +1,115 @@
import test from "node:test";
import assert from "node:assert/strict";
import {buildConfiguration, cloudflareRuleFromSpec, definitionsFromLabels, syncCloudflareRule} from "./controller.mjs";
const labels = {
"com.docker.compose.project": "authentik",
"com.docker.compose.service": "authentik",
"traefik.enable": "true",
"traefik.http.routers.authentik.rule": "Host(`auth.xiteng.site`)",
"traefik.http.routers.authentik.entrypoints": "websecure",
"traefik.http.routers.authentik.service": "authentik",
"traefik.http.routers.authentik.middlewares": "force-https-headers",
"traefik.http.routers.authentik.tls": "true",
"traefik.http.routers.authentik.tls.certresolver": "cfresolver",
"traefik.http.routers.authentik-http.rule": "Host(`auth.xiteng.site`)",
"traefik.http.routers.authentik-http.entrypoints": "web",
"traefik.http.routers.authentik-http.service": "authentik",
"xiteng.site.cache.frontend.enabled": "true",
"xiteng.site.cache.frontend.routers": "authentik,authentik-http",
"xiteng.site.cache.frontend.paths": "/static/dist/,/assets/",
"xiteng.site.cache.frontend.edge-ttl": "604800",
"xiteng.site.cache.frontend.browser-ttl": "3600"
};
function reverseObjectKeys(value) {
if (Array.isArray(value)) {
return value.map(reverseObjectKeys);
}
if (value && typeof value === "object") {
return Object.fromEntries(
Object.entries(value).reverse().map(([key, nested]) => [key, reverseObjectKeys(nested)])
);
}
return value;
}
test("parses cache definitions without exposing unrelated labels", () => {
const definitions = definitionsFromLabels(labels);
assert.equal(definitions.size, 1);
assert.equal(definitions.get("frontend").paths, "/static/dist/,/assets/");
assert.equal(definitions.get("frontend").routers, "authentik,authentik-http");
});
test("builds higher-priority GET/HEAD routers and preserves base middleware", () => {
const built = buildConfiguration([{Id: "one", Labels: labels}], {zoneName: "xiteng.site"});
assert.deepEqual(built.errors, []);
assert.equal(built.policyCount, 1);
assert.equal(built.routerCount, 2);
const generated = Object.values(built.traefik.http.routers);
assert.ok(generated.every((router) => router.rule.includes("Method(`GET`)")));
assert.ok(generated.every((router) => router.rule.includes("PathPrefix(`/static/dist/`)")));
assert.ok(generated.some((router) => router.middlewares.includes("force-https-headers@docker")));
assert.ok(generated.every((router) => router.middlewares[0].startsWith("xiteng-static-")));
assert.ok(generated.some((router) => router.tls?.certResolver === "cfresolver"));
const middleware = Object.values(built.traefik.http.middlewares)[0];
assert.equal(middleware.headers.customResponseHeaders["Cache-Control"], "public, max-age=3600, stale-while-revalidate=86400");
assert.match(middleware.headers.customResponseHeaders["Cloudflare-CDN-Cache-Control"], /s-maxage=604800/);
});
test("creates one Cloudflare expression from exact hosts and safe path prefixes", () => {
const built = buildConfiguration([{Id: "one", Labels: labels}], {zoneName: "xiteng.site"});
assert.match(built.cloudflare.expression, /http\.host eq "auth\.xiteng\.site"/);
assert.match(built.cloudflare.expression, /starts_with\(http\.request\.uri\.path, "\/assets\/"\)/);
const rule = cloudflareRuleFromSpec(built.cloudflare);
assert.equal(rule.action_parameters.cache, true);
assert.equal(rule.action_parameters.edge_ttl.status_code_ttl[1].value, 0);
assert.equal(rule.enabled, true);
});
test("does not update a semantically identical Cloudflare rule when API reorders fields", async () => {
const built = buildConfiguration([{Id: "one", Labels: labels}], {zoneName: "xiteng.site"});
const current = {id: "managed-rule", ...reverseObjectKeys(cloudflareRuleFromSpec(built.cloudflare))};
const originalFetch = globalThis.fetch;
globalThis.fetch = async () => new Response(JSON.stringify({
success: true,
result: {id: "cache-ruleset", rules: [current]}
}), {status: 200, headers: {"Content-Type": "application/json"}});
try {
const result = await syncCloudflareRule(built.cloudflare, {zoneId: "zone", token: "token", dryRun: true});
assert.deepEqual(result, {changed: false, action: "unchanged", ruleId: "managed-rule"});
} finally {
globalThis.fetch = originalFetch;
}
});
test("does not override browser Cache-Control unless browser-ttl is explicit", () => {
const edgeOnly = {...labels};
delete edgeOnly["xiteng.site.cache.frontend.browser-ttl"];
const built = buildConfiguration([{Id: "edge-only", Labels: edgeOnly}], {zoneName: "xiteng.site"});
const headers = Object.values(built.traefik.http.middlewares)[0].headers.customResponseHeaders;
assert.equal(headers["Cache-Control"], undefined);
assert.match(headers["Cloudflare-CDN-Cache-Control"], /s-maxage=604800/);
});
test("rejects root and traversal-like cache paths", () => {
for (const paths of ["/", "/static/../admin/", "/static//nested/"]) {
const broken = {...labels, "xiteng.site.cache.frontend.paths": paths};
const built = buildConfiguration([{Id: paths, Labels: broken}], {zoneName: "xiteng.site"});
assert.equal(built.policyCount, 0);
assert.equal(built.errors.length, 1);
assert.equal(Object.keys(built.traefik.http.routers).length, 0);
assert.equal(Object.keys(built.traefik.http.middlewares).length, 0);
}
});
test("does not let labels cache a hostname outside the managed zone", () => {
const broken = {
...labels,
"traefik.http.routers.authentik.rule": "Host(`example.com`)",
"traefik.http.routers.authentik-http.rule": "Host(`example.com`)"
};
const built = buildConfiguration([{Id: "external", Labels: broken}], {zoneName: "xiteng.site"});
assert.equal(built.policyCount, 0);
assert.match(built.errors[0], /outside xiteng\.site/);
});
+1
View File
@@ -0,0 +1 @@
+1
View File
@@ -0,0 +1 @@
+1
View File
@@ -0,0 +1 @@
+12
View File
@@ -5,6 +5,18 @@ services:
restart: unless-stopped restart: unless-stopped
volumes: volumes:
- ./frpc.toml:/etc/frp/frpc.toml:ro - ./frpc.toml:/etc/frp/frpc.toml:ro
labels:
- "xiteng.site.component.frpc.enabled=true"
- "xiteng.site.component.frpc.name=FRP Client"
- "xiteng.site.component.frpc.description=通过阿里云 VPS 中转 Gitea SSH 流量。"
- "xiteng.site.component.frpc.section=infrastructure"
- "xiteng.site.component.frpc.category=边缘与网络"
- "xiteng.site.component.frpc.endpoint=git.xiteng.site:22"
- "xiteng.site.component.frpc.access=ssh-key"
- "xiteng.site.component.frpc.access-label=需要 SSH Key"
- "xiteng.site.component.frpc.icon=FRP"
- "xiteng.site.component.frpc.accent=blue"
- "xiteng.site.component.frpc.order=120"
networks: networks:
- homelab_net - homelab_net
+38 -15
View File
@@ -1,8 +1,20 @@
services: services:
db: db:
image: postgres:15 image: postgres:15@sha256:6eb0add3b77c081df18aa518ce43df58fdcc40f2e6d868a6fd08038dc7acd425
restart: always restart: always
env_file: ../.env env_file: ../.env
labels:
- "xiteng.site.component.gitea-db.enabled=true"
- "xiteng.site.component.gitea-db.name=Gitea PostgreSQL"
- "xiteng.site.component.gitea-db.description=保存仓库平台的账户、Issue、PR 与系统元数据。"
- "xiteng.site.component.gitea-db.section=infrastructure"
- "xiteng.site.component.gitea-db.category=数据层"
- "xiteng.site.component.gitea-db.access=internal"
- "xiteng.site.component.gitea-db.access-label=仅容器网络"
- "xiteng.site.component.gitea-db.icon=PG"
- "xiteng.site.component.gitea-db.icon-url=https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/postgresql.svg"
- "xiteng.site.component.gitea-db.accent=blue"
- "xiteng.site.component.gitea-db.order=530"
volumes: volumes:
- ./docker-entrypoint-initdb.d:/docker-entrypoint-initdb.d - ./docker-entrypoint-initdb.d:/docker-entrypoint-initdb.d
- ./db_data:/var/lib/postgresql/data - ./db_data:/var/lib/postgresql/data
@@ -10,7 +22,7 @@ services:
- homelab_net - homelab_net
gitea: gitea:
image: gitea/gitea:latest image: gitea/gitea:1.27.1
container_name: gitea container_name: gitea
restart: unless-stopped restart: unless-stopped
env_file: ../.env env_file: ../.env
@@ -26,6 +38,7 @@ services:
GITEA__server__SSH_DOMAIN: git.xiteng.site GITEA__server__SSH_DOMAIN: git.xiteng.site
volumes: volumes:
- ./data:/data - ./data:/data
- ./custom/extra_links.tmpl:/data/gitea/templates/custom/extra_links.tmpl:ro
# 保留 SSH 端口以便通过 SSH 推送代码(可选),移除 HTTP 宿主端口,由 Traefik 暴露 # 保留 SSH 端口以便通过 SSH 推送代码(可选),移除 HTTP 宿主端口,由 Traefik 暴露
ports: ports:
- "3004:3000" - "3004:3000"
@@ -42,23 +55,33 @@ services:
# --- 开启 TLS 并指定解析器 --- # --- 开启 TLS 并指定解析器 ---
- "traefik.http.routers.gitea.tls=true" - "traefik.http.routers.gitea.tls=true"
- "traefik.http.routers.gitea.tls.certresolver=cfresolver" - "traefik.http.routers.gitea.tls.certresolver=cfresolver"
- "xiteng.site.cache.gitea.enabled=true"
- "xiteng.site.cache.gitea.routers=gitea,gitea-http"
- "xiteng.site.cache.gitea.paths=/assets/,/css/,/js/,/images/"
- "xiteng.site.cache.gitea.edge-ttl=604800"
- "xiteng.site.cache.gitea.stale-while-revalidate=86400"
# TCP Config (SSH) # TCP Config (SSH)
- "traefik.tcp.routers.gitea-ssh.rule=HostSNI(`*`)" # SNI must be * for SSH - "traefik.tcp.routers.gitea-ssh.rule=HostSNI(`*`)" # SNI must be * for SSH
- "traefik.tcp.routers.gitea-ssh.entrypoints=ssh" - "traefik.tcp.routers.gitea-ssh.entrypoints=ssh"
- "traefik.tcp.services.gitea-ssh.loadbalancer.server.port=22" - "traefik.tcp.services.gitea-ssh.loadbalancer.server.port=22"
# --- 2. Homepage 自动发现设置 --- # --- Xiteng Site dynamic catalog ---
- "homepage.group=我的服务" # 分组名称 - "xiteng.site.component.gitea.enabled=true"
- "homepage.name=Gitea" # 显示名称 - "xiteng.site.component.gitea.name=Gitea"
- "homepage.icon=gitea" # 图标 (支持 si, mdi 等前缀) - "xiteng.site.component.gitea.description=自托管代码、Issue、Pull Request 与包管理平台。"
- "homepage.href=https://gitea.xiteng.site" # 点击跳转的链接 - "xiteng.site.component.gitea.section=services"
- "homepage.description=代码托管平台" # 副标题描述 - "xiteng.site.component.gitea.category=开发与协作"
# --- 3. AutoKuma 自动发现设置 --- - "xiteng.site.component.gitea.url=https://gitea.xiteng.site/liooil"
# 格式: kuma.<自定义ID>.<监控类型>.<属性> - "xiteng.site.component.gitea.endpoint=git.xiteng.site:22"
- "kuma.gitea.http.name=Gitea" # 监控项名称 - "xiteng.site.component.gitea.access=mixed"
- "kuma.gitea.http.url=http://gitea:3000" # 内网直连 - "xiteng.site.component.gitea.access-label=公开浏览 · 写操作需登录"
# (可选) 每 60 秒检查一次,重试 3 次 - "xiteng.site.component.gitea.icon=GT"
- "kuma.gitea.http.interval=60" - "xiteng.site.component.gitea.icon-url=https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/gitea.svg"
- "kuma.gitea.http.max_retries=3" - "xiteng.site.component.gitea.accent=green"
- "xiteng.site.component.gitea.order=100"
- "xiteng.site.component.gitea.navigation=new-tab"
- "xiteng.site.component.gitea.portal-link=native"
- "xiteng.site.component.gitea.monitor.enabled=true"
- "xiteng.site.component.gitea.monitor.url=http://gitea:3000"
networks: networks:
- homelab_net - homelab_net
+1
View File
@@ -0,0 +1 @@
<a class="item" href="https://xiteng.site/?focus=gitea#services" title="返回 xiteng.site">← xiteng.site</a>
+37 -15
View File
@@ -1,19 +1,31 @@
services: services:
hedgedoc-db: hedgedoc-db:
image: postgres:16-alpine image: postgres:16-alpine@sha256:57c72fd2a128e416c7fcc499958864df5301e940bca0a56f58fddf30ffc07777
environment: environment:
- POSTGRES_USER=$POSTGRES_USER - POSTGRES_USER=$POSTGRES_USER
- POSTGRES_PASSWORD=$POSTGRES_PASSWORD - POSTGRES_PASSWORD=$POSTGRES_PASSWORD
- POSTGRES_DB=$POSTGRES_DB - POSTGRES_DB=$POSTGRES_DB
container_name: hedgedoc_db container_name: hedgedoc_db
restart: unless-stopped restart: unless-stopped
labels:
- "xiteng.site.component.hedgedoc-db.enabled=true"
- "xiteng.site.component.hedgedoc-db.name=HedgeDoc PostgreSQL"
- "xiteng.site.component.hedgedoc-db.description=保存笔记、修订记录、会话与权限元数据。"
- "xiteng.site.component.hedgedoc-db.section=infrastructure"
- "xiteng.site.component.hedgedoc-db.category=数据层"
- "xiteng.site.component.hedgedoc-db.access=internal"
- "xiteng.site.component.hedgedoc-db.access-label=仅容器网络"
- "xiteng.site.component.hedgedoc-db.icon=PG"
- "xiteng.site.component.hedgedoc-db.icon-url=https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/postgresql.svg"
- "xiteng.site.component.hedgedoc-db.accent=blue"
- "xiteng.site.component.hedgedoc-db.order=540"
volumes: volumes:
- ./data:/var/lib/postgresql/data - ./data:/var/lib/postgresql/data
networks: networks:
- homelab_net - homelab_net
hedgedoc: hedgedoc:
image: quay.io/hedgedoc/hedgedoc:latest image: quay.io/hedgedoc/hedgedoc:1.11.1
container_name: hedgedoc container_name: hedgedoc
restart: unless-stopped restart: unless-stopped
environment: environment:
@@ -40,6 +52,7 @@ services:
- CMD_OAUTH2_USER_PROFILE_EMAIL_ATTR=email - CMD_OAUTH2_USER_PROFILE_EMAIL_ATTR=email
volumes: volumes:
- ./uploads:/hedgedoc/public/uploads - ./uploads:/hedgedoc/public/uploads
- ./custom/index-body.ejs:/hedgedoc/public/views/index/body.ejs:ro
depends_on: depends_on:
- hedgedoc-db - hedgedoc-db
ports: ports:
@@ -62,19 +75,28 @@ services:
# --- 新增:开启 TLS 并指定解析器 --- # --- 新增:开启 TLS 并指定解析器 ---
- "traefik.http.routers.hedgedoc.tls=true" - "traefik.http.routers.hedgedoc.tls=true"
- "traefik.http.routers.hedgedoc.tls.certresolver=cfresolver" - "traefik.http.routers.hedgedoc.tls.certresolver=cfresolver"
# --- 2. Homepage 自动发现设置 --- - "xiteng.site.cache.hedgedoc.enabled=true"
- "homepage.group=我的服务" # 分组名称 - "xiteng.site.cache.hedgedoc.routers=hedgedoc,hedgedoc-http"
- "homepage.name=HedgeDoc" # 显示名称 - "xiteng.site.cache.hedgedoc.paths=/assets/,/static/"
- "homepage.icon=si-hedgedoc" # 图标 (支持 si, mdi 等前缀) - "xiteng.site.cache.hedgedoc.edge-ttl=604800"
- "homepage.href=https://notes.xiteng.site" # 点击跳转的链接 - "xiteng.site.cache.hedgedoc.stale-while-revalidate=86400"
- "homepage.description=实时 Markdown 协作" # 副标题描述 # --- Xiteng Site dynamic catalog ---
# --- 3. AutoKuma 自动发现设置 --- - "xiteng.site.component.hedgedoc.enabled=true"
# 格式: kuma.<自定义ID>.<监控类型>.<属性> - "xiteng.site.component.hedgedoc.name=HedgeDoc"
- "kuma.hedgedoc.http.name=HedgeDoc" # 监控项名称 - "xiteng.site.component.hedgedoc.description=实时 Markdown 协作、分享与技术笔记。"
- "kuma.hedgedoc.http.url=http://hedgedoc:3000" # 内网直连 - "xiteng.site.component.hedgedoc.section=services"
# (可选) 每 60 秒检查一次,重试 3 次 - "xiteng.site.component.hedgedoc.category=开发与协作"
- "kuma.hedgedoc.http.interval=60" - "xiteng.site.component.hedgedoc.url=https://notes.xiteng.site"
- "kuma.hedgedoc.http.max_retries=3" - "xiteng.site.component.hedgedoc.access=mixed"
- "xiteng.site.component.hedgedoc.access-label=公开入口 · 内容按笔记授权"
- "xiteng.site.component.hedgedoc.icon=HD"
- "xiteng.site.component.hedgedoc.icon-url=https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/hedgedoc.svg"
- "xiteng.site.component.hedgedoc.accent=yellow"
- "xiteng.site.component.hedgedoc.order=110"
- "xiteng.site.component.hedgedoc.navigation=new-tab"
- "xiteng.site.component.hedgedoc.portal-link=native"
- "xiteng.site.component.hedgedoc.monitor.enabled=true"
- "xiteng.site.component.hedgedoc.monitor.url=http://hedgedoc:3000"
networks: networks:
- homelab_net - homelab_net
+182
View File
@@ -0,0 +1,182 @@
<div class="site-wrapper">
<div class="site-wrapper-inner">
<div class="cover-container">
<div class="masthead clearfix">
<div class="inner">
<h3 class="masthead-brand"></h3>
<nav>
<ul class="nav masthead-nav">
<li><a href="https://xiteng.site/?focus=hedgedoc#services">← xiteng.site</a></li>
<li class="ui-home<% if(!signin) { %> active<% } %>"><a href="#"><%= __('Intro') %></a>
</li>
<li class="ui-history<% if(signin) { %> active<% } %>"><a href="#"><%= __('History') %></a>
</li>
<div class="ui-signin" style="float: right; margin-top: 8px;<% if(signin) { %> display: none;<% } %>">
<% if (allowAnonymous && !disableNoteCreation) { %>
<a type="button" href="<%- serverURL %>/new" class="btn btn-sm btn-primary"><i class="fa fa-plus"></i> <%= __('New guest note') %></a>
<% } %>
<% if (authProviders.facebook || authProviders.twitter || authProviders.github || authProviders.gitlab || authProviders.mattermost || authProviders.dropbox || authProviders.google || authProviders.ldap || authProviders.saml || authProviders.oauth2 || authProviders.email) { %>
<button class="btn btn-sm btn-success ui-signin" data-toggle="modal" data-target=".signin-modal"><%= __('Sign In') %></button>
<% } %>
</div>
<div class="ui-signout" style="float: right; margin-top: 8px;<% if(!signin) { %> display: none;<% } %>">
<% if (!disableNoteCreation) { %>
<a type="button" href="<%- serverURL %>/new" class="btn btn-sm btn-primary"><i class="fa fa-plus"></i> <%= __('New note') %></a>
<% } %>
<span class="ui-profile dropdown pull-right">
<button id="profileLabel" class="btn btn-sm btn-link ui-profile-label" style="padding-right: 0;" data-toggle="dropdown" aria-haspopup="true" aria-expanded="false">
<img class="ui-avatar" width="20" height="20"><span class="hidden-xs hidden-sm">&ensp;<span class="ui-name"></span></span>&ensp;<i class="fa fa-caret-down"></i>
</button>
<ul class="dropdown-menu" aria-labelledby="profileLabel">
<li><a href="<%- serverURL %>/features"><i class="fa fa-dot-circle-o fa-fw"></i> <%= __('Features') %></a></li>
<li><a href="<%- serverURL %>/me/export"><i class="fa fa-cloud-download fa-fw"></i> <%= __('Export user data') %></a></li>
<li><a class="ui-delete-user" data-toggle="modal" data-target=".delete-user-modal"><i class="fa fa-trash fa-fw"></i> <%= __('Delete user') %></a></li>
<li><a href="<%- serverURL %>/logout"><i class="fa fa-sign-out fa-fw"></i> <%= __('Sign Out') %></a></li>
</ul>
</span>
</div>
</ul>
</nav>
</div>
</div>
<div id="home" class="section"<% if(signin) { %> style="display:none;"<% } %>>
<div class="inner cover">
<h1 class="cover-heading"><img src="<%- serverURL %>/banner/banner_vertical_color.svg" width="400" alt="HedgeDoc logo"></h1>
<p class="lead">
Ideas grow better together
</p>
<% if (infoMessage && infoMessage.length > 0) { %>
<div class="alert alert-info" style="max-width: 400px; margin: 0 auto;"><%= infoMessage %></div>
<% } %>
<% if (errorMessage && errorMessage.length > 0) { %>
<div class="alert alert-danger" style="max-width: 400px; margin: 0 auto;"><%= errorMessage %></div>
<% } %>
<% if (authProviders.facebook || authProviders.twitter || authProviders.github || authProviders.gitlab || authProviders.mattermost || authProviders.dropbox || authProviders.google || authProviders.ldap || authProviders.saml || authProviders.oauth2 || authProviders.email) { %>
<span class="ui-signin">
<br>
<a type="button" class="btn btn-lg btn-success ui-signin" data-toggle="modal" data-target=".signin-modal" style="min-width: 200px;"><%= __('Sign In') %></a>
</span>
<span class="ui-or"><%= __('or') %></span>
<% } %>
<span class="ui-signin">
<a type="button" href="<%- serverURL %>/features" class="btn btn-lg btn-primary" style="min-width: 200px;"><%= __('Explore all features') %></a>
<br>
<br>
<img src="<%- serverURL %>/screenshot.png" class="screenshot ui-signin">
</span>
<div class="lead row" style="width: 90%; margin: 0 auto;">
<div class="col-md-4 inner">
<a href="<%- serverURL %>/features#Share-Notes">
<i class="fa fa-bolt fa-3x"></i>
<h4><%= __('Collaborate with URL') %></h4>
</a>
</div>
<div class="col-md-4 inner">
<a href="<%- serverURL %>/features#MathJax">
<i class="fa fa-bar-chart fa-3x"></i>
<h4><%= __('Support charts and MathJax') %></h4>
</a>
</div>
<div class="col-md-4 inner">
<a href="<%- serverURL %>/features#Slide-Mode">
<i class="fa fa-tv fa-3x"></i>
<h4><%= __('Support slide mode') %></h4>
</a>
</div>
</div>
</div>
</div>
<div id="history" class="section"<% if(!signin) { %> style="display:none;"<% } %>>
<div class="ui-signin"<% if(signin) { %> style="display:none;"<% } %>>
<p><%= __('Below is the history from browser') %></p>
</div>
<br>
<form class="form-inline">
<div class="form-group" style="vertical-align: bottom;">
<input class="form-control ui-use-tags" placeholder="<%= __('Select tags...') %>" />
</div>
<div class="form-group">
<input class="search form-control" placeholder="<%= __('Search keyword...') %>" />
</div>
<a href="#" class="sort btn btn-default" data-sort="text" title="<%= __('Sort by title') %>">
<%= __('Title') %>
</a>
<a href="#" class="sort btn btn-default" data-sort="timestamp" title="<%= __('Sort by time') %>">
<%= __('Time') %>
</a>
<span class="hidden-xs hidden-sm">
<a href="#" class="btn btn-default ui-save-history" title="<%= __('Export history') %>"><i class="fa fa-save"></i></a>
<span class="btn btn-default btn-file ui-open-history" title="<%= __('Import history') %>">
<i class="fa fa-folder-open-o"></i><input type="file" />
</span>
<a href="#" class="btn btn-default ui-clear-history" title="<%= __('Clear history') %>" data-toggle="modal" data-target=".delete-history-modal"><i class="fa fa-trash-o"></i></a>
</span>
<a href="#" class="btn btn-default ui-refresh-history" title="<%= __('Refresh history') %>"><i class="fa fa-refresh"></i></a>
</form>
<h4 class="ui-nohistory" style="display:none;">
<%= __('No history') %>
</h4>
<a href="#" class="btn btn-primary ui-import-from-browser" style="display:none;"><%= __('Import from browser') %></a>
<ul id="history-list" class="list">
</ul>
<ul class="pagination"></ul>
</div>
<div class="mastfoot">
<div class="inner">
<select class="ui-locale"></select>
<p>
<%- __('Powered by %s', '<a href="https://hedgedoc.org">HedgeDoc</a>') %> | <a href="<%- serverURL %>/s/release-notes" target="_blank" rel="noopener"><%= __('Releases') %></a> | <a href="<%- sourceURL %>" target="_blank" rel="noopener"><%= __('Source Code') %></a><% if(imprint) { %> | <a href="<%- serverURL %>/s/imprint" target="_blank" rel="noopener"><%= __('Imprint') %></a><% } %><% if(privacyStatement) { %> | <a href="<%- serverURL %>/s/privacy" target="_blank" rel="noopener"><%= __('Privacy') %></a><% } %><% if(termsOfUse) { %> | <a href="<%- serverURL %>/s/terms-of-use" target="_blank" rel="noopener"><%= __('Terms of Use') %></a><% } %>
</p>
<h6 class="social-foot">
<%- __('Follow us on %s and %s.', '<a href="https://github.com/hedgedoc/hedgedoc" target="_blank" rel="noopener"><i class="fa fa-github"></i> GitHub</a>, <a href="https://community.hedgedoc.org" target="_blank" rel="noopener"><i class="fa fa-users" aria-hidden="true"></i> Discourse</a>, <a href="https://chat.hedgedoc.org" target="_blank" rel="noopener"><i class="fa fa-comments"></i> Matrix</a>, <a href="https://social.hedgedoc.org/mastodon" target="_blank" rel="noopener"><i class="fa fa-mastodon"></i> Mastodon</a>', '<a href="https://translate.hedgedoc.org" target="_blank" rel="noopener"><i class="fa fa-globe"></i> POEditor</a>') %>
</h6>
</div>
</div>
</div>
</div>
</div>
<!-- delete history modal -->
<div class="modal fade delete-history-modal" tabindex="-1" role="dialog" aria-labelledby="myModalLabel" aria-hidden="true">
<div class="modal-dialog modal-sm">
<div class="modal-content">
<div class="modal-header">
<button type="button" class="close" data-dismiss="modal" aria-label="Close"><span aria-hidden="true">&times;</span>
</button>
<h4 class="modal-title" id="myModalLabel"><%= __('Are you sure?') %></h4>
</div>
<div class="modal-body">
<h5 class="ui-delete-history-modal-msg"></h5>
<strong class="ui-delete-history-modal-item"></strong>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-default" data-dismiss="modal"><%= __('Cancel') %></button>
<button type="button" class="btn btn-danger ui-delete-history-modal-confirm"><%= __('Yes, do it!') %></button>
</div>
</div>
</div>
</div>
<!-- delete user modal -->
<div class="modal fade delete-user-modal" tabindex="-1" role="dialog" aria-labelledby="myModalLabel" aria-hidden="true">
<div class="modal-dialog modal-sm">
<div class="modal-content">
<div class="modal-header">
<button type="button" class="close" data-dismiss="modal" aria-label="Close"><span aria-hidden="true">&times;</span>
</button>
<h4 class="modal-title" id="myModalLabel"><%= __('Are you sure?') %></h4>
</div>
<div class="modal-body">
<h5 class="ui-delete-user-modal-msg"><%= __('Do you really want to delete your user account?') %></h5>
<strong class="ui-delete-user-modal-item"><%= __('This will delete your account, all notes that are owned by you and remove all references to your account from other notes.') %></strong>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-default ui-delete-user-modal-cancel" data-dismiss="modal"><%= __('Cancel') %></button>
<a type="button" class="btn btn-danger" href="<%- serverURL %>/me/delete/<%- deleteToken %>"><%= __('Yes, do it!') %></a>
</div>
</div>
</div>
</div>
<%- include('../shared/signin-modal') %>
+103
View File
@@ -0,0 +1,103 @@
services:
hedgedoc2:
build:
context: /home/xiteng/src/hedgedoc
dockerfile: Dockerfile
image: hedgedoc2:ai-preview
container_name: hedgedoc2
restart: unless-stopped
environment:
- HD_BACKEND_PORT=3000
- HD_BACKEND_BIND_IP=0.0.0.0
- HD_FRONTEND_PORT=3001
- PORT=3001
- HD_DATABASE_TYPE=sqlite
- HD_DATABASE_NAME=/data/hedgedoc.sqlite
- HD_BASE_URL=https://notes2.xiteng.site
- HD_RENDERER_BASE_URL=https://notes2.xiteng.site
- HD_INTERNAL_API_URL=http://localhost:3000/
# Auth — Authentik OIDC
- HD_AUTH_SESSION_SECRET=hedgedoc2-experimental-session-secret-min-32-chars!!
- HD_AUTH_OIDC_SERVERS=AUTHENTIK
- HD_AUTH_OIDC_AUTHENTIK_ISSUER=https://auth.xiteng.site/application/o/hedgedoc2/
- HD_AUTH_OIDC_AUTHENTIK_CLIENT_ID=hedgedoc2
- HD_AUTH_OIDC_AUTHENTIK_CLIENT_SECRET=hedgedoc2-secret-9
- HD_AUTH_OIDC_AUTHENTIK_PROVIDER_NAME=Authentik
- HD_AUTH_OIDC_AUTHENTIK_AUTHORIZE_URL=https://auth.xiteng.site/application/o/authorize/
- HD_AUTH_OIDC_AUTHENTIK_TOKEN_URL=https://auth.xiteng.site/application/o/token/
- HD_AUTH_OIDC_AUTHENTIK_USERINFO_URL=https://auth.xiteng.site/application/o/userinfo/
- HD_AUTH_OIDC_AUTHENTIK_END_SESSION_URL=https://auth.xiteng.site/application/o/hedgedoc2/end-session/
- HD_AUTH_LOCAL_ENABLE_LOGIN=true
# Permissions
- HD_NOTE_PERMISSIONS_DEFAULT_EVERYONE=write
# Media
- HD_MEDIA_BACKEND_TYPE=filesystem
- HD_MEDIA_BACKEND_FILESYSTEM_UPLOAD_PATH=/data/uploads
# AI assistant config
- HD_AI_ENABLED=true
- HD_AI_TIMEOUT_MS=60000
- HD_AI_MAX_INPUT_CHARS=32000
- HD_AI_KEY_VAULT_URL=http://ai-gateway:8093
- HD_AI_KEY_VAULT_TOKEN_FILE=/run/secrets/key_vault_service_token
- HD_AI_KEY_VAULT_ISSUER=https://auth.xiteng.site
- HD_AI_KEY_VAULT_OIDC_IDENTIFIER=authentik
volumes:
- ./data:/data
- ../ai-gateway/secrets/portal_gateway_hmac:/run/secrets/key_vault_service_token:ro
networks:
- homelab_net
labels:
# Traefik routing
- "traefik.enable=true"
# Backend routes: API, realtime websocket, uploads, and media
- "traefik.http.routers.hedgedoc2-api.rule=Host(`notes2.xiteng.site`) && (PathPrefix(`/api`) || PathPrefix(`/realtime`) || PathPrefix(`/uploads`) || PathPrefix(`/media`))"
- "traefik.http.routers.hedgedoc2-api.entrypoints=websecure"
- "traefik.http.routers.hedgedoc2-api.tls=true"
- "traefik.http.routers.hedgedoc2-api.tls.certresolver=cfresolver"
- "traefik.http.routers.hedgedoc2-api.service=hedgedoc2-api"
- "traefik.http.services.hedgedoc2-api.loadbalancer.server.port=3000"
# Backend HTTP entrypoint
- "traefik.http.routers.hedgedoc2-api-http.rule=Host(`notes2.xiteng.site`) && (PathPrefix(`/api`) || PathPrefix(`/realtime`) || PathPrefix(`/uploads`) || PathPrefix(`/media`))"
- "traefik.http.routers.hedgedoc2-api-http.entrypoints=web"
- "traefik.http.routers.hedgedoc2-api-http.service=hedgedoc2-api"
- "traefik.http.routers.hedgedoc2-api-http.middlewares=force-https"
- "traefik.http.routers.hedgedoc2-api.middlewares=force-https"
# Frontend → frontend (3001)
- "traefik.http.routers.hedgedoc2.rule=Host(`notes2.xiteng.site`)"
- "traefik.http.routers.hedgedoc2.entrypoints=websecure"
- "traefik.http.routers.hedgedoc2.tls=true"
- "traefik.http.routers.hedgedoc2.tls.certresolver=cfresolver"
- "traefik.http.routers.hedgedoc2.service=hedgedoc2"
- "traefik.http.services.hedgedoc2.loadbalancer.server.port=3001"
# HTTP entrypoint (Cloudflare Tunnel)
- "traefik.http.routers.hedgedoc2-http.rule=Host(`notes2.xiteng.site`)"
- "traefik.http.routers.hedgedoc2-http.entrypoints=web"
- "traefik.http.routers.hedgedoc2-http.service=hedgedoc2"
- "xiteng.site.cache.hedgedoc2.enabled=true"
- "xiteng.site.cache.hedgedoc2.routers=hedgedoc2,hedgedoc2-http"
- "xiteng.site.cache.hedgedoc2.paths=/assets/,/static/"
- "xiteng.site.cache.hedgedoc2.edge-ttl=604800"
- "xiteng.site.cache.hedgedoc2.stale-while-revalidate=86400"
# Force X-Forwarded-Proto
- "traefik.http.routers.hedgedoc2-http.middlewares=force-https"
- "traefik.http.routers.hedgedoc2.middlewares=force-https"
- "xiteng.site.component.hedgedoc2.enabled=true"
- "xiteng.site.component.hedgedoc2.name=HedgeDoc 2 (AI)"
- "xiteng.site.component.hedgedoc2.description=带本地模型写作助手的 HedgeDoc 2 实验环境。"
- "xiteng.site.component.hedgedoc2.section=services"
- "xiteng.site.component.hedgedoc2.category=开发与协作"
- "xiteng.site.component.hedgedoc2.url=https://notes2.xiteng.site"
- "xiteng.site.component.hedgedoc2.access=sso"
- "xiteng.site.component.hedgedoc2.access-label=需要登录"
- "xiteng.site.component.hedgedoc2.icon=H2"
- "xiteng.site.component.hedgedoc2.icon-url=https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/hedgedoc.svg"
- "xiteng.site.component.hedgedoc2.accent=blue"
- "xiteng.site.component.hedgedoc2.order=120"
- "xiteng.site.component.hedgedoc2.navigation=new-tab"
- "xiteng.site.component.hedgedoc2.portal-link=native"
- "xiteng.site.component.hedgedoc2.monitor.enabled=true"
- "xiteng.site.component.hedgedoc2.monitor.url=http://hedgedoc2:3001"
networks:
homelab_net:
external: true
+201
View File
@@ -0,0 +1,201 @@
#!/usr/bin/env bash
set -euo pipefail
SCRIPT_DIR=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)
AUTHENTIK_COMPOSE="$SCRIPT_DIR/authentik/compose.yml"
VAULT_COMPOSE="$SCRIPT_DIR/ai-gateway/compose.yml"
usage() {
cat <<'EOF'
Usage: ./homelab-emergency <command> [arguments]
Bootstrap and configuration:
init-secrets
identity-bootstrap
Recovery:
status
identity-recovery [username] default: liooil
identity-set-password [username] default: liooil
identity-reset-2fa [username] delete all TOTP devices; default: liooil
identity-reset-passkeys [username] delete all Passkeys; default: liooil
vault-list [owner-sub]
vault-audit [limit]
vault-verify <credential-id>
vault-delete <credential-id>
vault-reassign <credential-id> <issuer> <sub> <username>
vault-backup <destination.db>
vault-rotate-key <new-key-file>
The script never prints stored provider credentials.
EOF
}
require_command() {
command -v "$1" >/dev/null 2>&1 || {
echo "Required command not found: $1" >&2
exit 1
}
}
ensure_parent() {
mkdir -p -- "$(dirname -- "$1")"
}
generate_base64_key() {
local destination=$1
if [[ -e "$destination" ]]; then
echo "Keeping existing secret: $destination"
return
fi
ensure_parent "$destination"
chmod 700 -- "$(dirname -- "$destination")"
openssl rand 32 | base64 -w 0 >"$destination"
chmod 600 -- "$destination"
echo "Created: $destination"
}
vault_exec() {
if docker ps --format '{{.Names}}' | grep -Fxq ai-gateway; then
docker compose -f "$VAULT_COMPOSE" exec -T ai-gateway node /app/cli.mjs "$@"
else
docker compose -f "$VAULT_COMPOSE" run --rm --no-deps ai-gateway node /app/cli.mjs "$@"
fi
}
identity_reset_authenticators() {
local username=$1
local model_module=$2
local model_name=$3
docker compose -f "$AUTHENTIK_COMPOSE" exec -T -e TARGET_USERNAME="$username" authentik \
ak shell -c "import os; from authentik.core.models import User; from $model_module import $model_name; user=User.objects.get(username=os.environ['TARGET_USERNAME']); print($model_name.objects.filter(user=user).delete()[0])"
}
identity_audit() {
local action=$1
local username=$2
local encoded
encoded=$(printf '%s' "$username" | base64 -w 0)
mkdir -p -- "$SCRIPT_DIR/site/data"
printf '{"createdAt":"%s","actor":"homelab-emergency","action":"%s","target":"base64:%s","detail":"{}"}\n' \
"$(date --iso-8601=seconds)" "$action" "$encoded" >>"$SCRIPT_DIR/site/data/identity-audit.jsonl"
chmod 600 -- "$SCRIPT_DIR/site/data/identity-audit.jsonl"
}
command_name=${1:-}
shift || true
case "$command_name" in
init-secrets)
require_command openssl
require_command base64
umask 077
generate_base64_key "$SCRIPT_DIR/ai-gateway/secrets/vault_master_key"
generate_base64_key "$SCRIPT_DIR/ai-gateway/secrets/portal_gateway_hmac"
;;
identity-bootstrap)
docker compose -f "$AUTHENTIK_COMPOSE" exec -T authentik \
ak shell -c "exec(open('/bootstrap/portal_identity.py').read())"
docker compose -f "$SCRIPT_DIR/site/compose.yml" up -d --force-recreate xiteng-site
;;
identity-recovery)
username=${1:-liooil}
docker compose -f "$AUTHENTIK_COMPOSE" exec -T authentik ak create_recovery_key 60 "$username" -v 0
;;
identity-set-password)
username=${1:-liooil}
docker compose -f "$AUTHENTIK_COMPOSE" exec authentik ak changepassword "$username"
;;
identity-reset-2fa)
username=${1:-liooil}
read -r -p "Delete every TOTP device for $username? [y/N] " confirm
[[ "$confirm" == "y" || "$confirm" == "Y" ]] || exit 1
identity_reset_authenticators "$username" "authentik.stages.authenticator_totp.models" "TOTPDevice"
identity_audit "emergency.totp.reset" "$username"
;;
identity-reset-passkeys)
username=${1:-liooil}
read -r -p "Delete every Passkey for $username? [y/N] " confirm
[[ "$confirm" == "y" || "$confirm" == "Y" ]] || exit 1
identity_reset_authenticators "$username" "authentik.stages.authenticator_webauthn.models" "WebAuthnDevice"
identity_audit "emergency.passkeys.reset" "$username"
;;
status)
docker compose -f "$AUTHENTIK_COMPOSE" ps authentik worker postgres redis
docker compose -f "$VAULT_COMPOSE" ps
;;
vault-list)
vault_exec list "$@"
;;
vault-audit)
vault_exec audit "$@"
;;
vault-verify)
vault_exec verify "$@"
;;
vault-delete)
credential_id=${1:-}
if [[ -z "$credential_id" ]]; then
echo "Credential ID is required" >&2
exit 1
fi
read -r -p "Permanently delete credential $credential_id? [y/N] " confirm
[[ "$confirm" == "y" || "$confirm" == "Y" ]] || exit 1
vault_exec delete "$credential_id"
;;
vault-reassign)
if [[ $# -lt 4 ]]; then
echo "credential-id, issuer, sub and username are required" >&2
exit 1
fi
vault_exec reassign "$@"
;;
vault-backup)
destination=${1:-}
if [[ -z "$destination" ]]; then
echo "Destination DB path is required" >&2
exit 1
fi
absolute_destination=$(realpath -m -- "$destination")
case "$absolute_destination" in
"$SCRIPT_DIR/ai-gateway/data/"*) ;;
*)
echo "Vault CLI backup target must be under ai-gateway/data so the container can write it." >&2
echo "Suggested: ai-gateway/data/backups/vault-$(date +%Y%m%d-%H%M%S).db" >&2
exit 1
;;
esac
relative_destination=/data/${absolute_destination#"$SCRIPT_DIR/ai-gateway/data/"}
ensure_parent "$absolute_destination"
vault_exec backup "$relative_destination"
chmod 600 -- "$absolute_destination"
echo "Vault backup written to $absolute_destination"
;;
vault-rotate-key)
new_key_file=${1:-}
if [[ -z "$new_key_file" || ! -f "$new_key_file" ]]; then
echo "A readable new key file is required" >&2
exit 1
fi
read -r -p "Rewrap every Vault data key and then replace vault_master_key? [y/N] " confirm
[[ "$confirm" == "y" || "$confirm" == "Y" ]] || exit 1
temporary_key="$SCRIPT_DIR/ai-gateway/secrets/vault_master_key.next"
cp -- "$new_key_file" "$temporary_key"
chmod 600 -- "$temporary_key"
vault_exec rotate-master "/run/secrets/vault_master_key.next"
mv -- "$temporary_key" "$SCRIPT_DIR/ai-gateway/secrets/vault_master_key"
chmod 600 -- "$SCRIPT_DIR/ai-gateway/secrets/vault_master_key"
docker compose -f "$VAULT_COMPOSE" restart ai-gateway
echo "Vault master key rotated and service restarted. Back up the new key now."
;;
""|-h|--help|help)
usage
;;
*)
echo "Unknown command: $command_name" >&2
usage >&2
exit 1
;;
esac
-56
View File
@@ -1,56 +0,0 @@
services:
homepage:
image: ghcr.io/gethomepage/homepage:latest
container_name: homepage
restart: unless-stopped
volumes:
- ./config:/app/config
- ./config/icons:/app/public/icons:ro
- /var/run/docker.sock:/var/run/docker.sock:ro
ports:
- "3003:3000"
environment:
- HOMEPAGE_ALLOWED_HOSTS=home.xiteng.site,xiteng.site
- HOMEPAGE_AUTH_PROVIDER=oidc
- HOMEPAGE_AUTH_OIDC_ISSUER=https://auth.xiteng.site/application/o/homepage/
- HOMEPAGE_AUTH_OIDC_CLIENT_ID=homepage-64bfd7a7
- HOMEPAGE_AUTH_OIDC_CLIENT_SECRET=b5d710941dba4a4694a92fe7bc381f3c853a248b5114467d9b82b0953928326b
- HOMEPAGE_AUTH_OIDC_SCOPE=openid profile email
labels:
# --- 1. Traefik 路由设置 ---
- "traefik.enable=true"
- "traefik.http.routers.homepage.rule=Host(`home.xiteng.site`)"
- "traefik.http.services.homepage.loadbalancer.server.port=3000"
- "traefik.http.routers.homepage.entrypoints=websecure"
# --- 根域名 xiteng.site ---
- "traefik.http.routers.homepage-root.rule=Host(`xiteng.site`)"
- "traefik.http.routers.homepage-root.service=homepage"
- "traefik.http.routers.homepage-root.entrypoints=websecure"
# --- HTTP 入口(来自 cloudflared tunnel---
- "traefik.http.routers.homepage-root-http.rule=Host(`xiteng.site`)"
- "traefik.http.routers.homepage-root-http.service=homepage"
- "traefik.http.routers.homepage-root-http.entrypoints=web"
- "traefik.http.routers.homepage-root.tls=true"
- "traefik.http.routers.homepage-root.tls.certresolver=cfresolver"
# --- 新增:开启 TLS 并指定解析器 ---
- "traefik.http.routers.homepage.tls=true"
- "traefik.http.routers.homepage.tls.certresolver=cfresolver"
# --- 3. Homepage 自动发现设置 ---
- "homepage.group=我的服务"
- "homepage.name=HomePage"
- "homepage.icon=homepage"
- "homepage.href=https://home.xiteng.site"
- "homepage.description=导航主页"
# --- 4. AutoKuma 自动发现设置 ---
# 格式: kuma.<自定义ID>.<监控类型>.<属性>
- "kuma.homepage.http.name=HomePage" # 监控项名称
- "kuma.homepage.http.url=http://homepage:3000" # 内网直连
# (可选) 每 60 秒检查一次,重试 3 次
- "kuma.homepage.http.interval=60"
- "kuma.homepage.http.max_retries=3"
networks:
- homelab_net
networks:
homelab_net:
external: true
+62
View File
@@ -0,0 +1,62 @@
services:
invokeai:
image: ghcr.io/invoke-ai/invokeai:6.13.7-cuda
container_name: invokeai
restart: unless-stopped
environment:
INVOKEAI_ROOT: /invokeai
INVOKEAI_HOST: 0.0.0.0
INVOKEAI_PORT: 9090
volumes:
- ./data:/invokeai:rw
deploy:
resources:
reservations:
devices:
- driver: nvidia
count: all
capabilities: [gpu]
networks:
- homelab_net
labels:
# Traefik
- "traefik.enable=true"
- "traefik.http.routers.invokeai.rule=Host(`invoke.xiteng.site`)"
- "traefik.http.routers.invokeai.entrypoints=websecure"
- "traefik.http.routers.invokeai.tls=true"
- "traefik.http.routers.invokeai.tls.certresolver=cfresolver"
- "traefik.http.routers.invokeai.service=invokeai"
- "traefik.http.routers.invokeai.middlewares=invokeai-scheme,invokeai-auth"
- "traefik.http.services.invokeai.loadbalancer.server.port=9090"
- "traefik.http.routers.invokeai-http.rule=Host(`invoke.xiteng.site`)"
- "traefik.http.routers.invokeai-http.entrypoints=web"
- "traefik.http.routers.invokeai-http.service=invokeai"
- "traefik.http.routers.invokeai-http.middlewares=invokeai-scheme,invokeai-auth"
- "xiteng.site.cache.invokeai.enabled=true"
- "xiteng.site.cache.invokeai.routers=invokeai,invokeai-http"
- "xiteng.site.cache.invokeai.paths=/assets/,/static/,/css/,/js/"
- "xiteng.site.cache.invokeai.edge-ttl=604800"
- "xiteng.site.cache.invokeai.stale-while-revalidate=86400"
# Authentik ForwardAuth
- "traefik.http.middlewares.invokeai-scheme.headers.customrequestheaders.X-Forwarded-Proto=https"
- "traefik.http.middlewares.invokeai-auth.forwardauth.address=http://authentik-outpost:9000/outpost.goauthentik.io/auth/traefik"
- "traefik.http.middlewares.invokeai-auth.forwardauth.trustForwardHeader=true"
- "traefik.http.middlewares.invokeai-auth.forwardauth.authResponseHeaders=X-authentik-username,X-authentik-groups,X-authentik-email,X-authentik-name"
- "xiteng.site.component.invokeai.enabled=true"
- "xiteng.site.component.invokeai.name=InvokeAI"
- "xiteng.site.component.invokeai.description=图像生成、画布编辑、图库和模型管理工作台。"
- "xiteng.site.component.invokeai.section=services"
- "xiteng.site.component.invokeai.category=AI"
- "xiteng.site.component.invokeai.url=https://invoke.xiteng.site"
- "xiteng.site.component.invokeai.access=sso"
- "xiteng.site.component.invokeai.access-label=需要 Authentik"
- "xiteng.site.component.invokeai.icon=IA"
- "xiteng.site.component.invokeai.icon-url=https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/invoke-ai.svg"
- "xiteng.site.component.invokeai.accent=purple"
- "xiteng.site.component.invokeai.order=220"
- "xiteng.site.component.invokeai.monitor.enabled=true"
- "xiteng.site.component.invokeai.monitor.url=http://invokeai:9090"
networks:
homelab_net:
external: true
-58
View File
@@ -1,58 +0,0 @@
services:
openwebui:
image: ghcr.io/open-webui/open-webui:v0.9.5
container_name: openwebui
restart: unless-stopped
volumes:
- ./data:/app/backend/data
environment:
# --- OIDC 认证 (Authentik) ---
- WEBUI_AUTH=true
- ENABLE_OAUTH_SIGNUP=true
- OAUTH_CLIENT_ID=openwebui-63f6865e
- OAUTH_CLIENT_SECRET=65cecaf8cc974c7fad55c749c65e50ee06bae0a9c8614252b4a44707a700c188
- OPENID_PROVIDER_URL=https://auth.xiteng.site/application/o/openwebui/.well-known/openid-configuration
- OAUTH_SCOPES=openid email profile
- OAUTH_PROVIDER_NAME=Authentik
- WEBUI_URL=https://ai.xiteng.site
# --- Session ---
- WEBUI_SECRET_KEY=${OPENWEBUI_SECRET_KEY}
# --- 网络 (解决国内 IPv6/ghcr 访问问题) ---
- HF_ENDPOINT=https://hf-mirror.com
dns:
- 223.5.5.5
- 114.114.114.114
labels:
# ========== Traefik ==========
- "traefik.enable=true"
# HTTPS router (cert management)
- "traefik.http.routers.openwebui.rule=Host(`ai.xiteng.site`)"
- "traefik.http.routers.openwebui.entrypoints=websecure"
- "traefik.http.routers.openwebui.tls=true"
- "traefik.http.routers.openwebui.tls.certresolver=cfresolver"
- "traefik.http.services.openwebui.loadbalancer.server.port=8080"
# HTTP router (Cloudflare Tunnel ingress)
- "traefik.http.routers.openwebui-http.rule=Host(`ai.xiteng.site`)"
- "traefik.http.routers.openwebui-http.service=openwebui"
- "traefik.http.routers.openwebui-http.entrypoints=web"
# X-Forwarded-Proto middleware
- "traefik.http.middlewares.force-https.headers.customrequestheaders.X-Forwarded-Proto=https"
- "traefik.http.routers.openwebui.middlewares=force-https"
- "traefik.http.routers.openwebui-http.middlewares=force-https"
# ========== Homepage ==========
- "homepage.group=我的服务"
- "homepage.name=OpenWebUI"
- "homepage.icon=si-openwebui"
- "homepage.href=https://ai.xiteng.site"
- "homepage.description=AI Chat 界面"
# ========== AutoKuma ==========
- "kuma.openwebui.http.name=OpenWebUI"
- "kuma.openwebui.http.url=http://openwebui:8080"
- "kuma.openwebui.http.interval=60"
- "kuma.openwebui.http.max_retries=3"
networks:
- homelab_net
networks:
homelab_net:
external: true
+14 -1
View File
@@ -1,12 +1,25 @@
services: services:
outpost: outpost:
image: ghcr.io/goauthentik/proxy:2026.5.0 image: ghcr.io/goauthentik/proxy:2026.5.6
container_name: authentik-outpost-seaweedfs container_name: authentik-outpost-seaweedfs
restart: unless-stopped restart: unless-stopped
environment: environment:
AUTHENTIK_HOST: https://auth.xiteng.site AUTHENTIK_HOST: https://auth.xiteng.site
AUTHENTIK_TOKEN: ${AUTHENTIK_OUTPOST_SEAWEEDFS_TOKEN} AUTHENTIK_TOKEN: ${AUTHENTIK_OUTPOST_SEAWEEDFS_TOKEN}
AUTHENTIK_INSECURE: "true" AUTHENTIK_INSECURE: "true"
labels:
- "traefik.enable=false"
- "xiteng.site.component.seaweedfs-outpost.enabled=true"
- "xiteng.site.component.seaweedfs-outpost.name=SeaweedFS Auth Outpost"
- "xiteng.site.component.seaweedfs-outpost.description=为 SeaweedFS Web 入口提供独立 ForwardAuth 会话。"
- "xiteng.site.component.seaweedfs-outpost.section=infrastructure"
- "xiteng.site.component.seaweedfs-outpost.category=身份与访问"
- "xiteng.site.component.seaweedfs-outpost.access=internal"
- "xiteng.site.component.seaweedfs-outpost.access-label=认证回调组件"
- "xiteng.site.component.seaweedfs-outpost.icon=OP"
- "xiteng.site.component.seaweedfs-outpost.icon-url=https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/authentik.svg"
- "xiteng.site.component.seaweedfs-outpost.accent=blue"
- "xiteng.site.component.seaweedfs-outpost.order=220"
ports: ports:
- "9003:9000" - "9003:9000"
networks: networks:
+68 -1
View File
@@ -1,6 +1,6 @@
services: services:
outpost: outpost:
image: ghcr.io/goauthentik/proxy:2026.5.0 image: ghcr.io/goauthentik/proxy:2026.5.6
container_name: authentik-outpost container_name: authentik-outpost
restart: unless-stopped restart: unless-stopped
environment: environment:
@@ -21,6 +21,73 @@ services:
- "traefik.http.routers.outpost-http.rule=Host(`outpost.xiteng.site`)" - "traefik.http.routers.outpost-http.rule=Host(`outpost.xiteng.site`)"
- "traefik.http.routers.outpost-http.service=outpost" - "traefik.http.routers.outpost-http.service=outpost"
- "traefik.http.routers.outpost-http.entrypoints=web" - "traefik.http.routers.outpost-http.entrypoints=web"
# --- ComfyUI ForwardAuth callback ---
- "traefik.http.routers.comfyui-outpost.rule=Host(`comfy.xiteng.site`) && PathPrefix(`/outpost.goauthentik.io/`)"
- "traefik.http.routers.comfyui-outpost.entrypoints=websecure"
- "traefik.http.routers.comfyui-outpost.service=outpost"
- "traefik.http.routers.comfyui-outpost.priority=100"
- "traefik.http.routers.comfyui-outpost.tls=true"
- "traefik.http.routers.comfyui-outpost.tls.certresolver=cfresolver"
- "traefik.http.routers.comfyui-outpost-http.rule=Host(`comfy.xiteng.site`) && PathPrefix(`/outpost.goauthentik.io/`)"
- "traefik.http.routers.comfyui-outpost-http.entrypoints=web"
- "traefik.http.routers.comfyui-outpost-http.service=outpost"
- "traefik.http.routers.comfyui-outpost-http.priority=100"
# --- InvokeAI ForwardAuth callback ---
- "traefik.http.routers.invokeai-outpost.rule=Host(`invoke.xiteng.site`) && PathPrefix(`/outpost.goauthentik.io/`)"
- "traefik.http.routers.invokeai-outpost.entrypoints=websecure"
- "traefik.http.routers.invokeai-outpost.service=outpost"
- "traefik.http.routers.invokeai-outpost.priority=100"
- "traefik.http.routers.invokeai-outpost.tls=true"
- "traefik.http.routers.invokeai-outpost.tls.certresolver=cfresolver"
- "traefik.http.routers.invokeai-outpost-http.rule=Host(`invoke.xiteng.site`) && PathPrefix(`/outpost.goauthentik.io/`)"
- "traefik.http.routers.invokeai-outpost-http.entrypoints=web"
- "traefik.http.routers.invokeai-outpost-http.service=outpost"
- "traefik.http.routers.invokeai-outpost-http.priority=100"
# --- code-server ForwardAuth callback ---
- "traefik.http.routers.code-server-outpost.rule=Host(`code.xiteng.site`) && PathPrefix(`/outpost.goauthentik.io/`)"
- "traefik.http.routers.code-server-outpost.entrypoints=websecure"
- "traefik.http.routers.code-server-outpost.service=outpost"
- "traefik.http.routers.code-server-outpost.priority=100"
- "traefik.http.routers.code-server-outpost.tls=true"
- "traefik.http.routers.code-server-outpost.tls.certresolver=cfresolver"
- "traefik.http.routers.code-server-outpost-http.rule=Host(`code.xiteng.site`) && PathPrefix(`/outpost.goauthentik.io/`)"
- "traefik.http.routers.code-server-outpost-http.entrypoints=web"
- "traefik.http.routers.code-server-outpost-http.service=outpost"
- "traefik.http.routers.code-server-outpost-http.priority=100"
# --- Xiteng Chat ForwardAuth callback ---
- "traefik.http.routers.xiteng-chat-outpost.rule=Host(`chat.xiteng.site`) && PathPrefix(`/outpost.goauthentik.io/`)"
- "traefik.http.routers.xiteng-chat-outpost.entrypoints=websecure"
- "traefik.http.routers.xiteng-chat-outpost.service=outpost"
- "traefik.http.routers.xiteng-chat-outpost.priority=100"
- "traefik.http.routers.xiteng-chat-outpost.tls=true"
- "traefik.http.routers.xiteng-chat-outpost.tls.certresolver=cfresolver"
- "traefik.http.routers.xiteng-chat-outpost-http.rule=Host(`chat.xiteng.site`) && PathPrefix(`/outpost.goauthentik.io/`)"
- "traefik.http.routers.xiteng-chat-outpost-http.entrypoints=web"
- "traefik.http.routers.xiteng-chat-outpost-http.service=outpost"
- "traefik.http.routers.xiteng-chat-outpost-http.priority=100"
# --- Xiteng Portal administration ForwardAuth callback ---
- "traefik.http.routers.xiteng-outpost.rule=Host(`xiteng.site`) && PathPrefix(`/outpost.goauthentik.io/`)"
- "traefik.http.routers.xiteng-outpost.entrypoints=websecure"
- "traefik.http.routers.xiteng-outpost.service=outpost"
- "traefik.http.routers.xiteng-outpost.priority=300"
- "traefik.http.routers.xiteng-outpost.tls=true"
- "traefik.http.routers.xiteng-outpost.tls.certresolver=cfresolver"
- "traefik.http.routers.xiteng-outpost-http.rule=Host(`xiteng.site`) && PathPrefix(`/outpost.goauthentik.io/`)"
- "traefik.http.routers.xiteng-outpost-http.entrypoints=web"
- "traefik.http.routers.xiteng-outpost-http.service=outpost"
- "traefik.http.routers.xiteng-outpost-http.priority=300"
# --- Xiteng Site dynamic catalog ---
- "xiteng.site.component.authentik-outpost.enabled=true"
- "xiteng.site.component.authentik-outpost.name=Authentik Proxy Outpost"
- "xiteng.site.component.authentik-outpost.description=为受保护 Web 服务执行 ForwardAuth 流程。"
- "xiteng.site.component.authentik-outpost.section=infrastructure"
- "xiteng.site.component.authentik-outpost.category=身份与访问"
- "xiteng.site.component.authentik-outpost.access=internal"
- "xiteng.site.component.authentik-outpost.access-label=认证回调组件"
- "xiteng.site.component.authentik-outpost.icon=OP"
- "xiteng.site.component.authentik-outpost.icon-url=https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/authentik.svg"
- "xiteng.site.component.authentik-outpost.accent=red"
- "xiteng.site.component.authentik-outpost.order=210"
networks: networks:
- homelab_net - homelab_net
+57
View File
@@ -0,0 +1,57 @@
services:
remark42:
image: ghcr.io/umputun/remark42:v1.16.4
container_name: remark42
restart: unless-stopped
environment:
- REMARK_URL=https://remark.xiteng.site
- SECRET=60719f2fbf0c4fff4fffb3c3fa9a385953402601caf4324d734985c0afd9f7a9
- SITE=remark
- TRUSTED_PROXY=172.18.0.0/16
- AUTH_ANON=false
- AUTH_CUSTOM_NAME=Authentik
- AUTH_CUSTOM_CID=remark42-acd7c6f8
- AUTH_CUSTOM_CSEC=cb2163478f1959c7d900e98a9f0f89ef6ce9345989dc5043cd7442c33f56edb5
- AUTH_CUSTOM_AUTH_URL=https://auth.xiteng.site/application/o/authorize/
- AUTH_CUSTOM_TOKEN_URL=https://auth.xiteng.site/application/o/token/
- AUTH_CUSTOM_INFO_URL=https://auth.xiteng.site/application/o/userinfo/
- AUTH_CUSTOM_SCOPES=openid,profile,email
volumes:
- ./var:/srv/var
labels:
# ========== Traefik ==========
- "traefik.enable=true"
# HTTPS
- "traefik.http.routers.remark42.rule=Host(`remark.xiteng.site`)"
- "traefik.http.routers.remark42.entrypoints=websecure"
- "traefik.http.routers.remark42.tls=true"
- "traefik.http.routers.remark42.tls.certresolver=cfresolver"
- "traefik.http.services.remark42.loadbalancer.server.port=8080"
# HTTP (Cloudflare Tunnel)
- "traefik.http.routers.remark42-http.rule=Host(`remark.xiteng.site`)"
- "traefik.http.routers.remark42-http.service=remark42"
- "traefik.http.routers.remark42-http.entrypoints=web"
- "xiteng.site.cache.remark42.enabled=true"
- "xiteng.site.cache.remark42.routers=remark42,remark42-http"
- "xiteng.site.cache.remark42.paths=/assets/,/static/,/js/,/css/"
- "xiteng.site.cache.remark42.edge-ttl=604800"
- "xiteng.site.cache.remark42.stale-while-revalidate=86400"
- "xiteng.site.component.remark42.enabled=true"
- "xiteng.site.component.remark42.name=Remark42"
- "xiteng.site.component.remark42.description=可嵌入页面的评论服务;匿名发表评论已关闭。"
- "xiteng.site.component.remark42.section=services"
- "xiteng.site.component.remark42.category=内容与互动"
- "xiteng.site.component.remark42.endpoint=remark.xiteng.site"
- "xiteng.site.component.remark42.access=sso"
- "xiteng.site.component.remark42.access-label=发表评论需 Authentik"
- "xiteng.site.component.remark42.icon=R42"
- "xiteng.site.component.remark42.accent=green"
- "xiteng.site.component.remark42.order=320"
- "xiteng.site.component.remark42.monitor.enabled=true"
- "xiteng.site.component.remark42.monitor.url=http://remark42:8080/ping"
networks:
- homelab_net
networks:
homelab_net:
external: true
+41 -20
View File
@@ -1,6 +1,6 @@
services: services:
seaweedfs: seaweedfs:
image: chrislusf/seaweedfs:latest image: chrislusf/seaweedfs:4.41
container_name: seaweedfs container_name: seaweedfs
restart: unless-stopped restart: unless-stopped
command: "server -s3 -s3.port=8333 -master.port=9333 -volume.port=8080 -filer -filer.port=8888 -dir=/data -s3.domainName=file.xiteng.site" command: "server -s3 -s3.port=8333 -master.port=9333 -volume.port=8080 -filer -filer.port=8888 -dir=/data -s3.domainName=file.xiteng.site"
@@ -22,6 +22,7 @@ services:
- "traefik.http.routers.minio.entrypoints=websecure" - "traefik.http.routers.minio.entrypoints=websecure"
- "traefik.http.routers.minio.tls=true" - "traefik.http.routers.minio.tls=true"
- "traefik.http.routers.minio.tls.certresolver=cfresolver" - "traefik.http.routers.minio.tls.certresolver=cfresolver"
- "traefik.http.routers.minio.service=minio"
- "traefik.http.routers.minio-http.rule=Host(`file.xiteng.site`)" - "traefik.http.routers.minio-http.rule=Host(`file.xiteng.site`)"
- "traefik.http.routers.minio-http.service=minio" - "traefik.http.routers.minio-http.service=minio"
- "traefik.http.routers.minio-http.entrypoints=web" - "traefik.http.routers.minio-http.entrypoints=web"
@@ -33,25 +34,45 @@ services:
- "traefik.http.routers.minio.middlewares=minio-scheme,minio-auth" - "traefik.http.routers.minio.middlewares=minio-scheme,minio-auth"
- "traefik.http.routers.minio-http.middlewares=minio-scheme,minio-auth" - "traefik.http.routers.minio-http.middlewares=minio-scheme,minio-auth"
# --- Traefik: S3 API (8333) --- # --- Traefik: S3 API (8333) ---
- "traefik.http.routers.minio-api.rule=Host(`minio-api.xiteng.site`)" - "traefik.http.routers.s3.rule=Host(`s3.xiteng.site`)"
- "traefik.http.services.minio-api.loadbalancer.server.port=8333" - "traefik.http.services.s3.loadbalancer.server.port=8333"
- "traefik.http.routers.minio-api.entrypoints=websecure" - "traefik.http.routers.s3.entrypoints=websecure"
- "traefik.http.routers.minio-api.tls=true" - "traefik.http.routers.s3.tls=true"
- "traefik.http.routers.minio-api.tls.certresolver=cfresolver" - "traefik.http.routers.s3.tls.certresolver=cfresolver"
- "traefik.http.routers.minio-api-http.rule=Host(`minio-api.xiteng.site`)" - "traefik.http.routers.s3-http.rule=Host(`s3.xiteng.site`)"
- "traefik.http.routers.minio-api-http.service=minio-api" - "traefik.http.routers.s3-http.service=s3"
- "traefik.http.routers.minio-api-http.entrypoints=web" - "traefik.http.routers.s3-http.entrypoints=web"
# --- Homepage --- - "xiteng.site.cache.seaweedfs-minio.enabled=true"
- "homepage.group=我的服务" - "xiteng.site.cache.seaweedfs-minio.routers=minio,minio-http"
- "homepage.name=SeaweedFS" - "xiteng.site.cache.seaweedfs-minio.paths=/assets/,/static/,/ui/"
- "homepage.icon=/icons/seaweedfs-local.png" - "xiteng.site.cache.seaweedfs-minio.edge-ttl=604800"
- "homepage.href=https://file.xiteng.site" - "xiteng.site.cache.seaweedfs-minio.stale-while-revalidate=86400"
- "homepage.description=对象存储" # --- Xiteng Site dynamic catalog: one container, two public components ---
# --- AutoKuma --- - "xiteng.site.component.seaweedfs.enabled=true"
- "kuma.minio.http.name=SeaweedFS" - "xiteng.site.component.seaweedfs.name=SeaweedFS"
- "kuma.minio.http.url=http://seaweedfs:8888" - "xiteng.site.component.seaweedfs.description=分布式文件、Filer Web 与对象存储核心。"
- "kuma.minio.http.interval=60" - "xiteng.site.component.seaweedfs.section=services"
- "kuma.minio.http.max_retries=3" - "xiteng.site.component.seaweedfs.category=存储"
- "xiteng.site.component.seaweedfs.url=https://file.xiteng.site"
- "xiteng.site.component.seaweedfs.access=sso"
- "xiteng.site.component.seaweedfs.access-label=需要 Authentik"
- "xiteng.site.component.seaweedfs.icon=SW"
- "xiteng.site.component.seaweedfs.accent=green"
- "xiteng.site.component.seaweedfs.order=500"
- "xiteng.site.component.seaweedfs.monitor.enabled=true"
- "xiteng.site.component.seaweedfs.monitor.url=http://seaweedfs:8888"
- "xiteng.site.component.s3-api.enabled=true"
- "xiteng.site.component.s3-api.name=SeaweedFS S3 API"
- "xiteng.site.component.s3-api.description=S3 兼容对象存储协议入口。"
- "xiteng.site.component.s3-api.section=services"
- "xiteng.site.component.s3-api.category=存储"
- "xiteng.site.component.s3-api.url=https://s3.xiteng.site"
- "xiteng.site.component.s3-api.endpoint=s3.xiteng.site:443"
- "xiteng.site.component.s3-api.access=access-key"
- "xiteng.site.component.s3-api.access-label=需要 Access Key"
- "xiteng.site.component.s3-api.icon=S3"
- "xiteng.site.component.s3-api.accent=blue"
- "xiteng.site.component.s3-api.order=510"
networks: networks:
- homelab_net - homelab_net

Some files were not shown because too many files have changed in this diff Show More