Compare commits

..
Author SHA1 Message Date
liooil 4f87ae2809 fix(chat): preserve settings context 2026-08-13 20:46:22 +08:00
xiteng abc090ac80 feat(chat): simplify model settings 2026-08-13 20:12:59 +08:00
liooil ee61b8cdb9 fix(auth): align service SSO routing 2026-08-13 15:55:34 +08:00
liooil 8991f78f9f feat(chat): add local-first conversation workspace 2026-08-13 15:55:15 +08:00
liooil d1d50d722c feat(chat): add edge cache labels for chat static assets 2026-08-12 18:22:20 +08:00
liooil 5b84988789 feat: rebuild xiteng.site homelab platform 2026-08-12 09:48:25 +08:00
liooil 557b0eca33 feat: add OpenWebUI AI chat service (ai.xiteng.site)
- New service: OpenWebUI v0.9.5 with Authentik OIDC auth
- compose.yml with Traefik, Homepage, AutoKuma labels
- Network fix: HF_ENDPOINT mirror + custom DNS for China
- Authentik grant_types fix documented in README
- .gitignore: openwebui/data/ and openwebui/.env
2026-06-01 08:42:58 +08:00
liooil 8c7eddbaee feat: SeaweedFS 替代 MinIO,Authentik ForwardAuth,多服务升级
新增:
- SeaweedFS v4.28 替代 MinIO (域名 file.xiteng.site)
- Authentik Outpost (Uptime Kuma + SeaweedFS 独立实例)
- ForwardAuth 为 Uptime Kuma 和 SeaweedFS 提供认证
- gpu-exporter.py 监控脚本

升级:
- Traefik v3.6 → v3.7
- Authentik 固定版本 2026.5.0 + trusted proxy CIDR
- HedgeDoc PG 11 → 16,添加 OIDC 认证
- Homepage 添加 OIDC 认证

安全:
- Outpost token 迁移至 .env 引用(不再硬编码)
- .gitignore 扩展覆盖数据/缓存/旧 MinIO
- security.toml 清理注释掉的旧密钥

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

After

Width:  |  Height:  |  Size: 1.1 KiB

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

After

Width:  |  Height:  |  Size: 569 B

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

After

Width:  |  Height:  |  Size: 912 B

+102
View File
@@ -0,0 +1,102 @@
version: 1
metadata:
name: Xiteng - Brand and login flow
labels:
blueprints.goauthentik.io/instantiate: "true"
entries:
- model: authentik_brands.brand
state: present
identifiers:
domain: authentik-default
default: true
attrs:
branding_title: Xiteng
# Versioned repository assets live in Authentik's built-in static tree.
branding_logo: /static/dist/assets/xiteng/xiteng-wordmark-v1.svg
branding_favicon: /static/dist/assets/xiteng/xiteng-favicon-v1.svg
branding_default_flow_background: /static/dist/assets/xiteng/xiteng-background-v1.svg
attributes:
settings:
locale: zh-hans
branding_custom_css: |
:root {
--ak-font-family-sans-serif: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
--ak-font-family-heading: var(--ak-font-family-sans-serif);
--ak-global--background-color: #f7efe0;
--ak-c-login--MaxWidth: 31rem;
--ak-c-login__content-BoxShadow: none;
--ak-c-login__main--BackgroundColor: #fffaf0;
--ak-c-login__main--Color: #1c1712;
--ak-c-login__main--BoxShadow: 8px 8px 0 #1c1712;
--ak-c-login__footer--Color: #675f55;
--pf-global--primary-color--100: #d83b21;
--pf-global--primary-color--200: #a92a18;
--pf-global--Color--100: #1c1712;
--pf-global--Color--200: #675f55;
--pf-global--BorderColor--100: #1c1712;
--pf-global--BorderRadius--sm: 6px;
--pf-c-login__main--BackgroundColor: #fffaf0;
--pf-c-card--BackgroundColor: #fffaf0;
--pf-c-button--m-primary--BackgroundColor: #d83b21;
--pf-c-button--m-primary--hover--BackgroundColor: #bc321d;
--pf-c-button--m-primary--active--BackgroundColor: #a92a18;
--pf-c-button--m-secondary--Color: #1c1712;
--pf-c-button--m-secondary--BorderColor: #1c1712;
}
ak-flow-executor::part(locale-select) {
display: none;
}
.pf-c-login__main,
.pf-c-card {
border: 2px solid #1c1712;
border-radius: 10px;
}
.pf-c-button.pf-m-primary {
border: 2px solid #1c1712;
border-radius: 6px;
box-shadow: 3px 3px 0 #1c1712;
font-weight: 800;
}
.pf-c-button.pf-m-primary:hover {
box-shadow: 1px 1px 0 #1c1712;
transform: translate(2px, 2px);
}
.pf-c-form-control {
border: 2px solid #1c1712;
border-radius: 6px;
}
@media (max-width: 576px) {
:root {
--ak-c-login--MaxWidth: calc(100vw - 2rem);
--ak-c-login__main--BoxShadow: 5px 5px 0 #1c1712;
}
}
- model: authentik_core.application
state: present
identifiers:
slug: xiteng-portal
attrs:
name: Xiteng Portal
meta_launch_url: https://xiteng.site/?focus=authentik#infrastructure
open_in_new_tab: false
meta_description: 返回 xiteng.site 服务目录
meta_publisher: Xiteng
meta_icon: https://xiteng.site/favicon.svg
- model: authentik_flows.flow
state: present
identifiers:
slug: default-authentication-flow
attrs:
name: Xiteng 登录
title: 欢迎回来
designation: authentication
authentication: none
layout: stacked
+39
View File
@@ -0,0 +1,39 @@
version: 1
# Managed ForwardAuth application and outpost assignment for chat.xiteng.site.
metadata:
name: Xiteng Chat ForwardAuth
labels:
blueprints.goauthentik.io/instantiate: "true"
entries:
- id: xiteng-chat-provider
model: authentik_providers_proxy.proxyprovider
state: present
identifiers:
name: Xiteng Chat
attrs:
authorization_flow: !Find [authentik_flows.flow, [slug, default-provider-authorization-implicit-consent]]
invalidation_flow: !Find [authentik_flows.flow, [slug, default-provider-invalidation-flow]]
external_host: https://chat.xiteng.site
mode: forward_single
- model: authentik_core.application
state: present
identifiers:
slug: xiteng-chat
attrs:
name: Xiteng Chat
provider: !KeyOf xiteng-chat-provider
meta_launch_url: https://chat.xiteng.site
open_in_new_tab: true
- model: authentik_outposts.outpost
state: present
identifiers:
name: uptime-kuma-outpost
attrs:
providers:
- !Find [authentik_core.provider, [name, Xiteng Portal Admin]]
- !Find [authentik_core.provider, [name, comfyui-proxy]]
- !Find [authentik_core.provider, [name, invokeai-proxy]]
- !Find [authentik_core.provider, [name, code-server-proxy]]
- !KeyOf xiteng-chat-provider
@@ -0,0 +1,35 @@
version: 1
metadata:
name: Xiteng - Passkey passwordless login
entries:
- model: authentik_stages_identification.identificationstage
state: present
identifiers:
name: default-authentication-identification
attrs:
user_fields:
- email
- username
password_stage: null
captcha_stage: null
webauthn_stage: !Find [authentik_stages_authenticator_validate.authenticatorvalidatestage, [name, default-authentication-mfa-validation]]
case_insensitive_matching: true
show_matched_user: true
pretend_user_exists: true
enable_remember_me: false
enrollment_flow: null
recovery_flow: null
passwordless_flow: null
show_source_labels: false
# Authentik 2026.5 ships the password/MFA skip policies in its default
# authentication blueprint. Remove the temporary local copies if present.
- model: authentik_policies_expression.expressionpolicy
state: absent
identifiers:
name: xiteng-authentication-password-optional
- model: authentik_policies_expression.expressionpolicy
state: absent
identifiers:
name: xiteng-authentication-mfa-after-passkey
+98
View File
@@ -0,0 +1,98 @@
version: 1
metadata:
name: Xiteng password recovery
labels:
blueprints.goauthentik.io/instantiate: "true"
entries:
- id: recovery-flow
model: authentik_flows.flow
state: present
identifiers:
slug: xiteng-recovery
attrs:
name: Xiteng 密码恢复
title: 设置新密码
designation: recovery
authentication: require_unauthenticated
- id: password
model: authentik_stages_prompt.prompt
state: present
identifiers:
name: xiteng-recovery-password
attrs:
field_key: password
label: 新密码
type: password
required: true
order: 0
placeholder: 新密码
placeholder_expression: false
- id: password-repeat
model: authentik_stages_prompt.prompt
state: present
identifiers:
name: xiteng-recovery-password-repeat
attrs:
field_key: password_repeat
label: 再次输入新密码
type: password
required: true
order: 1
placeholder: 再次输入新密码
placeholder_expression: false
- id: password-stage
model: authentik_stages_prompt.promptstage
state: present
identifiers:
name: Xiteng 设置新密码
attrs:
fields:
- !KeyOf password
- !KeyOf password-repeat
validation_policies: []
- id: user-write
model: authentik_stages_user_write.userwritestage
state: present
identifiers:
name: xiteng-recovery-user-write
attrs:
user_creation_mode: never_create
- id: user-login
model: authentik_stages_user_login.userloginstage
state: present
identifiers:
name: xiteng-recovery-user-login
- model: authentik_flows.flowstagebinding
state: present
identifiers:
target: !KeyOf recovery-flow
stage: !KeyOf password-stage
order: 10
- model: authentik_flows.flowstagebinding
state: present
identifiers:
target: !KeyOf recovery-flow
stage: !KeyOf user-write
order: 20
- model: authentik_flows.flowstagebinding
state: present
identifiers:
target: !KeyOf recovery-flow
stage: !KeyOf user-login
order: 30
- model: authentik_brands.brand
state: present
identifiers:
domain: authentik-default
default: true
attrs:
flow_recovery: !KeyOf recovery-flow
+66
View File
@@ -0,0 +1,66 @@
from pathlib import Path
from authentik.core.models import Application, Group, Token, TokenIntents, User
from authentik.policies.models import PolicyBinding
ADMIN_USERNAME = "liooil"
FAMILY_GROUP = "liuhome"
TOKEN_IDENTIFIER = "xiteng-portal-admin"
TOKEN_PATH = Path("/run/secrets/portal_api_token")
MANAGED_APPLICATIONS = [
"xiteng-portal-admin",
"xiteng-portal",
"xiteng-chat",
"code-server",
"comfyui",
"invokeai",
"gitea",
"hedgedoc",
"hedgedoc2",
"minio",
"seaweedfs",
"remark42",
]
liooil = User.objects.get(username=ADMIN_USERNAME)
admin_group = Group.objects.get(name="authentik Admins")
admin_group.users.add(liooil)
User.objects.filter(username="akadmin").delete()
liuhome, _ = Group.objects.get_or_create(
name=FAMILY_GROUP,
defaults={"is_superuser": False},
)
if liuhome.is_superuser:
liuhome.is_superuser = False
liuhome.save(update_fields=["is_superuser"])
for username in ["liooil", "ziyue"]:
liuhome.users.add(User.objects.get(username=username))
for application in Application.objects.filter(slug__in=MANAGED_APPLICATIONS):
PolicyBinding.objects.filter(target=application).delete()
PolicyBinding.objects.create(
target=application,
group=liuhome,
order=0,
enabled=True,
negate=False,
failure_result=False,
)
Token.objects.filter(identifier=TOKEN_IDENTIFIER).delete()
token = Token.objects.create(
identifier=TOKEN_IDENTIFIER,
intent=TokenIntents.INTENT_API,
user=liooil,
description="Xiteng Portal Authentik administration",
expiring=False,
)
TOKEN_PATH.write_text(token.key, encoding="utf-8")
TOKEN_PATH.chmod(0o600)
print(
f"Identity bootstrap complete: administrator={ADMIN_USERNAME}, "
f"group={FAMILY_GROUP}, applications={len(MANAGED_APPLICATIONS)}"
)
+158
View File
@@ -0,0 +1,158 @@
services:
authentik:
image: ghcr.io/goauthentik/server:2026.5.6
container_name: authentik
restart: unless-stopped
command: server
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
AUTHENTIK_SECURITY__TRUSTED_PROXY_CIDR: 172.18.0.0/16
volumes:
- ./media:/media
- ./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:
# --- Traefik 路由 ---
- "traefik.enable=true"
- "traefik.http.routers.authentik.rule=Host(`auth.xiteng.site`)"
- "traefik.http.services.authentik.loadbalancer.server.port=9000"
- "traefik.http.routers.authentik.entrypoints=websecure"
- "traefik.http.routers.authentik.tls=true"
- "traefik.http.routers.authentik.tls.certresolver=cfresolver"
# 强制 X-Forwarded-Proto 为 httpsTunnel 走 HTTP:80 入口,Traefik 不知道外层是 HTTPS
- "traefik.http.middlewares.force-https-headers.headers.customrequestheaders.X-Forwarded-Proto=https"
- "traefik.http.routers.authentik.middlewares=force-https-headers"
- "traefik.http.routers.authentik-http.middlewares=force-https-headers"
# --- HTTP 入口 ---
- "traefik.http.routers.authentik-http.rule=Host(`auth.xiteng.site`)"
- "traefik.http.routers.authentik-http.service=authentik"
- "traefik.http.routers.authentik-http.entrypoints=web"
# Label-managed static paths. The shared edge-cache controller creates
# higher-priority Traefik routes and the matching Cloudflare Cache Rule.
- "xiteng.site.cache.frontend.enabled=true"
- "xiteng.site.cache.frontend.routers=authentik,authentik-http"
- "xiteng.site.cache.frontend.paths=/static/dist/"
- "xiteng.site.cache.frontend.edge-ttl=604800"
- "xiteng.site.cache.frontend.stale-while-revalidate=86400"
# --- Xiteng Site dynamic catalog ---
- "xiteng.site.component.authentik.enabled=true"
- "xiteng.site.component.authentik.name=Authentik"
- "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:
- homelab_net
postgres:
image: postgres:16-alpine@sha256:57c72fd2a128e416c7fcc499958864df5301e940bca0a56f58fddf30ffc07777
container_name: authentik-db
restart: unless-stopped
environment:
POSTGRES_USER: authentik
POSTGRES_PASSWORD: ${AUTHENTIK_DB_PASSWORD}
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:
- ./pgdata:/var/lib/postgresql/data
networks:
- homelab_net
redis:
image: redis:alpine@sha256:978f0e01593e65eed801f2402944efcd936d43b5027e4908a7897baf88ed6241
container_name: authentik-redis
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:
- ./redis:/data
networks:
- homelab_net
networks:
homelab_net:
external: true
View File
+5
View File
@@ -0,0 +1,5 @@
node_modules
.git
*.md
start-local-ai.bat
/tmp
+43
View File
@@ -0,0 +1,43 @@
# CAT-BODHI Sprite Segmentation API
# Node.js + Python + PyTorch in one container
#
# Build (CPU, default):
# docker build -t cat-bodhi .
# Build (GPU, requires nvidia-container-toolkit):
# docker build --build-arg TORCH_INDEX=https://download.pytorch.org/whl/cu126 -t cat-bodhi .
FROM python:3.11-slim
ARG NODE_MAJOR=22
ARG TORCH_INDEX=https://download.pytorch.org/whl/cpu
ARG PIP_MIRROR=https://pypi.tuna.tsinghua.edu.cn/simple
# 中科大 Debian 镜像源
RUN sed -i 's|http://deb.debian.org/debian|https://mirrors.ustc.edu.cn/debian|g' /etc/apt/sources.list.d/debian.sources \
&& sed -i 's|http://deb.debian.org/debian-security|https://mirrors.ustc.edu.cn/debian-security|g' /etc/apt/sources.list.d/debian.sources
# 安装 Node.js LTS
RUN apt-get update && apt-get install -y --no-install-recommends \
ca-certificates curl gnupg \
&& mkdir -p /etc/apt/keyrings \
&& curl -fsSL https://deb.nodesource.com/gpgkey/nodesource-repo.gpg.key | gpg --dearmor -o /etc/apt/keyrings/nodesource.gpg \
&& echo "deb [signed-by=/etc/apt/keyrings/nodesource.gpg] https://deb.nodesource.com/node_${NODE_MAJOR}.x nodistro main" > /etc/apt/sources.list.d/nodesource.list \
&& apt-get update && apt-get install -y --no-install-recommends nodejs \
&& rm -rf /var/lib/apt/lists/*
# 安装 PyTorch(从官方 index+ 其他 Python 包(从清华镜像)
RUN pip install --no-cache-dir \
--index-url ${TORCH_INDEX} \
--extra-index-url ${PIP_MIRROR} \
torch torchvision \
opencv-python-headless \
numpy \
Pillow
WORKDIR /app
COPY . .
RUN mkdir -p assets/ai/cats assets/ai/beads
EXPOSE 8080
ENV PORT=8080
CMD ["node", "server.mjs"]
+71
View File
@@ -0,0 +1,71 @@
services:
cat-bodhi:
build:
context: .
# GPU 加速:取消下面注释(需先安装 nvidia-container-toolkit
# args:
# TORCH_INDEX: https://download.pytorch.org/whl/cu126
container_name: cat-bodhi
restart: unless-stopped
env_file:
- .env
environment:
- PORT=8080
- SPRITE_SEG_ROOT=/opt/sprite_alpha_seg_pytorch
- SPRITE_SEG_PYTHON=/usr/local/bin/python
- SPRITE_SEG_CHECKPOINT=/opt/sprite_alpha_seg_pytorch/checkpoints/unet_sprite_ft.pt
- SPRITE_SEG_OUT_DIR=/opt/sprite_alpha_seg_pytorch/outputs/cat_match
volumes:
# 模型文件目录(需要手动放入 infer_spritesheet_hybrid.py + checkpoints/
- ./sprite_alpha_seg_pytorch:/opt/sprite_alpha_seg_pytorch:rw
# AI 生成图片持久化(可选,不挂载则容器重启后丢失)
- ./data/assets-ai:/app/assets/ai:rw
networks:
- homelab_net
# GPU 支持:取消下面注释(需先安装 nvidia-container-toolkit
# deploy:
# resources:
# reservations:
# devices:
# - driver: nvidia
# count: 1
# capabilities: [gpu]
labels:
# ========== Traefik ==========
- "traefik.enable=true"
# HTTPS router (cert management)
- "traefik.http.routers.cat-bodhi.rule=Host(`cat-bodhi.xiteng.site`)"
- "traefik.http.routers.cat-bodhi.entrypoints=websecure"
- "traefik.http.routers.cat-bodhi.tls=true"
- "traefik.http.routers.cat-bodhi.tls.certresolver=cfresolver"
- "traefik.http.services.cat-bodhi.loadbalancer.server.port=8080"
# HTTP router (Cloudflare Tunnel ingress)
- "traefik.http.routers.cat-bodhi-http.rule=Host(`cat-bodhi.xiteng.site`)"
- "traefik.http.routers.cat-bodhi-http.service=cat-bodhi"
- "traefik.http.routers.cat-bodhi-http.entrypoints=web"
- "xiteng.site.cache.cat-bodhi.enabled=true"
- "xiteng.site.cache.cat-bodhi.routers=cat-bodhi,cat-bodhi-http"
- "xiteng.site.cache.cat-bodhi.paths=/assets/,/data/assets-ai/"
- "xiteng.site.cache.cat-bodhi.edge-ttl=604800"
- "xiteng.site.cache.cat-bodhi.stale-while-revalidate=86400"
- "xiteng.site.component.cat-bodhi.enabled=true"
- "xiteng.site.component.cat-bodhi.name=猫猫盘珠日记"
- "xiteng.site.component.cat-bodhi.description=文玩手串与猫咪主题游戏,以及 Sprite 抠图导入工具。"
- "xiteng.site.component.cat-bodhi.section=services"
- "xiteng.site.component.cat-bodhi.category=作品与实验"
- "xiteng.site.component.cat-bodhi.url=https://cat-bodhi.xiteng.site"
- "xiteng.site.component.cat-bodhi.access=public"
- "xiteng.site.component.cat-bodhi.access-label=无需登录"
- "xiteng.site.component.cat-bodhi.icon=猫"
- "xiteng.site.component.cat-bodhi.accent=yellow"
- "xiteng.site.component.cat-bodhi.order=300"
- "xiteng.site.component.cat-bodhi.navigation=new-tab"
- "xiteng.site.component.cat-bodhi.portal-link=embedded"
networks:
homelab_net:
external: true
+7
View File
@@ -0,0 +1,7 @@
node_modules
dist
.next
package-lock.json
npm-debug.log
data
run
+25
View File
@@ -0,0 +1,25 @@
FROM oven/bun:1.3.5-alpine AS dependencies
WORKDIR /app
COPY package.json bun.lock ./
RUN bun install --frozen-lockfile --production
FROM oven/bun:1.3.5-alpine AS builder
WORKDIR /app
COPY package.json bun.lock ./
RUN bun install --frozen-lockfile
COPY . .
RUN bun run build
FROM oven/bun:1.3.5-alpine AS runner
WORKDIR /app
ENV NODE_ENV=production
ENV PORT=3000
ENV STATIC_ROOT=/app/dist
RUN apk add --no-cache su-exec && addgroup -S app && adduser -S app -G app
COPY --from=dependencies --chown=app:app /app/node_modules ./node_modules
COPY --from=builder --chown=app:app /app/dist ./dist
COPY --from=builder --chown=app:app /app/src/server.ts ./src/server.ts
COPY --from=builder --chown=app:app /app/lib ./lib
COPY --chmod=755 entrypoint.sh /entrypoint.sh
EXPOSE 3000
ENTRYPOINT ["/entrypoint.sh"]
+25
View File
@@ -0,0 +1,25 @@
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});
mkdirSync("dist/assets/mathjax/4.1.3/fonts/mathjax-newcm-font/svg/dynamic", {recursive: true});
cpSync("node_modules/mathjax/tex-svg-nofont.js", "dist/assets/mathjax/4.1.3/tex-svg-nofont.js");
cpSync("node_modules/@mathjax/mathjax-newcm-font/svg.js", "dist/assets/mathjax/4.1.3/fonts/mathjax-newcm-font/svg.js");
cpSync("node_modules/@mathjax/mathjax-newcm-font/svg/dynamic", "dist/assets/mathjax/4.1.3/fonts/mathjax-newcm-font/svg/dynamic", {recursive: true});
cpSync("node_modules/mathjax/input/tex/extensions", "dist/assets/mathjax/4.1.3/input/tex/extensions", {recursive: true});
+147
View File
@@ -0,0 +1,147 @@
{
"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",
"fflate": "0.8.2",
"marked": "15.0.12",
"node-fetch": "3.3.2",
"proxy-agent": "8.0.2",
},
"devDependencies": {
"@types/bun": "1.3.5",
"mathjax": "4.1.3",
"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=="],
"@mathjax/mathjax-newcm-font": ["@mathjax/[email protected]", "", {}, "sha512-gzAB3dFHilHX1l5x2xUqRL+1jDQt3Fyza1DkEMVXWC4E8SvsGdlgEza47HYi2WhVcgfkvf4zgUGzuhbq3Pjlew=="],
"@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=="],
"fflate": ["[email protected]", "", {}, "sha512-cPJU47OaAoCbg0pBvzsgpTPhmhqI5eJjh/JIu8tPj5q+T7iLvW/JAYUqmE7KOB4R1ZyEhzBaIQpQpardBF5z8A=="],
"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=="],
"mathjax": ["[email protected]", "", { "dependencies": { "@mathjax/mathjax-newcm-font": "^4.1.3" } }, "sha512-BN/8Pkgn7G1pIDYJqd9md+JHsE/jydSYbyOZnSdSA0WziuVO8mRxdYiWFumkVVly/8U+hm9DpIIoWuvySverzw=="],
"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=="],
}
}
+179
View File
@@ -0,0 +1,179 @@
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
ollama-host-bridge:
image: node:24-alpine
container_name: xiteng-ollama-host-bridge
restart: unless-stopped
network_mode: host
command: ["node", "/app/proxy-bridge.mjs"]
environment:
BRIDGE_MODE: host
BRIDGE_SOCKET: /run/provider-proxy/ollama.sock
UPSTREAM_HOST: 127.0.0.1
UPSTREAM_PORT: "11434"
volumes:
- ./proxy-bridge.mjs:/app/proxy-bridge.mjs:ro
- ./run:/run/provider-proxy
security_opt:
- no-new-privileges:true
ollama-provider-bridge:
image: node:24-alpine
container_name: xiteng-ollama-provider-bridge
restart: unless-stopped
command: ["node", "/app/proxy-bridge.mjs"]
environment:
BRIDGE_MODE: network
BRIDGE_SOCKET: /run/provider-proxy/ollama.sock
LISTEN_PORT: "11434"
UPSTREAM_HTTP_HOST: localhost:11434
volumes:
- ./proxy-bridge.mjs:/app/proxy-bridge.mjs:ro
- ./run:/run/provider-proxy
depends_on:
- ollama-host-bridge
security_opt:
- no-new-privileges:true
healthcheck:
test: ["CMD", "node", "-e", "fetch('http://127.0.0.1:11434/api/tags').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))"]
interval: 15s
timeout: 5s
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
PUBLIC_PROVIDER_CATALOG_FILE: /app/providers.json
volumes:
- ../ai-gateway/secrets/portal_gateway_hmac:/run/secrets/portal_gateway_hmac:ro
- ../ai-gateway/providers.json:/app/providers.json:ro
- ./data:/data
security_opt:
- no-new-privileges:true
healthcheck:
test: ["CMD", "wget", "-q", "-O", "/dev/null", "http://127.0.0.1:3000/chat/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(`xiteng.site`) && (Path(`/chat`) || PathPrefix(`/chat/`))"
- "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"
- "traefik.http.routers.xiteng-chat.priority=300"
- "traefik.http.routers.xiteng-chat-api.rule=Host(`xiteng.site`) && PathPrefix(`/chat/api/`)"
- "traefik.http.routers.xiteng-chat-api.entrypoints=websecure"
- "traefik.http.routers.xiteng-chat-api.tls=true"
- "traefik.http.routers.xiteng-chat-api.tls.certresolver=cfresolver"
- "traefik.http.routers.xiteng-chat-api.service=xiteng-chat"
- "traefik.http.routers.xiteng-chat-api.middlewares=xiteng-chat-scheme,xiteng-chat-auth"
- "traefik.http.routers.xiteng-chat-api.priority=320"
- "traefik.http.routers.xiteng-chat-public-api.rule=Host(`xiteng.site`) && Path(`/chat/api/public-config`)"
- "traefik.http.routers.xiteng-chat-public-api.entrypoints=websecure"
- "traefik.http.routers.xiteng-chat-public-api.tls=true"
- "traefik.http.routers.xiteng-chat-public-api.tls.certresolver=cfresolver"
- "traefik.http.routers.xiteng-chat-public-api.service=xiteng-chat"
- "traefik.http.routers.xiteng-chat-public-api.middlewares=xiteng-chat-scheme"
- "traefik.http.routers.xiteng-chat-public-api.priority=330"
- "traefik.http.routers.xiteng-chat-http.rule=Host(`xiteng.site`) && (Path(`/chat`) || PathPrefix(`/chat/`))"
- "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"
- "traefik.http.routers.xiteng-chat-http.priority=300"
- "traefik.http.routers.xiteng-chat-api-http.rule=Host(`xiteng.site`) && PathPrefix(`/chat/api/`)"
- "traefik.http.routers.xiteng-chat-api-http.entrypoints=web"
- "traefik.http.routers.xiteng-chat-api-http.service=xiteng-chat"
- "traefik.http.routers.xiteng-chat-api-http.middlewares=xiteng-chat-scheme,xiteng-chat-auth"
- "traefik.http.routers.xiteng-chat-api-http.priority=320"
- "traefik.http.routers.xiteng-chat-public-api-http.rule=Host(`xiteng.site`) && Path(`/chat/api/public-config`)"
- "traefik.http.routers.xiteng-chat-public-api-http.entrypoints=web"
- "traefik.http.routers.xiteng-chat-public-api-http.service=xiteng-chat"
- "traefik.http.routers.xiteng-chat-public-api-http.middlewares=xiteng-chat-scheme"
- "traefik.http.routers.xiteng-chat-public-api-http.priority=330"
- "xiteng.site.cache.chat.enabled=true"
- "xiteng.site.cache.chat.routers=xiteng-chat,xiteng-chat-http"
- "xiteng.site.cache.chat.paths=/chat/assets/,/chat/icons/"
- "xiteng.site.cache.chat.edge-ttl=604800"
- "xiteng.site.cache.chat.stale-while-revalidate=86400"
- "xiteng.site.component.chat.enabled=true"
- "xiteng.site.component.chat.name=Xiteng Chat"
- "xiteng.site.component.chat.description=本地优先的 AI 对话界面;无需登录即可使用浏览器 Provider,登录后启用个人同步与 Key Vault。"
- "xiteng.site.component.chat.section=services"
- "xiteng.site.component.chat.category=AI"
- "xiteng.site.component.chat.url=https://xiteng.site/chat/"
- "xiteng.site.component.chat.access=public"
- "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=same-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/chat/api/health"
networks:
homelab_net:
external: true
+7
View File
@@ -0,0 +1,7 @@
#!/bin/sh
set -eu
install -m 0400 -o app -g app "$KEY_VAULT_TOKEN_FILE" /tmp/key_vault_service_token
export KEY_VAULT_TOKEN_FILE=/tmp/key_vault_service_token
install -d -m 0700 -o app -g app /data
exec su-exec app bun src/server.ts
+333
View File
@@ -0,0 +1,333 @@
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";
import {Database} from "bun:sqlite";
import {createHash} from "node:crypto";
import {createMessageObject} from "./lib/message-object.ts";
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");
const legacyDatabase = new Database(databasePath, {create: true});
legacyDatabase.run(`
CREATE TABLE 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 TABLE 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, parts_json TEXT NOT NULL,
created_at TEXT NOT NULL, PRIMARY KEY (conversation_id, id), UNIQUE (conversation_id, ordinal)
);
`);
const legacyTimestamp = new Date().toISOString();
legacyDatabase.query("INSERT INTO chat_conversation VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)")
.run("legacy-conversation", "https://auth.xiteng.site", "legacy-sub", "legacy/chat", "openai", "gpt-legacy", "{}", legacyTimestamp, legacyTimestamp);
legacyDatabase.query("INSERT INTO chat_message VALUES (?, ?, ?, ?, ?, ?)")
.run("legacy-conversation", "legacy-user", 0, "user", JSON.stringify([{type: "text", text: "legacy question"}]), legacyTimestamp);
legacyDatabase.query("INSERT INTO chat_message VALUES (?, ?, ?, ?, ?, ?)")
.run("legacy-conversation", "legacy-assistant", 1, "assistant", JSON.stringify([{type: "text", text: "legacy answer"}]), legacyTimestamp);
legacyDatabase.close();
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}/chat/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}/chat${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"};
const legacyOwner = {username: "history-legacy", sub: "legacy-sub"};
const distributedOwner = {username: "distributed-owner", sub: "distributed-sub"};
const localRepositoryOwner = {username: "local-repository-owner", sub: "local-repository-sub"};
const untitledOwner = {username: "untitled-owner", sub: "untitled-sub"};
let server;
try {
server = await startServer();
const publicConfigResponse = await fetch(`${server.origin}/chat/api/public-config`);
assert.equal(publicConfigResponse.status, 200);
const publicConfig = await publicConfigResponse.json();
assert.ok(publicConfig.providers.length > 0);
assert.ok(publicConfig.providers.every((provider) => provider.connection.type === "frontend" && provider.credentials.length === 0));
const anonymousPrivateConfig = await fetch(`${server.origin}/chat/api/config`);
assert.equal(anonymousPrivateConfig.status, 401);
const migratedLegacy = await api(server.origin, legacyOwner, "/api/conversations/legacy-conversation");
assert.equal(migratedLegacy.response.status, 200);
assert.equal(migratedLegacy.payload.conversation.name, "legacy/chat");
assert.equal(migratedLegacy.payload.conversation.messages.length, 2);
assert.ok(migratedLegacy.payload.conversation.messages.every((message) => message.id.startsWith("sha256:")));
assert.equal(migratedLegacy.payload.conversation.messages[1].parentMessageId, migratedLegacy.payload.conversation.messages[0].id);
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, name: "work/chat"})
});
assert.equal(createdResult.response.status, 201);
assert.deepEqual(createdResult.payload.conversation.generationSettings, initialSettings);
assert.equal(createdResult.payload.conversation.name, "work/chat");
assert.equal(createdResult.payload.conversation.headMessageId, null);
const untitledResult = await api(server.origin, untitledOwner, "/api/conversations", {
method: "POST",
body: JSON.stringify({providerId: "openai", model: "gpt-test", generationSettings: initialSettings})
});
assert.equal(untitledResult.response.status, 201);
assert.equal(untitledResult.payload.conversation.name, "");
const untitledUser = await api(server.origin, untitledOwner, `/api/conversations/${untitledResult.payload.conversation.id}/messages`, {
method: "POST",
body: JSON.stringify({
id: "untitled-user",
expectedHeadId: null,
parentMessageId: null,
role: "user",
parts: [{type: "text", text: "这段文字不能直接成为标题"}],
origin: {type: "user"},
completion: {status: "complete"}
})
});
assert.equal(untitledUser.response.status, 201);
assert.equal(untitledUser.payload.conversation.name, "");
const conversationId = createdResult.payload.conversation.id;
const userResult = await api(server.origin, owner, `/api/conversations/${conversationId}/messages`, {
method: "POST",
body: JSON.stringify({
id: "user-1",
expectedHeadId: null,
parentMessageId: null,
role: "user",
parts: [{type: "text", text: "persistent question"}],
origin: {type: "user", clientId: "history-test"},
completion: {status: "complete"}
})
});
assert.equal(userResult.response.status, 201);
assert.equal(userResult.payload.conversation.headMessageId, "user-1");
const responseMetadata = {providerId: "openai", model: "gpt-test", durationMs: 2000, outputTokens: 40, tokensPerSecond: 20};
const assistantResult = await api(server.origin, owner, `/api/conversations/${conversationId}/messages`, {
method: "POST",
body: JSON.stringify({
id: "assistant-1",
expectedHeadId: "user-1",
parentMessageId: "user-1",
role: "assistant",
parts: [{type: "reasoning", text: "persistent reasoning"}, {type: "text", text: "persistent answer"}],
origin: {type: "model", providerId: "openai", model: "gpt-test", attemptId: "attempt-1"},
completion: {status: "complete"},
metadata: {custom: {response: responseMetadata}}
})
});
assert.equal(assistantResult.response.status, 201);
assert.equal(assistantResult.payload.conversation.messageCount, 2);
const conflict = await api(server.origin, owner, `/api/conversations/${conversationId}/messages`, {
method: "POST",
body: JSON.stringify({id: "conflict", expectedHeadId: "user-1", parentMessageId: "user-1", role: "assistant", parts: [], origin: {type: "legacy"}, completion: {status: "complete"}})
});
assert.equal(conflict.response.status, 409);
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, name: "work/chat/main"})
});
assert.equal(settingsResult.response.status, 200);
assert.equal(settingsResult.payload.conversation.name, "work/chat/main");
const branchResult = await api(server.origin, owner, "/api/conversations", {
method: "POST",
body: JSON.stringify({providerId: "openai", model: "gpt-test", generationSettings: initialSettings, name: "work/chat/branch", headMessageId: "user-1"})
});
assert.equal(branchResult.response.status, 201);
assert.equal(branchResult.payload.conversation.messageCount, 1);
assert.equal(branchResult.payload.conversation.messages[0].id, "user-1");
const namespace = createHash("sha256").update(`https://auth.xiteng.site\0${distributedOwner.sub}`).digest("hex").slice(0, 32);
const objectTimestamp = new Date().toISOString();
const distributedMessage = await createMessageObject({
parentMessageId: null,
role: "user",
parts: [{type: "text", text: "local-first message"}],
origin: {type: "user", clientId: "test-replica"},
completion: {status: "complete"},
createdAt: objectTimestamp,
completedAt: objectTimestamp
}, namespace);
const distributedRef = {
conversationId: "distributed-conversation",
expectedHeadMessageId: null,
expectedHeadVersion: 0,
expectedMetadataVersion: 0,
headMessageId: distributedMessage.id,
name: "",
providerId: "openai",
model: "gpt-test",
generationSettings: initialSettings,
createdAt: objectTimestamp,
updatedAt: objectTimestamp
};
const pushed = await api(server.origin, distributedOwner, "/api/sync/push", {
method: "POST",
body: JSON.stringify({objects: [distributedMessage], refs: [distributedRef]})
});
assert.equal(pushed.response.status, 200);
assert.equal(pushed.payload.refs[0].status, "ok");
assert.equal(pushed.payload.refs[0].ref.headMessageId, distributedMessage.id);
const fetched = await api(server.origin, distributedOwner, "/api/sync/fetch", {
method: "POST",
body: JSON.stringify({haveObjectIds: []})
});
assert.equal(fetched.response.status, 200);
assert.equal(fetched.payload.objects[0].id, distributedMessage.id);
assert.equal(fetched.payload.refs[0].id, "distributed-conversation");
assert.equal(fetched.payload.refs[0].name, "");
const fetchedWithHave = await api(server.origin, distributedOwner, "/api/sync/fetch", {
method: "POST",
body: JSON.stringify({haveObjectIds: [distributedMessage.id]})
});
assert.deepEqual(fetchedWithHave.payload.objects, []);
const alternateMessage = await createMessageObject({
parentMessageId: null,
role: "user",
parts: [{type: "text", text: "alternate root"}],
origin: {type: "user", clientId: "test-replica", sourceMessageId: distributedMessage.id},
completion: {status: "complete"},
createdAt: new Date(Date.parse(objectTimestamp) + 1000).toISOString(),
completedAt: new Date(Date.parse(objectTimestamp) + 1000).toISOString()
}, namespace);
const movedRef = await api(server.origin, distributedOwner, "/api/sync/push", {
method: "POST",
body: JSON.stringify({
objects: [alternateMessage],
refs: [{
...distributedRef,
expectedHeadMessageId: distributedMessage.id,
expectedHeadVersion: pushed.payload.refs[0].ref.headVersion,
expectedMetadataVersion: pushed.payload.refs[0].ref.metadataVersion,
headMessageId: alternateMessage.id
}]
})
});
assert.equal(movedRef.payload.refs[0].status, "ok");
const graphFetch = await api(server.origin, distributedOwner, "/api/sync/fetch", {
method: "POST",
body: JSON.stringify({haveObjectIds: []})
});
assert.deepEqual(new Set(graphFetch.payload.objects.map((message) => message.id)), new Set([distributedMessage.id, alternateMessage.id]));
const localRepositoryId = "local:12345678-1234-4234-8234-123456789abc";
const localRepositoryMessage = await createMessageObject({
parentMessageId: null,
role: "user",
parts: [{type: "text", text: "created before login"}],
origin: {type: "user", clientId: "12345678-1234-4234-8234-123456789abc"},
completion: {status: "complete"},
createdAt: objectTimestamp,
completedAt: objectTimestamp
}, localRepositoryId);
const localRepositoryPush = await api(server.origin, localRepositoryOwner, "/api/sync/push", {
method: "POST",
body: JSON.stringify({
repositoryId: localRepositoryId,
objects: [localRepositoryMessage],
refs: [{...distributedRef, conversationId: "pre-login-conversation", headMessageId: localRepositoryMessage.id}]
})
});
assert.equal(localRepositoryPush.response.status, 200);
assert.equal(localRepositoryPush.payload.refs[0].status, "ok");
const rejectedHash = await api(server.origin, distributedOwner, "/api/sync/push", {
method: "POST",
body: JSON.stringify({objects: [{...distributedMessage, id: `sha256:${"0".repeat(64)}`}], refs: []})
});
assert.equal(rejectedHash.response.status, 400);
const rejectedLease = await api(server.origin, distributedOwner, "/api/sync/push", {
method: "POST",
body: JSON.stringify({objects: [], refs: [{...distributedRef, expectedHeadVersion: 0, headMessageId: null}]})
});
assert.equal(rejectedLease.payload.refs[0].status, "conflict");
await stopServer(server.child);
server = await startServer();
const restored = await api(server.origin, owner, `/api/conversations/${conversationId}`);
assert.equal(restored.response.status, 200);
assert.equal(restored.payload.conversation.messages.length, 2);
assert.ok(restored.payload.conversation.messages.every((message) => message.id.startsWith("sha256:")));
assert.equal(restored.payload.conversation.messages[1].parentMessageId, restored.payload.conversation.messages[0].id);
assert.deepEqual(restored.payload.conversation.messages[1].metadata.custom.response, responseMetadata);
assert.deepEqual(restored.payload.conversation.generationSettings, updatedSettings);
const ownerList = await api(server.origin, owner, "/api/conversations");
assert.equal(ownerList.payload.conversations.length, 2);
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);
assert.equal((await api(server.origin, owner, `/api/conversations/${branchResult.payload.conversation.id}`)).payload.conversation.messages[0].parts[0].text, "persistent question");
console.log("Chat history API tests passed");
} finally {
if (server) await stopServer(server.child);
rmSync(temporaryDirectory, {recursive: true, force: true});
}
+23
View File
@@ -0,0 +1,23 @@
import type {ProviderDefinition, ProviderSecret} from "./provider-types";
export function createBrowserProviderFetch(provider: ProviderDefinition, secret: ProviderSecret) {
const proxy = provider.connection.proxy;
if (!proxy) return fetch;
if (proxy.type !== "relay") throw new Error(`Unsupported frontend proxy: ${proxy.type}`);
return async (input: RequestInfo | URL, init: RequestInit = {}) => {
const headers = new Headers(init.headers);
const relayHeaders: Record<string, string> = {"Content-Type": "application/json"};
if (secret.proxy?.token) relayHeaders.Authorization = `Bearer ${secret.proxy.token}`;
return fetch(proxy.url, {
method: "POST",
headers: relayHeaders,
body: JSON.stringify({
url: String(input),
method: init.method || "GET",
headers: Object.fromEntries(headers.entries()),
body: typeof init.body === "string" ? init.body : null
}),
signal: init.signal
});
};
}
+24
View File
@@ -0,0 +1,24 @@
import type {ProviderDefinition, ProviderSecret} from "./provider-types";
function inferredDiscoveryUrl(provider: ProviderDefinition, baseUrl: string) {
if (provider.discovery.type === "anthropic-models-list") return `${baseUrl.endsWith("/v1") ? baseUrl : `${baseUrl}/v1`}/models?limit=200`;
if (provider.discovery.type === "google-models-list") return `${baseUrl}/models?pageSize=200`;
return `${baseUrl}/models`;
}
export function applyBrowserProviderSettings(provider: ProviderDefinition, secret: ProviderSecret) {
const configuredBaseUrl = secret.provider?.baseUrl?.trim();
if (!configuredBaseUrl) return provider;
const url = new URL(configuredBaseUrl);
if (!["http:", "https:"].includes(url.protocol)) throw new Error("Provider Base URL 必须使用 http 或 https");
const baseUrl = configuredBaseUrl.replace(/\/+$/, "");
const originalBaseUrl = provider.connection.baseUrl.replace(/\/+$/, "");
const discoveryUrl = provider.discovery.url.startsWith(originalBaseUrl)
? inferredDiscoveryUrl(provider, baseUrl)
: provider.discovery.url;
return {
...provider,
connection: {...provider.connection, baseUrl},
discovery: {...provider.discovery, url: discoveryUrl}
};
}
+199
View File
@@ -0,0 +1,199 @@
import type {
Conversation,
ConversationRefState,
ConversationSummary,
MessageCompletion,
MessageOrigin,
RepositoryFetch,
StoredChatMessage
} from "./conversation-types";
import type {GenerationSettings} from "./generation-settings";
import {
applyRepositoryFetch,
applyRepositoryPushResults,
cacheConversation,
cacheConversationSummaries,
commitLocalMessage,
createLocalConversation,
deleteLocalConversation,
listCachedObjectIds,
listPendingConversationChanges,
loadCachedConversation,
loadCachedConversationSummaries,
moveLocalConversationHead,
queueConversationChange,
queueLocalRefUpdate,
removePendingConversationChange,
repositoryPushPayload
} from "./offline-history";
import {repositoryPushBatches} from "./repository-push-batches";
const chatBasePath = "/chat";
const chatApi = (pathname: string) => `${chatBasePath}${pathname}`;
export type MessageCommitInput = {
id: string;
expectedHeadId: string | null;
parentMessageId: string | null;
role: StoredChatMessage["role"];
parts: StoredChatMessage["parts"];
origin: MessageOrigin;
completion: MessageCompletion;
createdAt: string;
completedAt: string;
metadata?: StoredChatMessage["metadata"];
providerId?: string;
model?: string;
};
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;
}
export function listConversationHistory() {
return loadCachedConversationSummaries();
}
export async function createConversationHistory(
providerId: string,
model: string,
generationSettings: GenerationSettings,
name: string,
headMessageId: string | null = null,
messages: StoredChatMessage[] = []
) {
const timestamp = new Date().toISOString();
const conversation: Conversation = {
id: crypto.randomUUID(),
name,
headMessageId,
upstreamHeadMessageId: null,
providerId,
model,
generationSettings,
headVersion: 0,
metadataVersion: 0,
messageCount: messages.length,
createdAt: timestamp,
updatedAt: timestamp,
messages
};
return createLocalConversation(conversation);
}
export async function getConversationHistory(id: string) {
const cached = await loadCachedConversation(id);
if (!cached) throw new Error("Local conversation is unavailable; fetch may still be in progress");
return cached;
}
export async function updateConversationHistory(
id: string,
providerId: string,
model: string,
generationSettings: GenerationSettings,
name?: string
) {
const cached = await getConversationHistory(id);
const updated: Conversation = {
...cached,
providerId,
model,
generationSettings,
...(name === undefined ? {} : {name}),
updatedAt: new Date().toISOString()
};
return queueLocalRefUpdate(updated);
}
export async function commitConversationMessage(conversationId: string, input: MessageCommitInput) {
const cached = await getConversationHistory(conversationId);
if (cached.headMessageId !== input.expectedHeadId || input.parentMessageId !== input.expectedHeadId) throw new Error("Local conversation head changed");
const message: StoredChatMessage = {
id: input.id,
parentMessageId: input.parentMessageId,
role: input.role,
parts: input.parts,
origin: input.origin,
completion: input.completion,
createdAt: input.createdAt,
completedAt: input.completedAt,
...(input.metadata ? {metadata: input.metadata} : {})
};
return commitLocalMessage(conversationId, message);
}
export function moveConversationHead(conversationId: string, headMessageId: string | null) {
return moveLocalConversationHead(conversationId, headMessageId);
}
// Compatibility for queued operations produced by an older client.
export async function saveConversationHistory(id: string, providerId: string, model: string, messages: StoredChatMessage[]) {
const body = JSON.stringify({providerId, model, messages});
const payload = await conversationRequest<{conversation: Conversation}>(chatApi(`/api/conversations/${encodeURIComponent(id)}`), {method: "PUT", body});
await cacheConversation(payload.conversation);
return payload.conversation;
}
export async function deleteConversationHistory(id: string) {
await deleteLocalConversation(id);
}
export async function flushPendingConversationChanges() {
const pending = await listPendingConversationChanges();
for (const change of pending) {
const requestPath = change.requestPath || `/api/conversations/${encodeURIComponent(change.conversationId)}`;
try {
await conversationRequest<void>(chatApi(requestPath), {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;
}
throw error;
}
}
}
export async function synchronizeConversationRepository() {
await flushPendingConversationChanges();
const push = await repositoryPushPayload();
let conflicts = 0;
if (push.refs.length || push.objects.length) {
for (const batch of repositoryPushBatches(push)) {
const pushed = await conversationRequest<{
refs: Array<{conversationId: string; status: "ok" | "conflict"; ref: ConversationRefState | null}>;
}>(chatApi("/api/sync/push"), {method: "POST", body: JSON.stringify(batch)});
await applyRepositoryPushResults(pushed.refs);
conflicts += pushed.refs.filter((result) => result.status === "conflict").length;
}
}
const haveObjectIds = await listCachedObjectIds();
const fetched = await conversationRequest<RepositoryFetch>(chatApi("/api/sync/fetch"), {
method: "POST",
body: JSON.stringify({haveObjectIds})
});
await applyRepositoryFetch(fetched);
return {summaries: await loadCachedConversationSummaries(), fetchedAt: fetched.fetchedAt, conflicts};
}
export const synchronizeOfflineConversationHistory = synchronizeConversationRepository;
+18
View File
@@ -0,0 +1,18 @@
import {describe, expect, test} from "bun:test";
import {conversationHash, conversationIdFromHash} from "./conversation-hash.ts";
describe("conversation hash routing", () => {
test("round-trips a conversation id", () => {
const id = "f60dbe18-92ca-4a7d-9d5d-242d0ed4d042";
expect(conversationIdFromHash(conversationHash(id))).toBe(id);
});
test("preserves URL-sensitive ids", () => {
expect(conversationIdFromHash(conversationHash("local/id + draft"))).toBe("local/id + draft");
});
test("ignores unrelated or oversized hashes", () => {
expect(conversationIdFromHash("#services")).toBe("");
expect(conversationIdFromHash(`#conversation=${"x".repeat(121)}`)).toBe("");
});
});
+13
View File
@@ -0,0 +1,13 @@
const conversationHashKey = "conversation";
export function conversationIdFromHash(hash: string) {
const input = hash.startsWith("#") ? hash.slice(1) : hash;
const id = new URLSearchParams(input).get(conversationHashKey)?.trim() || "";
return id && id.length <= 120 ? id : "";
}
export function conversationHash(id: string) {
const parameters = new URLSearchParams();
parameters.set(conversationHashKey, id);
return `#${parameters.toString()}`;
}
+21
View File
@@ -0,0 +1,21 @@
import {describe, expect, test} from "bun:test";
import {conversationTitlePrompt, normalizeGeneratedConversationTitle} from "./conversation-title.ts";
describe("AI conversation titles", () => {
test("builds a bounded prompt from the first messages", () => {
const prompt = conversationTitlePrompt([
{role: "user", parts: [{type: "text", text: "如何优化流式 Markdown"}]},
{role: "assistant", parts: [{type: "text", text: "可以按稳定块增量渲染。"}]}
]);
expect(prompt).toContain("用户:如何优化流式 Markdown");
expect(prompt).toContain("助手:可以按稳定块增量渲染。");
});
test("removes common model formatting and unsafe path separators", () => {
expect(normalizeGeneratedConversationTitle("## 标题:\“流式 Markdown / 渲染优化\”\n说明")).toBe("流式 Markdown 渲染优化");
});
test("allows an empty result to remain untitled", () => {
expect(normalizeGeneratedConversationTitle("<think>no title</think>\n")).toBe("");
});
});
+37
View File
@@ -0,0 +1,37 @@
import type {StoredChatMessage} from "./conversation-types";
export const untitledConversationLabel = "未命名会话";
function messageText(message: StoredChatMessage) {
return message.parts
.filter((part) => part.type === "text" && typeof part.text === "string")
.map((part) => String(part.text))
.join("\n")
.trim();
}
export function conversationTitlePrompt(messages: StoredChatMessage[]) {
const transcript = messages
.filter((message) => message.role === "user" || message.role === "assistant")
.slice(0, 4)
.map((message) => `${message.role === "user" ? "用户" : "助手"}${messageText(message).slice(0, 2000)}`)
.filter((line) => !line.endsWith(""))
.join("\n\n");
return `请为下面的对话生成一个简洁、具体的中文标题。只输出标题,不要解释,不要加引号、Markdown 或“标题:”前缀。标题不超过 30 个字符,不要使用斜杠。\n\n${transcript}`;
}
export function normalizeGeneratedConversationTitle(value: string) {
let title = value
.replace(/<think>[\s\S]*?<\/think>/gi, "")
.trim()
.split(/\r?\n/)[0]
.replace(/^#{1,6}\s*/, "")
.replace(/^(?:标题|title)\s*[:]\s*/i, "")
.replace(/^[`'“”‘’\"]+|[`'“”‘’\"]+$/g, "")
.replace(/[\u0000-\u001f]/g, " ")
.replace(/\s+/g, " ")
.replaceAll("/", "")
.trim();
title = [...title].slice(0, 60).join("").trim();
return title;
}
+108
View File
@@ -0,0 +1,108 @@
import type {GenerationSettings} from "./generation-settings";
export type ResponseMetadata = {
providerId: string;
model: string;
durationMs: number;
outputTokens: number | null;
tokensPerSecond: number | null;
};
export type MessageOrigin =
| {type: "user"; clientId?: string; sourceMessageId?: string}
| {type: "manual"; clientId?: string; sourceMessageId?: string}
| {type: "model"; providerId: string; model: string; attemptId: string}
| {type: "system"; source: string}
| {type: "legacy"};
export type MessageCompletion = {
status: "complete" | "partial";
reason?: "stop" | "user-cancelled" | "connection-lost" | "provider-error" | "timeout";
};
export type StoredChatMessage = {
id: string;
parentMessageId: string | null;
role: "system" | "user" | "assistant";
parts: Array<Record<string, unknown> & {type: string}>;
origin: MessageOrigin;
completion: MessageCompletion;
createdAt: string;
completedAt: string;
metadata?: {custom?: {response?: ResponseMetadata}};
};
export type ConversationSummary = {
id: string;
name: string;
headMessageId: string | null;
providerId: string;
model: string;
messageCount: number;
createdAt: string;
updatedAt: string;
upstreamHeadMessageId?: string | null;
headVersion?: number;
metadataVersion?: number;
};
export type Conversation = ConversationSummary & {
generationSettings: GenerationSettings;
messages: StoredChatMessage[];
};
export type WorkingItemKind = "user-draft" | "assistant-stream";
export type WorkingItemStatus = "editing" | "streaming" | "interrupted" | "failed";
export type WorkingItem = {
id: string;
conversationId: string;
kind: WorkingItemKind;
observedHeadId: string | null;
editSourceMessageId?: string;
messageRole?: "user" | "assistant";
requestAssistantReply?: boolean;
incompleteTargetAction?: "interrupt" | "append";
parts: StoredChatMessage["parts"];
status: WorkingItemStatus;
attemptId?: string;
providerId?: string;
model?: string;
failureReason?: MessageCompletion["reason"];
metadata?: StoredChatMessage["metadata"];
createdAt: string;
updatedAt: string;
};
export type ConversationRefState = {
id: string;
name: string;
headMessageId: string | null;
providerId: string;
model: string;
generationSettings: GenerationSettings;
headVersion: number;
metadataVersion: number;
createdAt: string;
updatedAt: string;
};
export type RepositoryFetch = {
refs: ConversationRefState[];
objects: StoredChatMessage[];
fetchedAt: string;
};
export type RepositoryRefUpdate = {
conversationId: string;
expectedHeadMessageId: string | null;
expectedHeadVersion: number;
expectedMetadataVersion: number;
headMessageId: string | null;
name: string;
providerId: string;
model: string;
generationSettings: GenerationSettings;
createdAt: string;
updatedAt: string;
};
+705
View File
@@ -0,0 +1,705 @@
import {createHash, 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,
ConversationRefState,
ConversationSummary,
MessageCompletion,
MessageOrigin,
ResponseMetadata,
RepositoryFetch,
RepositoryRefUpdate,
StoredChatMessage
} from "./conversation-types";
import {canonicalMessage} from "./message-object";
const databasePath = process.env.CHAT_DATABASE_PATH || "/data/chat.db";
let database: Database | undefined;
type ConversationRow = {
id: string;
name: string;
head_message_id: string | null;
provider_id: string;
model: string;
settings_json: string;
head_version: number;
metadata_version: number;
message_count: number;
created_at: string;
updated_at: string;
};
type MessageRow = {
id: string;
parent_message_id: string | null;
role: StoredChatMessage["role"];
parts_json: string;
origin_json: string;
completion_json: string;
metadata_json: string;
depth: number;
created_at: string;
completed_at: string;
};
function now() {
return new Date().toISOString();
}
function columns(opened: Database, table: string) {
return opened.query(`PRAGMA table_info(${table})`).all() as Array<{name: string}>;
}
function migrateLegacyMessages(opened: Database) {
const conversations = opened.query(`
SELECT id, owner_issuer, owner_sub FROM chat_conversation
WHERE head_message_id IS NULL
AND EXISTS (SELECT 1 FROM chat_message WHERE conversation_id = chat_conversation.id)
`).all() as Array<{id: string; owner_issuer: string; owner_sub: string}>;
if (!conversations.length) return;
opened.run("BEGIN IMMEDIATE");
try {
const existingNode = opened.query("SELECT id FROM chat_message_node WHERE id = ?");
const insertNode = opened.query(`
INSERT INTO chat_message_node (
id, owner_issuer, owner_sub, parent_message_id, role, parts_json, origin_json,
completion_json, metadata_json, depth, created_at, completed_at
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`);
const updateHead = opened.query("UPDATE chat_conversation SET head_message_id = ? WHERE id = ?");
for (const conversation of conversations) {
const messages = opened.query(`
SELECT id, role, parts_json, created_at FROM chat_message
WHERE conversation_id = ? ORDER BY ordinal
`).all(conversation.id) as Array<{id: string; role: StoredChatMessage["role"]; parts_json: string; created_at: string}>;
let parentId: string | null = null;
messages.forEach((legacy, depth) => {
let id = legacy.id;
if (existingNode.get(id)) id = randomUUID();
const parsed = JSON.parse(legacy.parts_json) as StoredChatMessage["parts"];
const metadataPart = parsed.find((part) => part.type === "data-response-metadata" && part.data && typeof part.data === "object");
const parts = parsed.filter((part) => part.type !== "data-response-metadata");
const metadata = metadataPart ? {custom: {response: metadataPart.data as ResponseMetadata}} : {};
insertNode.run(
id,
conversation.owner_issuer,
conversation.owner_sub,
parentId,
legacy.role,
JSON.stringify(parts),
JSON.stringify({type: "legacy"}),
JSON.stringify({status: "complete"}),
JSON.stringify(metadata),
depth,
legacy.created_at,
legacy.created_at
);
parentId = id;
});
updateHead.run(parentId, conversation.id);
}
opened.run("COMMIT");
} catch (error) {
opened.run("ROLLBACK");
throw error;
}
}
function migrateConversationHeadsToContentObjects(opened: Database) {
const conversations = opened.query(`
SELECT id, owner_issuer, owner_sub, head_message_id
FROM chat_conversation
WHERE head_message_id IS NOT NULL AND head_message_id NOT LIKE 'sha256:%'
`).all() as Array<{id: string; owner_issuer: string; owner_sub: string; head_message_id: string}>;
if (!conversations.length) return;
opened.run("BEGIN IMMEDIATE");
try {
const read = opened.query(`
SELECT id, parent_message_id, role, parts_json, origin_json, completion_json, metadata_json,
depth, created_at, completed_at
FROM chat_message_node WHERE id = ? AND owner_issuer = ? AND owner_sub = ?
`);
const insert = opened.query(`
INSERT OR IGNORE INTO chat_message_node (
id, owner_issuer, owner_sub, parent_message_id, role, parts_json, origin_json,
completion_json, metadata_json, depth, created_at, completed_at
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`);
const update = opened.query("UPDATE chat_conversation SET head_message_id = ?, head_version = head_version + 1 WHERE id = ?");
for (const conversation of conversations) {
const path: MessageRow[] = [];
let cursor: string | null = conversation.head_message_id;
while (cursor) {
const row = read.get(cursor, conversation.owner_issuer, conversation.owner_sub) as MessageRow | undefined;
if (!row) throw new Error(`Legacy object ${cursor} is unavailable`);
path.push(row);
cursor = row.parent_message_id;
}
path.reverse();
const namespace = createHash("sha256").update(`${conversation.owner_issuer}\0${conversation.owner_sub}`).digest("hex").slice(0, 32);
let parentMessageId: string | null = null;
for (const row of path) {
const parsed = parsedMessage(row);
const {id: _legacyId, ...legacyContent} = parsed;
const content: Omit<StoredChatMessage, "id"> = {...legacyContent, parentMessageId};
const id: string = `sha256:${createHash("sha256").update(`${namespace}\0${canonicalMessage(content)}`).digest("hex")}`;
insert.run(
id,
conversation.owner_issuer,
conversation.owner_sub,
parentMessageId,
parsed.role,
JSON.stringify(parsed.parts),
JSON.stringify(parsed.origin),
JSON.stringify(parsed.completion),
JSON.stringify(parsed.metadata || {}),
row.depth,
parsed.createdAt,
parsed.completedAt
);
parentMessageId = id;
}
update.run(parentMessageId, conversation.id);
}
opened.run("COMMIT");
} catch (error) {
opened.run("ROLLBACK");
throw error;
}
}
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 conversationColumns = columns(opened, "chat_conversation");
if (!conversationColumns.some((column) => column.name === "settings_json")) {
opened.run("ALTER TABLE chat_conversation ADD COLUMN settings_json TEXT NOT NULL DEFAULT '{}'");
}
if (!conversationColumns.some((column) => column.name === "name")) {
opened.run("ALTER TABLE chat_conversation ADD COLUMN name TEXT");
opened.run("UPDATE chat_conversation SET name = title WHERE name IS NULL");
}
if (!conversationColumns.some((column) => column.name === "head_message_id")) {
opened.run("ALTER TABLE chat_conversation ADD COLUMN head_message_id TEXT");
}
if (!conversationColumns.some((column) => column.name === "head_version")) {
opened.run("ALTER TABLE chat_conversation ADD COLUMN head_version INTEGER NOT NULL DEFAULT 0");
}
if (!conversationColumns.some((column) => column.name === "metadata_version")) {
opened.run("ALTER TABLE chat_conversation ADD COLUMN metadata_version INTEGER NOT NULL DEFAULT 0");
}
opened.run(`
CREATE TABLE IF NOT EXISTS chat_message_node (
id TEXT PRIMARY KEY,
owner_issuer TEXT NOT NULL,
owner_sub TEXT NOT NULL,
parent_message_id TEXT REFERENCES chat_message_node(id),
role TEXT NOT NULL CHECK (role IN ('system', 'user', 'assistant')),
parts_json TEXT NOT NULL,
origin_json TEXT NOT NULL,
completion_json TEXT NOT NULL,
metadata_json TEXT NOT NULL DEFAULT '{}',
depth INTEGER NOT NULL,
created_at TEXT NOT NULL,
completed_at TEXT NOT NULL
);
CREATE INDEX IF NOT EXISTS chat_message_node_owner_parent
ON chat_message_node (owner_issuer, owner_sub, parent_message_id);
CREATE INDEX IF NOT EXISTS chat_conversation_owner_name
ON chat_conversation (owner_issuer, owner_sub, name);
`);
migrateLegacyMessages(opened);
migrateConversationHeadsToContentObjects(opened);
database = opened;
return opened;
}
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 conversationName(value: unknown) {
if (typeof value !== "string") throw new Error("name must be a string");
return value.trim().slice(0, 300);
}
function nullableId(value: unknown, field: string) {
if (value === null || value === undefined || value === "") return null;
if (typeof value !== "string" || !value.trim()) throw new Error(`${field} is invalid`);
return value.trim().slice(0, 160);
}
function conversationSummary(row: ConversationRow): ConversationSummary {
return {
id: row.id,
name: row.name,
headMessageId: row.head_message_id,
providerId: row.provider_id,
model: row.model,
messageCount: Number(row.message_count),
createdAt: row.created_at,
updatedAt: row.updated_at,
upstreamHeadMessageId: row.head_message_id,
headVersion: Number(row.head_version || 0),
metadataVersion: Number(row.metadata_version || 0)
};
}
const conversationSelect = `
SELECT c.id, COALESCE(c.name, c.title) AS name, c.head_message_id, c.provider_id, c.model,
c.head_version, c.metadata_version,
c.settings_json, c.created_at, c.updated_at, COALESCE(h.depth + 1, 0) AS message_count
FROM chat_conversation c
LEFT JOIN chat_message_node h ON h.id = c.head_message_id
`;
function ownedConversation(identity: ChatIdentity, id: string) {
return getDatabase().query(`${conversationSelect}
WHERE c.id = ? AND c.owner_issuer = ? AND c.owner_sub = ?
`).get(id, identity.issuer, identity.sub) as ConversationRow | undefined;
}
function ownedMessage(identity: ChatIdentity, id: string) {
return getDatabase().query(`
SELECT id, parent_message_id, role, parts_json, origin_json, completion_json, metadata_json,
depth, created_at, completed_at
FROM chat_message_node WHERE id = ? AND owner_issuer = ? AND owner_sub = ?
`).get(id, identity.issuer, identity.sub) as MessageRow | undefined;
}
function parsedMessage(row: MessageRow): StoredChatMessage {
const metadata = JSON.parse(row.metadata_json || "{}") as StoredChatMessage["metadata"];
return {
id: row.id,
parentMessageId: row.parent_message_id,
role: row.role,
parts: JSON.parse(row.parts_json),
origin: JSON.parse(row.origin_json),
completion: JSON.parse(row.completion_json),
createdAt: row.created_at,
completedAt: row.completed_at,
...(metadata && Object.keys(metadata).length ? {metadata} : {})
};
}
function messagePath(identity: ChatIdentity, headId: string | null) {
const reversed: StoredChatMessage[] = [];
const seen = new Set<string>();
let id = headId;
while (id) {
if (seen.has(id) || reversed.length >= 500) throw new Error("Message history is cyclic or too long");
seen.add(id);
const row = ownedMessage(identity, id);
if (!row) throw new Error("Conversation points to an unavailable message");
reversed.push(parsedMessage(row));
id = row.parent_message_id;
}
return reversed.reverse();
}
function normalizedParts(value: unknown) {
if (!Array.isArray(value)) throw new Error("parts is required");
const encoded = JSON.stringify(value);
if (Buffer.byteLength(encoded) > 1024 * 1024) throw new Error("message is too large");
return JSON.parse(encoded) as StoredChatMessage["parts"];
}
function normalizedOrigin(value: unknown, role: StoredChatMessage["role"]): MessageOrigin {
if (value && typeof value === "object" && !Array.isArray(value) && typeof (value as {type?: unknown}).type === "string") {
return JSON.parse(JSON.stringify(value)) as MessageOrigin;
}
if (role === "user") return {type: "user"};
if (role === "system") return {type: "system", source: "chat"};
return {type: "legacy"};
}
function normalizedCompletion(value: unknown): MessageCompletion {
if (value && typeof value === "object" && !Array.isArray(value) && (value as {status?: unknown}).status === "partial") {
return JSON.parse(JSON.stringify(value)) as MessageCompletion;
}
return {status: "complete"};
}
function normalizedMetadata(value: unknown) {
const metadata = value && typeof value === "object" && !Array.isArray(value) ? value : {};
const encoded = JSON.stringify(metadata);
if (Buffer.byteLength(encoded) > 64 * 1024) throw new Error("message metadata is too large");
return encoded;
}
export function listConversations(identity: ChatIdentity): ConversationSummary[] {
return (getDatabase().query(`${conversationSelect}
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;
name?: unknown;
headMessageId?: unknown;
}): Conversation {
const providerId = requiredString(input.providerId, "providerId", 80);
const model = requiredString(input.model, "model", 300);
const name = input.name === undefined ? "" : conversationName(input.name);
const headMessageId = nullableId(input.headMessageId, "headMessageId");
if (headMessageId && !ownedMessage(identity, headMessageId)) throw new Error("headMessageId is unavailable");
const generationSettings = normalizeGenerationSettings(input.generationSettings);
const timestamp = now();
const id = randomUUID();
getDatabase().query(`
INSERT INTO chat_conversation (
id, owner_issuer, owner_sub, title, name, head_message_id, provider_id, model,
settings_json, created_at, updated_at
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`).run(id, identity.issuer, identity.sub, name, name, headMessageId, providerId, model, JSON.stringify(generationSettings), timestamp, timestamp);
return getConversation(identity, id)!;
}
export function getConversation(identity: ChatIdentity, id: string): Conversation | null {
const row = ownedConversation(identity, id);
if (!row) return null;
return {
...conversationSummary(row),
generationSettings: normalizeGenerationSettings(JSON.parse(row.settings_json || "{}")),
messages: messagePath(identity, row.head_message_id)
};
}
export function updateConversation(identity: ChatIdentity, id: string, input: {
providerId?: unknown;
model?: unknown;
generationSettings?: unknown;
name?: unknown;
}) {
const existing = ownedConversation(identity, id);
if (!existing) return null;
const providerId = input.providerId === undefined ? existing.provider_id : requiredString(input.providerId, "providerId", 80);
const model = input.model === undefined ? existing.model : requiredString(input.model, "model", 300);
const name = input.name === undefined ? existing.name : conversationName(input.name);
const generationSettings = input.generationSettings === undefined
? normalizeGenerationSettings(JSON.parse(existing.settings_json || "{}"))
: normalizeGenerationSettings(input.generationSettings);
getDatabase().query(`
UPDATE chat_conversation SET title = ?, name = ?, provider_id = ?, model = ?, settings_json = ?, metadata_version = metadata_version + 1, updated_at = ?
WHERE id = ? AND owner_issuer = ? AND owner_sub = ?
`).run(name, name, providerId, model, JSON.stringify(generationSettings), now(), id, identity.issuer, identity.sub);
return getConversation(identity, id);
}
export function appendConversationMessage(identity: ChatIdentity, conversationId: string, input: {
id?: unknown;
expectedHeadId?: unknown;
parentMessageId?: unknown;
role?: unknown;
parts?: unknown;
origin?: unknown;
completion?: unknown;
metadata?: unknown;
providerId?: unknown;
model?: unknown;
}) {
const role = String(input.role || "") as StoredChatMessage["role"];
if (!["system", "user", "assistant"].includes(role)) throw new Error("role is invalid");
const id = input.id === undefined ? randomUUID() : requiredString(input.id, "id", 160);
const expectedHeadId = nullableId(input.expectedHeadId, "expectedHeadId");
const parentMessageId = nullableId(input.parentMessageId, "parentMessageId");
const parts = normalizedParts(input.parts);
const origin = normalizedOrigin(input.origin, role);
const completion = normalizedCompletion(input.completion);
const metadataJson = normalizedMetadata(input.metadata);
const timestamp = now();
getDatabase().run("BEGIN IMMEDIATE");
try {
const existing = ownedConversation(identity, conversationId);
if (!existing) {
getDatabase().run("ROLLBACK");
return {status: "missing" as const};
}
if (existing.head_message_id !== expectedHeadId) {
getDatabase().run("ROLLBACK");
return {status: "conflict" as const, conversation: getConversation(identity, conversationId)!};
}
const parent = parentMessageId ? ownedMessage(identity, parentMessageId) : undefined;
if (parentMessageId && !parent) throw new Error("parentMessageId is unavailable");
const depth = parent ? parent.depth + 1 : 0;
const already = ownedMessage(identity, id);
if (already) {
const same = already.parent_message_id === parentMessageId
&& already.role === role
&& already.parts_json === JSON.stringify(parts)
&& already.origin_json === JSON.stringify(origin)
&& already.completion_json === JSON.stringify(completion)
&& already.metadata_json === metadataJson;
if (!same) throw new Error("message id already exists with different content");
} else {
getDatabase().query(`
INSERT INTO chat_message_node (
id, owner_issuer, owner_sub, parent_message_id, role, parts_json, origin_json,
completion_json, metadata_json, depth, created_at, completed_at
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`).run(
id,
identity.issuer,
identity.sub,
parentMessageId,
role,
JSON.stringify(parts),
JSON.stringify(origin),
JSON.stringify(completion),
metadataJson,
depth,
timestamp,
timestamp
);
}
const providerId = input.providerId === undefined ? existing.provider_id : requiredString(input.providerId, "providerId", 80);
const model = input.model === undefined ? existing.model : requiredString(input.model, "model", 300);
const name = existing.name;
getDatabase().query(`
UPDATE chat_conversation SET title = ?, name = ?, head_message_id = ?, provider_id = ?, model = ?, head_version = head_version + 1, updated_at = ?
WHERE id = ? AND owner_issuer = ? AND owner_sub = ?
`).run(name, name, id, providerId, model, timestamp, conversationId, identity.issuer, identity.sub);
getDatabase().run("COMMIT");
return {status: "ok" as const, conversation: getConversation(identity, conversationId)!};
} catch (error) {
try { getDatabase().run("ROLLBACK"); } catch {}
throw error;
}
}
function normalizeLegacyMessages(value: unknown): StoredChatMessage[] {
if (!Array.isArray(value) || value.length > 500) throw new Error("messages must contain at most 500 entries");
const timestamp = now();
let parentMessageId: string | null = null;
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 = String(record.role) as StoredChatMessage["role"];
if (!["system", "user", "assistant"].includes(role)) throw new Error(`messages[${index}].role is invalid`);
const normalized: StoredChatMessage = {
id: typeof record.id === "string" && record.id.trim() ? record.id.trim().slice(0, 160) : randomUUID(),
parentMessageId,
role,
parts: normalizedParts(record.parts),
origin: normalizedOrigin(record.origin, role),
completion: normalizedCompletion(record.completion),
createdAt: typeof record.createdAt === "string" ? record.createdAt : timestamp,
completedAt: typeof record.completedAt === "string" ? record.completedAt : timestamp,
...(record.metadata ? {metadata: JSON.parse(normalizedMetadata(record.metadata))} : {})
};
parentMessageId = normalized.id;
return normalized;
});
}
// Compatibility for pending writes created by the previous client. New code commits one immutable message at a time.
export function saveConversationMessages(identity: ChatIdentity, id: string, input: {providerId: unknown; model: unknown; messages: unknown}) {
const existing = ownedConversation(identity, id);
if (!existing) return null;
const messages = normalizeLegacyMessages(input.messages);
let expectedHeadId = existing.head_message_id;
for (const message of messages) {
const already = ownedMessage(identity, message.id);
if (already) {
expectedHeadId = message.id;
continue;
}
const result = appendConversationMessage(identity, id, {
...message,
expectedHeadId,
providerId: input.providerId,
model: input.model
});
if (result.status !== "ok") throw new Error("Unable to import legacy conversation path");
expectedHeadId = message.id;
}
if (!messages.length && existing.head_message_id) {
getDatabase().query(`
UPDATE chat_conversation SET head_message_id = NULL, provider_id = ?, model = ?, updated_at = ?
WHERE id = ? AND owner_issuer = ? AND owner_sub = ?
`).run(requiredString(input.providerId, "providerId", 80), requiredString(input.model, "model", 300), now(), id, identity.issuer, identity.sub);
}
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;
}
function conversationRef(row: ConversationRow): ConversationRefState {
return {
id: row.id,
name: row.name,
headMessageId: row.head_message_id,
providerId: row.provider_id,
model: row.model,
generationSettings: normalizeGenerationSettings(JSON.parse(row.settings_json || "{}")),
headVersion: Number(row.head_version || 0),
metadataVersion: Number(row.metadata_version || 0),
createdAt: row.created_at,
updatedAt: row.updated_at
};
}
export function fetchRepository(identity: ChatIdentity, haveObjectIds: unknown): RepositoryFetch {
const have = new Set(Array.isArray(haveObjectIds)
? haveObjectIds.filter((value): value is string => typeof value === "string").slice(0, 100_000)
: []);
const refs = (getDatabase().query(`${conversationSelect}
WHERE c.owner_issuer = ? AND c.owner_sub = ? ORDER BY c.updated_at DESC
`).all(identity.issuer, identity.sub) as ConversationRow[]).map(conversationRef);
const objects = (getDatabase().query(`
SELECT id, parent_message_id, role, parts_json, origin_json, completion_json, metadata_json,
depth, created_at, completed_at
FROM chat_message_node
WHERE owner_issuer = ? AND owner_sub = ?
ORDER BY depth, created_at, id
`).all(identity.issuer, identity.sub) as MessageRow[])
.filter((row) => !have.has(row.id))
.map(parsedMessage);
return {refs, objects, fetchedAt: now()};
}
export function putRepositoryObjects(identity: ChatIdentity, objects: unknown) {
if (!Array.isArray(objects) || objects.length > 1000) throw new Error("objects must contain at most 1000 entries");
let inserted = 0;
getDatabase().run("BEGIN IMMEDIATE");
try {
const insert = getDatabase().query(`
INSERT INTO chat_message_node (
id, owner_issuer, owner_sub, parent_message_id, role, parts_json, origin_json,
completion_json, metadata_json, depth, created_at, completed_at
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`);
for (const value of objects) {
if (!value || typeof value !== "object" || Array.isArray(value)) throw new Error("repository object is invalid");
const object = value as StoredChatMessage;
if (typeof object.id !== "string" || !object.id.startsWith("sha256:")) throw new Error("repository object id is invalid");
if (ownedMessage(identity, object.id)) continue;
const role = object.role;
if (!["system", "user", "assistant"].includes(role)) throw new Error("repository object role is invalid");
const parentMessageId = nullableId(object.parentMessageId, "parentMessageId");
const parent = parentMessageId ? ownedMessage(identity, parentMessageId) : undefined;
if (parentMessageId && !parent) throw new Error(`parent object ${parentMessageId} is unavailable`);
insert.run(
object.id,
identity.issuer,
identity.sub,
parentMessageId,
role,
JSON.stringify(normalizedParts(object.parts)),
JSON.stringify(normalizedOrigin(object.origin, role)),
JSON.stringify(normalizedCompletion(object.completion)),
normalizedMetadata(object.metadata),
parent ? parent.depth + 1 : 0,
requiredString(object.createdAt, "createdAt", 80),
requiredString(object.completedAt, "completedAt", 80)
);
inserted += 1;
}
getDatabase().run("COMMIT");
} catch (error) {
try { getDatabase().run("ROLLBACK"); } catch {}
throw error;
}
return inserted;
}
export function pushRepositoryRef(identity: ChatIdentity, update: RepositoryRefUpdate) {
const id = requiredString(update.conversationId, "conversationId", 160);
const expectedHeadMessageId = nullableId(update.expectedHeadMessageId, "expectedHeadMessageId");
const headMessageId = nullableId(update.headMessageId, "headMessageId");
const existing = ownedConversation(identity, id);
if (!existing) {
if (expectedHeadMessageId !== null || Number(update.expectedHeadVersion || 0) !== 0 || Number(update.expectedMetadataVersion || 0) !== 0) {
return {status: "conflict" as const, ref: null};
}
if (headMessageId && !ownedMessage(identity, headMessageId)) throw new Error("head object is unavailable");
const name = conversationName(update.name);
const providerId = requiredString(update.providerId, "providerId", 80);
const model = requiredString(update.model, "model", 300);
const timestamp = now();
getDatabase().query(`
INSERT INTO chat_conversation (
id, owner_issuer, owner_sub, title, name, head_message_id, provider_id, model,
settings_json, head_version, metadata_version, created_at, updated_at
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, 1, 1, ?, ?)
`).run(id, identity.issuer, identity.sub, name, name, headMessageId, providerId, model, JSON.stringify(normalizeGenerationSettings(update.generationSettings)), update.createdAt || timestamp, timestamp);
return {status: "ok" as const, ref: conversationRef(ownedConversation(identity, id)!)};
}
if (existing.head_message_id !== expectedHeadMessageId
|| Number(existing.head_version || 0) !== Number(update.expectedHeadVersion || 0)
|| Number(existing.metadata_version || 0) !== Number(update.expectedMetadataVersion || 0)) {
return {status: "conflict" as const, ref: conversationRef(existing)};
}
if (headMessageId && !ownedMessage(identity, headMessageId)) throw new Error("head object is unavailable");
const name = conversationName(update.name);
const providerId = requiredString(update.providerId, "providerId", 80);
const model = requiredString(update.model, "model", 300);
const headChanged = existing.head_message_id !== headMessageId;
const metadataChanged = existing.name !== name
|| existing.provider_id !== providerId
|| existing.model !== model
|| existing.settings_json !== JSON.stringify(normalizeGenerationSettings(update.generationSettings));
getDatabase().query(`
UPDATE chat_conversation SET title = ?, name = ?, head_message_id = ?, provider_id = ?, model = ?, settings_json = ?,
head_version = head_version + ?, metadata_version = metadata_version + ?, updated_at = ?
WHERE id = ? AND owner_issuer = ? AND owner_sub = ?
`).run(
name,
name,
headMessageId,
providerId,
model,
JSON.stringify(normalizeGenerationSettings(update.generationSettings)),
headChanged ? 1 : 0,
metadataChanged ? 1 : 0,
now(),
id,
identity.issuer,
identity.sub
);
return {status: "ok" as const, ref: conversationRef(ownedConversation(identity, id)!)};
}
+21
View File
@@ -0,0 +1,21 @@
import {describe, expect, test} from "bun:test";
import {
fullscreenEditorCharacterThreshold,
fullscreenEditorLineThreshold,
shouldOpenFullscreenEditor
} from "./fullscreen-editor.ts";
describe("fullscreen editor", () => {
test("keeps short messages in the compact composer", () => {
expect(shouldOpenFullscreenEditor("a".repeat(fullscreenEditorCharacterThreshold - 1))).toBe(false);
expect(shouldOpenFullscreenEditor(Array(fullscreenEditorLineThreshold - 1).fill("line").join("\n"))).toBe(false);
});
test("opens for a long single-line message", () => {
expect(shouldOpenFullscreenEditor("a".repeat(fullscreenEditorCharacterThreshold))).toBe(true);
});
test("opens for a message with many lines", () => {
expect(shouldOpenFullscreenEditor(Array(fullscreenEditorLineThreshold).fill("line").join("\n"))).toBe(true);
});
});
+8
View File
@@ -0,0 +1,8 @@
export const fullscreenEditorCharacterThreshold = 600;
export const fullscreenEditorLineThreshold = 8;
export function shouldOpenFullscreenEditor(value: unknown) {
const text = String(value ?? "");
const lineCount = text ? text.split(/\r?\n/).length : 0;
return text.length >= fullscreenEditorCharacterThreshold || lineCount >= fullscreenEditorLineThreshold;
}
+64
View File
@@ -0,0 +1,64 @@
import {describe, expect, test} from "bun:test";
import {generationCallOptions, normalizeGenerationSettings} from "./generation-settings.ts";
function provider(api) {
return {
id: api,
name: api,
api,
connection: {type: "backend", baseUrl: "https://example.com/v1", proxy: null},
auth: {type: "bearer"},
headers: {},
defaultModel: "test-model",
discovery: {type: "openai-models-list", url: "https://example.com/v1/models"},
builtin: false,
credentialState: "configured",
credentials: []
};
}
describe("generation settings", () => {
test("normalizes user-controlled values", () => {
expect(normalizeGenerationSettings({reasoning: "high", showReasoningSummary: true, temperature: 5, maxOutputTokens: 12.8})).toEqual({
reasoning: "high",
showReasoningSummary: true,
temperature: 2,
maxOutputTokens: 12
});
});
test("maps OpenAI reasoning and summary", () => {
expect(generationCallOptions(provider("openai-responses"), {
reasoning: "high",
showReasoningSummary: true,
temperature: null,
maxOutputTokens: 4096
})).toEqual({
reasoning: "high",
maxOutputTokens: 4096,
providerOptions: {openai: {reasoningSummary: "auto"}}
});
});
test("maps Anthropic adaptive thinking", () => {
expect(generationCallOptions(provider("anthropic-messages"), {
reasoning: "medium",
showReasoningSummary: true,
temperature: 0.4,
maxOutputTokens: null
})).toEqual({
reasoning: "medium",
temperature: 0.4,
providerOptions: {anthropic: {thinking: {type: "adaptive", display: "summarized"}}}
});
});
test("keeps auto mode provider-default", () => {
expect(generationCallOptions(provider("openai-completions"), {
reasoning: "auto",
showReasoningSummary: false,
temperature: null,
maxOutputTokens: null
})).toEqual({});
});
});
+55
View File
@@ -0,0 +1,55 @@
import type {ProviderDefinition} from "./provider-types";
type JsonValue = null | string | number | boolean | JsonValue[] | {[key: string]: JsonValue};
export type ReasoningLevel = "auto" | "none" | "low" | "medium" | "high";
export type GenerationSettings = {
reasoning: ReasoningLevel;
showReasoningSummary: boolean;
temperature: number | null;
maxOutputTokens: number | null;
};
export const defaultGenerationSettings: GenerationSettings = {
reasoning: "auto",
showReasoningSummary: false,
temperature: null,
maxOutputTokens: null
};
export function normalizeGenerationSettings(value: unknown): GenerationSettings {
const input = value && typeof value === "object" && !Array.isArray(value) ? value as Record<string, unknown> : {};
const reasoning = ["auto", "none", "low", "medium", "high"].includes(String(input.reasoning))
? input.reasoning as ReasoningLevel
: "auto";
const temperature = typeof input.temperature === "number" && Number.isFinite(input.temperature)
? Math.min(2, Math.max(0, input.temperature))
: null;
const maxOutputTokens = typeof input.maxOutputTokens === "number" && Number.isFinite(input.maxOutputTokens)
? Math.min(1_000_000, Math.max(1, Math.floor(input.maxOutputTokens)))
: null;
return {
reasoning,
showReasoningSummary: input.showReasoningSummary === true,
temperature,
maxOutputTokens
};
}
export function generationCallOptions(provider: ProviderDefinition, settings: GenerationSettings) {
const providerOptions: Record<string, {[key: string]: JsonValue}> = {};
if (provider.api === "openai-responses") {
providerOptions.openai = {reasoningSummary: settings.showReasoningSummary ? "auto" : null};
} else if (provider.api === "anthropic-messages" && settings.showReasoningSummary && settings.reasoning !== "none") {
providerOptions.anthropic = {thinking: {type: "adaptive", display: "summarized"}};
} else if (provider.api === "google-generative-ai" && settings.showReasoningSummary) {
providerOptions.google = {thinkingConfig: {includeThoughts: true}};
}
return {
...(settings.reasoning !== "auto" ? {reasoning: settings.reasoning} : {}),
...(settings.temperature !== null ? {temperature: settings.temperature} : {}),
...(settings.maxOutputTokens !== null ? {maxOutputTokens: settings.maxOutputTokens} : {}),
...(Object.keys(providerOptions).length ? {providerOptions} : {})
};
}
+31
View File
@@ -0,0 +1,31 @@
import {describe, expect, test} from "bun:test";
import {applyImportTitleTemplate, importFileStem, importSourceFolder} from "./import-title-template.ts";
const context = {
title: "Repair auth",
format: "codex",
file: "rollout-123",
folder: "13",
date: "2026-08-13",
model: "gpt-5.6-sol",
provider: "openai",
index: 7
};
describe("import title templates", () => {
test("renders paths and import metadata", () => {
expect(applyImportTitleTemplate("{format}/{date}/{title}", context)).toBe("codex/2026-08-13/Repair auth");
expect(applyImportTitleTemplate("{index} · {file} · {model}", context)).toBe("7 · rollout-123 · gpt-5.6-sol");
});
test("defaults to the source title and rejects unknown variables", () => {
expect(applyImportTitleTemplate("", context)).toBe("Repair auth");
expect(() => applyImportTitleTemplate("{project}/{title}", context)).toThrow("未知标题变量");
});
test("extracts source file and folder labels", () => {
expect(importFileStem("backup.xiteng-chat.json")).toBe("backup");
expect(importFileStem("rollout.jsonl")).toBe("rollout");
expect(importSourceFolder("sessions.zip / nested / rollout.jsonl")).toBe("nested");
});
});
+32
View File
@@ -0,0 +1,32 @@
export type ImportTitleContext = {
title: string;
format: string;
file: string;
folder: string;
date: string;
model: string;
provider: string;
index: number;
};
const placeholders = new Set(["title", "format", "file", "folder", "date", "model", "provider", "index"]);
export function applyImportTitleTemplate(template: string, context: ImportTitleContext) {
const source = template.trim() || "{title}";
const unknown = [...source.matchAll(/\{([^{}]+)\}/g)]
.map((match) => match[1])
.filter((name) => !placeholders.has(name));
if (unknown.length) throw new Error(`未知标题变量:${[...new Set(unknown)].map((name) => `{${name}}`).join("、")}`);
const values: Record<string, string> = {...context, index: String(context.index)};
const rendered = source.replace(/\{([^{}]+)\}/g, (match, name) => values[name] ?? match).trim();
return rendered || context.title.trim() || "导入的会话";
}
export function importFileStem(filename: string) {
return filename.replace(/\.xiteng-chat\.json$/i, "").replace(/\.(?:jsonl|json)$/i, "");
}
export function importSourceFolder(source: string) {
const parts = source.replaceAll("\\", "/").split("/").map((part) => part.trim()).filter(Boolean);
return parts.length > 1 ? parts.at(-2)! : "";
}
@@ -0,0 +1,73 @@
import {describe, expect, test} from "bun:test";
import {IncrementalMarkdownCache} from "./incremental-markdown-cache.ts";
describe("incremental markdown cache", () => {
test("never parses a stable prefix again", () => {
const cache = new IncrementalMarkdownCache();
const calls = [];
const render = (source, type, index, stable) => {
calls.push({source, type, index, stable});
return {rendered: source.toUpperCase()};
};
cache.render("message", "first", render);
const afterFirst = calls.length;
const closed = cache.render("message", "first\n\n", render);
const afterClosed = calls.length;
const growingTail = cache.render("message", "first\n\nsecond", render);
const longerTail = cache.render("message", "first\n\nsecond grows", render);
expect(afterFirst).toBe(1);
expect(afterClosed).toBe(2);
expect(closed.blocks[0].stable).toBe(true);
expect(growingTail.reused).toBe(1);
expect(growingTail.parsed).toBe(1);
expect(longerTail.reused).toBe(1);
expect(longerTail.parsed).toBe(1);
expect(calls.filter((call) => call.source === "first")).toHaveLength(2);
});
test("assigns stable indexes as blocks become frozen", () => {
const cache = new IncrementalMarkdownCache();
const render = () => ({});
const first = cache.render("message", "one\n\ntwo", render);
const second = cache.render("message", "one\n\ntwo\n\nthree", render);
expect(first.blocks.map((block) => [block.index, block.stable])).toEqual([[0, true], [1, false]]);
expect(second.blocks.map((block) => [block.index, block.stable])).toEqual([[0, true], [1, true], [2, false]]);
});
test("resets when the source is edited before the stable prefix", () => {
const cache = new IncrementalMarkdownCache();
const render = (source) => ({rendered: source});
cache.render("message", "one\n\ntwo", render);
const result = cache.render("message", "changed\n\ntwo", render);
expect(result.reused).toBe(0);
expect(result.blocks[0].source).toBe("changed");
});
test("moves cached blocks when a working message receives its immutable id", () => {
const cache = new IncrementalMarkdownCache();
const render = (source) => ({rendered: source});
cache.render("working", "first\n\n", render);
cache.move("working", "immutable");
const result = cache.render("immutable", "first\n\nsecond", render);
expect(cache.has("working")).toBe(false);
expect(cache.has("immutable")).toBe(true);
expect(result.reused).toBe(1);
expect(result.blocks.map((block) => block.index)).toEqual([0, 1]);
});
test("freezes the final open block when the stream completes", () => {
const cache = new IncrementalMarkdownCache();
const render = (source) => ({rendered: source});
cache.render("message", "last paragraph", render);
const result = cache.render("message", "last paragraph", render, undefined, true);
expect(result.blocks).toHaveLength(1);
expect(result.blocks[0].stable).toBe(true);
});
});
+79
View File
@@ -0,0 +1,79 @@
import {splitStreamingMarkdown} from "./streaming-markdown";
export type IncrementalMarkdownBlock<T> = T & {
source: string;
type: string;
index: number;
stable: boolean;
};
export type IncrementalMarkdownResult<T> = {
blocks: Array<IncrementalMarkdownBlock<T>>;
parsed: number;
reused: number;
};
type IncrementalMarkdownState<T> = {
stableSource: string;
stableBlocks: Array<IncrementalMarkdownBlock<T>>;
nextIndex: number;
};
export class IncrementalMarkdownCache<T> {
private readonly states = new Map<string, IncrementalMarkdownState<T>>();
render(
messageId: string,
value: string,
renderBlock: (source: string, type: string, index: number, stable: boolean) => T,
onSplit?: (durationMs: number) => void,
complete = false
): IncrementalMarkdownResult<T> {
let state = this.states.get(messageId);
if (!state || !value.startsWith(state.stableSource)) {
state = {stableSource: "", stableBlocks: [], nextIndex: 0};
this.states.set(messageId, state);
}
if (value === state.stableSource) return {blocks: state.stableBlocks, parsed: 0, reused: state.stableBlocks.length};
const tail = value.slice(state.stableSource.length);
const startedAt = performance.now();
const split = splitStreamingMarkdown(tail, complete);
onSplit?.(performance.now() - startedAt);
const renderedTail = split.blocks.map((block, offset) => ({
...renderBlock(block.source, block.type, state!.nextIndex + offset, block.stable),
source: block.source,
type: block.type,
index: state!.nextIndex + offset,
stable: block.stable
}));
const newlyStable = renderedTail.filter((_block, index) => split.blocks[index].end <= split.stableOffset);
const previouslyStableCount = state.stableBlocks.length;
if (split.stableOffset > 0) {
state.stableSource += tail.slice(0, split.stableOffset);
state.stableBlocks.push(...newlyStable);
state.nextIndex += newlyStable.length;
}
return {
blocks: [...state.stableBlocks.slice(0, state.stableBlocks.length - newlyStable.length), ...renderedTail],
parsed: renderedTail.length,
reused: previouslyStableCount
};
}
delete(messageId: string) {
this.states.delete(messageId);
}
move(fromMessageId: string, toMessageId: string) {
if (fromMessageId === toMessageId) return;
const state = this.states.get(fromMessageId);
if (!state) return;
this.states.set(toMessageId, state);
this.states.delete(fromMessageId);
}
has(messageId: string) {
return this.states.has(messageId);
}
}
+53
View File
@@ -0,0 +1,53 @@
import {readFileSync} from "node:fs";
export type ChatIdentity = {issuer: string; sub: string; username: string; name: string; email: string};
type HeaderReader = Pick<Headers, "get">;
const keyVaultUrl = process.env.KEY_VAULT_URL || "http://ai-gateway:8093";
const authentikIssuer = process.env.AUTHENTIK_ISSUER || "https://auth.xiteng.site";
const serviceTokenFile = process.env.KEY_VAULT_TOKEN_FILE || "/run/secrets/portal_gateway_hmac";
let serviceToken: string | null = null;
function loadServiceToken() {
if (!serviceToken) serviceToken = readFileSync(/* turbopackIgnore: true */ serviceTokenFile, "utf8").trim();
return serviceToken;
}
export function identityFromHeaders(headers: HeaderReader): ChatIdentity {
const username = headers.get("x-authentik-username")?.trim() || "";
const sub = headers.get("x-authentik-uid")?.trim() || "";
if (!username || !sub) {
const error = new Error("Authenticated user context is required");
Object.assign(error, {statusCode: 401});
throw error;
}
return {
issuer: authentikIssuer,
sub,
username,
name: headers.get("x-authentik-name")?.trim() || username,
email: headers.get("x-authentik-email")?.trim() || ""
};
}
export function keyVaultFetch(
pathname: string,
identity: ChatIdentity,
init: {method?: string; body?: string; headers?: HeadersInit} = {}
) {
const method = init.method || "GET";
return fetch(new URL(pathname, keyVaultUrl), {
method,
headers: {
"Accept": "application/json",
"Authorization": `Bearer ${loadServiceToken()}`,
...init.headers,
"X-Key-Vault-Actor-Issuer": identity.issuer,
"X-Key-Vault-Actor-Sub": identity.sub,
"X-Key-Vault-Actor-Username": identity.username
},
body: init.body,
cache: "no-store",
signal: AbortSignal.timeout(300000)
});
}
+44
View File
@@ -0,0 +1,44 @@
import {describe, expect, test} from "bun:test";
import {applyBrowserProviderSettings} from "./browser-provider-settings.ts";
import {discoverProviderModels} from "./provider-connectivity.ts";
function llamaProvider() {
return {
id: "llama.cpp",
name: "llama.cpp",
api: "openai-completions",
connection: {type: "frontend", baseUrl: "http://127.0.0.1:8080/v1", proxy: null},
auth: {type: "none"},
headers: {},
defaultModel: "local-model",
discovery: {type: "openai-models-list", url: "http://127.0.0.1:8080/v1/models"},
builtin: true,
credentialState: "local",
credentials: []
};
}
describe("llama.cpp discovery", () => {
test("applies a browser-local endpoint override", () => {
const provider = applyBrowserProviderSettings(llamaProvider(), {provider: {baseUrl: "http://192.168.4.20:8081/v1/"}});
expect(provider.connection.baseUrl).toBe("http://192.168.4.20:8081/v1");
expect(provider.discovery.url).toBe("http://192.168.4.20:8081/v1/models");
});
test("falls back to /props when /v1/models is unavailable", async () => {
const requests = [];
const result = await discoverProviderModels(llamaProvider(), {}, async (input) => {
requests.push(String(input));
if (String(input).endsWith("/v1/models")) {
return new Response(JSON.stringify({error: "Not Found"}), {status: 404, headers: {"Content-Type": "application/json"}});
}
return new Response(JSON.stringify({
model_path: "/models/Qwen3.5-9B-Q4_K_M.gguf",
default_generation_settings: {n_ctx: 32768}
}), {status: 200, headers: {"Content-Type": "application/json"}});
});
expect(requests).toEqual(["http://127.0.0.1:8080/v1/models", "http://127.0.0.1:8080/props"]);
expect(result.endpoint).toBe("http://127.0.0.1:8080/props");
expect(result.models).toEqual([{id: "Qwen3.5-9B-Q4_K_M.gguf", name: "Qwen3.5-9B-Q4_K_M.gguf", ownedBy: "llamacpp", contextWindow: 32768}]);
});
});
+66
View File
@@ -0,0 +1,66 @@
import type {ProviderSecret} from "./provider-types";
export type LocalCredential = {
id: string;
providerId: string;
name: string;
secret: ProviderSecret;
createdAt: string;
updatedAt: string;
};
const databaseName = "xiteng-chat-local-vault";
const storeName = "credentials";
function openDatabase() {
return new Promise<IDBDatabase>((resolve, reject) => {
const request = indexedDB.open(databaseName, 1);
request.onupgradeneeded = () => {
const database = request.result;
if (!database.objectStoreNames.contains(storeName)) database.createObjectStore(storeName, {keyPath: "id"});
};
request.onsuccess = () => resolve(request.result);
request.onerror = () => reject(request.error || new Error("Unable to open local credential store"));
});
}
async function transaction<T>(mode: IDBTransactionMode, operation: (store: IDBObjectStore) => IDBRequest<T>) {
const database = await openDatabase();
return new Promise<T>((resolve, reject) => {
const request = operation(database.transaction(storeName, mode).objectStore(storeName));
request.onsuccess = () => resolve(request.result);
request.onerror = () => reject(request.error || new Error("Local credential operation failed"));
}).finally(() => database.close());
}
export function localCredentialId(providerId: string, name = "default") {
return `${providerId}/${name}`;
}
export function getLocalCredential(providerId: string, name = "default") {
return transaction<LocalCredential | undefined>("readonly", (store) => store.get(localCredentialId(providerId, name)));
}
export async function listLocalCredentials() {
return transaction<LocalCredential[]>("readonly", (store) => store.getAll());
}
export async function saveLocalCredential(providerId: string, name: string, secret: ProviderSecret) {
const id = localCredentialId(providerId, name);
const existing = await getLocalCredential(providerId, name);
const timestamp = new Date().toISOString();
const credential: LocalCredential = {
id,
providerId,
name,
secret,
createdAt: existing?.createdAt || timestamp,
updatedAt: timestamp
};
await transaction("readwrite", (store) => store.put(credential));
return credential;
}
export function deleteLocalCredential(providerId: string, name = "default") {
return transaction("readwrite", (store) => store.delete(localCredentialId(providerId, name)));
}
+39
View File
@@ -0,0 +1,39 @@
import {describe, expect, test} from "bun:test";
import {marked} from "marked";
import {protectMath, restoreMath} from "./math-markdown.ts";
function parse(value) {
const {source, fragments} = protectMath(value);
return {html: restoreMath(marked.parse(source), fragments), fragments};
}
describe("math markdown boundary", () => {
test("preserves inline and display TeX delimiters through Markdown", () => {
const {html, fragments} = parse("Inline \\(x^2 + y^2\\) and $z^2$.\n\n$$\\int_0^1 x\\,dx$$\n\n\\[\\frac{a}{b}\\]");
expect(fragments).toHaveLength(4);
expect(html).toContain("\\(x^2 + y^2\\)");
expect(html).toContain("$z^2$");
expect(html).toContain("$$\\int_0^1 x\\,dx$$");
expect(html).toContain("\\[\\frac{a}{b}\\]");
expect(html.match(/class="math-fragment"/g)).toHaveLength(4);
});
test("does not treat escaped dollars as math", () => {
const {fragments} = protectMath("Price: \\$5");
expect(fragments).toHaveLength(0);
});
test("keeps TeX-looking code as code for MathJax skip tags", () => {
const {html} = parse("`$not_math$` and $math$");
expect(html).toContain("<code>$not_math$</code>");
expect(html).toContain("and <span");
expect(html).toContain(">$math$</span>");
expect(html.match(/class="math-fragment"/g)).toHaveLength(1);
});
test("escapes HTML embedded inside a math fragment", () => {
const {html} = parse("$x <img src=x onerror=alert(1)> y$");
expect(html).not.toContain("<img");
expect(html).toContain("&lt;img");
});
});
+41
View File
@@ -0,0 +1,41 @@
const mathPattern = /(?<!\\)\$\$[\s\S]*?(?<!\\)\$\$|\\\[[\s\S]*?\\\]|\\\([^\n]*?\\\)|(?<![\\$])\$(?!\$)(?:\\.|[^\\$\n])+?(?<!\\)\$(?!\$)/g;
const tokenPattern = /\uE000xiteng-math-(\d+)\uE001/g;
function escapeHtml(value: string) {
return value.replace(/[&<>"']/g, (character) => ({
"&": "&amp;",
"<": "&lt;",
">": "&gt;",
'"': "&quot;",
"'": "&#39;"
})[character]!);
}
export function protectMath(value: string) {
const fragments: string[] = [];
const source = value.replace(mathPattern, (fragment) => {
const index = fragments.push(fragment) - 1;
return `\uE000xiteng-math-${index}\uE001`;
});
return {source, fragments};
}
function mathFragmentKey(fragment: string, index: number) {
let hash = 2166136261;
for (let offset = 0; offset < fragment.length; offset += 1) {
hash ^= fragment.charCodeAt(offset);
hash = Math.imul(hash, 16777619);
}
return `${index}-${fragment.length}-${(hash >>> 0).toString(36)}`;
}
export function restoreMath(value: string, fragments: string[]) {
return value.replace(tokenPattern, (_token, rawIndex: string, offset: number) => {
const index = Number(rawIndex);
const fragment = fragments[index] || "";
const codeStart = value.lastIndexOf("<code", offset);
const codeEnd = value.lastIndexOf("</code>", offset);
if (codeStart > codeEnd) return escapeHtml(fragment);
return `<span class="math-fragment" data-math-key="${mathFragmentKey(fragment, index)}">${escapeHtml(fragment)}</span>`;
});
}
+35
View File
@@ -0,0 +1,35 @@
import {describe, expect, test} from "bun:test";
import {mergeMessageGraph, messageChildrenInGraph, messagePathInGraph, newestBranchTipInGraph, rootEditAlternativesInGraph} from "./message-graph.ts";
const node = (id, parentMessageId, createdAt) => ({
id, parentMessageId, role: id.startsWith("a") ? "assistant" : "user", parts: [],
origin: {type: "legacy"}, completion: {status: "complete"}, createdAt, completedAt: createdAt
});
describe("message graph", () => {
const root = node("u1", null, "2026-01-01T00:00:00Z");
const answerA = node("a1", "u1", "2026-01-01T00:00:01Z");
const answerB = node("a2", "u1", "2026-01-01T00:00:02Z");
const followupB = node("u2", "a2", "2026-01-01T00:00:03Z");
const graph = mergeMessageGraph([root, answerA], [answerB, followupB]);
test("sorts sibling branches deterministically", () => {
expect(messageChildrenInGraph(graph, "u1").map((message) => message.id)).toEqual(["a1", "a2"]);
});
test("builds the selected path without changing a ref", () => {
expect(messagePathInGraph(graph, "u2").map((message) => message.id)).toEqual(["u1", "a2", "u2"]);
});
test("uses the current ref for its branch and newest descendants for alternatives", () => {
expect(newestBranchTipInGraph(graph, "a1", new Set(["u1", "a1"]), "a1")).toBe("a1");
expect(newestBranchTipInGraph(graph, "a2", new Set(["u1", "a1"]), "a1")).toBe("u2");
});
test("does not mix unrelated root messages into first-message edit branches", () => {
const editedRoot = {...node("u3", null, "2026-01-01T00:00:04Z"), origin: {type: "user", sourceMessageId: "u1"}};
const unrelated = node("u4", null, "2026-01-01T00:00:05Z");
const roots = mergeMessageGraph([...graph.values()], [editedRoot, unrelated]);
expect(rootEditAlternativesInGraph(roots, "u3").map((message) => message.id)).toEqual(["u1", "u3"]);
});
});
+62
View File
@@ -0,0 +1,62 @@
import type {StoredChatMessage} from "./conversation-types";
export function mergeMessageGraph(...groups: StoredChatMessage[][]) {
return new Map(groups.flat().map((message) => [message.id, message]));
}
export function messagePathInGraph(messages: Map<string, StoredChatMessage>, headMessageId: string | null) {
const reversed: StoredChatMessage[] = [];
const seen = new Set<string>();
let cursor = headMessageId;
while (cursor) {
if (seen.has(cursor) || reversed.length >= 500) return [];
seen.add(cursor);
const message = messages.get(cursor);
if (!message) return [];
reversed.push(message);
cursor = message.parentMessageId;
}
return reversed.reverse();
}
export function messageChildrenInGraph(messages: Map<string, StoredChatMessage>, parentMessageId: string | null) {
return [...messages.values()]
.filter((message) => message.parentMessageId === parentMessageId)
.sort((left, right) => left.createdAt.localeCompare(right.createdAt) || left.id.localeCompare(right.id));
}
export function rootEditAlternativesInGraph(messages: Map<string, StoredChatMessage>, messageId: string) {
const roots = [...messages.values()].filter((message) => message.parentMessageId === null);
const related = new Set([messageId]);
let changed = true;
while (changed) {
changed = false;
for (const message of roots) {
const sourceMessageId = "sourceMessageId" in message.origin ? message.origin.sourceMessageId : undefined;
if (!related.has(message.id) && (!sourceMessageId || !related.has(sourceMessageId))) continue;
if (!related.has(message.id)) { related.add(message.id); changed = true; }
if (sourceMessageId && !related.has(sourceMessageId)) { related.add(sourceMessageId); changed = true; }
}
}
return roots
.filter((message) => related.has(message.id))
.sort((left, right) => left.createdAt.localeCompare(right.createdAt) || left.id.localeCompare(right.id));
}
export function newestBranchTipInGraph(
messages: Map<string, StoredChatMessage>,
startId: string,
currentPathIds: Set<string>,
currentHeadMessageId: string | null
) {
if (currentPathIds.has(startId)) return currentHeadMessageId || startId;
let cursor = startId;
const seen = new Set<string>();
while (!seen.has(cursor)) {
seen.add(cursor);
const children = messageChildrenInGraph(messages, cursor);
if (!children.length) return cursor;
cursor = children.at(-1)!.id;
}
return startId;
}
+29
View File
@@ -0,0 +1,29 @@
import type {StoredChatMessage} from "./conversation-types";
function canonicalValue(value: unknown): unknown {
if (Array.isArray(value)) return value.map(canonicalValue);
if (!value || typeof value !== "object") return value;
return Object.fromEntries(Object.entries(value as Record<string, unknown>)
.filter(([, entry]) => entry !== undefined)
.sort(([left], [right]) => left.localeCompare(right))
.map(([key, entry]) => [key, canonicalValue(entry)]));
}
export function canonicalMessage(message: Omit<StoredChatMessage, "id">) {
return JSON.stringify(canonicalValue(message));
}
export async function messageObjectId(message: Omit<StoredChatMessage, "id">, namespace = "") {
const digest = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(`${namespace}\0${canonicalMessage(message)}`));
return `sha256:${[...new Uint8Array(digest)].map((byte) => byte.toString(16).padStart(2, "0")).join("")}`;
}
export async function createMessageObject(message: Omit<StoredChatMessage, "id">, namespace = ""): Promise<StoredChatMessage> {
return {...message, id: await messageObjectId(message, namespace)};
}
export async function validMessageObjectId(message: StoredChatMessage, namespace = "") {
if (!message.id.startsWith("sha256:")) return false;
const {id: _id, ...content} = message;
return message.id === await messageObjectId(content, namespace);
}
+14
View File
@@ -0,0 +1,14 @@
import {describe, expect, test} from "bun:test";
import {compactModelName} from "./model-display.ts";
describe("compact model names", () => {
test("hides an Ollama model tag in compact UI", () => {
expect(compactModelName("gemma4:e4b-it-qat")).toBe("gemma4");
expect(compactModelName("qwen3.5:9b-q4_K_M")).toBe("qwen3.5");
});
test("keeps ordinary model identifiers unchanged", () => {
expect(compactModelName("claude-sonnet-4-6")).toBe("claude-sonnet-4-6");
expect(compactModelName("Qwen3.5-9B-Q4_K_M.gguf")).toBe("Qwen3.5-9B-Q4_K_M.gguf");
});
});
+5
View File
@@ -0,0 +1,5 @@
export function compactModelName(value: unknown) {
const name = String(value ?? "").trim();
const tagIndex = name.indexOf(":");
return tagIndex > 0 ? name.slice(0, tagIndex) : name;
}
+763
View File
@@ -0,0 +1,763 @@
import type {Conversation, ConversationRefState, ConversationSummary, RepositoryFetch, RepositoryRefUpdate, StoredChatMessage, WorkingItem} from "./conversation-types";
const databaseName = "xiteng-chat-offline";
const databaseVersion = 3;
const activeProfileKey = "xiteng-chat-offline-profile";
type CachedProfile<T = unknown> = {
id: string;
config: T;
summaries: ConversationSummary[];
updatedAt: string;
lastFetchAt?: string;
};
type CachedConversationRef = Omit<Conversation, "messages"> & {
cacheKey: string;
profileId: string;
messages?: StoredChatMessage[];
};
type CachedMessage = StoredChatMessage & {cacheKey: string; profileId: string};
type CachedWorkingItem = WorkingItem & {cacheKey: string; profileId: string};
type CachedReflog = {
cacheKey: string;
profileId: string;
conversationId: string;
oldHeadMessageId: string | null;
newHeadMessageId: string | null;
reason: "commit" | "create" | "fetch" | "reset" | "rename" | "delete";
createdAt: string;
};
export type RepositoryOutboxRecord = {
cacheKey: string;
profileId: string;
conversationId: string;
objectIds: string[];
expectedHeadMessageId: string | null;
expectedHeadVersion: number;
expectedMetadataVersion: number;
createdAt: string;
updatedAt: string;
};
export type PendingConversationChange = {
cacheKey: string;
profileId: string;
conversationId: string;
requestPath?: string;
method: "POST" | "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");
}
if (!database.objectStoreNames.contains("messages")) {
const messages = database.createObjectStore("messages", {keyPath: "cacheKey"});
messages.createIndex("profileId", "profileId");
}
if (!database.objectStoreNames.contains("working")) {
const working = database.createObjectStore("working", {keyPath: "cacheKey"});
working.createIndex("profileId", "profileId");
working.createIndex("profileConversation", ["profileId", "conversationId"]);
}
if (!database.objectStoreNames.contains("reflog")) {
const reflog = database.createObjectStore("reflog", {keyPath: "cacheKey"});
reflog.createIndex("profileConversation", ["profileId", "conversationId"]);
}
if (!database.objectStoreNames.contains("repositoryOutbox")) {
const outbox = database.createObjectStore("repositoryOutbox", {keyPath: "cacheKey"});
outbox.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 profileCacheKey(profileId: string, id: string) {
return `${profileId}:${id}`;
}
function normalizedCachedMessage(message: Partial<StoredChatMessage>, parentMessageId: string | null, timestamp: string): StoredChatMessage {
const role = message.role || "user";
return {
id: message.id || crypto.randomUUID(),
parentMessageId: message.parentMessageId === undefined ? parentMessageId : message.parentMessageId,
role,
parts: Array.isArray(message.parts) ? message.parts : [],
origin: message.origin || (role === "user" ? {type: "user"} : role === "system" ? {type: "system", source: "legacy-cache"} : {type: "legacy"}),
completion: message.completion || {status: "complete"},
createdAt: message.createdAt || timestamp,
completedAt: message.completedAt || timestamp,
...(message.metadata ? {metadata: message.metadata} : {})
};
}
function normalizedConversationSummary(summary: Partial<ConversationSummary> & {title?: unknown}): ConversationSummary | null {
if (typeof summary.id !== "string" || !summary.id) return null;
const timestamp = new Date().toISOString();
return {
id: summary.id,
name: typeof summary.name === "string" ? summary.name : typeof summary.title === "string" ? summary.title : "",
headMessageId: typeof summary.headMessageId === "string" ? summary.headMessageId : null,
providerId: typeof summary.providerId === "string" ? summary.providerId : "",
model: typeof summary.model === "string" ? summary.model : "",
messageCount: typeof summary.messageCount === "number" && Number.isFinite(summary.messageCount) ? summary.messageCount : 0,
createdAt: typeof summary.createdAt === "string" ? summary.createdAt : timestamp,
updatedAt: typeof summary.updatedAt === "string" ? summary.updatedAt : typeof summary.createdAt === "string" ? summary.createdAt : timestamp,
...(summary.upstreamHeadMessageId === null || typeof summary.upstreamHeadMessageId === "string" ? {upstreamHeadMessageId: summary.upstreamHeadMessageId} : {}),
...(typeof summary.headVersion === "number" ? {headVersion: summary.headVersion} : {}),
...(typeof summary.metadataVersion === "number" ? {metadataVersion: summary.metadataVersion} : {})
};
}
export function activateOfflineProfile(profileId: string) {
window.localStorage.setItem(activeProfileKey, profileId);
}
export function activeOfflineProfileId() {
return activeProfileId();
}
export async function mergeOfflineProfiles(sourceProfileId: string, targetProfileId: string) {
if (!sourceProfileId || sourceProfileId === targetProfileId) return;
const database = await openDatabase();
await new Promise<void>((resolve, reject) => {
const storeNames = ["profiles", "conversations", "pending", "messages", "working", "reflog", "repositoryOutbox"];
const current = database.transaction(storeNames, "readwrite");
const profiles = current.objectStore("profiles");
const sourceProfileRequest = profiles.get(sourceProfileId);
const targetProfileRequest = profiles.get(targetProfileId);
let sourceProfile: CachedProfile | undefined;
let targetProfile: CachedProfile | undefined;
const profileReady = () => {
if (sourceProfileRequest.readyState !== "done" || targetProfileRequest.readyState !== "done") return;
sourceProfile = sourceProfileRequest.result as CachedProfile | undefined;
targetProfile = targetProfileRequest.result as CachedProfile | undefined;
if (!sourceProfile) return;
const summaries = new Map<string, ConversationSummary>();
for (const summary of [...(sourceProfile.summaries || []), ...(targetProfile?.summaries || [])]) {
const existing = summaries.get(summary.id);
if (!existing || summary.updatedAt > existing.updatedAt) summaries.set(summary.id, summary);
}
profiles.put({
...(sourceProfile || {}),
...(targetProfile || {}),
id: targetProfileId,
summaries: [...summaries.values()].sort((left, right) => right.updatedAt.localeCompare(left.updatedAt)),
updatedAt: new Date().toISOString(),
lastFetchAt: targetProfile?.lastFetchAt || sourceProfile.lastFetchAt
} satisfies CachedProfile);
};
sourceProfileRequest.onsuccess = profileReady;
targetProfileRequest.onsuccess = profileReady;
for (const storeName of storeNames.slice(1)) {
const store = current.objectStore(storeName);
const indexName = storeName === "reflog" ? "profileConversation" : "profileId";
const range = storeName === "reflog"
? IDBKeyRange.bound([sourceProfileId, ""], [sourceProfileId, "\uffff"])
: IDBKeyRange.only(sourceProfileId);
const request = store.index(indexName).getAll(range);
request.onsuccess = () => {
for (const raw of request.result as Array<Record<string, unknown>>) {
const oldKey = String(raw.cacheKey || "");
const suffix = oldKey.startsWith(`${sourceProfileId}:`) ? oldKey.slice(sourceProfileId.length) : `:${crypto.randomUUID()}`;
const migrated = {...raw, profileId: targetProfileId, cacheKey: `${targetProfileId}${suffix}`};
const existingRequest = store.get(migrated.cacheKey as IDBValidKey);
existingRequest.onsuccess = () => {
const existing = existingRequest.result as Record<string, unknown> | undefined;
if (!existing || String(raw.updatedAt || raw.createdAt || "") > String(existing.updatedAt || existing.createdAt || "")) store.put(migrated);
};
}
};
}
current.oncomplete = () => { database.close(); resolve(); };
current.onerror = () => { database.close(); reject(current.error || new Error("Unable to merge local repositories")); };
current.onabort = () => { database.close(); reject(current.error || new Error("Local repository merge was aborted")); };
});
}
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(),
lastFetchAt: current?.lastFetchAt
};
await transaction<IDBValidKey>("profiles", "readwrite", (store) => store.put(profile));
}
export async function loadCachedChatConfig<T>(requestedProfileId?: string) {
const profileId = requestedProfileId || 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, lastFetchAt: profile.lastFetchAt || ""} : null;
}
export async function cachedLastFetchAt() {
const profileId = activeProfileId();
if (!profileId) return "";
const profile = await transaction<CachedProfile | undefined>("profiles", "readonly", (store) => store.get(profileId));
return profile?.lastFetchAt || "";
}
export async function recordRepositoryFetch(timestamp: string) {
const profileId = activeProfileId();
if (!profileId) return;
const profile = await transaction<CachedProfile | undefined>("profiles", "readonly", (store) => store.get(profileId));
if (!profile) return;
await transaction<IDBValidKey>("profiles", "readwrite", (store) => store.put({...profile, lastFetchAt: timestamp}));
}
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;
const normalized = summaries.map((summary) => normalizedConversationSummary(summary)).filter((summary): summary is ConversationSummary => Boolean(summary));
await transaction<IDBValidKey>("profiles", "readwrite", (store) => store.put({...current, summaries: normalized, 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 || []).map((summary) => normalizedConversationSummary(summary)).filter((summary): summary is ConversationSummary => Boolean(summary));
}
export async function cacheConversation(conversation: Conversation) {
const profileId = activeProfileId();
if (!profileId) return;
let parentMessageId: string | null = null;
for (const candidate of conversation.messages) {
const message = normalizedCachedMessage(candidate, parentMessageId, conversation.updatedAt);
const record: CachedMessage = {...message, cacheKey: profileCacheKey(profileId, message.id), profileId};
await transaction<IDBValidKey>("messages", "readwrite", (store) => store.put(record));
parentMessageId = message.id;
}
const {messages: _messages, ...summary} = conversation;
const ref: CachedConversationRef = {
...summary,
upstreamHeadMessageId: conversation.upstreamHeadMessageId === undefined ? conversation.headMessageId : conversation.upstreamHeadMessageId,
headVersion: conversation.headVersion || 0,
metadataVersion: conversation.metadataVersion || 0,
cacheKey: profileCacheKey(profileId, conversation.id),
profileId
};
await transaction<IDBValidKey>("conversations", "readwrite", (store) => store.put(ref));
await cacheConversationSummaries([
summary,
...(await loadCachedConversationSummaries()).filter((item) => item.id !== conversation.id)
].sort((left, right) => right.updatedAt.localeCompare(left.updatedAt)));
}
async function loadCachedMessage(profileId: string, id: string) {
const record = await transaction<CachedMessage | undefined>("messages", "readonly", (store) => store.get(profileCacheKey(profileId, id)));
if (!record) return null;
const {cacheKey: _cacheKey, profileId: _profileId, ...message} = record;
return message;
}
export async function loadCachedConversation(id: string) {
const profileId = activeProfileId();
if (!profileId) return null;
const record = await transaction<CachedConversationRef | undefined>("conversations", "readonly", (store) => store.get(profileCacheKey(profileId, id)));
if (!record) return null;
const {cacheKey: _cacheKey, profileId: _profileId, messages: legacyMessages, ...conversation} = record;
const normalizedSummary = normalizedConversationSummary(conversation as Partial<ConversationSummary> & {title?: unknown});
if (!normalizedSummary) return null;
if (legacyMessages) {
const normalized: Conversation = {
...conversation,
...normalizedSummary,
headMessageId: normalizedSummary.headMessageId || legacyMessages.at(-1)?.id || null,
messages: legacyMessages.map((message, index) => normalizedCachedMessage(message, index ? legacyMessages[index - 1].id : null, conversation.updatedAt))
};
await cacheConversation(normalized);
return normalized;
}
const reversed: StoredChatMessage[] = [];
const seen = new Set<string>();
let messageId = conversation.headMessageId;
while (messageId) {
if (seen.has(messageId) || reversed.length >= 500) return null;
seen.add(messageId);
const message = await loadCachedMessage(profileId, messageId);
if (!message) return null;
reversed.push(message);
messageId = message.parentMessageId;
}
return {...conversation, ...normalizedSummary, messages: reversed.reverse()};
}
export async function removeCachedConversation(id: string) {
const profileId = activeProfileId();
if (!profileId) return;
await transaction<undefined>("conversations", "readwrite", (store) => store.delete(profileCacheKey(profileId, id)));
await transaction<undefined>("repositoryOutbox", "readwrite", (store) => store.delete(repositoryOutboxKey(profileId, id)));
await cacheConversationSummaries((await loadCachedConversationSummaries()).filter((conversation) => conversation.id !== id));
}
export async function deleteLocalConversation(id: string) {
const profileId = activeProfileId();
if (!profileId) return;
const conversation = await loadCachedConversation(id);
await transaction<undefined>("conversations", "readwrite", (store) => store.delete(profileCacheKey(profileId, id)));
await cacheConversationSummaries((await loadCachedConversationSummaries()).filter((item) => item.id !== id));
if (conversation?.upstreamHeadMessageId !== undefined && ((conversation.headVersion || 0) > 0 || (conversation.metadataVersion || 0) > 0)) {
await queueConversationChange({conversationId: id, method: "DELETE"});
}
await transaction<undefined>("repositoryOutbox", "readwrite", (store) => store.delete(repositoryOutboxKey(profileId, id)));
}
export async function saveWorkingItem(item: WorkingItem) {
const profileId = activeProfileId();
if (!profileId) return item;
const record: CachedWorkingItem = {...item, cacheKey: profileCacheKey(profileId, item.id), profileId};
await transaction<IDBValidKey>("working", "readwrite", (store) => store.put(record));
return item;
}
export async function listWorkingItems(conversationId?: string) {
const profileId = activeProfileId();
if (!profileId) return [];
const database = await openDatabase();
return new Promise<WorkingItem[]>((resolve, reject) => {
const current = database.transaction("working", "readonly");
const store = current.objectStore("working");
const request = conversationId
? store.index("profileConversation").getAll([profileId, conversationId])
: store.index("profileId").getAll(profileId);
request.onsuccess = () => resolve((request.result as CachedWorkingItem[])
.map(({cacheKey: _cacheKey, profileId: _profileId, ...item}) => item)
.sort((left, right) => right.updatedAt.localeCompare(left.updatedAt)));
request.onerror = () => reject(request.error || new Error("Unable to read working items"));
current.oncomplete = () => database.close();
});
}
export async function removeWorkingItem(id: string) {
const profileId = activeProfileId();
if (!profileId) return;
await transaction<undefined>("working", "readwrite", (store) => store.delete(profileCacheKey(profileId, id)));
}
export async function listCachedObjectIds() {
const profileId = activeProfileId();
if (!profileId) return [];
const database = await openDatabase();
return new Promise<string[]>((resolve, reject) => {
const current = database.transaction("messages", "readonly");
const request = current.objectStore("messages").index("profileId").getAllKeys(IDBKeyRange.only(profileId));
request.onsuccess = () => resolve(request.result.map((key) => String(key).slice(profileId.length + 1)));
request.onerror = () => reject(request.error || new Error("Unable to list local objects"));
current.oncomplete = () => database.close();
});
}
export async function listCachedMessages() {
const profileId = activeProfileId();
if (!profileId) return [];
const database = await openDatabase();
return new Promise<StoredChatMessage[]>((resolve, reject) => {
const current = database.transaction("messages", "readonly");
const request = current.objectStore("messages").index("profileId").getAll(profileId);
request.onsuccess = () => resolve((request.result as CachedMessage[]).map(({cacheKey: _cacheKey, profileId: _profileId, ...message}) => message));
request.onerror = () => reject(request.error || new Error("Unable to list local message objects"));
current.oncomplete = () => database.close();
});
}
function repositoryOutboxKey(profileId: string, conversationId: string) {
return `${profileId}:${conversationId}:repository`;
}
export async function commitLocalMessage(conversationId: string, message: StoredChatMessage) {
const profileId = activeProfileId();
if (!profileId) throw new Error("Local repository profile is unavailable");
const database = await openDatabase();
await new Promise<void>((resolve, reject) => {
const current = database.transaction(["conversations", "messages", "reflog", "repositoryOutbox"], "readwrite");
const refs = current.objectStore("conversations");
const objects = current.objectStore("messages");
const reflog = current.objectStore("reflog");
const outbox = current.objectStore("repositoryOutbox");
const refKey = profileCacheKey(profileId, conversationId);
const outboxKey = repositoryOutboxKey(profileId, conversationId);
const refRequest = refs.get(refKey);
refRequest.onsuccess = () => {
const ref = refRequest.result as CachedConversationRef | undefined;
if (!ref) {
current.abort();
reject(new Error("Local conversation ref is unavailable"));
return;
}
if (ref.headMessageId !== message.parentMessageId) {
current.abort();
reject(new Error("Local conversation head changed"));
return;
}
const existingOutboxRequest = outbox.get(outboxKey);
existingOutboxRequest.onsuccess = () => {
const timestamp = new Date().toISOString();
const existing = existingOutboxRequest.result as RepositoryOutboxRecord | undefined;
const object: CachedMessage = {...message, cacheKey: profileCacheKey(profileId, message.id), profileId};
objects.put(object);
refs.put({...ref, headMessageId: message.id, messageCount: ref.messageCount + 1, updatedAt: timestamp});
const log: CachedReflog = {
cacheKey: `${profileId}:${conversationId}:${timestamp}:${crypto.randomUUID()}`,
profileId,
conversationId,
oldHeadMessageId: ref.headMessageId,
newHeadMessageId: message.id,
reason: "commit",
createdAt: timestamp
};
reflog.put(log);
outbox.put({
cacheKey: outboxKey,
profileId,
conversationId,
objectIds: [...new Set([...(existing?.objectIds || []), message.id])],
expectedHeadMessageId: existing?.expectedHeadMessageId ?? ref.upstreamHeadMessageId ?? null,
expectedHeadVersion: existing?.expectedHeadVersion ?? ref.headVersion ?? 0,
expectedMetadataVersion: existing?.expectedMetadataVersion ?? ref.metadataVersion ?? 0,
createdAt: existing?.createdAt || timestamp,
updatedAt: timestamp
} satisfies RepositoryOutboxRecord);
};
};
current.oncomplete = () => { database.close(); resolve(); };
current.onerror = () => { database.close(); reject(current.error || new Error("Local commit failed")); };
current.onabort = () => database.close();
});
const conversation = await loadCachedConversation(conversationId);
if (!conversation) throw new Error("Local commit could not be loaded");
await cacheConversationSummariesFromConversation(conversation);
return conversation;
}
export async function moveLocalConversationHead(conversationId: string, headMessageId: string | null) {
const profileId = activeProfileId();
if (!profileId) throw new Error("Local repository profile is unavailable");
const conversation = await loadCachedConversation(conversationId);
if (!conversation) throw new Error("Local conversation ref is unavailable");
const targetPath = await messagePathFromCache(profileId, headMessageId);
if (headMessageId !== null && targetPath.at(-1)?.id !== headMessageId) throw new Error("Target message is unavailable in the local graph");
if (conversation.headMessageId === headMessageId) return conversation;
const database = await openDatabase();
await new Promise<void>((resolve, reject) => {
const current = database.transaction(["conversations", "reflog", "repositoryOutbox"], "readwrite");
const refs = current.objectStore("conversations");
const reflog = current.objectStore("reflog");
const outbox = current.objectStore("repositoryOutbox");
const refKey = profileCacheKey(profileId, conversationId);
const outboxKey = repositoryOutboxKey(profileId, conversationId);
const refRequest = refs.get(refKey);
refRequest.onsuccess = () => {
const ref = refRequest.result as CachedConversationRef | undefined;
if (!ref || ref.headMessageId !== conversation.headMessageId) {
current.abort();
reject(new Error("Local conversation head changed"));
return;
}
const outboxRequest = outbox.get(outboxKey);
outboxRequest.onsuccess = () => {
const timestamp = new Date().toISOString();
const existing = outboxRequest.result as RepositoryOutboxRecord | undefined;
refs.put({...ref, headMessageId, messageCount: targetPath.length, updatedAt: timestamp});
reflog.put({
cacheKey: `${profileId}:${conversationId}:${timestamp}:${crypto.randomUUID()}`,
profileId,
conversationId,
oldHeadMessageId: ref.headMessageId,
newHeadMessageId: headMessageId,
reason: "reset",
createdAt: timestamp
} satisfies CachedReflog);
outbox.put({
cacheKey: outboxKey,
profileId,
conversationId,
objectIds: existing?.objectIds || [],
expectedHeadMessageId: existing?.expectedHeadMessageId ?? ref.upstreamHeadMessageId ?? null,
expectedHeadVersion: existing?.expectedHeadVersion ?? ref.headVersion ?? 0,
expectedMetadataVersion: existing?.expectedMetadataVersion ?? ref.metadataVersion ?? 0,
createdAt: existing?.createdAt || timestamp,
updatedAt: timestamp
} satisfies RepositoryOutboxRecord);
};
};
current.oncomplete = () => { database.close(); resolve(); };
current.onerror = () => { database.close(); reject(current.error || new Error("Unable to move local conversation head")); };
current.onabort = () => database.close();
});
const updated = await loadCachedConversation(conversationId);
if (!updated) throw new Error("Moved conversation could not be loaded");
await cacheConversationSummariesFromConversation(updated);
return updated;
}
async function cacheConversationSummariesFromConversation(conversation: Conversation) {
const summary: ConversationSummary = {
id: conversation.id,
name: conversation.name,
headMessageId: conversation.headMessageId,
providerId: conversation.providerId,
model: conversation.model,
messageCount: conversation.messageCount,
createdAt: conversation.createdAt,
updatedAt: conversation.updatedAt,
upstreamHeadMessageId: conversation.upstreamHeadMessageId,
headVersion: conversation.headVersion,
metadataVersion: conversation.metadataVersion
};
await cacheConversationSummaries([summary, ...(await loadCachedConversationSummaries()).filter((item) => item.id !== conversation.id)]
.sort((left, right) => right.updatedAt.localeCompare(left.updatedAt)));
}
export async function createLocalConversation(conversation: Conversation) {
const profileId = activeProfileId();
if (!profileId) throw new Error("Local repository profile is unavailable");
const timestamp = new Date().toISOString();
const local: Conversation = {...conversation, upstreamHeadMessageId: null, headVersion: 0, metadataVersion: 0};
const database = await openDatabase();
await new Promise<void>((resolve, reject) => {
const current = database.transaction(["conversations", "messages", "reflog", "repositoryOutbox"], "readwrite");
const refs = current.objectStore("conversations");
const objects = current.objectStore("messages");
const reflog = current.objectStore("reflog");
const outbox = current.objectStore("repositoryOutbox");
for (const message of local.messages) objects.put({...message, cacheKey: profileCacheKey(profileId, message.id), profileId} satisfies CachedMessage);
const {messages: _messages, ...summary} = local;
refs.put({...summary, cacheKey: profileCacheKey(profileId, local.id), profileId} satisfies CachedConversationRef);
reflog.put({
cacheKey: `${profileId}:${local.id}:${timestamp}:${crypto.randomUUID()}`,
profileId,
conversationId: local.id,
oldHeadMessageId: null,
newHeadMessageId: local.headMessageId,
reason: "create",
createdAt: timestamp
} satisfies CachedReflog);
outbox.put({
cacheKey: repositoryOutboxKey(profileId, local.id),
profileId,
conversationId: local.id,
objectIds: local.messages.filter((message) => message.id.startsWith("sha256:")).map((message) => message.id),
expectedHeadMessageId: null,
expectedHeadVersion: 0,
expectedMetadataVersion: 0,
createdAt: timestamp,
updatedAt: timestamp
} satisfies RepositoryOutboxRecord);
current.oncomplete = () => { database.close(); resolve(); };
current.onerror = () => { database.close(); reject(current.error || new Error("Unable to create local ref")); };
});
await cacheConversationSummariesFromConversation(local);
return local;
}
export async function queueLocalRefUpdate(conversation: Conversation) {
const profileId = activeProfileId();
if (!profileId) throw new Error("Local repository profile is unavailable");
await cacheConversation(conversation);
const key = repositoryOutboxKey(profileId, conversation.id);
const existing = await transaction<RepositoryOutboxRecord | undefined>("repositoryOutbox", "readonly", (store) => store.get(key));
const timestamp = new Date().toISOString();
const record: RepositoryOutboxRecord = {
cacheKey: key,
profileId,
conversationId: conversation.id,
objectIds: existing?.objectIds || [],
expectedHeadMessageId: existing?.expectedHeadMessageId ?? conversation.upstreamHeadMessageId ?? null,
expectedHeadVersion: existing?.expectedHeadVersion ?? conversation.headVersion ?? 0,
expectedMetadataVersion: existing?.expectedMetadataVersion ?? conversation.metadataVersion ?? 0,
createdAt: existing?.createdAt || timestamp,
updatedAt: timestamp
};
await transaction<IDBValidKey>("repositoryOutbox", "readwrite", (store) => store.put(record));
return conversation;
}
export async function repositoryPushPayload() {
const profileId = activeProfileId();
if (!profileId) return {repositoryId: "", objects: [] as StoredChatMessage[], refs: [] as RepositoryRefUpdate[]};
const database = await openDatabase();
const outbox = await new Promise<RepositoryOutboxRecord[]>((resolve, reject) => {
const current = database.transaction("repositoryOutbox", "readonly");
const request = current.objectStore("repositoryOutbox").index("profileId").getAll(profileId);
request.onsuccess = () => resolve(request.result);
request.onerror = () => reject(request.error);
current.oncomplete = () => database.close();
});
const objects: StoredChatMessage[] = [];
const refs: RepositoryRefUpdate[] = [];
for (const pending of outbox) {
const conversation = await loadCachedConversation(pending.conversationId);
if (!conversation) continue;
for (const id of pending.objectIds) {
const object = await loadCachedMessage(profileId, id);
if (object) objects.push(object);
}
refs.push({
conversationId: conversation.id,
expectedHeadMessageId: pending.expectedHeadMessageId,
expectedHeadVersion: pending.expectedHeadVersion,
expectedMetadataVersion: pending.expectedMetadataVersion,
headMessageId: conversation.headMessageId,
name: conversation.name,
providerId: conversation.providerId,
model: conversation.model,
generationSettings: conversation.generationSettings,
createdAt: conversation.createdAt,
updatedAt: conversation.updatedAt
});
}
return {repositoryId: profileId, objects, refs};
}
export async function applyRepositoryFetch(repository: RepositoryFetch) {
const profileId = activeProfileId();
if (!profileId) return;
for (const object of repository.objects) {
await transaction<IDBValidKey>("messages", "readwrite", (store) => store.put({...object, cacheKey: profileCacheKey(profileId, object.id), profileId}));
}
for (const remote of repository.refs) {
const local = await loadCachedConversation(remote.id);
const pending = await transaction<RepositoryOutboxRecord | undefined>("repositoryOutbox", "readonly", (store) => store.get(repositoryOutboxKey(profileId, remote.id)));
const canFastForward = !local || (!pending && local.headMessageId === (local.upstreamHeadMessageId ?? local.headMessageId));
const headMessageId = canFastForward ? remote.headMessageId : local!.headMessageId;
const messages = await messagePathFromCache(profileId, headMessageId);
const conversation: Conversation = {
id: remote.id,
name: pending && local ? local.name : remote.name,
headMessageId,
upstreamHeadMessageId: remote.headMessageId,
providerId: pending && local ? local.providerId : remote.providerId,
model: pending && local ? local.model : remote.model,
generationSettings: pending && local ? local.generationSettings : remote.generationSettings,
headVersion: remote.headVersion,
metadataVersion: remote.metadataVersion,
messageCount: messages.length,
createdAt: remote.createdAt,
updatedAt: pending && local ? local.updatedAt : remote.updatedAt,
messages
};
await cacheConversation(conversation);
}
const remoteIds = new Set(repository.refs.map((ref) => ref.id));
for (const local of await loadCachedConversationSummaries()) {
if (remoteIds.has(local.id)) continue;
const pending = await transaction<RepositoryOutboxRecord | undefined>("repositoryOutbox", "readonly", (store) => store.get(repositoryOutboxKey(profileId, local.id)));
if (!pending && ((local.headVersion || 0) > 0 || (local.metadataVersion || 0) > 0)) await removeCachedConversation(local.id);
}
await recordRepositoryFetch(repository.fetchedAt);
}
async function messagePathFromCache(profileId: string, headMessageId: string | null) {
const reversed: StoredChatMessage[] = [];
const seen = new Set<string>();
let id = headMessageId;
while (id) {
if (seen.has(id) || reversed.length >= 500) throw new Error("Local object history is cyclic or too long");
seen.add(id);
const object = await loadCachedMessage(profileId, id);
if (!object) throw new Error(`Local object ${id} is unavailable`);
reversed.push(object);
id = object.parentMessageId;
}
return reversed.reverse();
}
export async function applyRepositoryPushResults(results: Array<{conversationId: string; status: "ok" | "conflict"; ref: ConversationRefState | null}>) {
const profileId = activeProfileId();
if (!profileId) return;
for (const result of results) {
const local = await loadCachedConversation(result.conversationId);
if (!local || !result.ref) continue;
if (result.status === "ok") {
await cacheConversation({...local, upstreamHeadMessageId: result.ref.headMessageId, headVersion: result.ref.headVersion, metadataVersion: result.ref.metadataVersion});
const key = repositoryOutboxKey(profileId, result.conversationId);
if (local.headMessageId === result.ref.headMessageId) {
await transaction<undefined>("repositoryOutbox", "readwrite", (store) => store.delete(key));
} else {
const pending = await transaction<RepositoryOutboxRecord | undefined>("repositoryOutbox", "readonly", (store) => store.get(key));
if (pending) await transaction<IDBValidKey>("repositoryOutbox", "readwrite", (store) => store.put({
...pending,
expectedHeadMessageId: result.ref!.headMessageId,
expectedHeadVersion: result.ref!.headVersion,
expectedMetadataVersion: result.ref!.metadataVersion
}));
}
} else {
await cacheConversation({...local, upstreamHeadMessageId: result.ref.headMessageId, headVersion: result.ref.headVersion, metadataVersion: result.ref.metadataVersion});
}
}
}
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}:${change.requestPath || "conversation"}`,
profileId,
createdAt: new Date().toISOString()
};
await transaction<IDBValidKey>("pending", "readwrite", (store) => store.put(record));
}
export async function listPendingConversationChanges() {
const profileId = activeProfileId();
if (!profileId) return [];
const database = await openDatabase();
return new Promise<PendingConversationChange[]>((resolve, reject) => {
const current = database.transaction("pending", "readonly");
const request = current.objectStore("pending").index("profileId").getAll(profileId);
request.onsuccess = () => resolve(request.result.sort((left, right) => left.createdAt.localeCompare(right.createdAt)));
request.onerror = () => reject(request.error || new Error("Unable to read pending history changes"));
current.oncomplete = () => database.close();
});
}
export async function removePendingConversationChange(cacheKey: string) {
await transaction<undefined>("pending", "readwrite", (store) => store.delete(cacheKey));
}
+5
View File
@@ -0,0 +1,5 @@
export type ChatProfile = {
username: string;
name: string;
email: string;
};
+188
View File
@@ -0,0 +1,188 @@
import {applyProviderAuthentication} from "./provider-model";
import type {ProviderDefinition, ProviderModel, ProviderSecret} from "./provider-types";
type ProviderFetch = (input: RequestInfo | URL, init?: RequestInit) => Promise<Response>;
type JsonRecord = Record<string, unknown>;
function accountEndpoints(provider: ProviderDefinition) {
const baseUrl = provider.connection.baseUrl.replace(/\/+$/, "");
const url = new URL(baseUrl);
if (url.hostname === "openrouter.ai") return ["https://openrouter.ai/api/v1/auth/key"];
if (url.hostname === "api.deepseek.com") return [`${url.origin}/user/balance`];
if (url.hostname === "api.moonshot.cn") return [`${baseUrl}/users/me/balance`];
if (url.hostname === "api.openai.com") return [`${baseUrl}/dashboard/billing/credit_grants`];
if (["openai-completions", "openai-responses"].includes(provider.api)) return [`${baseUrl}/dashboard/billing/credit_grants`];
return [];
}
async function responseText(response: Response, maximum = 4 * 1024 * 1024) {
const text = await response.text();
if (text.length > maximum) throw new Error("Provider response is too large");
return text;
}
function jsonRecord(value: unknown): JsonRecord | null {
return value && typeof value === "object" && !Array.isArray(value) ? value as JsonRecord : null;
}
function publicMetadata(value: unknown, depth = 0): unknown {
if (value === null || ["string", "number", "boolean"].includes(typeof value)) return value;
if (depth >= 3) return undefined;
if (Array.isArray(value)) return value.slice(0, 20).map((item) => publicMetadata(item, depth + 1)).filter((item) => item !== undefined);
const record = jsonRecord(value);
if (!record) return undefined;
return Object.fromEntries(Object.entries(record).slice(0, 50).map(([key, item]) => [key, publicMetadata(item, depth + 1)]).filter((entry) => entry[1] !== undefined));
}
function selectedMetadata(record: JsonRecord, pattern: RegExp) {
return Object.fromEntries(Object.entries(record).filter(([key]) => pattern.test(key)).map(([key, value]) => [key, publicMetadata(value)]).filter((entry) => entry[1] !== undefined));
}
export function normalizeDiscoveredModels(payload: unknown): ProviderModel[] {
const root = jsonRecord(payload);
if (!root) return [];
const source = Array.isArray(root.data) ? root.data : Array.isArray(root.models) ? root.models : [];
return source.slice(0, 300).map((value) => {
const model = jsonRecord(value);
if (!model) return null;
const rawId = model.id || model.name || model.model;
if (typeof rawId !== "string" || !rawId.trim()) return null;
const id = rawId.replace(/^models\//, "");
const pricing = selectedMetadata(model, /price|pricing|cost|rate|token/i);
return {
id,
name: typeof model.displayName === "string" ? model.displayName : typeof model.name === "string" ? model.name.replace(/^models\//, "") : id,
...(typeof model.owned_by === "string" ? {ownedBy: model.owned_by} : {}),
...(Number.isFinite(model.context_length) ? {contextWindow: Number(model.context_length)} : {}),
...(Object.keys(pricing).length ? {pricing} : {})
};
}).filter((model): model is ProviderModel => model !== null);
}
function normalizeLlamaCppProps(payload: unknown, fallbackModel: string): ProviderModel[] {
const root = jsonRecord(payload);
if (!root) return [];
const modelPath = typeof root.model_path === "string" ? root.model_path : "";
const modelAlias = typeof root.model_alias === "string" ? root.model_alias : "";
const id = modelAlias.trim() || modelPath.split(/[\\/]/).filter(Boolean).at(-1) || fallbackModel;
const generationSettings = jsonRecord(root.default_generation_settings);
const contextWindow = Number(generationSettings?.n_ctx);
return [{
id,
name: id,
ownedBy: "llamacpp",
...(Number.isFinite(contextWindow) && contextWindow > 0 ? {contextWindow} : {})
}];
}
function responseRateLimits(response: Response) {
return Object.fromEntries([...response.headers.entries()].filter(([name]) => /rate.?limit|retry-after|quota/i.test(name)));
}
async function fetchJson(providerFetch: ProviderFetch, endpoint: string, headers: Headers, timeoutMs = 15000) {
const response = await providerFetch(endpoint, {
method: "GET",
headers,
signal: AbortSignal.timeout(timeoutMs)
});
const text = await responseText(response);
let payload: unknown = null;
try {
payload = text ? JSON.parse(text) : null;
} catch {
payload = {preview: text.slice(0, 1000)};
}
return {response, payload};
}
async function modelProbe(provider: ProviderDefinition, secret: ProviderSecret, providerFetch: ProviderFetch) {
const endpoint = provider.discovery.url;
const headers = applyProviderAuthentication(provider, secret, {
"Accept": "application/json",
...(provider.discovery.type === "anthropic-models-list" ? {"anthropic-version": "2023-06-01"} : {})
});
const startedAt = performance.now();
const initial = await fetchJson(providerFetch, endpoint, headers);
let {response, payload} = initial;
let resolvedEndpoint = endpoint;
let models = response.ok ? normalizeDiscoveredModels(payload) : [];
if (provider.id === "llama.cpp" && models.length === 0) {
const propsEndpoint = `${provider.connection.baseUrl.replace(/\/v1\/?$/, "")}/props`;
try {
const props = await fetchJson(providerFetch, propsEndpoint, headers, 5000);
const propsModels = props.response.ok ? normalizeLlamaCppProps(props.payload, provider.defaultModel) : [];
if (propsModels.length > 0) {
response = props.response;
payload = props.payload;
resolvedEndpoint = propsEndpoint;
models = propsModels;
}
} catch {
// Preserve the primary /v1/models error when the compatibility probe also fails.
}
}
const latencyMs = Math.max(0, Math.round(performance.now() - startedAt));
if (!response.ok) {
const root = jsonRecord(payload);
const detail = root?.preview || root?.error || root?.message;
const error = new Error(typeof detail === "string" ? detail : `Provider HTTP ${response.status}`);
Object.assign(error, {statusCode: response.status});
throw error;
}
return {endpoint: resolvedEndpoint, headers, response, payload, latencyMs, models};
}
export async function discoverProviderModels(
provider: ProviderDefinition,
secret: ProviderSecret,
providerFetch: ProviderFetch = fetch
) {
const probe = await modelProbe(provider, secret, providerFetch);
return {
endpoint: probe.endpoint,
status: probe.response.status,
latencyMs: probe.latencyMs,
models: probe.models
};
}
export async function testProviderConnectivity(
provider: ProviderDefinition,
secret: ProviderSecret,
providerFetch: ProviderFetch = fetch
) {
const probe = await modelProbe(provider, secret, providerFetch);
const {endpoint, headers, response, payload, latencyMs, models} = probe;
const root = jsonRecord(payload) || {};
const account = selectedMetadata(root, /balance|credit|quota|usage|limit|billing|currency/i);
const rateLimits = responseRateLimits(response);
const accountUrls = accountEndpoints(provider);
let accountProbe: unknown = null;
for (const accountUrl of accountUrls) {
try {
const probe = await fetchJson(providerFetch, accountUrl, headers, 5000);
if (!probe.response.ok) continue;
accountProbe = {
endpoint: accountUrl,
status: probe.response.status,
data: publicMetadata(jsonRecord(probe.payload)?.data || probe.payload)
};
break;
} catch {
// Account metadata is best-effort and must not fail a successful model probe.
}
}
return {
ok: true,
status: response.status,
latencyMs,
endpoint,
modelCount: models.length,
models,
account: Object.keys(account).length ? account : null,
accountProbe,
rateLimits: Object.keys(rateLimits).length ? rateLimits : null
};
}
+66
View File
@@ -0,0 +1,66 @@
import type {ProviderDefinition, ProviderSecret} from "./provider-types";
type ProviderFetch = (input: RequestInfo | URL, init?: RequestInit) => Promise<Response>;
export function applyProviderAuthentication(
provider: ProviderDefinition,
secret: ProviderSecret,
inputHeaders?: HeadersInit
) {
const headers = new Headers(inputHeaders);
headers.delete("authorization");
headers.delete("x-api-key");
headers.delete("x-goog-api-key");
for (const [name, value] of Object.entries(provider.headers || {})) headers.set(name, value);
for (const [name, value] of Object.entries(secret.provider?.headers || {})) headers.set(name, value);
const apiKey = secret.provider?.apiKey || "";
if (provider.auth.type === "bearer" && apiKey) headers.set("Authorization", `Bearer ${apiKey}`);
if (provider.auth.type === "header" && provider.auth.header && apiKey) headers.set(provider.auth.header, apiKey);
return headers;
}
export async function createProviderModel(
provider: ProviderDefinition,
secret: ProviderSecret,
modelId: string,
providerFetch: ProviderFetch
) {
const authenticatedFetch: ProviderFetch = (input, init = {}) => providerFetch(input, {
...init,
headers: applyProviderAuthentication(provider, secret, init.headers)
});
// Provider API comes from the runtime Registry; dynamic imports keep unused SDKs out of the initial client chunk.
if (provider.api === "openai-completions") {
const {createOpenAICompatible} = await import("@ai-sdk/openai-compatible");
return createOpenAICompatible({
name: provider.id,
baseURL: provider.connection.baseUrl,
fetch: authenticatedFetch as typeof fetch
}).chatModel(modelId);
}
if (provider.api === "openai-responses") {
const {createOpenAI} = await import("@ai-sdk/openai");
return createOpenAI({
name: provider.id,
baseURL: provider.connection.baseUrl,
apiKey: secret.provider?.apiKey || "browser-managed",
fetch: authenticatedFetch as typeof fetch
}).responses(modelId);
}
if (provider.api === "anthropic-messages") {
const {createAnthropic} = await import("@ai-sdk/anthropic");
return createAnthropic({
name: provider.id,
baseURL: provider.connection.baseUrl,
apiKey: secret.provider?.apiKey || "browser-managed",
fetch: authenticatedFetch as typeof fetch
}).messages(modelId);
}
const {createGoogleGenerativeAI} = await import("@ai-sdk/google");
return createGoogleGenerativeAI({
name: provider.id,
baseURL: provider.connection.baseUrl,
apiKey: secret.provider?.apiKey || "browser-managed",
fetch: authenticatedFetch as typeof fetch
}).chat(modelId);
}
+45
View File
@@ -0,0 +1,45 @@
export type ProviderApi = "openai-completions" | "openai-responses" | "anthropic-messages" | "google-generative-ai";
export type ProviderModel = {
id: string;
name: string;
contextWindow?: number;
maxTokens?: number;
input?: string[];
reasoning?: boolean;
ownedBy?: string;
pricing?: Record<string, unknown>;
};
export type ProviderDiscovery = {
type: "openai-models-list" | "anthropic-models-list" | "google-models-list";
url: string;
};
export type ProviderDefinition = {
id: string;
name: string;
api: ProviderApi;
connection: {
type: "frontend" | "backend";
baseUrl: string;
proxy: null | {type: "relay" | "http" | "https" | "socks5"; url: string};
};
auth: {type: "bearer" | "header" | "none"; header?: string};
headers: Record<string, string>;
defaultModel: string;
discovery: ProviderDiscovery;
builtin: boolean;
credentialState: "configured" | "missing" | "local";
credentials: Array<{id: string; providerId: string; name: string; fingerprint: string}>;
};
export type ProviderSecret = {
provider?: {apiKey?: string; headers?: Record<string, string>; baseUrl?: string};
proxy?: {username?: string; password?: string; token?: string};
};
export type ResolvedBackendProvider = {
provider: ProviderDefinition;
credential: {id: string; name: string; secret: ProviderSecret};
};
+20
View File
@@ -0,0 +1,20 @@
import {describe, expect, test} from "bun:test";
import {repositoryPushBatches} from "./repository-push-batches.ts";
describe("repository push batches", () => {
test("keeps requests within server limits", () => {
const objects = Array.from({length: 2001}, (_, index) => ({id: `sha256:${index}`}));
const refs = Array.from({length: 201}, (_, index) => ({conversationId: String(index)}));
const batches = repositoryPushBatches({repositoryId: "local:test", objects, refs});
expect(batches.map((batch) => [batch.objects.length, batch.refs.length])).toEqual([
[1000, 0], [1000, 0], [1, 0], [0, 100], [0, 100], [0, 1]
]);
});
test("deduplicates shared message objects", () => {
const object = {id: "sha256:shared"};
const batches = repositoryPushBatches({repositoryId: "local:test", objects: [object, object], refs: []});
expect(batches).toHaveLength(1);
expect(batches[0].objects).toHaveLength(1);
});
});
+21
View File
@@ -0,0 +1,21 @@
import type {RepositoryRefUpdate, StoredChatMessage} from "./conversation-types";
export type RepositoryPushPayload = {
repositoryId: string;
objects: StoredChatMessage[];
refs: RepositoryRefUpdate[];
};
function chunks<T>(items: T[], size: number) {
const result: T[][] = [];
for (let index = 0; index < items.length; index += size) result.push(items.slice(index, index + size));
return result;
}
export function repositoryPushBatches(payload: RepositoryPushPayload) {
const objectBatches = chunks([...new Map(payload.objects.map((object) => [object.id, object])).values()], 1000)
.map((objects) => ({repositoryId: payload.repositoryId, objects, refs: [] as RepositoryRefUpdate[]}));
const refBatches = chunks(payload.refs, 100)
.map((refs) => ({repositoryId: payload.repositoryId, objects: [] as StoredChatMessage[], refs}));
return [...objectBatches, ...refBatches];
}
+26
View File
@@ -0,0 +1,26 @@
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();
});
test("falls back to estimated tokens when provider output is absent", () => {
const metadata = responseMetadata("local", "model", performance.now() - 2000, undefined, 80);
expect(metadata.outputTokens).toBe(80);
expect(metadata.tokensPerSecond).toBeGreaterThanOrEqual(39);
expect(metadata.tokensPerSecond).toBeLessThanOrEqual(41);
});
});
+26
View File
@@ -0,0 +1,26 @@
import type {ResponseMetadata} from "./conversation-types";
function normalizeTokenCount(value: unknown): number | null {
if (typeof value === "number" && Number.isFinite(value) && value >= 0) return Math.round(value);
if (typeof value === "bigint" && value >= 0n) return Number(value);
if (typeof value === "string") {
const parsed = Number(value);
if (Number.isFinite(parsed) && parsed >= 0) return Math.round(parsed);
}
return null;
}
export function responseMetadata(
providerId: string,
model: string,
startedAt: number,
outputTokens: number | undefined | null,
fallbackOutputTokens?: number | undefined | null
): ResponseMetadata {
const durationMs = Math.max(1, Math.round(performance.now() - startedAt));
const normalizedTokens = normalizeTokenCount(outputTokens) ?? normalizeTokenCount(fallbackOutputTokens);
const tokensPerSecond = normalizedTokens === null
? null
: Math.round((normalizedTokens / (durationMs / 1000)) * 10) / 10;
return {providerId, model, durationMs, outputTokens: normalizedTokens, tokensPerSecond};
}
+16
View File
@@ -0,0 +1,16 @@
import nodeFetch from "node-fetch";
import {ProxyAgent} from "proxy-agent";
import type {ProviderDefinition, ProviderSecret} from "./provider-types";
export function createServerProviderFetch(provider: ProviderDefinition, secret: ProviderSecret) {
const proxy = provider.connection.proxy;
if (!proxy) return fetch;
const proxyUrl = new URL(proxy.url);
if (secret.proxy?.username) proxyUrl.username = secret.proxy.username;
if (secret.proxy?.password) proxyUrl.password = secret.proxy.password;
const agent = new ProxyAgent({getProxyForUrl: () => proxyUrl.toString()});
return async (input: RequestInfo | URL, init?: RequestInit) => {
const response = await nodeFetch(String(input), {...init, agent} as never);
return response as unknown as Response;
};
}
+81
View File
@@ -0,0 +1,81 @@
import {describe, expect, test} from "bun:test";
import {
currentPath,
detectSessionTransferFormat,
parseSessionTransfer,
serializeSessionTransfer,
serializeXitengArchive
} from "./session-transfer.ts";
const lines = (values) => `${values.map((value) => JSON.stringify(value)).join("\n")}\n`;
describe("session transfer formats", () => {
test("imports Codex rollout records without duplicate event messages", () => {
const text = lines([
{type: "session_meta", timestamp: "2026-08-13T00:00:00Z", payload: {id: "11111111-1111-4111-8111-111111111111", timestamp: "2026-08-13T00:00:00Z", cwd: "/tmp"}},
{type: "turn_context", timestamp: "2026-08-13T00:00:00Z", payload: {model: "gpt-5.6-sol"}},
{type: "response_item", timestamp: "2026-08-13T00:00:01Z", payload: {type: "message", role: "user", content: [{type: "input_text", text: "hello"}]}},
{type: "event_msg", timestamp: "2026-08-13T00:00:01Z", payload: {type: "user_message", message: "hello"}},
{type: "response_item", timestamp: "2026-08-13T00:00:02Z", payload: {type: "message", role: "assistant", content: [{type: "output_text", text: "hi"}]}},
{type: "event_msg", timestamp: "2026-08-13T00:00:02Z", payload: {type: "agent_message", message: "hi"}}
]);
const parsed = parseSessionTransfer(text, "rollout.jsonl");
expect(parsed.format).toBe("codex");
expect(parsed.nodes.map((node) => node.role)).toEqual(["user", "assistant"]);
expect(parsed.sessions[0].model).toBe("gpt-5.6-sol");
});
test("preserves Claude parent branches", () => {
const text = lines([
{type: "user", sessionId: "s1", uuid: "u1", parentUuid: null, timestamp: "2026-08-13T00:00:00Z", message: {role: "user", content: "question"}},
{type: "assistant", sessionId: "s1", uuid: "a1", parentUuid: "u1", timestamp: "2026-08-13T00:00:01Z", message: {role: "assistant", model: "claude-sonnet-4-5", content: [{type: "text", text: "first"}]}},
{type: "assistant", sessionId: "s1", uuid: "a2", parentUuid: "u1", timestamp: "2026-08-13T00:00:02Z", message: {role: "assistant", model: "claude-sonnet-4-5", content: [{type: "text", text: "second"}]}},
{type: "custom-title", sessionId: "s1", customTitle: "branched"}
]);
const parsed = parseSessionTransfer(text, "claude.jsonl");
expect(parsed.nodes.map((node) => node.parentSourceId)).toEqual([null, "u1", "u1"]);
expect(parsed.sessions[0].headSourceId).toBe("a2");
expect(parsed.sessions[0].name).toBe("branched");
});
test("imports current OMP title slot and tree", () => {
const text = lines([
{type: "title", v: 1, title: "OMP task", updatedAt: "2026-08-13T00:00:00Z", pad: ""},
{type: "session", version: 3, id: "omp-1", timestamp: "2026-08-13T00:00:00Z", cwd: "/tmp"},
{type: "message", id: "u1", parentId: null, timestamp: "2026-08-13T00:00:01Z", message: {role: "user", content: [{type: "text", text: "hello"}]}},
{type: "message", id: "a1", parentId: "u1", timestamp: "2026-08-13T00:00:02Z", message: {role: "assistant", content: [{type: "text", text: "hi"}]}}
]);
const parsed = parseSessionTransfer(text, "omp.jsonl");
expect(parsed.sessions[0].name).toBe("OMP task");
expect(currentPath(parsed.nodes, "a1").map((node) => node.sourceId)).toEqual(["u1", "a1"]);
});
test("exports parseable Codex, Claude and OMP JSONL", () => {
const document = {
format: "xiteng",
sessions: [{sourceId: "22222222-2222-4222-8222-222222222222", name: "round trip", headSourceId: "a1", providerId: "openai", model: "gpt-5.6", generationSettings: {reasoning: "auto", showReasoningSummary: false, temperature: null, maxOutputTokens: null}, createdAt: "2026-08-13T00:00:00Z", updatedAt: "2026-08-13T00:00:02Z"}],
nodes: [
{sourceId: "u1", parentSourceId: null, role: "user", parts: [{type: "text", text: "hello"}], createdAt: "2026-08-13T00:00:01Z", completedAt: "2026-08-13T00:00:01Z"},
{sourceId: "a1", parentSourceId: "u1", role: "assistant", parts: [{type: "reasoning", text: "brief"}, {type: "text", text: "hi"}], createdAt: "2026-08-13T00:00:02Z", completedAt: "2026-08-13T00:00:02Z"}
]
};
for (const format of ["codex", "claude", "omp"]) {
const exported = serializeSessionTransfer(document, format);
expect(detectSessionTransferFormat(exported)).toBe(format);
const imported = parseSessionTransfer(exported);
expect(imported.nodes.some((node) => node.parts.some((part) => part.type === "text" && part.text === "hi"))).toBe(true);
}
});
test("native archive keeps graph objects and working items", () => {
const text = serializeXitengArchive([
{id: "c1", name: "native", headMessageId: "m1", providerId: "p", model: "m", messageCount: 1, createdAt: "2026-08-13T00:00:00Z", updatedAt: "2026-08-13T00:00:01Z", generationSettings: {reasoning: "auto", showReasoningSummary: false, temperature: null, maxOutputTokens: null}, messages: []}
], [
{id: "m1", parentMessageId: null, role: "user", parts: [{type: "text", text: "draft"}], origin: {type: "user"}, completion: {status: "complete"}, createdAt: "2026-08-13T00:00:00Z", completedAt: "2026-08-13T00:00:00Z"}
], []);
const parsed = parseSessionTransfer(text, "backup.xiteng-chat.json");
expect(parsed.format).toBe("xiteng");
expect(parsed.sessions).toHaveLength(1);
expect(parsed.nodes).toHaveLength(1);
});
});
+485
View File
@@ -0,0 +1,485 @@
import type {Conversation, StoredChatMessage, WorkingItem} from "./conversation-types";
import {defaultGenerationSettings, normalizeGenerationSettings, type GenerationSettings} from "./generation-settings";
export type SessionTransferFormat = "xiteng" | "codex" | "claude" | "omp";
export type TransferNode = {
sourceId: string;
parentSourceId: string | null;
role: StoredChatMessage["role"];
parts: StoredChatMessage["parts"];
createdAt: string;
completedAt: string;
completion?: StoredChatMessage["completion"];
origin?: StoredChatMessage["origin"];
metadata?: StoredChatMessage["metadata"];
};
export type TransferSession = {
sourceId: string;
name: string;
headSourceId: string | null;
providerId: string;
model: string;
generationSettings: GenerationSettings;
createdAt: string;
updatedAt: string;
};
export type TransferDocument = {
format: SessionTransferFormat;
sessions: TransferSession[];
nodes: TransferNode[];
workingItems?: WorkingItem[];
};
type JsonRecord = Record<string, unknown>;
function isRecord(value: unknown): value is JsonRecord {
return Boolean(value) && typeof value === "object" && !Array.isArray(value);
}
function stringValue(value: unknown, fallback = "") {
return typeof value === "string" ? value : fallback;
}
function isoTimestamp(value: unknown, fallback = new Date().toISOString()) {
if (typeof value === "number" && Number.isFinite(value)) {
const milliseconds = value < 10_000_000_000 ? value * 1000 : value;
return new Date(milliseconds).toISOString();
}
if (typeof value === "string" && Number.isFinite(Date.parse(value))) return new Date(value).toISOString();
return fallback;
}
function jsonLines(text: string) {
const values: JsonRecord[] = [];
for (const [index, line] of text.split(/\r?\n/).entries()) {
if (!line.trim()) continue;
try {
const value: unknown = JSON.parse(line);
if (isRecord(value)) values.push(value);
} catch {
throw new Error(`JSONL 第 ${index + 1} 行无法解析`);
}
}
return values;
}
function contentParts(value: unknown, textTypes = ["text", "input_text", "output_text"]): StoredChatMessage["parts"] {
if (typeof value === "string") return value ? [{type: "text", text: value}] : [];
if (!Array.isArray(value)) return [];
const parts: StoredChatMessage["parts"] = [];
for (const candidate of value) {
if (!isRecord(candidate)) continue;
if (textTypes.includes(stringValue(candidate.type)) && typeof candidate.text === "string") {
parts.push({type: "text", text: candidate.text});
} else if (candidate.type === "thinking" && typeof candidate.thinking === "string") {
parts.push({type: "reasoning", text: candidate.thinking, ...(typeof candidate.signature === "string" ? {signature: candidate.signature} : {})});
} else if (candidate.type === "image" && isRecord(candidate.source)) {
const data = stringValue(candidate.source.data);
const mediaType = stringValue(candidate.source.media_type);
if (data && mediaType) parts.push({type: "image", data, mimeType: mediaType});
} else if (candidate.type === "input_image" && typeof candidate.image_url === "string") {
parts.push({type: "image-url", url: candidate.image_url, ...(typeof candidate.detail === "string" ? {detail: candidate.detail} : {})});
}
}
return parts;
}
function firstText(nodes: TransferNode[]) {
for (const node of nodes) {
if (node.role !== "user") continue;
const text = node.parts.filter((part) => part.type === "text" && typeof part.text === "string").map((part) => String(part.text)).join("").trim();
if (text && !/^<(environment_context|permissions instructions)>/i.test(text) && !/^# AGENTS\.md instructions/i.test(text)) return text.replace(/\s+/g, " ").slice(0, 80);
}
return "导入的会话";
}
function newestLeaf(nodes: TransferNode[]) {
const parents = new Set(nodes.map((node) => node.parentSourceId).filter((id): id is string => Boolean(id)));
return [...nodes].filter((node) => !parents.has(node.sourceId)).sort((left, right) => right.completedAt.localeCompare(left.completedAt))[0]?.sourceId || nodes.at(-1)?.sourceId || null;
}
function transferNode(sourceId: string, parentSourceId: string | null, role: StoredChatMessage["role"], parts: StoredChatMessage["parts"], timestamp: string, extra: Partial<TransferNode> = {}): TransferNode {
return {
sourceId,
parentSourceId,
role,
parts,
createdAt: timestamp,
completedAt: timestamp,
completion: {status: "complete"},
origin: role === "user" ? {type: "user"} : role === "system" ? {type: "system", source: "session-import"} : {type: "legacy"},
...extra
};
}
function parseXiteng(value: JsonRecord): TransferDocument {
if (value.type !== "xiteng-chat-archive" || value.version !== 1 || !Array.isArray(value.conversations) || !Array.isArray(value.objects)) {
throw new Error("不支持的 Xiteng Chat 备份版本");
}
const nodes: TransferNode[] = [];
for (const candidate of value.objects) {
if (!isRecord(candidate) || typeof candidate.id !== "string" || !["system", "user", "assistant"].includes(String(candidate.role)) || !Array.isArray(candidate.parts)) continue;
nodes.push({
sourceId: candidate.id,
parentSourceId: typeof candidate.parentMessageId === "string" ? candidate.parentMessageId : null,
role: candidate.role as StoredChatMessage["role"],
parts: candidate.parts as StoredChatMessage["parts"],
origin: isRecord(candidate.origin) ? candidate.origin as StoredChatMessage["origin"] : {type: "legacy"},
completion: isRecord(candidate.completion) ? candidate.completion as StoredChatMessage["completion"] : {status: "complete"},
createdAt: isoTimestamp(candidate.createdAt),
completedAt: isoTimestamp(candidate.completedAt, isoTimestamp(candidate.createdAt)),
...(isRecord(candidate.metadata) ? {metadata: candidate.metadata as StoredChatMessage["metadata"]} : {})
});
}
const sessions: TransferSession[] = value.conversations.flatMap((candidate) => {
if (!isRecord(candidate) || typeof candidate.id !== "string") return [];
return [{
sourceId: candidate.id,
name: stringValue(candidate.name),
headSourceId: typeof candidate.headMessageId === "string" ? candidate.headMessageId : null,
providerId: stringValue(candidate.providerId, "imported"),
model: stringValue(candidate.model, "imported"),
generationSettings: normalizeGenerationSettings(candidate.generationSettings),
createdAt: isoTimestamp(candidate.createdAt),
updatedAt: isoTimestamp(candidate.updatedAt)
}];
});
return {format: "xiteng", sessions, nodes, workingItems: Array.isArray(value.workingItems) ? value.workingItems as WorkingItem[] : []};
}
function parseCodex(records: JsonRecord[]): TransferDocument {
const metadata = records.find((record) => record.type === "session_meta" && isRecord(record.payload));
const sessionPayload = metadata && isRecord(metadata.payload) ? metadata.payload : {};
const sessionId = stringValue(sessionPayload.id, crypto.randomUUID());
let model = "codex";
let name = "";
let parentSourceId: string | null = null;
let ordinal = 0;
const nodes: TransferNode[] = [];
const canonicalUserText = new Set<string>();
const canonicalAssistantText = new Set<string>();
for (const record of records) {
if (record.type !== "response_item" || !isRecord(record.payload) || record.payload.type !== "message") continue;
const text = contentParts(record.payload.content).filter((part) => part.type === "text").map((part) => String(part.text || "")).join("");
if (record.payload.role === "user") canonicalUserText.add(text);
if (record.payload.role === "assistant") canonicalAssistantText.add(text);
}
const pushNode = (role: StoredChatMessage["role"], parts: StoredChatMessage["parts"], timestamp: string) => {
if (!parts.length) return;
const sourceId = `codex-${sessionId}-${++ordinal}`;
nodes.push(transferNode(sourceId, parentSourceId, role, parts, timestamp));
parentSourceId = sourceId;
};
for (const record of records) {
const timestamp = isoTimestamp(record.timestamp, isoTimestamp(sessionPayload.timestamp));
if (!isRecord(record.payload)) continue;
const payload = record.payload;
if (record.type === "turn_context" && typeof payload.model === "string") model = payload.model;
if (record.type === "response_item") {
if (payload.type === "message" && (payload.role === "user" || payload.role === "assistant")) {
const parts = contentParts(payload.content);
const text = parts.filter((part) => part.type === "text").map((part) => String(part.text || "")).join("").trim();
if (payload.role !== "user" || !/^<(environment_context|permissions instructions)>/i.test(text)) pushNode(payload.role, parts, timestamp);
} else if (payload.type === "reasoning") {
const parts = [...(Array.isArray(payload.summary) ? payload.summary : []), ...(Array.isArray(payload.content) ? payload.content : [])]
.flatMap((item) => isRecord(item) && typeof item.text === "string" ? [{type: "reasoning", text: item.text}] : []);
pushNode("assistant", parts, timestamp);
} else if (["function_call", "custom_tool_call", "web_search_call", "tool_search_call"].includes(String(payload.type))) {
const callId = stringValue(payload.call_id, stringValue(payload.id, `call-${ordinal + 1}`));
const toolName = stringValue(payload.name, payload.type === "web_search_call" ? "web_search" : payload.type === "tool_search_call" ? "tool_search" : "unknown");
let argumentsValue: unknown = payload.type === "custom_tool_call" ? payload.input : payload.arguments ?? payload.action;
if (typeof argumentsValue === "string") {
try { argumentsValue = JSON.parse(argumentsValue); } catch { argumentsValue = {input: argumentsValue}; }
}
pushNode("assistant", [{type: "tool-call", id: callId, name: toolName, arguments: isRecord(argumentsValue) ? argumentsValue : {}}], timestamp);
} else if (["function_call_output", "custom_tool_call_output", "tool_search_output"].includes(String(payload.type))) {
pushNode("assistant", [{type: "tool-result", toolCallId: stringValue(payload.call_id), content: payload.output ?? payload.tools, isError: payload.status === "failed"}], timestamp);
}
} else if (record.type === "event_msg") {
if (payload.type === "thread_name_updated" && typeof payload.thread_name === "string") name = payload.thread_name;
if (payload.type === "user_message" && typeof payload.message === "string" && !canonicalUserText.has(payload.message)) pushNode("user", [{type: "text", text: payload.message}], timestamp);
if (payload.type === "agent_message" && typeof payload.message === "string" && !canonicalAssistantText.has(payload.message)) pushNode("assistant", [{type: "text", text: payload.message}], timestamp);
}
}
const createdAt = isoTimestamp(sessionPayload.timestamp, nodes[0]?.createdAt);
return {
format: "codex",
nodes,
sessions: [{sourceId: sessionId, name: name || firstText(nodes), headSourceId: parentSourceId, providerId: "openai", model, generationSettings: defaultGenerationSettings, createdAt, updatedAt: nodes.at(-1)?.completedAt || createdAt}]
};
}
function nearestRetainedParent(sourceParentId: string | null, sourceParents: Map<string, string | null>, retained: Map<string, string>) {
const seen = new Set<string>();
let cursor = sourceParentId;
while (cursor && !seen.has(cursor)) {
seen.add(cursor);
const retainedId = retained.get(cursor);
if (retainedId) return retainedId;
cursor = sourceParents.get(cursor) || null;
}
return null;
}
function parseClaude(records: JsonRecord[]): TransferDocument {
const sourceParents = new Map<string, string | null>();
for (const record of records) if (typeof record.uuid === "string") sourceParents.set(record.uuid, typeof record.parentUuid === "string" ? record.parentUuid : null);
const retained = new Map<string, string>();
const nodes: TransferNode[] = [];
let sessionId = "";
let name = "";
let model = "claude";
for (const [index, record] of records.entries()) {
sessionId ||= stringValue(record.sessionId);
if (record.type === "custom-title") name = stringValue(record.customTitle, name);
if (record.type === "ai-title" && !name) name = stringValue(record.aiTitle, name);
if ((record.type !== "user" && record.type !== "assistant") || record.isSidechain === true || record.isMeta === true || !isRecord(record.message)) continue;
const sourceUuid = stringValue(record.uuid, `claude-line-${index + 1}`);
let parentSourceId = nearestRetainedParent(typeof record.parentUuid === "string" ? record.parentUuid : null, sourceParents, retained);
const timestamp = isoTimestamp(record.timestamp);
const converted: TransferNode[] = [];
if (record.type === "assistant") {
model = stringValue(record.message.model, model);
const parts = contentParts(record.message.content);
if (Array.isArray(record.message.content)) {
for (const block of record.message.content) {
if (!isRecord(block) || block.type !== "tool_use") continue;
parts.push({type: "tool-call", id: stringValue(block.id), name: stringValue(block.name, "unknown"), arguments: isRecord(block.input) ? block.input : {}});
}
}
if (parts.length) converted.push(transferNode(sourceUuid, parentSourceId, "assistant", parts, timestamp));
} else {
const toolResults = Array.isArray(record.message.content) ? record.message.content.filter((block) => isRecord(block) && block.type === "tool_result") : [];
if (toolResults.length) {
for (const block of toolResults) {
if (!isRecord(block)) continue;
converted.push(transferNode(`${sourceUuid}-tool-result-${converted.length}`, parentSourceId, "assistant", [{type: "tool-result", toolCallId: stringValue(block.tool_use_id), content: block.content, isError: block.is_error === true}], timestamp));
parentSourceId = converted.at(-1)!.sourceId;
}
} else {
const parts = contentParts(record.message.content);
if (parts.length) converted.push(transferNode(sourceUuid, parentSourceId, "user", parts, timestamp));
}
}
for (const node of converted) nodes.push(node);
const tail = converted.at(-1)?.sourceId;
if (tail) retained.set(sourceUuid, tail);
}
sessionId ||= crypto.randomUUID();
const createdAt = nodes[0]?.createdAt || new Date().toISOString();
return {format: "claude", nodes, sessions: [{sourceId: sessionId, name: name || firstText(nodes), headSourceId: newestLeaf(nodes), providerId: "anthropic", model, generationSettings: defaultGenerationSettings, createdAt, updatedAt: nodes.at(-1)?.completedAt || createdAt}]};
}
function ompMessageParts(message: JsonRecord) {
const parts = contentParts(message.content);
if (Array.isArray(message.content)) {
for (const block of message.content) {
if (!isRecord(block)) continue;
if (block.type === "toolCall") parts.push({type: "tool-call", id: stringValue(block.id), name: stringValue(block.name, "unknown"), arguments: isRecord(block.arguments) ? block.arguments : {}});
}
}
return parts;
}
function parseOmp(records: JsonRecord[]): TransferDocument {
const header = records.find((record) => record.type === "session");
if (!header) throw new Error("OMP JSONL 缺少 session header");
const titleSlot = records.find((record) => record.type === "title");
const titleChanges = records.filter((record) => record.type === "title_change");
const sourceParents = new Map<string, string | null>();
for (const record of records) if (typeof record.id === "string" && record.type !== "session") sourceParents.set(record.id, typeof record.parentId === "string" ? record.parentId : null);
const retained = new Map<string, string>();
const nodes: TransferNode[] = [];
let model = "omp";
for (const record of records) {
if (record.type === "model_change" && typeof record.model === "string") model = record.model.includes("/") ? record.model.slice(record.model.indexOf("/") + 1) : record.model;
if (record.type !== "message" || typeof record.id !== "string" || !isRecord(record.message)) continue;
const parentSourceId = nearestRetainedParent(typeof record.parentId === "string" ? record.parentId : null, sourceParents, retained);
const timestamp = isoTimestamp(record.timestamp);
const role = record.message.role;
let parts: StoredChatMessage["parts"] = [];
if (role === "toolResult") parts = [{type: "tool-result", toolCallId: stringValue(record.message.toolCallId), toolName: stringValue(record.message.toolName, "unknown"), content: record.message.content, isError: record.message.isError === true}];
else parts = ompMessageParts(record.message);
if (!parts.length || (role !== "user" && role !== "assistant" && role !== "toolResult")) continue;
nodes.push(transferNode(record.id, parentSourceId, role === "toolResult" ? "assistant" : role, parts, timestamp));
retained.set(record.id, record.id);
}
const createdAt = isoTimestamp(header.timestamp, nodes[0]?.createdAt);
const latestTitle = titleChanges.at(-1);
const name = stringValue(latestTitle?.title, stringValue(titleSlot?.title, stringValue(header.title, firstText(nodes))));
return {format: "omp", nodes, sessions: [{sourceId: stringValue(header.id, crypto.randomUUID()), name, headSourceId: nodes.at(-1)?.sourceId || null, providerId: model.includes("claude") ? "anthropic" : model.includes("gpt") || model.includes("codex") ? "openai" : "imported", model, generationSettings: defaultGenerationSettings, createdAt, updatedAt: nodes.at(-1)?.completedAt || createdAt}]};
}
export function detectSessionTransferFormat(text: string, filename = ""): SessionTransferFormat {
const trimmed = text.trimStart();
if (trimmed.startsWith("{")) {
try {
const firstLine = JSON.parse(trimmed.split(/\r?\n/, 1)[0]) as unknown;
if (isRecord(firstLine)) {
if (firstLine.type === "xiteng-chat-archive") return "xiteng";
if (firstLine.type === "session_meta") return "codex";
if (firstLine.type === "session" || firstLine.type === "title") return "omp";
if (typeof firstLine.sessionId === "string" || typeof firstLine.uuid === "string" || ["user", "assistant", "custom-title", "ai-title"].includes(String(firstLine.type))) return "claude";
}
} catch {}
}
const lower = filename.toLowerCase();
if (lower.endsWith(".xiteng-chat.json")) return "xiteng";
throw new Error("无法识别会话格式;请选择 Xiteng Chat JSON 或 Codex / Claude Code / OMP JSONL");
}
export function parseSessionTransfer(text: string, filename = ""): TransferDocument {
const format = detectSessionTransferFormat(text, filename);
if (format === "xiteng") {
const value: unknown = JSON.parse(text);
if (!isRecord(value)) throw new Error("Xiteng Chat 备份不是 JSON 对象");
return parseXiteng(value);
}
const records = jsonLines(text);
if (format === "codex") return parseCodex(records);
if (format === "claude") return parseClaude(records);
return parseOmp(records);
}
function nodeTextParts(node: TransferNode) {
return node.parts.filter((part) => part.type === "text" && typeof part.text === "string").map((part) => ({type: node.role === "assistant" ? "output_text" : "input_text", text: String(part.text)}));
}
function assistantReasoningParts(node: TransferNode) {
return node.parts.filter((part) => part.type === "reasoning" && typeof part.text === "string").map((part) => String(part.text));
}
function portableUuid(value: string) {
const match = /[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/i.exec(value);
return match?.[0] || crypto.randomUUID();
}
function jsonl(records: JsonRecord[]) {
return `${records.map((record) => JSON.stringify(record)).join("\n")}\n`;
}
function serializeCodex(document: TransferDocument) {
const session = document.sessions[0];
if (!session) throw new Error("没有可导出的会话");
const id = portableUuid(session.sourceId);
const records: JsonRecord[] = [{type: "session_meta", timestamp: session.createdAt, payload: {id, timestamp: session.createdAt, cwd: "/workspace", originator: "xiteng-chat", cli_version: "0.147.0", source: "cli", model_provider: session.providerId}}];
records.push({type: "turn_context", timestamp: session.createdAt, payload: {cwd: "/workspace", model: session.model}});
for (const node of currentPath(document.nodes, session.headSourceId)) {
const text = nodeTextParts(node);
if (text.length) records.push({type: "response_item", timestamp: node.createdAt, payload: {type: "message", role: node.role, content: text, ...(node.role === "assistant" ? {phase: "final_answer"} : {})}});
for (const reasoning of assistantReasoningParts(node)) records.push({type: "response_item", timestamp: node.createdAt, payload: {type: "reasoning", summary: [{type: "summary_text", text: reasoning}]}});
for (const part of node.parts) {
if (part.type === "tool-call") records.push({type: "response_item", timestamp: node.createdAt, payload: {type: "function_call", call_id: stringValue(part.id, crypto.randomUUID()), name: stringValue(part.name, "unknown"), arguments: JSON.stringify(isRecord(part.arguments) ? part.arguments : {})}});
if (part.type === "tool-result") records.push({type: "response_item", timestamp: node.createdAt, payload: {type: "function_call_output", call_id: stringValue(part.toolCallId), output: part.content ?? ""}});
}
}
if (session.name) records.push({type: "event_msg", timestamp: session.updatedAt, payload: {type: "thread_name_updated", thread_name: session.name}});
return jsonl(records);
}
function claudeContent(node: TransferNode) {
const content: JsonRecord[] = [];
for (const part of node.parts) {
if (part.type === "text" && typeof part.text === "string") content.push({type: "text", text: part.text});
if (part.type === "reasoning" && typeof part.text === "string") content.push({type: "thinking", thinking: part.text, signature: stringValue(part.signature)});
if (part.type === "tool-call") content.push({type: "tool_use", id: stringValue(part.id, crypto.randomUUID()), name: stringValue(part.name, "unknown"), input: isRecord(part.arguments) ? part.arguments : {}});
}
return content;
}
function serializeClaude(document: TransferDocument) {
const session = document.sessions[0];
if (!session) throw new Error("没有可导出的会话");
const sessionId = portableUuid(session.sourceId);
const idMap = new Map(document.nodes.map((node) => [node.sourceId, portableUuid(node.sourceId)]));
const records: JsonRecord[] = [];
for (const node of document.nodes) {
const common = {sessionId, uuid: idMap.get(node.sourceId), parentUuid: node.parentSourceId ? idMap.get(node.parentSourceId) || null : null, timestamp: node.createdAt, cwd: "/workspace", version: "2.1.81", gitBranch: ""};
const toolResults = node.parts.filter((part) => part.type === "tool-result");
const content = claudeContent(node);
if (toolResults.length) {
records.push({...common, type: "user", message: {role: "user", content: toolResults.map((part) => ({type: "tool_result", tool_use_id: stringValue(part.toolCallId), content: part.content ?? "", is_error: part.isError === true}))}});
} else if (node.role === "user") {
records.push({...common, type: "user", message: {role: "user", content}});
} else if (node.role === "assistant" && content.length) {
records.push({...common, type: "assistant", message: {id: `msg_${idMap.get(node.sourceId)?.replaceAll("-", "")}`, type: "message", role: "assistant", model: session.model, content, stop_reason: "end_turn", stop_sequence: null, usage: {input_tokens: 0, output_tokens: 0}}});
}
}
if (session.name) records.push({type: "custom-title", customTitle: session.name, sessionId});
return jsonl(records);
}
function ompContent(node: TransferNode) {
const content: JsonRecord[] = [];
for (const part of node.parts) {
if (part.type === "text" && typeof part.text === "string") content.push({type: "text", text: part.text});
if (part.type === "reasoning" && typeof part.text === "string") content.push({type: "thinking", thinking: part.text});
if (part.type === "tool-call") content.push({type: "toolCall", id: stringValue(part.id, crypto.randomUUID()), name: stringValue(part.name, "unknown"), arguments: isRecord(part.arguments) ? part.arguments : {}});
}
return content;
}
function serializeOmp(document: TransferDocument) {
const session = document.sessions[0];
if (!session) throw new Error("没有可导出的会话");
const idMap = new Map(document.nodes.map((node, index) => [node.sourceId, `xt${(index + 1).toString(36).padStart(6, "0")}`]));
const records: JsonRecord[] = [{type: "session", version: 3, id: portableUuid(session.sourceId), timestamp: session.createdAt, cwd: "/workspace", title: session.name, titleSource: "user"}];
let modelParent: string | null = null;
if (session.model) {
modelParent = "xtmodel0";
records.push({type: "model_change", id: modelParent, parentId: null, timestamp: session.createdAt, model: `${session.providerId}/${session.model}`});
}
for (const node of document.nodes) {
const parentId = node.parentSourceId ? idMap.get(node.parentSourceId) || null : modelParent;
const toolResult = node.parts.find((part) => part.type === "tool-result");
if (toolResult) {
records.push({type: "message", id: idMap.get(node.sourceId), parentId, timestamp: node.createdAt, message: {role: "toolResult", toolCallId: stringValue(toolResult.toolCallId), toolName: stringValue(toolResult.toolName, "unknown"), content: toolResult.content ?? "", isError: toolResult.isError === true, timestamp: Date.parse(node.createdAt)}});
} else {
records.push({type: "message", id: idMap.get(node.sourceId), parentId, timestamp: node.createdAt, message: {role: node.role, content: ompContent(node), ...(node.role === "assistant" ? {api: "openai-responses", provider: session.providerId, model: session.model, usage: {input: 0, output: 0, cacheRead: 0, cacheWrite: 0, totalTokens: 0, cost: {input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0}}, stopReason: "stop"} : {}), timestamp: Date.parse(node.createdAt)}});
}
}
return jsonl(records);
}
export function currentPath(nodes: TransferNode[], headSourceId: string | null) {
const byId = new Map(nodes.map((node) => [node.sourceId, node]));
const reversed: TransferNode[] = [];
const seen = new Set<string>();
let cursor = headSourceId;
while (cursor && !seen.has(cursor)) {
seen.add(cursor);
const node = byId.get(cursor);
if (!node) break;
reversed.push(node);
cursor = node.parentSourceId;
}
return reversed.reverse();
}
export function serializeSessionTransfer(document: TransferDocument, format: Exclude<SessionTransferFormat, "xiteng">) {
if (format === "codex") return serializeCodex(document);
if (format === "claude") return serializeClaude(document);
return serializeOmp(document);
}
export function serializeXitengArchive(conversations: Conversation[], objects: StoredChatMessage[], workingItems: WorkingItem[]) {
return JSON.stringify({
type: "xiteng-chat-archive",
version: 1,
exportedAt: new Date().toISOString(),
conversations: conversations.map(({messages: _messages, ...conversation}) => conversation),
objects,
workingItems
}, null, 2);
}
export function conversationTransferDocument(conversation: Conversation, nodes: StoredChatMessage[]): TransferDocument {
return {
format: "xiteng",
sessions: [{sourceId: conversation.id, name: conversation.name, headSourceId: conversation.headMessageId, providerId: conversation.providerId, model: conversation.model, generationSettings: conversation.generationSettings, createdAt: conversation.createdAt, updatedAt: conversation.updatedAt}],
nodes: nodes.map((message) => ({sourceId: message.id, parentSourceId: message.parentMessageId, role: message.role, parts: message.parts, origin: message.origin, completion: message.completion, createdAt: message.createdAt, completedAt: message.completedAt, ...(message.metadata ? {metadata: message.metadata} : {})}))
};
}
+48
View File
@@ -0,0 +1,48 @@
import {describe, expect, test} from "bun:test";
import {splitStreamingMarkdown} from "./streaming-markdown.ts";
describe("streaming markdown blocks", () => {
test("freezes a paragraph after its blank-line boundary", () => {
const open = splitStreamingMarkdown("first paragraph");
expect(open.blocks.map((block) => block.stable)).toEqual([false]);
expect(open.stableOffset).toBe(0);
const closed = splitStreamingMarkdown("first paragraph\n\n");
expect(closed.blocks.map((block) => block.stable)).toEqual([true]);
expect(closed.stableOffset).toBe("first paragraph\n\n".length);
});
test("keeps only the trailing paragraph mutable", () => {
const source = "first paragraph\n\nsecond paragraph";
const result = splitStreamingMarkdown(source);
expect(result.blocks.map(({source, stable}) => [source, stable])).toEqual([
["first paragraph", true],
["second paragraph", false]
]);
expect(source.slice(0, result.stableOffset)).toBe("first paragraph\n\n");
});
test("keeps a list mutable because another item can merge after a blank line", () => {
const result = splitStreamingMarkdown("- first\n\n");
expect(result.blocks).toHaveLength(1);
expect(result.blocks[0].stable).toBe(false);
expect(result.stableOffset).toBe(0);
});
test("freezes fenced code only after its closing fence", () => {
expect(splitStreamingMarkdown("```ts\nconst x = 1").blocks[0].stable).toBe(false);
expect(splitStreamingMarkdown("```ts\nconst x = 1\n```\n").blocks[0].stable).toBe(true);
});
test("freezes a standalone display formula when its delimiter closes", () => {
expect(splitStreamingMarkdown("$$\\int_0^1 x dx").blocks[0].stable).toBe(false);
expect(splitStreamingMarkdown("$$\\int_0^1 x dx$$").blocks[0].stable).toBe(true);
expect(splitStreamingMarkdown("\\[\\frac{a}{b}\\]").blocks[0].stable).toBe(true);
});
test("marks every block stable after the stream completes", () => {
const result = splitStreamingMarkdown("paragraph without trailing newline", true);
expect(result.blocks[0].stable).toBe(true);
expect(result.stableOffset).toBe("paragraph without trailing newline".length);
});
});
+77
View File
@@ -0,0 +1,77 @@
import {marked} from "marked";
export type StreamingMarkdownBlock = {
source: string;
type: string;
start: number;
end: number;
stable: boolean;
};
export type StreamingMarkdownSplit = {
blocks: StreamingMarkdownBlock[];
stableOffset: number;
};
type BlockToken = {type: string; raw: string};
function hasClosedFence(raw: string) {
const opening = raw.match(/^( {0,3})(`{3,}|~{3,})[^\n]*(?:\n|$)/);
if (!opening) return false;
const marker = opening[2];
const character = marker[0].replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
const closing = new RegExp(`(?:^|\\n) {0,3}${character}{${marker.length},}[ \\t]*(?:\\n|$)`);
return closing.test(raw.slice(opening[0].length));
}
function isClosedDisplayMath(raw: string) {
const value = raw.trim();
return /^\$\$(?!\$)[\s\S]*?(?<!\\)\$\$$/.test(value)
|| /^\\\[[\s\S]*?\\\]$/.test(value);
}
function isSelfClosing(token: BlockToken, following: BlockToken | undefined) {
if (token.type === "heading") return token.raw.endsWith("\n");
if (token.type === "hr") return true;
if (token.type === "code") return hasClosedFence(token.raw);
if (token.type === "paragraph") {
if (isClosedDisplayMath(token.raw)) return true;
return following?.type === "space" && /\n[\t ]*\n/.test(following.raw);
}
if (token.type === "def") return token.raw.endsWith("\n");
return false;
}
export function splitStreamingMarkdown(source: string, complete = false): StreamingMarkdownSplit {
if (!source) return {blocks: [], stableOffset: 0};
const tokens = marked.lexer(source) as unknown as BlockToken[];
const records: Array<BlockToken & {start: number; end: number}> = [];
let offset = 0;
for (const token of tokens) {
const start = offset;
offset += token.raw.length;
records.push({...token, start, end: offset});
}
const semanticIndexes = records.flatMap((token, index) => token.type === "space" ? [] : [index]);
const lastSemanticIndex = semanticIndexes.at(-1) ?? -1;
let firstUnstableStart = source.length;
const blocks: StreamingMarkdownBlock[] = [];
for (const index of semanticIndexes) {
const token = records[index];
const stable = complete || index < lastSemanticIndex || isSelfClosing(token, records[index + 1]);
if (!stable && firstUnstableStart === source.length) firstUnstableStart = token.start;
if (token.type === "def") continue;
blocks.push({
source: token.raw,
type: token.type,
start: token.start,
end: token.end,
stable
});
}
if (complete || firstUnstableStart === source.length) return {blocks, stableOffset: source.length};
return {blocks, stableOffset: firstUnstableStart};
}
+30
View File
@@ -0,0 +1,30 @@
{
"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",
"fflate": "0.8.2",
"marked": "15.0.12",
"node-fetch": "3.3.2",
"proxy-agent": "8.0.2"
},
"devDependencies": {
"@types/bun": "1.3.5",
"mathjax": "4.1.3",
"typescript": "5.9.3"
}
}
+82
View File
@@ -0,0 +1,82 @@
import {mkdirSync, rmSync} from "node:fs";
import http from "node:http";
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);
const upstreamHttpHost = process.env.UPSTREAM_HTTP_HOST || "";
if (mode === "host") {
mkdirSync("/run/provider-proxy", {recursive: true});
rmSync(socketPath, {force: true});
}
function createTcpServer() {
return 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);
});
}
function createHttpServer() {
return http.createServer((request, response) => {
const upstream = http.request({
socketPath,
method: request.method,
path: request.url,
headers: {...request.headers, host: upstreamHttpHost}
}, (upstreamResponse) => {
response.writeHead(
upstreamResponse.statusCode || 502,
upstreamResponse.statusMessage,
upstreamResponse.headers
);
upstreamResponse.pipe(response);
});
upstream.on("error", () => {
if (!response.headersSent) response.writeHead(502);
response.end();
});
request.on("aborted", () => upstream.destroy());
request.pipe(upstream);
});
}
const server = mode === "network" && upstreamHttpHost
? createHttpServer()
: createTcpServer();
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;
const protocol = upstreamHttpHost ? `http host=${upstreamHttpHost}` : "tcp";
console.log(`provider proxy ${mode} bridge ${target} -> ${upstream} (${protocol})`);
});
function shutdown() {
server.close(() => {
if (mode === "host") rmSync(socketPath, {force: true});
process.exit(0);
});
}
process.on("SIGTERM", shutdown);
process.on("SIGINT", shutdown);
+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64"><rect width="64" height="64" rx="18" fill="#171717"/><path d="M17 20h30v20H31l-9 8v-8h-5z" fill="#fff"/><circle cx="25" cy="30" r="2" fill="#171717"/><circle cx="32" cy="30" r="2" fill="#171717"/><circle cx="39" cy="30" r="2" fill="#171717"/></svg>

After

Width:  |  Height:  |  Size: 309 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 80 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 80 KiB

+20
View File
@@ -0,0 +1,20 @@
{
"id": "/chat/",
"name": "Xiteng Chat",
"short_name": "Chat",
"description": "本地优先、可选登录同步的轻量 AI 对话界面",
"start_url": "/chat/",
"scope": "/chat/",
"display": "standalone",
"launch_handler": {"client_mode": "navigate-existing"},
"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", "purpose": "any"},
{"src": "/icons/icon-512.png", "sizes": "512x512", "type": "image/png", "purpose": "any"},
{"src": "/icons/icon-maskable-512.png", "sizes": "512x512", "type": "image/png", "purpose": "maskable"}
]
}
+58
View File
@@ -0,0 +1,58 @@
const cachePrefix = "xiteng-chat-";
const cacheName = `${cachePrefix}shell-v8`;
const basePath = "/chat";
const shellAssets = [
`${basePath}/`,
`${basePath}/manifest.webmanifest`,
"/favicon.svg",
"/icons/favicon-32.png",
"/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.startsWith(cachePrefix) && 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(`${basePath}/api/`) || url.pathname.startsWith("/outpost.goauthentik.io/")) return;
if (request.mode === "navigate") {
event.respondWith(fetch(request).then((response) => {
if (response.ok) {
const cacheable = response.clone();
void caches.open(cacheName).then((cache) => cache.put(`${basePath}/`, cacheable)).catch(() => {});
}
return response;
}).catch(async () => (await caches.match(`${basePath}/`)) || 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) {
const cacheable = response.clone();
void caches.open(cacheName).then((cache) => cache.put(request, cacheable)).catch(() => {});
}
return response;
})));
}
});
+2608
View File
File diff suppressed because it is too large Load Diff
+24
View File
@@ -0,0 +1,24 @@
<!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="本地优先、可选登录同步的轻量 AI 对话界面">
<meta name="mobile-web-app-capable" content="yes">
<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>liooil / xiteng.site</title>
<link rel="manifest" href="/chat/manifest.webmanifest?v=__ASSET_VERSION__">
<link rel="icon" href="/favicon.svg" type="image/svg+xml">
<link rel="icon" href="/icons/favicon-32.png" sizes="32x32" type="image/png">
<link rel="apple-touch-icon" href="/icons/apple-touch-icon.png" sizes="180x180">
<link rel="stylesheet" href="/chat/styles.css?__ASSET_VERSION__">
<script type="module" src="/chat/assets/client.js?__ASSET_VERSION__"></script>
</head>
<body>
<div id="app"></div>
</body>
</html>
+576
View File
@@ -0,0 +1,576 @@
import {createHash, randomUUID} from "node:crypto";
import {readFileSync} from "node:fs";
import path from "node:path";
import {convertToModelMessages, streamText, type UIMessage} from "ai";
import {
appendConversationMessage,
createConversation,
deleteConversation,
fetchRepository,
getConversation,
listConversations,
pushRepositoryRef,
putRepositoryObjects,
saveConversationMessages,
updateConversation
} 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 {RepositoryRefUpdate, StoredChatMessage} from "../lib/conversation-types";
import {validMessageObjectId} from "../lib/message-object";
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 publicProviderCatalogFile = path.resolve(process.env.PUBLIC_PROVIDER_CATALOG_FILE || "../ai-gateway/providers.json");
const encoder = new TextEncoder();
const basePath = "/chat";
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 identityKey(identity: ChatIdentity) {
return createHash("sha256").update(`${identity.issuer}\0${identity.sub}`).digest("hex").slice(0, 32);
}
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)
]);
return json({providers, identityKey: identityKey(identity), profile});
} catch (error) {
return json({error: error instanceof Error ? error.message : "Provider configuration unavailable"}, errorStatus(error, 503));
}
}
function publicFrontendProviders(): ProviderDefinition[] {
const definitions = JSON.parse(readFileSync(publicProviderCatalogFile, "utf8")) as Array<Record<string, unknown>>;
return definitions.flatMap((definition) => {
const connection = definition.connection as {type?: string; baseUrl?: string; proxy?: ProviderDefinition["connection"]["proxy"]} | undefined;
if (connection?.type !== "frontend" || typeof connection.baseUrl !== "string") return [];
const api = definition.api as ProviderDefinition["api"];
const baseUrl = connection.baseUrl.replace(/\/+$/, "");
const discoveryType = api === "anthropic-messages"
? "anthropic-models-list"
: api === "google-generative-ai" ? "google-models-list" : "openai-models-list";
const discoveryUrl = discoveryType === "anthropic-models-list"
? `${baseUrl.endsWith("/v1") ? baseUrl : `${baseUrl}/v1`}/models?limit=200`
: discoveryType === "google-models-list" ? `${baseUrl}/models?pageSize=200` : `${baseUrl}/models`;
return [{
id: String(definition.id || ""),
name: String(definition.name || definition.id || ""),
api,
connection: {type: "frontend" as const, baseUrl, proxy: connection.proxy || null},
auth: (definition.auth || {type: "none"}) as ProviderDefinition["auth"],
headers: definition.headers && typeof definition.headers === "object" ? definition.headers as Record<string, string> : {},
defaultModel: String(definition.defaultModel || "local-model"),
discovery: {type: discoveryType, url: discoveryUrl},
builtin: true,
credentialState: "local" as const,
credentials: []
}];
});
}
function publicConfig() {
try {
return json({providers: publicFrontendProviders()});
} catch (error) {
return json({error: error instanceof Error ? error.message : "Public Provider configuration unavailable"}, 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 value = updateConversation(identity, id, await request.json());
return value ? json({conversation: value}) : 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);
}
}
async function conversationMessage(request: Request, id: string) {
try {
if (request.method !== "POST") return json({error: "Method not allowed"}, 405, {Allow: "POST"});
const identity = identityFromHeaders(request.headers);
const result = appendConversationMessage(identity, id, await request.json());
if (result.status === "missing") return json({error: "Conversation not found"}, 404);
if (result.status === "conflict") return json({error: "Conversation head changed", conversation: result.conversation}, 409);
return json({conversation: result.conversation}, 201);
} catch (error) {
return json({error: error instanceof Error ? error.message : "Message commit failed"}, 400);
}
}
async function repositoryFetch(request: Request) {
try {
if (request.method !== "POST") return json({error: "Method not allowed"}, 405, {Allow: "POST"});
const identity = identityFromHeaders(request.headers);
const input = await request.json() as {haveObjectIds?: unknown};
return json(fetchRepository(identity, input.haveObjectIds));
} catch (error) {
return json({error: error instanceof Error ? error.message : "Repository fetch failed"}, 400);
}
}
async function repositoryPush(request: Request) {
try {
if (request.method !== "POST") return json({error: "Method not allowed"}, 405, {Allow: "POST"});
const identity = identityFromHeaders(request.headers);
const input = await request.json() as {repositoryId?: unknown; objects?: unknown; refs?: unknown};
const repositoryId = typeof input.repositoryId === "string" && /^local:[a-zA-Z0-9-]{8,160}$/.test(input.repositoryId)
? input.repositoryId
: "";
const objects = Array.isArray(input.objects) ? input.objects as StoredChatMessage[] : [];
for (const object of objects) {
const validForRepository = repositoryId ? await validMessageObjectId(object, repositoryId) : false;
const validLegacyObject = validForRepository ? false : await validMessageObjectId(object, identityKey(identity));
if (!validForRepository && !validLegacyObject) return json({error: `Object ${object?.id || "unknown"} failed content verification`}, 400);
}
const insertedObjects = putRepositoryObjects(identity, objects);
const refs = Array.isArray(input.refs) ? input.refs as RepositoryRefUpdate[] : [];
if (refs.length > 100) return json({error: "refs must contain at most 100 entries"}, 400);
const results = refs.map((update) => ({conversationId: update.conversationId, ...pushRepositoryRef(identity, update)}));
return json({insertedObjects, refs: results, pushedAt: new Date().toISOString()});
} catch (error) {
return json({error: error instanceof Error ? error.message : "Repository push 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");
const timestamp = new Date().toISOString();
let parentMessageId: string | null = null;
return value.map((message) => {
if (!message || typeof message !== "object" || Array.isArray(message)) throw new Error("message is invalid");
const record = message as Partial<StoredChatMessage>;
const normalized: StoredChatMessage = {
id: typeof record.id === "string" ? record.id : randomUUID(),
parentMessageId: typeof record.parentMessageId === "string" ? record.parentMessageId : parentMessageId,
role: record.role as StoredChatMessage["role"],
parts: Array.isArray(record.parts) ? record.parts.filter((part) => part.type === "text" || part.type === "reasoning") : [],
origin: record.origin || {type: "legacy"},
completion: record.completion || {status: "complete"},
createdAt: record.createdAt || timestamp,
completedAt: record.completedAt || timestamp,
...(record.metadata ? {metadata: record.metadata} : {})
};
parentMessageId = normalized.id;
return normalized;
});
}
function asTokenCount(value: unknown): number | null {
if (typeof value === "number" && Number.isFinite(value) && value >= 0) return Math.round(value);
if (typeof value === "bigint" && value >= 0n) return Number(value);
if (typeof value === "string") {
const parsed = Number(value);
if (Number.isFinite(parsed) && parsed >= 0) return Math.round(parsed);
}
return null;
}
function extractOutputTokens(usage: unknown): number | undefined {
if (!usage || typeof usage !== "object") return;
const record = usage as Record<string, unknown>;
const directCandidates = [
"outputTokens",
"completionTokens",
"completion_tokens",
"output_tokens",
"responseTokens",
"generatedTokens",
"textGenerationTokens"
];
for (const key of directCandidates) {
const value = asTokenCount(record[key]);
if (value !== null) return value;
}
const total = asTokenCount(record.totalTokens) ?? asTokenCount(record.total_tokens) ?? asTokenCount(record.tokens);
const prompt = asTokenCount(record.promptTokens) ?? asTokenCount(record.prompt_tokens) ?? asTokenCount(record.inputTokens) ?? asTokenCount(record.input_tokens);
if (total !== null && prompt !== null) return Math.max(0, total - prompt);
return;
}
function estimateOutputTokens(text: string): number {
const trimmed = text.trim();
if (!trimmed) return 0;
const nonWhite = trimmed.replace(/\s+/g, "");
const chineseChars = (nonWhite.match(/\p{Script=Han}/gu) || []).length;
const otherChars = nonWhite.length - chineseChars;
return Math.max(0, Math.round(chineseChars + otherChars / 4));
}
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();
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 outputTokens = extractOutputTokens(usage);
const metadata = responseMetadata(
input.providerId!.trim(),
input.model!.trim(),
startedAt,
outputTokens,
outputTokens === undefined ? estimateOutputTokens(text) : undefined
);
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))
|| relative.startsWith("assets/mathjax/4.1.3/");
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 === basePath) return Response.redirect(new URL(`${basePath}/${url.search}${url.hash}`, request.url), 308);
if (!url.pathname.startsWith(`${basePath}/`)) return json({error: "Not found"}, 404);
const pathname = url.pathname.slice(basePath.length) || "/";
if (pathname === "/api/health" && request.method === "GET") return json({status: "ok"});
if (pathname === "/api/public-config" && request.method === "GET") return publicConfig();
if (pathname === "/api/login" && request.method === "GET") return Response.redirect(new URL(`${basePath}/`, request.url), 302);
if (pathname === "/api/config" && request.method === "GET") return config(request);
if (pathname === "/api/sync/fetch") return repositoryFetch(request);
if (pathname === "/api/sync/push") return repositoryPush(request);
if (pathname === "/api/conversations") return conversations(request);
const conversationMessageMatch = pathname.match(/^\/api\/conversations\/([^/]+)\/messages$/);
if (conversationMessageMatch) return conversationMessage(request, decodeURIComponent(conversationMessageMatch[1]));
const conversationMatch = pathname.match(/^\/api\/conversations\/([^/]+)$/);
if (conversationMatch) return conversation(request, decodeURIComponent(conversationMatch[1]));
if (pathname === "/api/provider-test" && request.method === "POST") return providerTest(request);
if (pathname === "/api/chat" && request.method === "POST") {
server.timeout(request, 0);
return chat(request);
}
if (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(pathname);
}
});
console.log(`xiteng-chat Bun server listening on ${server.url}`);
+426
View File
@@ -0,0 +1,426 @@
.ui-icon { 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; }
.welcome-mark { display: grid; place-items: center; color: #fff; background: #171717; font-weight: 800; letter-spacing: -0.08em; }
.portal-home-link { display: grid; width: 36px; height: 36px; place-items: center; border-radius: 11px; transition: background .15s ease, transform .15s ease; }
.portal-home-link:hover { background: var(--soft); transform: translateY(-1px); }
.portal-home-link:focus-visible { outline: 2px solid var(--text); outline-offset: 2px; }
.portal-home-link img { display: block; width: 30px; height: 30px; border-radius: 7px; }
.chat-controls { display: flex; align-items: center; justify-self: end; gap: 8px; }
.identity-sync-control { display: inline-flex; height: 36px; align-items: center; gap: 7px; border: 1px solid var(--line); border-radius: 11px; padding: 4px 10px 4px 5px; background: rgba(255,255,255,.72); color: var(--muted); font-size: 10px; font-weight: 650; text-decoration: none; white-space: nowrap; cursor: pointer; }
.identity-sync-control:hover { border-color: rgba(23,23,23,.2); background: #fff; color: var(--text); }
.identity-sync-control:focus-visible { outline: 2px solid var(--text); outline-offset: 2px; }
.identity-sync-avatar { position: relative; display: grid; width: 26px; height: 26px; flex: 0 0 26px; place-items: center; }
.identity-sync-local { border-radius: 8px; background: var(--soft); color: var(--muted); }
.identity-sync-local .ui-icon { width: 15px; height: 15px; }
.header-avatar { width: 26px; height: 26px; border-radius: 8px; object-fit: cover; background: var(--soft); }
.identity-sync-status { position: absolute; right: -2px; bottom: -2px; width: 8px; height: 8px; border: 2px solid var(--panel); border-radius: 50%; background: #36a269; box-shadow: 0 0 0 1px rgba(54,162,105,.25); }
.identity-sync-control.local .identity-sync-status { background: #6f6f6a; box-shadow: 0 0 0 1px rgba(111,111,106,.25); }
.identity-sync-control.offline .identity-sync-status { background: var(--danger); box-shadow: 0 0 0 1px rgba(163,56,45,.25); }
.identity-sync-control.error .identity-sync-status { background: #d3932f; box-shadow: 0 0 0 1px rgba(211,147,47,.3); }
.identity-sync-control.fetching .identity-sync-status { border: 1px solid var(--muted); border-top-color: transparent; background: var(--panel); box-shadow: none; animation: spin .75s linear infinite; }
.model-provider-settings { display: grid; gap: 6px; }
.model-provider-settings > .settings-section-heading { padding: 0 2px 4px; }
.local-key-entry { display: flex; min-width: 0; align-items: center; justify-content: space-between; gap: 12px; border: 1px solid var(--line); border-radius: 11px; padding: 10px 11px; background: color-mix(in srgb, var(--panel) 70%, transparent); }
.local-key-entry:hover { background: var(--panel); }
.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: 3px; color: var(--muted); font-size: 10px; }
.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; }
.model-provider-settings button { border: 0; border-radius: 8px; padding: 8px 9px; background: var(--soft); color: var(--text); text-align: left; cursor: pointer; }
.model-provider-settings button:hover { background: color-mix(in srgb, var(--soft) 76%, var(--text)); }
.model-provider-settings button.dangerous { color: #b42318; }
.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 .picker-label { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.picker-icons { display: inline-flex; flex: 0 0 auto; align-items: center; gap: 4px; color: var(--muted); }
.picker-icons > svg:first-child { width: 15px; height: 15px; }
.picker-chevron { display: inline-flex; transition: transform .15s ease; }
.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 .picker-chevron { transform: rotate(180deg); }
.model-menu { position: fixed; z-index: 30; top: 56px; right: 22px; display: grid; width: min(360px, calc(100vw - 24px)); max-height: min(520px, calc(100dvh - 82px)); overflow-y: auto; border: 1px solid var(--line); border-radius: 15px; padding: 8px; background: var(--panel); box-shadow: 0 18px 48px rgba(0,0,0,.15); backdrop-filter: blur(18px); }
.quick-models { min-height: 0; }
.quick-config-heading, .effort-heading { display: flex; align-items: baseline; justify-content: space-between; gap: 12px; padding: 7px 8px; }
.quick-config-heading strong, .effort-heading strong { font-size: 11px; }
.quick-config-heading small, .effort-heading small { color: var(--muted); font-size: 9px; }
.quick-model-list { display: grid; max-height: 238px; overflow-y: auto; gap: 2px; }
.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-option.active::after { flex: 0 0 auto; color: var(--text); content: "✓"; font-size: 11px; font-weight: 800; }
.model-option.active > small { display: none; }
.effort-control { display: grid; gap: 3px; margin-top: 7px; border-top: 1px solid var(--line); padding-top: 5px; }
.effort-options { display: grid; grid-template-columns: repeat(5, minmax(0, 1fr)); gap: 3px; padding: 0 3px 6px; }
.effort-options label { min-width: 0; cursor: pointer; }
.effort-options input { position: absolute; opacity: 0; pointer-events: none; }
.effort-options span { display: grid; height: 30px; place-items: center; border-radius: 8px; color: var(--muted); font-size: 10px; }
.effort-options input:checked + span { background: var(--soft); color: var(--text); font-weight: 700; }
.effort-options input:focus-visible + span { outline: 2px solid var(--text); outline-offset: 1px; }
.open-settings-button { display: grid; width: 100%; grid-template-columns: 28px minmax(0, 1fr); align-items: center; gap: 9px; margin-top: 3px; border: 0; border-top: 1px solid var(--line); padding: 11px 8px 6px; background: transparent; color: var(--text); text-align: left; cursor: pointer; }
.open-settings-button:hover { background: var(--soft); }
.open-settings-button .ui-icon { color: var(--muted); }
.open-settings-button strong, .open-settings-button small { display: block; }
.open-settings-button strong { font-size: 11px; }
.open-settings-button small { margin-top: 2px; color: var(--muted); font-size: 9px; }
.settings-section-heading { display: grid; gap: 2px; margin-bottom: 2px; }
.settings-section-heading strong { font-size: 12px; }
.settings-section-heading small { color: var(--muted); font-size: 9px; }
.settings-page { position: fixed; z-index: 90; inset: 0; display: flex; flex-direction: column; background: var(--bg); }
.settings-page-header { display: flex; height: 64px; flex: 0 0 auto; align-items: center; gap: 12px; border-bottom: 1px solid var(--line); padding: 0 22px; background: var(--panel); }
.settings-page-header button { display: grid; width: 34px; height: 34px; place-items: center; border: 0; border-radius: 10px; background: transparent; color: var(--text); cursor: pointer; }
.settings-page-header button:hover { background: var(--soft); }
.settings-page-header > span strong, .settings-page-header > span small { display: block; }
.settings-page-header > span strong { font-size: 15px; }
.settings-page-header > span small { margin-top: 2px; color: var(--muted); font-size: 9px; }
.settings-layout { display: grid; min-height: 0; flex: 1; grid-template-columns: 176px minmax(0, 760px); justify-content: center; gap: 28px; overflow: hidden; padding: 28px 24px; }
.settings-nav { display: grid; align-content: start; gap: 3px; }
.settings-nav button { border: 0; border-radius: 9px; padding: 9px 11px; background: transparent; color: var(--muted); font-size: 11px; font-weight: 650; text-align: left; cursor: pointer; }
.settings-nav button:hover { background: var(--soft); color: var(--text); }
.settings-content { display: grid; min-width: 0; align-content: start; overflow-y: auto; gap: 16px; padding: 0 8px 40px 0; scroll-behavior: smooth; }
.settings-card { border: 1px solid var(--line); border-radius: 16px; padding: 18px; background: var(--panel); scroll-margin-top: 12px; }
.settings-card > header { margin-bottom: 16px; }
.settings-card h2, .settings-card h3, .settings-card p { margin: 0; }
.settings-card h2 { font-size: 16px; letter-spacing: -.02em; }
.settings-card > header p { margin-top: 5px; color: var(--muted); font-size: 10px; line-height: 1.5; }
.settings-model-search { display: flex; height: 40px; align-items: center; gap: 8px; border: 1px solid var(--line); border-radius: 10px; padding: 0 11px; background: var(--bg); color: var(--muted); }
.settings-model-search input { min-width: 0; width: 100%; border: 0; outline: 0; background: transparent; color: var(--text); }
.settings-model-groups { display: grid; gap: 13px; margin-top: 13px; }
.settings-model-group { display: grid; gap: 5px; }
.settings-model-group h3 { padding: 0 8px; color: var(--muted); font-size: 10px; }
.settings-model-group > div { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 3px; }
.settings-model-group .model-option { border: 1px solid transparent; }
.settings-model-group .model-option.active { border-color: var(--line); }
.settings-empty { color: var(--muted); font-size: 11px; text-align: center; }
.settings-card > .effort-control { margin: 0; border: 0; padding: 0; }
.settings-card .effort-heading { padding: 0 0 8px; }
.settings-card .effort-options { max-width: 440px; padding: 0; }
.settings-field-grid { display: grid; grid-template-columns: minmax(0, 1fr) minmax(160px, .65fr) minmax(160px, .65fr); gap: 10px; margin-top: 18px; }
.settings-field-grid > label:not(.settings-check) { display: grid; align-content: start; gap: 6px; color: var(--muted); font-size: 10px; }
.settings-field-grid input[type="number"] { min-width: 0; width: 100%; border: 1px solid var(--line); border-radius: 9px; padding: 9px; background: var(--bg); color: var(--text); }
.settings-check { display: flex; min-width: 0; align-items: flex-start; gap: 9px; border-radius: 10px; padding: 9px; color: var(--text); cursor: pointer; }
.settings-check:hover { background: var(--soft); }
.settings-check input { margin-top: 2px; }
.settings-check span { min-width: 0; }
.settings-check strong, .settings-check small { display: block; }
.settings-check strong { font-size: 11px; }
.settings-check small { margin-top: 3px; color: var(--muted); font-size: 9px; line-height: 1.45; }
.settings-reset-button { margin-top: 14px; border: 0; border-radius: 9px; padding: 9px 11px; background: var(--soft); color: var(--text); cursor: pointer; }
.settings-account-link { display: flex; align-items: center; gap: 9px; margin-top: 12px; border: 1px solid var(--line); border-radius: 11px; padding: 11px; color: var(--text); font-size: 11px; font-weight: 650; text-decoration: none; }
.settings-account-link:hover { background: var(--soft); }
.settings-account-link .ui-icon { color: var(--muted); }
.settings-interface-options { display: grid; gap: 4px; }
.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-transfer { position: relative; }
.history-transfer > summary { display: grid; width: 30px; height: 30px; place-items: center; border-radius: 8px; cursor: pointer; list-style: none; }
.history-transfer > summary::-webkit-details-marker { display: none; }
.history-transfer > summary:hover, .history-transfer[open] > summary { background: var(--soft); }
.history-transfer-menu { position: absolute; z-index: 45; top: 36px; right: -65px; display: grid; width: 248px; gap: 2px; border: 1px solid var(--line); border-radius: 12px; padding: 7px; background: var(--panel); box-shadow: 0 14px 36px rgba(0,0,0,.14); }
.history-transfer-menu button { display: grid; width: 100%; height: auto; grid-template-columns: 28px minmax(0, 1fr); gap: 8px; align-items: center; padding: 8px; text-align: left; }
.history-transfer-menu button > span:last-child { min-width: 0; }
.history-transfer-menu strong, .history-transfer-menu small { display: block; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.history-transfer-menu strong { font-size: 11px; }
.history-transfer-menu small { margin-top: 2px; color: var(--muted); font-size: 9px; }
.history-transfer-menu hr { width: 100%; margin: 3px 0; border: 0; border-top: 1px solid var(--line); }
.format-mark { display: grid; width: 25px; height: 25px; place-items: center; border: 1px solid var(--line); border-radius: 7px; color: var(--muted); font-size: 8px; font-weight: 800; letter-spacing: .04em; }
.session-import-overlay { position: fixed; z-index: 80; inset: 0; display: grid; place-items: center; padding: 20px; background: rgba(0,0,0,.42); }
.session-import-panel { width: min(620px, 100%); max-height: min(760px, calc(100dvh - 40px)); overflow-y: auto; border: 1px solid var(--line); border-radius: 18px; padding: 18px; background: var(--panel); box-shadow: 0 24px 80px rgba(0,0,0,.25); backdrop-filter: blur(22px); }
.session-import-panel > header { display: flex; align-items: flex-start; justify-content: space-between; gap: 18px; }
.session-import-panel h2, .session-import-panel h3, .session-import-panel p { margin: 0; }
.session-import-panel h2 { font-size: 17px; }
.session-import-panel header p { margin-top: 5px; color: var(--muted); font-size: 10px; line-height: 1.5; }
.session-import-panel header button { display: grid; width: 30px; height: 30px; flex: 0 0 auto; place-items: center; border: 0; border-radius: 8px; background: transparent; color: var(--text); cursor: pointer; }
.session-import-panel header button:hover { background: var(--soft); }
.session-location-help { margin-top: 16px; border: 1px solid var(--line); border-radius: 13px; padding: 13px; background: color-mix(in srgb, var(--soft) 56%, transparent); }
.session-location-help h3 { font-size: 11px; }
.session-location-help dl { display: grid; gap: 7px; margin: 10px 0; }
.session-location-help dl > div { display: grid; min-width: 0; grid-template-columns: 88px minmax(0, 1fr); gap: 8px; align-items: baseline; }
.session-location-help dt { font-size: 10px; font-weight: 700; }
.session-location-help dd { min-width: 0; margin: 0; color: var(--muted); font-size: 10px; }
.session-location-help code { overflow-wrap: anywhere; color: var(--text); font-size: 9px; }
.session-location-help > p { color: var(--muted); font-size: 9px; line-height: 1.55; }
.session-title-template { display: grid; grid-template-columns: auto minmax(0, 1fr); gap: 12px; align-items: center; margin-top: 12px; }
.session-title-template > span strong, .session-title-template > span small { display: block; }
.session-title-template > span strong { font-size: 10px; }
.session-title-template > span small { margin-top: 3px; color: var(--muted); font-size: 8px; }
.session-title-template input { min-width: 0; width: 100%; border: 1px solid var(--line); border-radius: 9px; padding: 8px 10px; outline: 0; background: var(--bg); color: var(--text); font: 10px/1.4 ui-monospace, SFMono-Regular, Menlo, monospace; }
.session-title-template input:focus { border-color: color-mix(in srgb, var(--text) 34%, var(--line)); }
.session-title-template input[aria-invalid="true"] { border-color: var(--danger); }
.session-title-template-help { margin-top: 7px !important; color: var(--muted); font-size: 8px; line-height: 1.55; }
.session-title-template-help code { color: var(--text); }
.session-title-template-preview { display: block; margin-top: 5px; overflow-wrap: anywhere; color: var(--muted); font-size: 9px; }
.session-title-template-preview.error { color: var(--danger); }
.session-import-actions { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 8px; margin-top: 12px; }
.session-import-actions button { display: grid; min-width: 0; grid-template-columns: 28px minmax(0, 1fr); gap: 8px; align-items: center; border: 1px solid var(--line); border-radius: 12px; padding: 10px; background: transparent; color: var(--text); text-align: left; cursor: pointer; }
.session-import-actions button:hover { border-color: rgba(23,23,23,.22); background: var(--soft); }
.session-import-actions button:disabled { cursor: wait; opacity: .55; }
.session-import-actions button > span:last-child { min-width: 0; }
.session-import-actions strong, .session-import-actions small { display: block; }
.session-import-actions strong { font-size: 10px; }
.session-import-actions small { margin-top: 3px; color: var(--muted); font-size: 8px; line-height: 1.4; }
.session-import-status { margin: 12px 0 0; border-radius: 10px; padding: 10px; overflow-wrap: anywhere; white-space: pre-wrap; background: var(--soft); color: var(--muted); font: 9px/1.55 ui-monospace, SFMono-Regular, Menlo, monospace; }
.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 28px; align-items: center; border-radius: 9px; padding-left: calc(var(--history-depth, 0) * 12px); }
.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, .history-rename { display: grid; width: 26px; height: 26px; place-items: center; border: 0; border-radius: 7px; background: transparent; color: var(--muted); cursor: pointer; opacity: 0; }
.history-item:hover .history-delete, .history-item.active .history-delete, .history-item:hover .history-rename, .history-item.active .history-rename { opacity: 1; }
.history-delete:hover, .history-rename:hover { background: var(--panel); color: var(--text); }
.history-folder { overflow: hidden; margin-top: 5px; padding: 6px 8px 3px calc(9px + var(--history-depth, 0) * 12px); color: var(--muted); font-size: 10px; font-weight: 750; letter-spacing: .06em; text-overflow: ellipsis; white-space: nowrap; }
.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; overflow-anchor: none; 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%); scroll-margin-block: 88px 180px; margin: 0 auto; padding: 14px 0; }
.user-message { display: grid; justify-items: end; }
.user-message-actions { display: flex; height: 30px; align-items: center; gap: 3px; margin: 2px 4px -8px 0; color: var(--muted); }
.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; }
.user-message.reply-target .user-content { box-shadow: 0 0 0 2px color-mix(in srgb, var(--accent) 28%, transparent); }
.assistant-content { padding: 2px 4px; }
.assistant-message.reply-target .assistant-content { border-left: 2px solid color-mix(in srgb, var(--accent) 45%, var(--muted)); border-radius: 0 9px 9px 0; padding: 6px 10px; background: color-mix(in srgb, var(--soft) 58%, transparent); }
.message.reply-target-pulse .message-content { animation: reply-target-pulse .9s ease; }
.assistant-content .aui-md { color: var(--text); }
.assistant-content .aui-md > .markdown-block:first-child > :first-child { margin-top: 0; }
.assistant-content .aui-md > .markdown-block:last-child > :last-child { margin-bottom: 0; }
.assistant-content .markdown-block:empty { display: none; }
.assistant-content .markdown-block[data-math-pending="1"] { min-height: 1.72em; }
.assistant-content .math-fragment { display: contents; }
.assistant-content .aui-md mjx-container[display="true"] { display: block; max-width: 100%; overflow-x: auto; overflow-y: hidden; padding: .15em 0; }
.assistant-content .aui-md mjx-container[display="true"] > svg { max-width: none; }
.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; }
.partial-badge { display: inline-flex; max-width: min(240px, 40vw); flex: 0 0 auto; overflow: hidden; border: 1px solid rgba(163,56,45,.22); border-radius: 999px; padding: 3px 8px; background: rgba(163,56,45,.07); color: var(--danger); font-size: 10px; font-weight: 700; text-overflow: ellipsis; white-space: nowrap; }
.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%); }
.thread-footer:has(.model-picker[open]) { z-index: 50; }
.edit-context { display: flex; width: calc(100% - 24px); align-items: center; justify-content: space-between; gap: 12px; margin: 0 12px -1px; border: 1px solid var(--line); border-bottom: 0; border-radius: 12px 12px 0 0; padding: 8px 12px 9px; background: var(--soft); color: var(--muted); font-size: 11px; }
.edit-context button { border: 0; background: transparent; color: var(--text); font: inherit; font-weight: 650; cursor: pointer; }
.edit-context + .composer { border-top-left-radius: 10px; border-top-right-radius: 10px; }
.composer-controls { display: flex; width: 100%; align-items: center; gap: 8px; padding: 0 4px 7px; color: var(--muted); font-size: 10px; }
.composer-controls:empty { display: none; }
.composer-controls label { display: flex; min-width: 0; align-items: center; gap: 5px; white-space: nowrap; }
.composer-controls select { min-width: 0; max-width: 230px; border: 1px solid var(--line); border-radius: 8px; padding: 5px 7px; background: var(--panel); color: var(--text); font: inherit; }
.reply-context { display: flex; min-width: 0; max-width: min(520px, 100%); align-items: center; gap: 5px; }
.reply-context > span { flex: 0 0 auto; }
.reply-context > .reply-context-icon { display: grid; width: 22px; height: 22px; place-items: center; color: var(--muted); }
.reply-context-icon .ui-icon { width: 15px; height: 15px; }
.reply-context > button { min-width: 0; overflow: hidden; border: 0; border-radius: 7px; padding: 5px 7px; background: var(--soft); color: var(--text); font: inherit; text-overflow: ellipsis; white-space: nowrap; cursor: pointer; }
.reply-context > button:hover { background: color-mix(in srgb, var(--soft) 76%, var(--text)); }
.reply-context > .reply-cancel { display: grid; width: 25px; height: 25px; flex: 0 0 auto; place-items: center; padding: 0; color: var(--muted); }
.branch-navigator { display: inline-flex; align-items: center; gap: 1px; color: var(--muted); font-size: 10px; white-space: nowrap; }
.branch-navigator .icon-button { width: 24px; height: 24px; font-size: 17px; }
.branch-preview-notice { position: relative; z-index: 5; display: flex; width: min(760px, 100%); flex: 0 0 auto; align-items: center; justify-content: space-between; gap: 12px; margin: 0 auto 10px; border: 1px solid rgba(211,147,47,.28); border-radius: 11px; padding: 8px 10px; background: color-mix(in srgb, var(--panel) 92%, #d3932f 8%); color: var(--muted); font-size: 11px; }
.branch-preview-notice > div { display: flex; gap: 5px; }
.branch-preview-notice button { border: 0; border-radius: 7px; padding: 6px 8px; background: var(--soft); color: var(--text); font: inherit; cursor: pointer; }
.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-summary { display: flex; min-width: 0; align-items: center; gap: 8px; }
.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: minmax(0, 1fr) auto auto 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); }
.composer > .model-picker { align-self: end; }
.composer > .model-picker > summary { min-width: 0; width: auto; max-width: 170px; height: 38px; border: 0; border-radius: 12px; padding: 0 9px; background: var(--soft); font-size: 10px; }
.composer > .model-picker > summary .picker-label { max-width: 132px; }
.composer > .model-picker .model-menu { top: auto; right: max(12px, calc((100vw - 760px) / 2)); bottom: 84px; }
.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 { min-width: 0; width: 100%; 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; }
.composer-actions { display: flex; align-items: center; gap: 4px; }
.send-button, .stop-button, .fullscreen-button { display: grid; width: 38px; height: 38px; place-items: center; border: 0; border-radius: 13px; cursor: pointer; }
.send-button { background: var(--accent); color: var(--accent-text); }
.stop-button, .fullscreen-button { border: 1px solid var(--line); background: var(--panel); color: var(--text); }
.fullscreen-button:hover { background: var(--soft); }
.send-button:disabled { cursor: default; opacity: .35; }
.composer-note { margin: 8px 0 0; color: #90908a; font-size: 11px; }
.composer-note.offline { color: var(--danger); }
.composer-note.queued { color: var(--text); }
.working-panel { display: flex; width: 100%; min-width: 0; align-items: center; justify-content: flex-end; gap: 6px; margin-bottom: 7px; }
.working-panel .assistant-reply-toggle { display: flex; min-width: 0; align-items: center; gap: 5px; margin-right: auto; color: var(--text); font-size: 10px; white-space: nowrap; }
.working-panel .assistant-reply-toggle input { margin: 0; }
.working-panel .composer-controls { width: auto; min-width: 0; flex: 1 1 auto; justify-content: flex-end; padding: 0; overflow: hidden; }
.working-panel .reply-context { max-width: 100%; }
.working-panel details { position: relative; }
.working-panel summary { display: flex; height: 28px; align-items: center; border: 1px solid var(--line); border-radius: 9px; padding: 0 9px; background: var(--panel); color: var(--muted); font-size: 10px; cursor: pointer; list-style: none; }
.working-panel summary::-webkit-details-marker { display: none; }
.working-panel details > div { position: absolute; z-index: 20; right: 0; bottom: 34px; display: grid; width: min(380px, calc(100vw - 24px)); max-height: 300px; overflow-y: auto; gap: 3px; border: 1px solid var(--line); border-radius: 12px; padding: 7px; background: var(--panel); box-shadow: 0 14px 36px rgba(0,0,0,.14); backdrop-filter: blur(18px); }
.new-draft { display: flex; align-items: center; gap: 7px; border: 0; border-radius: 8px; padding: 8px 9px; background: transparent; color: var(--text); cursor: pointer; }
.new-draft:hover { background: var(--soft); }
.draft-row { display: grid; grid-template-columns: minmax(0, 1fr) 30px; border-radius: 8px; }
.draft-row.active, .draft-row:hover { background: var(--soft); }
.draft-row > button, .unfinished-row button { border: 0; border-radius: 7px; padding: 7px 9px; background: transparent; color: var(--text); text-align: left; cursor: pointer; }
.draft-row > button:last-child { display: grid; width: 30px; place-items: center; padding: 0; color: var(--muted); }
.draft-row strong, .draft-row small, .unfinished-row strong, .unfinished-row small { display: block; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.draft-row strong, .unfinished-row strong { font-size: 11px; }
.draft-row small, .unfinished-row small { margin-top: 2px; color: var(--muted); font-size: 9px; }
.unfinished-row { display: grid; grid-template-columns: minmax(0, 1fr) auto auto; align-items: center; gap: 3px; border-radius: 8px; padding: 6px 4px 6px 9px; }
.unfinished-row:hover { background: var(--soft); }
.unfinished-row > span { min-width: 0; }
.unfinished-row button { padding: 7px; font-size: 10px; }
.thread-footer.fullscreen-editor { position: fixed; z-index: 70; inset: 0; bottom: auto; width: auto; height: 100dvh; align-items: stretch; margin: 0; padding: max(16px, env(safe-area-inset-top)) max(18px, env(safe-area-inset-right)) max(16px, env(safe-area-inset-bottom)) max(18px, env(safe-area-inset-left)); background: var(--bg); }
.fullscreen-editor-header { display: flex; min-height: 46px; flex: 0 0 auto; align-items: flex-start; justify-content: space-between; gap: 16px; }
.fullscreen-editor-header > span { display: grid; gap: 3px; }
.fullscreen-editor-header strong { font-size: 14px; }
.fullscreen-editor-header small { color: var(--muted); font-size: 10px; }
.fullscreen-editor-header button { display: grid; width: 34px; height: 34px; flex: 0 0 auto; place-items: center; border: 0; border-radius: 10px; background: transparent; color: var(--text); cursor: pointer; }
.fullscreen-editor-header button:hover { background: var(--soft); }
.fullscreen-editor .scroll-button { display: none; }
.fullscreen-editor .working-panel { flex: 0 0 auto; }
.fullscreen-editor .edit-context, .fullscreen-editor .composer-controls { flex: 0 0 auto; }
.fullscreen-editor .composer { min-height: 0; flex: 1; grid-template-rows: minmax(0, 1fr) auto; border-radius: 16px; padding: 12px; }
.fullscreen-editor .composer-input { width: 100%; height: 100% !important; max-height: none; min-height: 0; grid-column: 1 / -1; grid-row: 1; align-self: stretch; padding: 10px 4px; resize: none; font-size: 15px; line-height: 1.65; }
.fullscreen-editor .composer-actions { grid-column: 2; grid-row: 2; }
.fullscreen-editor .composer > .model-picker { grid-column: 3; grid-row: 2; }
.fullscreen-editor .send-button { grid-column: 4; grid-row: 2; }
.fullscreen-editor .composer > .model-picker .model-menu { top: auto; right: max(18px, env(safe-area-inset-right)); bottom: max(76px, calc(env(safe-area-inset-bottom) + 70px)); }
.fullscreen-editor .composer-note { flex: 0 0 auto; align-self: center; }
.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; } }
@keyframes reply-target-pulse { 0%, 100% { filter: none; } 38% { filter: brightness(.82); } }
@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); }
.app-header { overflow: clip; backdrop-filter: none; }
.identity-sync-control { width: 36px; justify-content: center; padding: 4px; }
.identity-sync-label { display: none; }
.provider-control select { max-width: 105px; }
.model-picker > summary { min-width: 132px; max-width: 170px; }
.picker-icons > svg:first-child { display: none; }
.model-menu { 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; }
.session-import-overlay { place-items: end center; padding: 0; }
.session-import-panel { width: 100%; max-height: calc(100dvh - 24px); border-width: 1px 0 0; border-radius: 18px 18px 0 0; padding: 16px 14px max(16px, env(safe-area-inset-bottom)); backdrop-filter: none; }
.session-location-help dl > div { grid-template-columns: 76px minmax(0, 1fr); }
.session-title-template { grid-template-columns: 1fr; gap: 6px; }
.session-import-actions { grid-template-columns: 1fr; }
.settings-page-header { height: 58px; padding: 0 12px; }
.settings-layout { display: flex; flex-direction: column; gap: 12px; padding: 12px 0 0; }
.settings-nav { display: flex; flex: 0 0 auto; gap: 2px; overflow-x: auto; padding: 0 12px; }
.settings-nav button { flex: 0 0 auto; padding: 8px 10px; }
.settings-content { gap: 12px; padding: 0 12px max(28px, env(safe-area-inset-bottom)); }
.settings-card { border-radius: 13px; padding: 14px; }
.settings-model-group > div { grid-template-columns: 1fr; }
.settings-field-grid { grid-template-columns: 1fr; }
.local-key-entry { align-items: flex-start; flex-direction: column; }
.local-key-entry > div { width: 100%; }
.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)); }
.thread-footer.fullscreen-editor { padding: max(10px, env(safe-area-inset-top)) max(12px, env(safe-area-inset-right)) max(10px, env(safe-area-inset-bottom)) max(12px, env(safe-area-inset-left)); }
.fullscreen-editor-header { min-height: 42px; }
.fullscreen-editor .working-panel { margin-bottom: 5px; }
.fullscreen-editor .composer { padding: 9px; }
.fullscreen-editor .composer-input { padding: 8px 3px; font-size: 16px; }
.fullscreen-editor .composer > .model-picker .model-menu { right: 12px; left: 12px; }
.composer-note { display: none; }
.composer-controls { overflow-x: auto; padding-inline: 0; }
.composer-controls select { max-width: 170px; }
.composer > .model-picker > summary { width: auto; min-width: 0; max-width: 116px; }
.composer > .model-picker > summary .picker-label { max-width: 86px; }
.composer > .model-picker .model-menu { top: auto; right: 12px; bottom: max(76px, calc(env(safe-area-inset-bottom) + 70px)); left: 12px; max-height: min(520px, calc(100dvh - 152px - env(safe-area-inset-bottom))); }
.branch-preview-notice { flex-direction: column; align-items: stretch; gap: 8px; margin-bottom: 6px; }
.branch-preview-notice > div { display: grid; width: 100%; grid-template-columns: auto minmax(0, 1fr); }
.branch-preview-notice button { min-width: 0; }
}
@media (max-width: 360px) {
.composer-actions:has(.stop-button) .fullscreen-button { display: none; }
.composer > .model-picker > summary { max-width: 96px; }
.composer > .model-picker > summary .picker-label { max-width: 68px; }
}
@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%); }
.identity-sync-control, .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; }
.identity-sync-control:hover { border-color: rgba(255,255,255,.22); background: #292a26; }
.assistant-content pre { background: #080908; }
.state-page { background: radial-gradient(circle at 50% 10%, #272824, var(--bg) 52%); }
.welcome-mark, .state-mark, .primary-link { background: #efefeb; color: #171717; }
.loader { border-top-color: #efefeb; }
}
+18
View File
@@ -0,0 +1,18 @@
{
"compilerOptions": {
"target": "ES2022",
"lib": ["dom", "dom.iterable", "esnext"],
"types": ["bun"],
"strict": true,
"noEmit": true,
"skipLibCheck": true,
"allowJs": false,
"esModuleInterop": true,
"module": "esnext",
"moduleResolution": "bundler",
"resolveJsonModule": true,
"isolatedModules": true
},
"include": ["src/**/*.ts", "lib/**/*.ts", "build.ts"],
"exclude": ["node_modules", "dist"]
}
+14 -1
View File
@@ -1,12 +1,25 @@
services:
cloudflared:
image: cloudflare/cloudflared:latest
image: cloudflare/cloudflared:2026.7.3
container_name: cloudflared
restart: unless-stopped
command: tunnel --protocol http2 run
volumes:
- ./config.yml:/etc/cloudflared/config.yml: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:
- homelab_net
+62
View File
@@ -0,0 +1,62 @@
services:
code-server:
image: codercom/code-server:4.132.0-39
container_name: code-server
restart: unless-stopped
user: "${CODE_SERVER_UID:-1000}:${CODE_SERVER_GID:-1000}"
environment:
DOCKER_USER: ${CODE_SERVER_USER:-coder}
command:
- --bind-addr
- 0.0.0.0:8080
- --auth
- none
- /home/coder/homelab
volumes:
- ./config:/home/coder/.config:rw
- ./local:/home/coder/.local:rw
- ../:/home/coder/homelab:rw
networks:
- homelab_net
labels:
# Traefik
- "traefik.enable=true"
- "traefik.http.routers.code-server.rule=Host(`code.xiteng.site`)"
- "traefik.http.routers.code-server.entrypoints=websecure"
- "traefik.http.routers.code-server.tls=true"
- "traefik.http.routers.code-server.tls.certresolver=cfresolver"
- "traefik.http.routers.code-server.service=code-server"
- "traefik.http.routers.code-server.middlewares=code-server-scheme,code-server-auth"
- "traefik.http.services.code-server.loadbalancer.server.port=8080"
- "traefik.http.routers.code-server-http.rule=Host(`code.xiteng.site`)"
- "traefik.http.routers.code-server-http.entrypoints=web"
- "traefik.http.routers.code-server-http.service=code-server"
- "traefik.http.routers.code-server-http.middlewares=code-server-scheme,code-server-auth"
- "xiteng.site.cache.code-server.enabled=true"
- "xiteng.site.cache.code-server.routers=code-server,code-server-http"
- "xiteng.site.cache.code-server.paths=/assets/,/static/"
- "xiteng.site.cache.code-server.edge-ttl=604800"
- "xiteng.site.cache.code-server.stale-while-revalidate=86400"
# Authentik ForwardAuth
- "traefik.http.middlewares.code-server-scheme.headers.customrequestheaders.X-Forwarded-Proto=https"
- "traefik.http.middlewares.code-server-auth.forwardauth.address=http://authentik-outpost:9000/outpost.goauthentik.io/auth/traefik"
- "traefik.http.middlewares.code-server-auth.forwardauth.trustForwardHeader=true"
- "traefik.http.middlewares.code-server-auth.forwardauth.authResponseHeaders=X-authentik-username,X-authentik-groups,X-authentik-email,X-authentik-name"
- "xiteng.site.component.code-server.enabled=true"
- "xiteng.site.component.code-server.name=Code Server"
- "xiteng.site.component.code-server.description=浏览器中的 VS Code 工作台,用于维护 Homelab 配置与脚本。"
- "xiteng.site.component.code-server.section=services"
- "xiteng.site.component.code-server.category=开发与协作"
- "xiteng.site.component.code-server.url=https://code.xiteng.site"
- "xiteng.site.component.code-server.access=sso"
- "xiteng.site.component.code-server.access-label=需要 Authentik"
- "xiteng.site.component.code-server.icon=CS"
- "xiteng.site.component.code-server.icon-url=https://cdn.simpleicons.org/coder"
- "xiteng.site.component.code-server.accent=blue"
- "xiteng.site.component.code-server.order=130"
- "xiteng.site.component.code-server.monitor.enabled=true"
- "xiteng.site.component.code-server.monitor.url=http://code-server:8080/healthz"
networks:
homelab_net:
external: true
+14
View File
@@ -0,0 +1,14 @@
FROM ghcr.nju.edu.cn/lecode-official/comfyui-docker:0.6.3-comfyui-0.8.2-comfyui-manager-4.0.5-pytorch-2.9.1-cuda-12.8-cudnn-9
USER root
WORKDIR /
RUN rm -rf /opt/comfyui /opt/comfyui-manager \
&& git clone --depth 1 --branch v0.31.0 https://github.com/Comfy-Org/ComfyUI.git /opt/comfyui \
&& git clone --depth 1 --branch 4.2.2 https://github.com/Comfy-Org/ComfyUI-Manager.git /opt/comfyui-manager
RUN /opt/conda/bin/pip install --index-url https://pypi.tuna.tsinghua.edu.cn/simple --requirement /opt/comfyui/requirements.txt \
&& if [ -f /opt/comfyui-manager/requirements.txt ]; then /opt/conda/bin/pip install --index-url https://pypi.tuna.tsinghua.edu.cn/simple --requirement /opt/comfyui-manager/requirements.txt; fi
WORKDIR /opt/comfyui
+10
View File
@@ -0,0 +1,10 @@
services:
comfyui:
command: ["--enable-manager"]
deploy:
resources:
reservations:
devices:
- driver: nvidia
count: all
capabilities: [gpu]
+62
View File
@@ -0,0 +1,62 @@
services:
comfyui:
build:
context: .
image: xiteng-comfyui:0.31.0-manager-4.2.2
container_name: comfyui
restart: unless-stopped
entrypoint: ["/bin/bash", "/entrypoint-patched.sh"]
environment:
USER_ID: ${COMFYUI_USER_ID:-1000}
GROUP_ID: ${COMFYUI_GROUP_ID:-1000}
PYTHONPATH: /opt/comfyui-manager
command: ["--cpu", "--enable-manager"]
volumes:
- ./entrypoint.sh:/entrypoint-patched.sh:ro
- ./models:/opt/comfyui/models:rw
- ./custom_nodes:/opt/comfyui/custom_nodes:rw
- ./output:/opt/comfyui/output:rw
networks:
- homelab_net
labels:
# Traefik
- "traefik.enable=true"
- "traefik.http.routers.comfyui.rule=Host(`comfy.xiteng.site`)"
- "traefik.http.routers.comfyui.entrypoints=websecure"
- "traefik.http.routers.comfyui.tls=true"
- "traefik.http.routers.comfyui.tls.certresolver=cfresolver"
- "traefik.http.routers.comfyui.service=comfyui"
- "traefik.http.routers.comfyui.middlewares=comfyui-scheme,comfyui-auth"
- "traefik.http.services.comfyui.loadbalancer.server.port=8188"
- "traefik.http.routers.comfyui-http.rule=Host(`comfy.xiteng.site`)"
- "traefik.http.routers.comfyui-http.entrypoints=web"
- "traefik.http.routers.comfyui-http.service=comfyui"
- "traefik.http.routers.comfyui-http.middlewares=comfyui-scheme,comfyui-auth"
- "xiteng.site.cache.comfyui.enabled=true"
- "xiteng.site.cache.comfyui.routers=comfyui,comfyui-http"
- "xiteng.site.cache.comfyui.paths=/assets/,/static/"
- "xiteng.site.cache.comfyui.edge-ttl=604800"
- "xiteng.site.cache.comfyui.stale-while-revalidate=86400"
# Authentik ForwardAuth
- "traefik.http.middlewares.comfyui-scheme.headers.customrequestheaders.X-Forwarded-Proto=https"
- "traefik.http.middlewares.comfyui-auth.forwardauth.address=http://authentik-outpost:9000/outpost.goauthentik.io/auth/traefik"
- "traefik.http.middlewares.comfyui-auth.forwardauth.trustForwardHeader=true"
- "traefik.http.middlewares.comfyui-auth.forwardauth.authResponseHeaders=X-authentik-username,X-authentik-groups,X-authentik-email,X-authentik-name"
- "xiteng.site.component.comfyui.enabled=true"
- "xiteng.site.component.comfyui.name=ComfyUI"
- "xiteng.site.component.comfyui.description=节点式图像生成、模型与工作流实验环境。"
- "xiteng.site.component.comfyui.section=services"
- "xiteng.site.component.comfyui.category=AI"
- "xiteng.site.component.comfyui.url=https://comfy.xiteng.site"
- "xiteng.site.component.comfyui.access=sso"
- "xiteng.site.component.comfyui.access-label=需要 Authentik"
- "xiteng.site.component.comfyui.icon=CU"
- "xiteng.site.component.comfyui.icon-url=https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/comfyui.svg"
- "xiteng.site.component.comfyui.accent=red"
- "xiteng.site.component.comfyui.order=210"
- "xiteng.site.component.comfyui.monitor.enabled=true"
- "xiteng.site.component.comfyui.monitor.url=http://comfyui:8188"
networks:
homelab_net:
external: true
+103
View File
@@ -0,0 +1,103 @@
#!/bin/bash
set -e
echo "Creating directories for models..."
MODEL_DIRECTORIES=(
"checkpoints"
"clip"
"clip_vision"
"configs"
"controlnet"
"diffusers"
"diffusion_models"
"embeddings"
"gligen"
"hypernetworks"
"loras"
"photomaker"
"style_models"
"text_encoders"
"unet"
"upscale_models"
"vae"
"vae_approx"
)
for MODEL_DIRECTORY in "${MODEL_DIRECTORIES[@]}"; do
mkdir -p "/opt/comfyui/models/$MODEL_DIRECTORY"
done
echo "Preparing ComfyUI Manager..."
export PYTHONPATH="/opt/comfyui-manager${PYTHONPATH:+:$PYTHONPATH}"
rm --force /opt/comfyui/custom_nodes/ComfyUI-Manager
ln -s /opt/comfyui-manager /opt/comfyui/custom_nodes/ComfyUI-Manager
mkdir -p /opt/comfyui/user/__manager
python - <<'PY'
from configparser import ConfigParser
from pathlib import Path
config_path = Path("/opt/comfyui/user/__manager/config.ini")
config = ConfigParser(strict=False)
config.read(config_path)
if "default" not in config:
config["default"] = {}
defaults = {
"git_exe": "",
"use_uv": "True",
"channel_url": "https://raw.githubusercontent.com/ltdrdata/ComfyUI-Manager/main",
"share_option": "all",
"bypass_ssl": "False",
"file_logging": "True",
"update_policy": "stable-comfyui",
"windows_selector_event_loop_policy": "False",
"model_download_by_agent": "False",
"downgrade_blacklist": "",
"security_level": "normal",
"always_lazy_install": "False",
"verbose": "False",
}
for key, value in defaults.items():
config["default"].setdefault(key, value)
config["default"]["network_mode"] = "offline"
config["default"]["db_mode"] = "local"
with config_path.open("w", encoding="utf-8") as handle:
config.write(handle)
PY
echo "Installing requirements for custom nodes..."
for CUSTOM_NODE_DIRECTORY in /opt/comfyui/custom_nodes/*; do
if [ "$CUSTOM_NODE_DIRECTORY" != "/opt/comfyui/custom_nodes/ComfyUI-Manager" ]; then
if [ -f "$CUSTOM_NODE_DIRECTORY/requirements.txt" ]; then
CUSTOM_NODE_NAME=${CUSTOM_NODE_DIRECTORY##*/}
CUSTOM_NODE_NAME=${CUSTOM_NODE_NAME//[-_]/ }
echo "Installing requirements for $CUSTOM_NODE_NAME..."
pip install --requirement "$CUSTOM_NODE_DIRECTORY/requirements.txt"
fi
fi
done
if [ -z "$USER_ID" ] || [ -z "$GROUP_ID" ]; then
echo "Running container as $USER..."
exec /opt/conda/bin/python main.py \
--port 8188 \
--listen 0.0.0.0 \
--disable-auto-launch \
"$@"
else
echo "Creating non-root user..."
getent group "$GROUP_ID" > /dev/null 2>&1 || groupadd --gid "$GROUP_ID" comfyui-user
id -u "$USER_ID" > /dev/null 2>&1 || useradd --uid "$USER_ID" --gid "$GROUP_ID" --create-home comfyui-user
chown --recursive "$USER_ID:$GROUP_ID" /opt/comfyui
chown --recursive "$USER_ID:$GROUP_ID" /opt/comfyui-manager
export PATH=$PATH:/home/comfyui-user/.local/bin
echo "Running container as comfyui-user ($USER_ID:$GROUP_ID)..."
sudo --set-home --preserve-env=PATH,PYTHONPATH --user "#$USER_ID" \
/opt/conda/bin/python main.py \
--port 8188 \
--listen 0.0.0.0 \
--disable-auto-launch \
"$@"
fi
+1 -3
View File
@@ -2,6 +2,4 @@ networks:
homelab_net:
driver: bridge
services:
# 这里只定义网络和基础服务,具体服务在各自子目录
# 可扩展如 traefik/nginx-proxy-manager 等
services: {}
+45
View File
@@ -0,0 +1,45 @@
services:
deck:
image: nginx:alpine@sha256:4a73073bd557c65b759505da037898b61f1be6cbcc3c2c3aeac22d2a470c1752
container_name: deck
restart: unless-stopped
volumes:
- ./index.html:/usr/share/nginx/html/index.html:ro
networks:
- homelab_net
labels:
# Traefik
- "traefik.enable=true"
- "traefik.http.routers.deck.rule=Host(`deck.xiteng.site`)"
- "traefik.http.routers.deck.entrypoints=websecure"
- "traefik.http.routers.deck.tls=true"
- "traefik.http.routers.deck.tls.certresolver=cfresolver"
- "traefik.http.routers.deck.service=deck"
- "traefik.http.services.deck.loadbalancer.server.port=80"
- "traefik.http.routers.deck-http.rule=Host(`deck.xiteng.site`)"
- "traefik.http.routers.deck-http.service=deck"
- "traefik.http.routers.deck-http.entrypoints=web"
- "xiteng.site.cache.deck.enabled=true"
- "xiteng.site.cache.deck.routers=deck,deck-http"
- "xiteng.site.cache.deck.paths=/assets/,/static/,/css/,/js/"
- "xiteng.site.cache.deck.edge-ttl=604800"
- "xiteng.site.cache.deck.stale-while-revalidate=86400"
- "xiteng.site.component.deck.enabled=true"
- "xiteng.site.component.deck.name=Xiteng Deck"
- "xiteng.site.component.deck.description=介绍个人基础设施与 xiteng.site 的公开演示文稿。"
- "xiteng.site.component.deck.section=services"
- "xiteng.site.component.deck.category=作品与实验"
- "xiteng.site.component.deck.url=https://deck.xiteng.site"
- "xiteng.site.component.deck.access=public"
- "xiteng.site.component.deck.access-label=无需登录"
- "xiteng.site.component.deck.icon=DK"
- "xiteng.site.component.deck.accent=red"
- "xiteng.site.component.deck.order=310"
- "xiteng.site.component.deck.navigation=new-tab"
- "xiteng.site.component.deck.portal-link=embedded"
- "xiteng.site.component.deck.monitor.enabled=true"
- "xiteng.site.component.deck.monitor.url=http://deck:80"
networks:
homelab_net:
external: true
+522
View File
@@ -0,0 +1,522 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>xiteng.site — Personal Infrastructure</title>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&family=JetBrains+Mono:wght@400;500;700&display=swap" rel="stylesheet">
<style>
:root {
--bg: #050508;
--surface: #0a0a14;
--card: rgba(255,255,255,0.025);
--card-hover: rgba(255,255,255,0.05);
--text: #e4e4ec;
--text-dim: #8888a0;
--text-muted: #5a5a72;
--accent: #6366f1;
--accent-glow: rgba(99,102,241,0.35);
--green: #22c55e;
--green-glow: rgba(34,197,94,0.3);
--amber: #f59e0b;
--blue: #3b82f6;
--red: #ef4444;
--border: rgba(255,255,255,0.06);
--border-visible: rgba(255,255,255,0.10);
--grid: rgba(255,255,255,0.025);
}
*,*::before,*::after{box-sizing:border-box;margin:0;padding:0}
html{scroll-behavior:smooth;background:var(--bg);-webkit-text-size-adjust:100%}
body{
font-family:'Inter',system-ui,-apple-system,sans-serif;
background:var(--bg);color:var(--text);
overflow-x:hidden;line-height:1.6;
-webkit-font-smoothing:antialiased;
}
/* ── Canvas Background ── */
#gridCanvas{position:fixed;inset:0;z-index:0;pointer-events:none;opacity:0.6}
/* ── Status Bar ── */
.statusbar{
position:fixed;top:0;left:0;right:0;z-index:100;
height:36px;display:flex;align-items:center;justify-content:space-between;
padding:0 20px;font-family:'JetBrains Mono',monospace;
font-size:11px;color:var(--text-muted);
background:rgba(5,5,8,0.85);
backdrop-filter:blur(12px);
border-bottom:1px solid var(--border);
}
.statusbar a{display:inline-flex;align-items:center;color:var(--text-dim);text-decoration:none;font-weight:600}
.statusbar a:hover{color:var(--text)}
.statusbar .left{display:flex;align-items:center;gap:16px}
.statusbar .dot{
width:7px;height:7px;border-radius:50%;background:var(--green);
box-shadow:0 0 6px var(--green-glow);
margin-right:8px;display:inline-block;vertical-align:middle;
animation:pulse-dot 2s ease-in-out infinite;
}
@keyframes pulse-dot{0%,100%{opacity:1}50%{opacity:0.5}}
.statusbar .right{display:flex;gap:16px}
/* ── Layout ── */
.container{position:relative;z-index:1;max-width:1100px;margin:0 auto;padding:0 24px}
section{padding:100px 0;position:relative}
section:first-of-type{padding-top:160px}
/* ── Typography ── */
.label{
font-family:'JetBrains Mono',monospace;
font-size:12px;font-weight:500;color:var(--accent);
text-transform:uppercase;letter-spacing:0.08em;margin-bottom:16px;display:block;
}
h1{
font-size:clamp(40px,6vw,80px);font-weight:700;
line-height:1.05;letter-spacing:-0.03em;margin-bottom:16px;
}
h1 .hl{color:var(--accent)}
h2{
font-size:clamp(28px,3.5vw,48px);font-weight:600;
line-height:1.15;letter-spacing:-0.02em;margin-bottom:12px;
}
.subtitle{
font-size:clamp(15px,1.4vw,20px);font-weight:300;color:var(--text-dim);
max-width:560px;line-height:1.7;
}
/* ── Hero ── */
.hero-grid{
display:grid;grid-template-columns:repeat(3,1fr);gap:12px;
margin-top:48px;max-width:600px;
}
.hero-stat{
background:var(--card);border:1px solid var(--border);
border-radius:8px;padding:16px 18px;
}
.hero-stat .val{
font-family:'JetBrains Mono',monospace;
font-size:22px;font-weight:700;color:var(--text);
}
.hero-stat .val.up{color:var(--green)}
.hero-stat .lbl{font-size:11px;color:var(--text-muted);margin-top:2px;text-transform:uppercase;letter-spacing:0.05em}
.hero-stat .val .unit{font-size:13px;color:var(--text-dim);font-weight:400}
/* ── Terminal Block ── */
.terminal{
background:rgba(0,0,0,0.5);border:1px solid var(--border-visible);
border-radius:10px;overflow:hidden;font-family:'JetBrains Mono',monospace;
font-size:clamp(11px,0.8vw,13px);line-height:1.8;
}
.terminal .bar{
height:28px;background:rgba(255,255,255,0.03);
display:flex;align-items:center;padding:0 12px;gap:6px;
border-bottom:1px solid var(--border);
}
.terminal .bar .tb{width:10px;height:10px;border-radius:50%}
.terminal .bar .tb.r{background:var(--red)}
.terminal .bar .tb.y{background:var(--amber)}
.terminal .bar .tb.g{background:var(--green)}
.terminal .bar .ttl{font-size:11px;color:var(--text-muted);margin-left:8px}
.terminal .body{padding:16px 18px;color:var(--text-dim)}
.terminal .body .prompt{color:var(--green)}
.terminal .body .cmd{color:var(--text)}
.terminal .body .out{color:var(--text-dim)}
.terminal .body .cursor{
display:inline-block;width:8px;height:15px;background:var(--accent);
vertical-align:text-bottom;margin-left:1px;
animation:blink 1s step-end infinite;
}
@keyframes blink{50%{opacity:0}}
/* ── Architecture Diagram ── */
.arch-diagram{
display:flex;align-items:center;gap:0;flex-wrap:wrap;
justify-content:center;padding:20px 0;
}
.arch-node{
background:var(--surface);border:1px solid var(--border-visible);
border-radius:12px;padding:20px 28px;text-align:center;
transition:all 0.3s;position:relative;
}
.arch-node:hover{
border-color:var(--accent);
box-shadow:0 0 24px var(--accent-glow);
transform:translateY(-2px);
}
.arch-node .n{font-size:18px;font-weight:600;color:var(--text)}
.arch-node .d{font-size:11px;color:var(--text-muted);margin-top:3px;font-family:'JetBrains Mono',monospace}
.arch-node.accent{border-color:var(--accent);background:rgba(99,102,241,0.04)}
.arch-node.accent .n{color:var(--accent)}
.arch-arrow{color:var(--text-muted);font-size:18px;padding:0 10px;font-family:'JetBrains Mono',monospace;flex-shrink:0}
.arch-pulse{animation:arch-glow 2.5s ease-in-out infinite}
.arch-pulse:nth-child(2){animation-delay:0.5s}
.arch-pulse:nth-child(3){animation-delay:1s}
@keyframes arch-glow{0%,100%{box-shadow:0 0 0 rgba(99,102,241,0)}50%{box-shadow:0 0 20px var(--accent-glow)}}
/* ── Service Cards ── */
.card-grid{
display:grid;grid-template-columns:repeat(auto-fill,minmax(280px,1fr));
gap:16px;margin-top:40px;
}
.svc-card{
background:var(--card);border:1px solid var(--border);
border-radius:12px;padding:28px;cursor:pointer;
transition:all 0.3s;position:relative;overflow:hidden;
}
.svc-card::before{
content:'';position:absolute;inset:0;border-radius:12px;
background:radial-gradient(600px circle at var(--mx,50%) var(--my,50%),rgba(99,102,241,0.06),transparent 40%);
opacity:0;transition:opacity 0.3s;
}
.svc-card:hover::before{opacity:1}
.svc-card:hover{
border-color:var(--border-visible);
background:var(--card-hover);
transform:translateY(-2px);
}
.svc-card .indicator{
width:8px;height:8px;border-radius:50%;background:var(--green);
box-shadow:0 0 8px var(--green-glow);
display:inline-block;margin-bottom:16px;
animation:pulse-dot 3s ease-in-out infinite;
}
.svc-card .indicator:nth-child(odd){animation-delay:1.5s}
.svc-card h3{font-size:20px;font-weight:600;margin-bottom:4px;letter-spacing:-0.01em}
.svc-card .url{
font-family:'JetBrains Mono',monospace;font-size:11px;
color:var(--text-muted);margin-bottom:12px;
}
.svc-card p{font-size:13px;color:var(--text-dim);line-height:1.6}
.svc-card .tags{margin-top:14px;display:flex;gap:6px;flex-wrap:wrap}
.svc-card .tag{
font-size:10px;font-weight:600;text-transform:uppercase;
letter-spacing:0.04em;padding:3px 8px;border-radius:4px;
color:var(--accent);background:rgba(99,102,241,0.08);
border:1px solid rgba(99,102,241,0.12);
}
/* ── Stack Pills ── */
.stack-section{display:flex;flex-direction:column;gap:20px;margin-top:40px}
.stack-row{display:flex;align-items:flex-start;gap:16px;flex-wrap:wrap}
.stack-label{
font-family:'JetBrains Mono',monospace;font-size:12px;font-weight:700;
color:var(--green);min-width:80px;text-align:right;padding-top:5px;
}
.stack-pills{display:flex;gap:8px;flex-wrap:wrap}
.stack-pill{
padding:6px 14px;border-radius:20px;font-size:13px;font-weight:500;
background:var(--card);border:1px solid var(--border);
color:var(--text-dim);transition:all 0.2s;
}
.stack-pill:hover{border-color:var(--border-visible);color:var(--text)}
.stack-pill.hl{border-color:var(--accent);color:var(--accent);background:rgba(99,102,241,0.06)}
/* ── CTA / Command Prompt ── */
.cmd-prompt{
background:var(--surface);border:1px solid var(--border-visible);
border-radius:12px;padding:20px 24px;display:flex;align-items:center;
gap:12px;font-family:'JetBrains Mono',monospace;font-size:15px;
max-width:560px;margin:0 auto;margin-top:48px;transition:all 0.3s;
}
.cmd-prompt:focus-within{border-color:var(--accent);box-shadow:0 0 20px var(--accent-glow)}
.cmd-prompt .pr{color:var(--green);flex-shrink:0}
.cmd-prompt input{
flex:1;background:none;border:none;outline:none;
color:var(--text);font:inherit;caret-color:var(--accent);
}
.cmd-prompt input::placeholder{color:var(--text-muted)}
/* ── Footer ── */
footer{
text-align:center;padding:60px 24px;border-top:1px solid var(--border);
font-family:'JetBrains Mono',monospace;font-size:12px;color:var(--text-muted);
}
footer a{color:var(--accent);text-decoration:none}
footer a:hover{text-decoration:underline}
/* ── Scroll Reveal ── */
.reveal{opacity:0;transform:translateY(30px);transition:all 0.7s cubic-bezier(0.16,1,0.3,1)}
.reveal.visible{opacity:1;transform:translateY(0)}
/* ── Responsive ── */
@media(max-width:768px){
section{padding:60px 0}
section:first-of-type{padding-top:120px}
.arch-diagram{flex-direction:column;gap:4px}
.arch-arrow{display:none}
.hero-grid{grid-template-columns:repeat(2,1fr)}
.stack-row{flex-direction:column;gap:6px}
.stack-label{text-align:left;min-width:auto}
.card-grid{grid-template-columns:1fr}
.statusbar .right{display:none}
}
@media(max-width:400px){
.hero-grid{grid-template-columns:1fr 1fr;gap:6px}
.hero-stat{padding:10px 12px}
.hero-stat .val{font-size:18px}
}
</style>
</head>
<body>
<canvas id="gridCanvas"></canvas>
<!-- ═══ Status Bar ═══ -->
<header class="statusbar">
<div class="left"><a href="https://xiteng.site/?focus=deck#services">← xiteng.site</a><span><span class="dot"></span> all systems operational</span></div>
<div class="right">UTC+8 · Arch Linux · Docker · <span style="color:var(--green)">UP 42d</span></div>
</header>
<main class="container">
<!-- ═══ Hero ═══ -->
<section>
<div class="reveal">
<span class="label">// Personal Infrastructure</span>
<h1><span class="hl">xiteng</span>.site</h1>
<p class="subtitle">
自建开发基础设施。一台 Linux 机器,Docker Compose 驱动,
Cloudflare Tunnel 接入,Authentik 统一认证。
</p>
</div>
<div class="reveal hero-grid">
<div class="hero-stat"><div class="val up">8</div><div class="lbl">Services</div></div>
<div class="hero-stat"><div class="val">1<span class="unit"> host</span></div><div class="lbl">Arch Linux</div></div>
<div class="hero-stat"><div class="val up">99.9<span class="unit">%</span></div><div class="lbl">Uptime (30d)</div></div>
</div>
<div class="reveal" style="margin-top:40px">
<div class="terminal">
<div class="bar"><div class="tb r"></div><div class="tb y"></div><div class="tb g"></div><span class="ttl">ssh aliyun — bash — 80×24</span></div>
<div class="body">
<span class="prompt">$</span> <span class="cmd">docker compose ps</span><br>
<span class="out">NAME &nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;STATUS<br>
gitea &nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;Up 7 days<br>
hedgedoc &nbsp;&nbsp;&nbsp;&nbsp;Up 7 days<br>
seaweedfs &nbsp;&nbsp;&nbsp;Up 7 days<br>
uptime-kuma &nbsp;Up 7 days<br>
homepage &nbsp;&nbsp;&nbsp;&nbsp;Up 7 days<br>
authentik &nbsp;&nbsp;&nbsp;Up 7 days<br>
deck &nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;Up 5 minutes<br>
cloudflared &nbsp;Up 7 days</span><br>
<span class="prompt">$</span> <span class="cursor"></span>
</div>
</div>
</div>
</section>
<!-- ═══ Architecture ═══ -->
<section>
<div class="reveal"><span class="label">// How It Works</span></div>
<div class="reveal"><h2>Traffic Flow</h2></div>
<div class="reveal arch-diagram">
<div class="arch-node"><div class="n">Internet</div><div class="d">HTTPS</div></div>
<div class="arch-arrow"></div>
<div class="arch-node accent arch-pulse"><div class="n">Cloudflare</div><div class="d">Tunnel · CDN</div></div>
<div class="arch-arrow"></div>
<div class="arch-node arch-pulse"><div class="n">Traefik</div><div class="d">TLS · Router</div></div>
<div class="arch-arrow"></div>
<div class="arch-node accent arch-pulse"><div class="n">Authentik</div><div class="d">SSO · Auth</div></div>
<div class="arch-arrow"></div>
<div class="arch-node"><div class="n">Services</div><div class="d">8 containers</div></div>
</div>
</section>
<!-- ═══ Services ═══ -->
<section>
<div class="reveal"><span class="label">// Services</span></div>
<div class="reveal"><h2>What's Running</h2></div>
<div class="reveal card-grid">
<a href="https://git.xiteng.site" class="svc-card" style="text-decoration:none;color:inherit">
<div class="indicator"></div>
<h3>Gitea</h3>
<div class="url">git.xiteng.site</div>
<p>自建 Git 托管。Pull Request、CI/CD Actions、包仓库、OAuth2 登录。</p>
<div class="tags"><span class="tag">git</span><span class="tag">ci/cd</span><span class="tag">oauth2</span></div>
</a>
<a href="https://notes.xiteng.site" class="svc-card" style="text-decoration:none;color:inherit">
<div class="indicator"></div>
<h3>HedgeDoc</h3>
<div class="url">notes.xiteng.site</div>
<p>实时协作 Markdown。Mermaid 图表、MathJax 公式、OIDC 登录。</p>
<div class="tags"><span class="tag">docs</span><span class="tag">collab</span><span class="tag">oidc</span></div>
</a>
<a href="https://s3.xiteng.site" class="svc-card" style="text-decoration:none;color:inherit">
<div class="indicator"></div>
<h3>SeaweedFS</h3>
<div class="url">s3.xiteng.site</div>
<p>S3 兼容对象存储。60MB 镜像,替代 MinIO。Filer UI + REST API。</p>
<div class="tags"><span class="tag">s3</span><span class="tag">storage</span></div>
</a>
<a href="https://status.xiteng.site" class="svc-card" style="text-decoration:none;color:inherit">
<div class="indicator"></div>
<h3>Uptime Kuma</h3>
<div class="url">status.xiteng.site</div>
<p>健康监控。60s 间隔探测,飞书/邮件告警,公开状态页。</p>
<div class="tags"><span class="tag">monitor</span><span class="tag">alerts</span></div>
</a>
<a href="https://xiteng.site" class="svc-card" style="text-decoration:none;color:inherit">
<div class="indicator"></div>
<h3>Homepage</h3>
<div class="url">xiteng.site</div>
<p>统一仪表盘。Docker 自动发现,书签 & 搜索,OIDC 登录。</p>
<div class="tags"><span class="tag">dashboard</span><span class="tag">oidc</span></div>
</a>
<a href="https://auth.xiteng.site" class="svc-card" style="text-decoration:none;color:inherit">
<div class="indicator"></div>
<h3>Authentik</h3>
<div class="url">auth.xiteng.site</div>
<p>身份认证平台。OAuth2/OIDC ProviderForwardAuth 代理保护。</p>
<div class="tags"><span class="tag">sso</span><span class="tag">security</span></div>
</a>
</div>
</section>
<!-- ═══ Stack ═══ -->
<section>
<div class="reveal"><span class="label">// Stack</span></div>
<div class="reveal"><h2>Technology</h2></div>
<div class="reveal stack-section">
<div class="stack-row">
<div class="stack-label">Infra</div>
<div class="stack-pills">
<span class="stack-pill hl">Docker Compose</span>
<span class="stack-pill">Arch Linux</span>
<span class="stack-pill">Cloudflare Tunnel</span>
<span class="stack-pill">frp Transit</span>
</div>
</div>
<div class="stack-row">
<div class="stack-label">Proxy</div>
<div class="stack-pills">
<span class="stack-pill hl">Traefik v3</span>
<span class="stack-pill">Let's Encrypt</span>
<span class="stack-pill">Cloudflare DNS</span>
</div>
</div>
<div class="stack-row">
<div class="stack-label">Data</div>
<div class="stack-pills">
<span class="stack-pill hl">PostgreSQL</span>
<span class="stack-pill">Redis</span>
<span class="stack-pill">SeaweedFS</span>
</div>
</div>
<div class="stack-row">
<div class="stack-label">Connect</div>
<div class="stack-pills">
<span class="stack-pill hl">Authentik</span>
<span class="stack-pill">Hermes Agent</span>
<span class="stack-pill">Feishu Bot</span>
</div>
</div>
</div>
</section>
<!-- ═══ CTA ═══ -->
<section style="text-align:center">
<div class="reveal">
<span class="label">// Try It</span>
<h2>Open a Terminal</h2>
<p class="subtitle" style="margin:0 auto 0 auto">访问任意服务,Authentik 统一认证。或输入命令探索。</p>
</div>
<div class="reveal cmd-prompt">
<span class="pr">$</span>
<input type="text" id="cmdInput" placeholder="输入 help 查看可用命令…" autocomplete="off" autocorrect="off" autocapitalize="off" spellcheck="false">
</div>
</section>
</main>
<footer>
<a href="https://git.xiteng.site">git.xiteng.site</a> &nbsp;·&nbsp;
<a href="https://notes.xiteng.site">notes</a> &nbsp;·&nbsp;
<a href="https://status.xiteng.site">status</a> &nbsp;·&nbsp;
<a href="https://xiteng.site">home</a> &nbsp;·&nbsp;
Built with Hermes Agent
</footer>
<script>
// ═══ Grid Canvas ═══
(function(){
const c=document.getElementById('gridCanvas'),x=c.getContext('2d');
let W,H,mx=-100,my=-100;
function resize(){W=c.width=window.innerWidth;H=c.height=window.innerHeight}
resize();window.addEventListener('resize',resize);
document.addEventListener('mousemove',e=>{mx=e.clientX;my=e.clientY});
const dots=[];
for(let i=0;i<80;i++)dots.push({x:Math.random()*2000,y:Math.random()*2000,r:1+Math.random()*1.5,vx:(Math.random()-0.5)*0.3,vy:(Math.random()-0.5)*0.3});
function draw(){
x.clearRect(0,0,W,H);
// Grid
x.strokeStyle='rgba(255,255,255,0.018)';x.lineWidth=0.5;
const s=50;
for(let gx=s;gx<W;gx+=s){x.beginPath();x.moveTo(gx,0);x.lineTo(gx,H);x.stroke()}
for(let gy=s;gy<H;gy+=s){x.beginPath();x.moveTo(0,gy);x.lineTo(W,gy);x.stroke()}
// Dots
dots.forEach(d=>{
d.x+=d.vx;d.y+=d.vy;
if(d.x<0)d.x=2000;if(d.x>2000)d.x=0;
if(d.y<0)d.y=2000;if(d.y>2000)d.y=0;
const dx=d.x-mx,dy=d.y-my,dist=Math.sqrt(dx*dx+dy*dy);
const glow=Math.max(0,1-dist/200);
const r=d.r+glow*2;
x.beginPath();x.arc(d.x,d.y,r,0,Math.PI*2);
const a=0.15+glow*0.4;
x.fillStyle=`rgba(99,102,241,${a})`;x.fill();
});
requestAnimationFrame(draw);
}
draw();
})();
// ═══ Scroll Reveal ═══
(function(){
const obs=new IntersectionObserver(entries=>{
entries.forEach(e=>{if(e.isIntersecting)e.target.classList.add('visible')});
},{threshold:0.15});
document.querySelectorAll('.reveal').forEach(el=>obs.observe(el));
})();
// ═══ Card Glow Follow ═══
document.querySelectorAll('.svc-card').forEach(card=>{
card.addEventListener('mousemove',e=>{
const r=card.getBoundingClientRect();
card.style.setProperty('--mx',(e.clientX-r.left)+'px');
card.style.setProperty('--my',(e.clientY-r.top)+'px');
});
});
// ═══ Command Prompt ═══
(function(){
const input=document.getElementById('cmdInput');
const routes={
'git':'https://git.xiteng.site',
'notes':'https://notes.xiteng.site',
's3':'https://s3.xiteng.site',
'status':'https://status.xiteng.site',
'home':'https://xiteng.site',
'auth':'https://auth.xiteng.site',
'deck':'https://deck.xiteng.site',
'file':'https://file.xiteng.site',
};
input.addEventListener('keydown',e=>{
if(e.key!=='Enter')return;
const v=input.value.trim().toLowerCase();
input.value='';
if(v==='help'||v==='?'){
let msg='Available: ';
Object.keys(routes).forEach(k=>msg+=k+' ');
msg+='\nType a service name to navigate, or "help" to see this.';
alert(msg);
}else if(routes[v]){
window.open(routes[v],'_blank');
}else if(v){
alert(`Unknown command: ${v}\nType "help" to see available commands.`);
}
});
})();
</script>
</body>
</html>
+6
View File
@@ -0,0 +1,6 @@
dynamic/*
!dynamic/.gitkeep
state/*
!state/.gitkeep
secrets/*
!secrets/.gitkeep
+66
View File
@@ -0,0 +1,66 @@
# Label-managed edge cache
Services opt paths into the shared edge cache with Docker labels. The controller discovers the
labels, writes higher-priority Traefik routers, and maintains a single Cloudflare Cache Rule that
makes the declared host/path pairs eligible for edge caching.
```yaml
labels:
- "xiteng.site.cache.frontend.enabled=true"
- "xiteng.site.cache.frontend.routers=example,example-http"
- "xiteng.site.cache.frontend.paths=/static/,/assets/"
- "xiteng.site.cache.frontend.edge-ttl=604800"
- "xiteng.site.cache.frontend.browser-ttl=0"
- "xiteng.site.cache.frontend.stale-while-revalidate=86400"
```
The policy id (`frontend`) only needs to be unique inside one Compose service. `routers` refers to
Traefik Docker router names declared on the same service. When omitted, all explicit HTTP routers on
the service are used. Each router must declare its entrypoints and service so it can be referenced
from the file provider. Existing router middlewares are inherited, so request headers and origin-side
authentication are not silently removed.
| Field | Default | Behavior |
|---|---:|---|
| `enabled` | required | Only the exact value `true` enables a policy. |
| `routers` | auto | Comma-separated Traefik router names on the same container. |
| `paths` | required | Comma-separated directory prefixes. Each must start and end with `/`; `/`, traversal and encoded paths are rejected. |
| `hosts` | from `Host()` | Optional exact hostnames for complex router rules; hosts must be inside `xiteng.site`. |
| `edge-ttl` | `604800` | Cloudflare TTL in seconds, bounded to 60 seconds365 days. |
| `browser-ttl` | `0` | When non-zero, also overrides the browser `Cache-Control` TTL. |
| `stale-while-revalidate` | `86400` | Stale revalidation window in seconds. |
The generated router only matches `GET` and `HEAD`, has priority 1000 above its base router, and adds
`Cloudflare-CDN-Cache-Control`. Cloudflare's managed rule makes extensionless files eligible for cache,
respects the TTL header for successful responses, and gives 3xx5xx responses a zero edge TTL.
Declaring a path static is a security decision: every response below the prefix must be public and
independent of users, cookies, authorization headers, and request-specific data. Never label HTML,
API, callback, admin, download-token, or user-content paths. A Cloudflare cache hit is served before
Traefik/ForwardAuth, even though cache misses still inherit the base router's middlewares.
Removing a policy does not revoke copies already held by browsers or edge locations; choose bounded
TTLs and purge Cloudflare explicitly when previously public content must be withdrawn immediately.
## Cloudflare token
Place an untracked API token at `secrets/cloudflare_cache_api_token`. It needs access to the
`xiteng.site` zone and the permissions required to edit Cache Rules. The existing DNS-01 token is
intentionally not reused. The controller remains healthy without this file and reports
`waiting-for-token`; in that state Traefik headers and default-extension caching work, but arbitrary
extensionless URLs are not guaranteed to be cached by Cloudflare.
Create the token in Cloudflare, then install it without exposing it in shell history or chat:
```sh
./edge-cache/configure-token
```
Inspect the generated rule without contacting Cloudflare:
```sh
docker compose -f edge-cache/compose.yml exec controller \
node /app/cloudflare.mjs plan
```
The live controller automatically creates or updates only the rule whose description is
`Xiteng label-managed static paths`; it preserves all unrelated Cache Rules.
+19
View File
@@ -0,0 +1,19 @@
import {readFileSync} from "node:fs";
import {cloudflareRuleFromSpec, syncCloudflareRule} from "./controller.mjs";
const command = process.argv[2] || "plan";
const specPath = process.env.CLOUDFLARE_SPEC_PATH || "/state/cloudflare-cache-rules.json";
const tokenPath = process.env.CLOUDFLARE_API_TOKEN_FILE || "/run/secrets/cloudflare_cache_api_token";
const zoneId = process.env.CLOUDFLARE_ZONE_ID || "";
const spec = JSON.parse(readFileSync(specPath, "utf8"));
if (command === "plan") {
console.log(JSON.stringify(cloudflareRuleFromSpec(spec), null, 2));
} else if (["check", "apply"].includes(command)) {
const token = readFileSync(tokenPath, "utf8").trim();
const result = await syncCloudflareRule(spec, {zoneId, token, dryRun: command === "check"});
console.log(JSON.stringify(result, null, 2));
} else {
console.error("Usage: node cloudflare.mjs [plan|check|apply]");
process.exit(2);
}

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