feat: rebuild xiteng.site homelab platform
@@ -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/
|
||||||
@@ -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`.
|
||||||
@@ -7,17 +7,21 @@
|
|||||||
```
|
```
|
||||||
公网用户
|
公网用户
|
||||||
│
|
│
|
||||||
├─ Web (443) ──→ Cloudflare Tunnel ──→ cloudflared (Docker, HTTP/2)
|
├─ Web (443) ──→ Cloudflare Tunnel ──→ Traefik
|
||||||
│ │
|
│ │
|
||||||
└─ SSH (22) ──→ 阿里云 VPS (frps) ──→ frpc (Docker) ──→ Gitea
|
│ ┌─────────────────┼─────────────────┐
|
||||||
|
│ ▼ ▼ ▼
|
||||||
|
│ xiteng.site OAuth2/OIDC ForwardAuth
|
||||||
|
│ 唯一公开目录 应用层认证 入口层认证
|
||||||
|
│ │
|
||||||
|
│ ├─→ Edge Cache Controller ──→ Traefik + Cloudflare Cache Rule
|
||||||
|
│ ▼
|
||||||
|
│ Site Registry ──→ Docker API
|
||||||
|
│ │ (Label + 容器状态)
|
||||||
|
│ ├─→ HTTP 探测器
|
||||||
|
│ └─→ SQLite (生命周期 + 历史 + 可用率)
|
||||||
│
|
│
|
||||||
Traefik v3.7.1 (反向代理)
|
└─ SSH (22) ──→ 阿里云 VPS (frps) ──→ frpc ──→ Gitea
|
||||||
│
|
|
||||||
┌───────────────┼───────────────────┐
|
|
||||||
▼ ▼ ▼
|
|
||||||
(有 Authentik SSO) (ForwardAuth) (免认证)
|
|
||||||
Gitea / HedgeDoc SeaweedFS / Gitea SSH
|
|
||||||
Homepage / OpenWebUI Uptime Kuma
|
|
||||||
```
|
```
|
||||||
|
|
||||||
| 入口 | 域名 | 传输 | 延迟 |
|
| 入口 | 域名 | 传输 | 延迟 |
|
||||||
@@ -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 SDK;Backend 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 SQLite,gitignore
|
||||||
|
│ └── 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 SQLite,gitignore
|
||||||
|
├── homelab-emergency # Authentik 与 Vault 本机恢复入口
|
||||||
|
├── chat/
|
||||||
|
│ ├── app/ # Next.js 页面与流式聊天 API
|
||||||
|
│ ├── components/ # assistant-ui 线程与页面壳层
|
||||||
|
│ ├── data/ # 用户聊天历史 SQLite,gitignore
|
||||||
|
│ ├── 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 中安装需要的模型。
|
||||||
|
|
||||||
## 所需外部资源
|
## 所需外部资源
|
||||||
|
|
||||||
| 资源 | 用途 |
|
| 资源 | 用途 |
|
||||||
|
|||||||
@@ -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.
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
@@ -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
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
|
]
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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.
|
||||||
@@ -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);
|
||||||
@@ -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});
|
||||||
|
}
|
||||||
@@ -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});
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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)}"
|
||||||
|
)
|
||||||
@@ -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:
|
||||||
|
|||||||
@@ -0,0 +1,5 @@
|
|||||||
|
node_modules
|
||||||
|
.git
|
||||||
|
*.md
|
||||||
|
start-local-ai.bat
|
||||||
|
/tmp
|
||||||
@@ -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"]
|
||||||
@@ -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
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
node_modules
|
||||||
|
dist
|
||||||
|
.next
|
||||||
|
package-lock.json
|
||||||
|
npm-debug.log
|
||||||
|
data
|
||||||
|
run
|
||||||
@@ -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"]
|
||||||
@@ -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});
|
||||||
@@ -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=="],
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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});
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
});
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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}
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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("");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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()}`;
|
||||||
|
}
|
||||||
@@ -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[];
|
||||||
|
};
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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({});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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} : {})
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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}]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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)));
|
||||||
|
}
|
||||||
@@ -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));
|
||||||
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
export type ChatProfile = {
|
||||||
|
username: string;
|
||||||
|
name: string;
|
||||||
|
email: string;
|
||||||
|
};
|
||||||
@@ -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
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -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};
|
||||||
|
};
|
||||||
@@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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};
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
@@ -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 |
|
After Width: | Height: | Size: 14 KiB |
|
After Width: | Height: | Size: 14 KiB |
|
After Width: | Height: | Size: 80 KiB |
|
After Width: | Height: | Size: 80 KiB |
@@ -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"}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
})));
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -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) => ({"&": "&", "<": "<", ">": ">", '"': """, "'": "'"})[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();
|
||||||
|
});
|
||||||
@@ -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>
|
||||||
@@ -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}`);
|
||||||
@@ -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; }
|
||||||
|
}
|
||||||
@@ -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"]
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
services:
|
||||||
|
comfyui:
|
||||||
|
command: ["--enable-manager"]
|
||||||
|
deploy:
|
||||||
|
resources:
|
||||||
|
reservations:
|
||||||
|
devices:
|
||||||
|
- driver: nvidia
|
||||||
|
count: all
|
||||||
|
capabilities: [gpu]
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -2,6 +2,4 @@ networks:
|
|||||||
homelab_net:
|
homelab_net:
|
||||||
driver: bridge
|
driver: bridge
|
||||||
|
|
||||||
services:
|
services: {}
|
||||||
# 这里只定义网络和基础服务,具体服务在各自子目录
|
|
||||||
# 可扩展如 traefik/nginx-proxy-manager 等
|
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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 STATUS<br>
|
||||||
|
gitea Up 7 days<br>
|
||||||
|
hedgedoc Up 7 days<br>
|
||||||
|
seaweedfs Up 7 days<br>
|
||||||
|
uptime-kuma Up 7 days<br>
|
||||||
|
homepage Up 7 days<br>
|
||||||
|
authentik Up 7 days<br>
|
||||||
|
deck Up 5 minutes<br>
|
||||||
|
cloudflared 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 Provider,ForwardAuth 代理保护。</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> ·
|
||||||
|
<a href="https://notes.xiteng.site">notes</a> ·
|
||||||
|
<a href="https://status.xiteng.site">status</a> ·
|
||||||
|
<a href="https://xiteng.site">home</a> ·
|
||||||
|
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>
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
dynamic/*
|
||||||
|
!dynamic/.gitkeep
|
||||||
|
state/*
|
||||||
|
!state/.gitkeep
|
||||||
|
secrets/*
|
||||||
|
!secrets/.gitkeep
|
||||||
@@ -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 seconds–365 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 3xx–5xx 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.
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -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
|
||||||
@@ -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."
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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/);
|
||||||
|
});
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
|
||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
<a class="item" href="https://xiteng.site/?focus=gitea#services" title="返回 xiteng.site">← xiteng.site</a>
|
||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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"> <span class="ui-name"></span></span> <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">×</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">×</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') %>
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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
|
|
||||||
@@ -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
|
||||||
@@ -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
|
|
||||||
@@ -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:
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||