Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4f87ae2809 | ||
|
|
abc090ac80 | ||
|
|
ee61b8cdb9 | ||
|
|
8991f78f9f | ||
|
|
d1d50d722c | ||
|
|
5b84988789 | ||
|
|
557b0eca33 | ||
|
|
8c7eddbaee | ||
|
|
9473a98165 | ||
|
|
5e113476b6 | ||
|
|
c90649e642 | ||
|
|
de50e47c76 |
@@ -4,6 +4,10 @@ gitea/data/
|
|||||||
hedgedoc/data/
|
hedgedoc/data/
|
||||||
uptime-kuma/data/
|
uptime-kuma/data/
|
||||||
homepage/config/
|
homepage/config/
|
||||||
|
site/data/
|
||||||
|
pocket-id/data/
|
||||||
|
ai-gateway/data/
|
||||||
|
hedgedoc2/data/
|
||||||
|
|
||||||
# 忽略 Docker 相关文件
|
# 忽略 Docker 相关文件
|
||||||
*.log
|
*.log
|
||||||
@@ -26,4 +30,68 @@ frpc/frpc.toml
|
|||||||
|
|
||||||
# 忽略 Traefik 证书
|
# 忽略 Traefik 证书
|
||||||
traefik/letsencrypt/
|
traefik/letsencrypt/
|
||||||
|
|
||||||
|
# 忽略数据库持久化数据
|
||||||
|
authentik/pgdata/
|
||||||
|
authentik/redis/
|
||||||
|
authentik/media/
|
||||||
|
gitea/db_data/
|
||||||
|
hedgedoc/data_pg11_backup/
|
||||||
|
|
||||||
|
# 忽略旧 MinIO(已迁移至 SeaweedFS)
|
||||||
|
minio/
|
||||||
|
|
||||||
|
# 忽略 SeaweedFS 运行时数据
|
||||||
|
seaweedfs/data/
|
||||||
|
|
||||||
|
# 忽略 AI 工具
|
||||||
.aider*
|
.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/
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
# Repository Guidelines
|
||||||
|
|
||||||
|
## Project Structure & Module Organization
|
||||||
|
|
||||||
|
This repository manages a Docker Compose homelab. The root `compose.yml` defines the shared `homelab_net` network. Each service lives in its own directory with a local Compose file, for example `traefik/compose.yml`, `authentik/compose.yml`, `gitea/compose.yml`, `seaweedfs/compose.yml`, and `chat/compose.yml`. Runtime state and secrets belong in service-local `data/`, `var/`, `letsencrypt/`, `.env`, or similar untracked paths.
|
||||||
|
|
||||||
|
`cat-bodhi/` is the main application code: `index.html`, `game.js`, `styles.css`, `server.mjs`, `assets/`, `data/`, and Python sprite tooling under `tools/` and `sprite_alpha_seg_pytorch/`.
|
||||||
|
|
||||||
|
## Build, Test, and Development Commands
|
||||||
|
|
||||||
|
- `docker compose -f compose.yml up -d`: create or update the shared network.
|
||||||
|
- `docker compose -f traefik/compose.yml up -d`: start a service stack; swap the path for other services.
|
||||||
|
- `docker compose -f <service>/compose.yml config`: validate a service Compose file before deploying it.
|
||||||
|
- `docker compose -f <service>/compose.yml logs -f`: inspect service logs during troubleshooting.
|
||||||
|
- `cd cat-bodhi && npm run dev`: run the local game and sprite import server on port `8080`.
|
||||||
|
- `cd cat-bodhi && npm run dev:ai:proxy`: run the same server with Node environment proxy support.
|
||||||
|
|
||||||
|
## Coding Style & Naming Conventions
|
||||||
|
|
||||||
|
Use two-space indentation in YAML, HTML, CSS, and JavaScript. Keep Compose service names, directories, and network aliases lowercase with hyphens, matching paths such as `uptime-kuma` and `outpost-seaweedfs`. Prefer explicit image tags over floating versions. Keep Traefik labels grouped with their service and preserve existing label patterns.
|
||||||
|
|
||||||
|
For `cat-bodhi`, keep the vanilla HTML/CSS/JS structure. Name JavaScript functions and variables in `camelCase`; use descriptive asset filenames such as `decor-cat-bed.png`.
|
||||||
|
|
||||||
|
## Testing Guidelines
|
||||||
|
|
||||||
|
There is no centralized test suite. Validate infrastructure edits with `docker compose -f <service>/compose.yml config` and, when practical, restart only the affected service. For `cat-bodhi`, run `npm run dev`, open `http://localhost:8080`, and manually verify the changed workflow. If sprite processing changes, verify `/api/sprite-status` and `/api/sprite-import`.
|
||||||
|
|
||||||
|
## Commit & Pull Request Guidelines
|
||||||
|
|
||||||
|
Recent history uses short Conventional Commit-style prefixes such as `feat:`, `fix:`, `docs:`, and `refactor:`, sometimes with Chinese descriptions. Keep commits focused on one service or feature. Pull requests should summarize changed services, include validation commands, call out environment or secret changes, and attach screenshots for UI changes.
|
||||||
|
|
||||||
|
## Security & Configuration Tips
|
||||||
|
|
||||||
|
Do not commit real `.env` files, tunnel tokens, private keys, certificates, or generated service data. When adding a public route, confirm the intended Authentik mode: app-level OAuth/OIDC or Traefik ForwardAuth. Keep exposed domains and Traefik routers consistent with `README.md`.
|
||||||
@@ -7,30 +7,80 @@
|
|||||||
```
|
```
|
||||||
公网用户
|
公网用户
|
||||||
│
|
│
|
||||||
├─ Web (443) ──→ Cloudflare Tunnel ──→ cloudflared (Docker) ──→ Traefik (反向代理)
|
├─ Web (443) ──→ Cloudflare Tunnel ──→ Traefik
|
||||||
│ │
|
│ │
|
||||||
└─ SSH (22) ──→ 阿里云 VPS (frps) ──→ frpc (Docker) ──→ Gitea │
|
│ ┌─────────────────┼─────────────────┐
|
||||||
|
│ ▼ ▼ ▼
|
||||||
|
│ xiteng.site OAuth2/OIDC ForwardAuth
|
||||||
|
│ 唯一公开目录 应用层认证 入口层认证
|
||||||
|
│ │
|
||||||
|
│ ├─→ Edge Cache Controller ──→ Traefik + Cloudflare Cache Rule
|
||||||
|
│ ▼
|
||||||
|
│ Site Registry ──→ Docker API
|
||||||
|
│ │ (Label + 容器状态)
|
||||||
|
│ ├─→ HTTP 探测器
|
||||||
|
│ └─→ SQLite (生命周期 + 历史 + 可用率)
|
||||||
│
|
│
|
||||||
┌───────────────┤
|
└─ SSH (22) ──→ 阿里云 VPS (frps) ──→ frpc ──→ Gitea
|
||||||
▼ ▼
|
|
||||||
各 Web 服务 Gitea SSH
|
|
||||||
```
|
```
|
||||||
|
|
||||||
| 入口 | 域名 | 传输 | 延迟 |
|
| 入口 | 域名 | 传输 | 延迟 |
|
||||||
|------|------|------|:--:|
|
|------|------|------|:--:|
|
||||||
| 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 |
|
| Git SSH | `git.xiteng.site` | 阿里云 VPS → frp → Gitea | ~5ms |
|
||||||
|
|
||||||
## 服务一览
|
## 服务与组件目录
|
||||||
|
|
||||||
| 服务 | 地址 | 说明 |
|
[xiteng.site](https://xiteng.site) 是唯一门户。未登录与已登录用户看到相同的服务、基础设施目录及 CPU/MEM/DISK/GPU 实时设备状态;认证只决定能否读取服务数据或执行操作,不用于隐藏组件的存在。
|
||||||
|------|------|------|
|
|
||||||
| Homepage | [xiteng.site](https://xiteng.site) | 导航面板 |
|
门户不维护硬编码服务清单。Docker 容器通过 `xiteng.site.component.<id>.*` Label 自行声明名称、分组、说明、入口、访问方式和可选的 HTTP 探测。Registry 以组件 ID 为唯一键,统一保存生命周期、检查历史与可用率。Label 规范见 [`site/README.md`](site/README.md)。
|
||||||
| Gitea | [gitea.xiteng.site](https://gitea.xiteng.site) | 代码托管, `git clone [email protected]:…` |
|
|
||||||
| HedgeDoc | [notes.xiteng.site](https://notes.xiteng.site) | Markdown 协作 |
|
服务可另用 `xiteng.site.cache.<id>.*` Label 声明公开静态目录。Edge Cache Controller 将其编译为高优先级 Traefik 路由及一条合并的 Cloudflare Cache Rule;规范和安全边界见 [`edge-cache/README.md`](edge-cache/README.md)。
|
||||||
| Uptime Kuma | [uptime.xiteng.site](https://uptime.xiteng.site) | 服务监控 |
|
|
||||||
| MinIO | 内网 | 对象存储(不暴露公网) |
|
| 服务 | 地址 | 认证 | 说明 |
|
||||||
| Traefik | 内网 | 反向代理 |
|
|------|------|------|------|
|
||||||
|
| 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 SDK;Backend Provider 由 Chat 服务端使用 Key Vault Credential 直连,Frontend Provider 由浏览器使用 IndexedDB 本地 Credential 直连;聊天记录按 Authentik `(issuer, sub)` 隔离并持久化到 `chat/data/chat.db`,不会保存 Credential 明文
|
||||||
|
- **Portal**: Authentik ForwardAuth 保护 `/admin` 与 `/account`;`/admin` 只允许 `liooil`,并提供用户、用户组、密码恢复、会话注销和 `liuhome` 应用权限矩阵
|
||||||
|
- **公开目录**: `xiteng.site` 不做登录判断,只展示 Label 明确声明的公开元数据;受控服务在点击后执行 Authentik 或服务自身认证
|
||||||
|
|
||||||
|
Authentik 数据库、Key Vault 的 `vault_master_key`、Portal HMAC Key 与 Chat 的 `chat/data/chat.db` 必须分别备份,不能放入 Docker Label 或 Git。
|
||||||
|
|
||||||
## 目录结构
|
## 目录结构
|
||||||
|
|
||||||
@@ -38,9 +88,16 @@
|
|||||||
homelab/
|
homelab/
|
||||||
├── compose.yml # 共享网络定义 (homelab_net)
|
├── compose.yml # 共享网络定义 (homelab_net)
|
||||||
├── .env # 统一环境变量(敏感,gitignore)
|
├── .env # 统一环境变量(敏感,gitignore)
|
||||||
|
├── authentik/
|
||||||
|
│ ├── compose.yml
|
||||||
|
│ └── data/
|
||||||
├── cloudflared/
|
├── cloudflared/
|
||||||
│ ├── compose.yml
|
│ ├── compose.yml
|
||||||
│ └── config.yml
|
│ └── config.yml
|
||||||
|
├── edge-cache/
|
||||||
|
│ ├── compose.yml # Docker Label → Traefik/Cloudflare 缓存控制面
|
||||||
|
│ ├── controller.mjs # 动态路由、TTL 与 Cache Rule 生成器
|
||||||
|
│ └── README.md # 静态路径 Label 规范及安全边界
|
||||||
├── frpc/
|
├── frpc/
|
||||||
│ ├── compose.yml
|
│ ├── compose.yml
|
||||||
│ └── frpc.toml # 敏感,gitignore
|
│ └── frpc.toml # 敏感,gitignore
|
||||||
@@ -50,18 +107,61 @@ homelab/
|
|||||||
├── hedgedoc/
|
├── hedgedoc/
|
||||||
│ ├── compose.yml
|
│ ├── compose.yml
|
||||||
│ └── data/
|
│ └── data/
|
||||||
├── homepage/
|
├── site/
|
||||||
│ ├── compose.yml
|
│ ├── compose.yml
|
||||||
│ └── config/
|
│ ├── index.html # 页面结构,不包含服务清单
|
||||||
├── minio/
|
│ ├── admin.html # 用户、权限、Vault 与生命周期管理页
|
||||||
|
│ ├── styles.css
|
||||||
|
│ ├── app.js # 动态渲染组件卡片
|
||||||
|
│ ├── server.mjs # 公网站点与同源 API
|
||||||
|
│ ├── registry.mjs # Label 发现、HTTP 探测与生命周期控制面
|
||||||
|
│ ├── import-kuma.mjs # 一次性旧历史迁移工具
|
||||||
|
│ ├── metrics.py # 只读主机与 NVIDIA GPU 指标
|
||||||
|
│ ├── data/ # Registry SQLite,gitignore
|
||||||
|
│ └── README.md # 组件 Label 规范
|
||||||
|
├── ai-gateway/ # 内部 Key Vault 服务(保留目录名以避免数据路径迁移)
|
||||||
│ ├── compose.yml
|
│ ├── compose.yml
|
||||||
|
│ ├── vault.mjs # 信封加密、所有权、Provider Registry 与审计
|
||||||
|
│ ├── providers.json # 内置 Provider Catalog
|
||||||
|
│ ├── providers.mjs # Provider 校验与合并
|
||||||
|
│ ├── server.mjs
|
||||||
|
│ └── data/ # Vault SQLite,gitignore
|
||||||
|
├── homelab-emergency # Authentik 与 Vault 本机恢复入口
|
||||||
|
├── chat/
|
||||||
|
│ ├── app/ # Next.js 页面与流式聊天 API
|
||||||
|
│ ├── components/ # assistant-ui 线程与页面壳层
|
||||||
|
│ ├── data/ # 用户聊天历史 SQLite,gitignore
|
||||||
|
│ ├── Dockerfile
|
||||||
|
│ └── compose.yml # 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/
|
│ └── data/
|
||||||
├── traefik/
|
└── traefik/
|
||||||
│ ├── compose.yml
|
|
||||||
│ └── letsencrypt/ # gitignore
|
|
||||||
└── uptime-kuma/
|
|
||||||
├── compose.yml
|
├── compose.yml
|
||||||
└── data/
|
└── letsencrypt/ # gitignore
|
||||||
```
|
```
|
||||||
|
|
||||||
## 网络
|
## 网络
|
||||||
@@ -73,15 +173,71 @@ homelab/
|
|||||||
```bash
|
```bash
|
||||||
# 按依赖顺序启动
|
# 按依赖顺序启动
|
||||||
docker compose -f compose.yml up -d # 创建网络
|
docker compose -f compose.yml up -d # 创建网络
|
||||||
|
docker compose -f edge-cache/compose.yml up -d
|
||||||
docker compose -f traefik/compose.yml up -d
|
docker compose -f traefik/compose.yml up -d
|
||||||
|
docker compose -f authentik/compose.yml up -d
|
||||||
docker compose -f gitea/compose.yml up -d
|
docker compose -f gitea/compose.yml up -d
|
||||||
docker compose -f hedgedoc/compose.yml up -d
|
docker compose -f hedgedoc/compose.yml up -d
|
||||||
docker compose -f uptime-kuma/compose.yml up -d
|
docker compose -f seaweedfs/compose.yml up -d
|
||||||
docker compose -f homepage/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 cloudflared/compose.yml up -d
|
||||||
docker compose -f frpc/compose.yml up -d
|
docker compose -f frpc/compose.yml up -d
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|
||||||
|
## 最终恢复入口
|
||||||
|
|
||||||
|
日常身份管理位于 `https://xiteng.site/admin`。Authentik 原生管理界面已隐藏;根目录的
|
||||||
|
`homelab-emergency` 提供不依赖 Portal 的恢复能力:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./homelab-emergency status
|
||||||
|
./homelab-emergency identity-recovery liooil
|
||||||
|
./homelab-emergency identity-set-password liooil
|
||||||
|
./homelab-emergency identity-reset-2fa liooil
|
||||||
|
./homelab-emergency identity-reset-passkeys liooil
|
||||||
|
./homelab-emergency identity-bootstrap
|
||||||
|
./homelab-emergency vault-list
|
||||||
|
./homelab-emergency vault-audit 100
|
||||||
|
./homelab-emergency vault-delete <credential-id>
|
||||||
|
./homelab-emergency vault-backup ai-gateway/data/backups/vault.db
|
||||||
|
```
|
||||||
|
|
||||||
|
`identity-bootstrap` 保证 `liooil` 是唯一人类管理员、`liooil` 与 `ziyue` 属于 `liuhome`,
|
||||||
|
并将当前非开放应用的准入用户组统一为 `liuhome`。`identity-reset-2fa` 与
|
||||||
|
`identity-reset-passkeys` 需要交互确认,只删除指定用户的认证器并写入身份审计。应急脚本不输出
|
||||||
|
Provider Key、TOTP Secret 或 Passkey 凭据明文。
|
||||||
|
|
||||||
|
Homepage、Beszel、Uptime Kuma 和 AutoKuma 已退役。它们不再有活动 Compose 定义;现有 `homepage/config/`、`beszel/data/` 与 `uptime-kuma/data/` 仅作为迁移后的回滚数据保留,不会被 Portal 或启动流程读取。
|
||||||
|
|
||||||
|
code-server 将当前仓库挂载到 `/home/coder/homelab`,并持久化 VS Code 配置与扩展到
|
||||||
|
`code-server/config/` 和 `code-server/local/`。公网入口必须保持 Authentik ForwardAuth 保护;
|
||||||
|
code-server 内置密码认证已关闭,避免重复登录。默认不挂载 Docker socket,如需从浏览器终端管理
|
||||||
|
Docker,应改用更窄的专用运维入口。
|
||||||
|
|
||||||
|
ComfyUI 默认配置不要求 Docker GPU runtime,模型、Custom Nodes 和生成结果分别持久化到 `comfyui/models/`、`comfyui/custom_nodes/`、`comfyui/output/`。ComfyUI Manager 可安装第三方节点,公网入口必须保持 Authentik ForwardAuth 保护。
|
||||||
|
|
||||||
|
启用 NVIDIA GPU 前,先在宿主安装并配置 NVIDIA Container Toolkit,然后用 override 启动:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo pacman -S --needed nvidia-container-toolkit
|
||||||
|
sudo nvidia-ctk runtime configure --runtime=docker
|
||||||
|
sudo systemctl restart docker
|
||||||
|
docker compose -f comfyui/compose.yml -f comfyui/compose.gpu.yml up -d
|
||||||
|
```
|
||||||
|
|
||||||
|
InvokeAI 使用 NVIDIA GPU,模型、配置和生成结果统一持久化到 `invokeai/data/`。公网入口必须保持 Authentik ForwardAuth 保护;首次进入后在 Model Manager 中安装需要的模型。
|
||||||
|
|
||||||
## 所需外部资源
|
## 所需外部资源
|
||||||
|
|
||||||
| 资源 | 用途 |
|
| 资源 | 用途 |
|
||||||
|
|||||||
@@ -0,0 +1,22 @@
|
|||||||
|
# Key Vault and Provider Registry
|
||||||
|
|
||||||
|
This internal service is the credential boundary between Authentik identities and Xiteng Chat. It stores
|
||||||
|
Backend Provider credentials with AES-256-GCM envelope encryption. Every credential has a random data-encryption
|
||||||
|
key; the master key wraps only those data keys, so master-key rotation is independent of provider-key rotation.
|
||||||
|
|
||||||
|
`providers.json` is the built-in Provider Catalog. Administrators can add Custom Providers in SQLite without
|
||||||
|
editing the built-in file. A provider declares its API protocol, frontend/backend connection type, endpoint,
|
||||||
|
optional proxy, authentication shape, default-model preference, and a model-discovery type plus URL. The Vault
|
||||||
|
does not store model lists; Xiteng Chat fetches them dynamically from each configured Provider.
|
||||||
|
|
||||||
|
The service is internal-only. `xiteng.site` and `xiteng-chat` sign requests with a dedicated service token after
|
||||||
|
Authentik checks. The Vault independently treats only the exact username `liooil` as an administrator. Other
|
||||||
|
identities can access only records whose `(issuer, sub)` pair matches their session.
|
||||||
|
|
||||||
|
Normal metadata APIs never return plaintext. The dedicated `/v1/resolve` endpoint is restricted to the exact
|
||||||
|
service name `xiteng-chat`; it decrypts one active Backend Credential owned by the signed user and returns it to
|
||||||
|
the Chat server for one provider request. Frontend Provider credentials never enter this service and remain in
|
||||||
|
the browser's IndexedDB.
|
||||||
|
|
||||||
|
Xiteng Chat connects directly: Backend Providers from the Chat server, Frontend Providers from the browser.
|
||||||
|
Vault audit records cover credential lifecycle, verification, reassignment, resolution, and Custom Provider changes.
|
||||||
@@ -0,0 +1,77 @@
|
|||||||
|
import {existsSync} from "node:fs";
|
||||||
|
import {loadBuiltinProviders} from "./providers.mjs";
|
||||||
|
import {breakGlassActor, loadKeyFile, Vault} from "./vault.mjs";
|
||||||
|
|
||||||
|
const databasePath = process.env.DATABASE_PATH || "/data/vault.db";
|
||||||
|
const masterKeyFile = process.env.MASTER_KEY_FILE || "/run/secrets/vault_master_key";
|
||||||
|
const [command, ...args] = process.argv.slice(2);
|
||||||
|
|
||||||
|
function usage() {
|
||||||
|
console.log(`Usage:
|
||||||
|
node cli.mjs list [owner-sub]
|
||||||
|
node cli.mjs audit [limit]
|
||||||
|
node cli.mjs verify <credential-id>
|
||||||
|
node cli.mjs delete <credential-id>
|
||||||
|
node cli.mjs reassign <credential-id> <issuer> <sub> <username>
|
||||||
|
node cli.mjs rotate-master <new-key-file>
|
||||||
|
node cli.mjs backup <destination.db>`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!command) {
|
||||||
|
usage();
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
const vault = new Vault({
|
||||||
|
databasePath,
|
||||||
|
masterKey: loadKeyFile(masterKeyFile),
|
||||||
|
builtinProviders: loadBuiltinProviders(process.env.PROVIDER_CATALOG_FILE || "/app/providers.json")
|
||||||
|
});
|
||||||
|
const actor = breakGlassActor();
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (command === "list") {
|
||||||
|
const credentials = vault.listCredentials(actor, {all: true});
|
||||||
|
const filtered = args[0]
|
||||||
|
? credentials.filter((credential) => credential.owner.sub === args[0])
|
||||||
|
: credentials;
|
||||||
|
console.table(filtered.map((credential) => ({
|
||||||
|
id: credential.id,
|
||||||
|
owner: credential.owner.username,
|
||||||
|
sub: credential.owner.sub,
|
||||||
|
provider: credential.provider,
|
||||||
|
name: credential.name,
|
||||||
|
fingerprint: credential.fingerprint,
|
||||||
|
status: credential.status
|
||||||
|
})));
|
||||||
|
} else if (command === "audit") {
|
||||||
|
console.table(vault.listAudit(actor, {all: true, limit: args[0] || 100}));
|
||||||
|
} else if (command === "verify" && args[0]) {
|
||||||
|
console.log(vault.verifyCredential(actor, args[0]));
|
||||||
|
} else if (command === "delete" && args[0]) {
|
||||||
|
console.log(vault.deleteCredential(actor, args[0]));
|
||||||
|
} else if (command === "reassign" && args.length >= 4) {
|
||||||
|
console.log(vault.reassignCredential(actor, args[0], {
|
||||||
|
issuer: args[1],
|
||||||
|
sub: args[2],
|
||||||
|
username: args[3],
|
||||||
|
type: "user"
|
||||||
|
}));
|
||||||
|
} else if (command === "rotate-master" && args[0]) {
|
||||||
|
if (!existsSync(args[0])) {
|
||||||
|
throw new Error(`New key file not found: ${args[0]}`);
|
||||||
|
}
|
||||||
|
console.log(vault.rotateMasterKey(actor, loadKeyFile(args[0])));
|
||||||
|
console.error("Rotation succeeded. Replace the mounted vault_master_key before restarting the service.");
|
||||||
|
} else if (command === "backup" && args[0]) {
|
||||||
|
if (existsSync(args[0])) {
|
||||||
|
throw new Error(`Refusing to overwrite existing backup: ${args[0]}`);
|
||||||
|
}
|
||||||
|
console.log(vault.backup(args[0]));
|
||||||
|
} else {
|
||||||
|
usage();
|
||||||
|
process.exitCode = 1;
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
vault.close();
|
||||||
|
}
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
services:
|
||||||
|
ai-gateway:
|
||||||
|
image: node:24-alpine
|
||||||
|
container_name: ai-gateway
|
||||||
|
restart: unless-stopped
|
||||||
|
command: ["node", "/app/server.mjs"]
|
||||||
|
environment:
|
||||||
|
PORT: "8093"
|
||||||
|
DATABASE_PATH: /data/vault.db
|
||||||
|
MASTER_KEY_FILE: /run/secrets/vault_master_key
|
||||||
|
SERVICE_TOKEN_FILE: /run/secrets/portal_gateway_hmac
|
||||||
|
PROVIDER_CATALOG_FILE: /app/providers.json
|
||||||
|
ADMIN_USERNAME: liooil
|
||||||
|
volumes:
|
||||||
|
- ./vault.mjs:/app/vault.mjs:ro
|
||||||
|
- ./server.mjs:/app/server.mjs:ro
|
||||||
|
- ./providers.mjs:/app/providers.mjs:ro
|
||||||
|
- ./providers.json:/app/providers.json:ro
|
||||||
|
- ./cli.mjs:/app/cli.mjs:ro
|
||||||
|
- ./data:/data
|
||||||
|
- ./secrets:/run/secrets:ro
|
||||||
|
read_only: true
|
||||||
|
security_opt:
|
||||||
|
- no-new-privileges:true
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "node", "-e", "fetch('http://127.0.0.1:8093/healthz').then(r => process.exit(r.ok ? 0 : 1)).catch(() => process.exit(1))"]
|
||||||
|
interval: 15s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 3
|
||||||
|
start_period: 5s
|
||||||
|
networks:
|
||||||
|
- homelab_net
|
||||||
|
labels:
|
||||||
|
- "traefik.enable=false"
|
||||||
|
- "xiteng.site.component.ai-gateway.enabled=true"
|
||||||
|
- "xiteng.site.component.ai-gateway.name=Key Vault"
|
||||||
|
- "xiteng.site.component.ai-gateway.description=按用户保存 Backend Provider 凭据,并提供内置与 Custom Provider Registry。"
|
||||||
|
- "xiteng.site.component.ai-gateway.section=infrastructure"
|
||||||
|
- "xiteng.site.component.ai-gateway.category=身份与访问"
|
||||||
|
- "xiteng.site.component.ai-gateway.access=internal"
|
||||||
|
- "xiteng.site.component.ai-gateway.access-label=仅通过 Portal/API 使用"
|
||||||
|
- "xiteng.site.component.ai-gateway.icon=KV"
|
||||||
|
- "xiteng.site.component.ai-gateway.accent=blue"
|
||||||
|
- "xiteng.site.component.ai-gateway.order=220"
|
||||||
|
- "xiteng.site.component.ai-gateway.monitor.enabled=true"
|
||||||
|
- "xiteng.site.component.ai-gateway.monitor.url=http://ai-gateway:8093/healthz"
|
||||||
|
|
||||||
|
networks:
|
||||||
|
homelab_net:
|
||||||
|
external: true
|
||||||
@@ -0,0 +1,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"
|
||||||
|
}
|
||||||
|
]
|
||||||
@@ -0,0 +1,97 @@
|
|||||||
|
import {readFileSync} from "node:fs";
|
||||||
|
|
||||||
|
const allowedApis = new Set([
|
||||||
|
"openai-completions",
|
||||||
|
"openai-responses",
|
||||||
|
"anthropic-messages",
|
||||||
|
"google-generative-ai"
|
||||||
|
]);
|
||||||
|
|
||||||
|
function stringValue(value, field, maximum = 200) {
|
||||||
|
if (typeof value !== "string" || !value.trim()) throw new Error(`${field} is required`);
|
||||||
|
return value.trim().slice(0, maximum);
|
||||||
|
}
|
||||||
|
|
||||||
|
function optionalUrl(value, field, protocols) {
|
||||||
|
if (value === null || value === undefined || value === "") return null;
|
||||||
|
const url = new URL(stringValue(value, field, 2000));
|
||||||
|
if (!protocols.includes(url.protocol)) throw new Error(`${field} uses an unsupported protocol`);
|
||||||
|
return url.toString().replace(/\/$/, "");
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeHeaders(value) {
|
||||||
|
if (!value || typeof value !== "object" || Array.isArray(value)) return {};
|
||||||
|
return Object.fromEntries(Object.entries(value).map(([name, headerValue]) => [
|
||||||
|
stringValue(name, "header name", 120),
|
||||||
|
stringValue(headerValue, `header ${name}`, 1000)
|
||||||
|
]));
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
export function normalizeProvider(input, {builtin = false} = {}) {
|
||||||
|
if (!input || typeof input !== "object" || Array.isArray(input)) throw new Error("Provider definition is required");
|
||||||
|
const id = stringValue(input.id, "provider.id", 80).toLowerCase();
|
||||||
|
if (!/^[a-z0-9][a-z0-9._-]*$/.test(id)) throw new Error("provider.id contains unsupported characters");
|
||||||
|
const api = stringValue(input.api, "provider.api", 80);
|
||||||
|
if (!allowedApis.has(api)) throw new Error(`Unsupported provider API: ${api}`);
|
||||||
|
const connectionType = input.connection?.type === "frontend" ? "frontend" : "backend";
|
||||||
|
const proxyInput = input.connection?.proxy;
|
||||||
|
let proxy = null;
|
||||||
|
if (proxyInput) {
|
||||||
|
const proxyType = stringValue(proxyInput.type, "provider.connection.proxy.type", 30);
|
||||||
|
const allowedProxyTypes = connectionType === "frontend" ? ["relay"] : ["http", "https", "socks5"];
|
||||||
|
if (!allowedProxyTypes.includes(proxyType)) throw new Error(`Unsupported ${connectionType} proxy type: ${proxyType}`);
|
||||||
|
proxy = {
|
||||||
|
type: proxyType,
|
||||||
|
url: optionalUrl(proxyInput.url, "provider.connection.proxy.url", proxyType === "socks5" ? ["socks5:"] : ["http:", "https:"])
|
||||||
|
};
|
||||||
|
}
|
||||||
|
const authType = ["bearer", "header", "none"].includes(input.auth?.type) ? input.auth.type : "bearer";
|
||||||
|
const auth = {
|
||||||
|
type: authType,
|
||||||
|
...(authType === "header" ? {header: stringValue(input.auth?.header, "provider.auth.header", 120)} : {})
|
||||||
|
};
|
||||||
|
const defaultModel = typeof input.defaultModel === "string" ? input.defaultModel.trim().slice(0, 300) : "";
|
||||||
|
if (!builtin && !defaultModel) throw new Error("provider.defaultModel is required");
|
||||||
|
const baseUrl = optionalUrl(input.connection?.baseUrl, "provider.connection.baseUrl", ["http:", "https:"]);
|
||||||
|
const inferredDiscoveryType = api === "anthropic-messages"
|
||||||
|
? "anthropic-models-list"
|
||||||
|
: api === "google-generative-ai" ? "google-models-list" : "openai-models-list";
|
||||||
|
const discoveryType = ["openai-models-list", "anthropic-models-list", "google-models-list"].includes(input.discovery?.type)
|
||||||
|
? input.discovery.type
|
||||||
|
: inferredDiscoveryType;
|
||||||
|
const inferredDiscoveryUrl = discoveryType === "anthropic-models-list"
|
||||||
|
? `${baseUrl.endsWith("/v1") ? baseUrl : `${baseUrl}/v1`}/models?limit=200`
|
||||||
|
: discoveryType === "google-models-list" ? `${baseUrl}/models?pageSize=200` : `${baseUrl}/models`;
|
||||||
|
const discovery = {
|
||||||
|
type: discoveryType,
|
||||||
|
url: optionalUrl(input.discovery?.url, "provider.discovery.url", ["http:", "https:"]) || inferredDiscoveryUrl
|
||||||
|
};
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
name: stringValue(input.name, "provider.name", 160),
|
||||||
|
api,
|
||||||
|
connection: {
|
||||||
|
type: connectionType,
|
||||||
|
baseUrl,
|
||||||
|
proxy
|
||||||
|
},
|
||||||
|
auth,
|
||||||
|
headers: normalizeHeaders(input.headers),
|
||||||
|
defaultModel,
|
||||||
|
discovery,
|
||||||
|
builtin
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function loadBuiltinProviders(path) {
|
||||||
|
const definitions = JSON.parse(readFileSync(path, "utf8"));
|
||||||
|
if (!Array.isArray(definitions)) throw new Error("Provider catalog must be an array");
|
||||||
|
const providers = new Map();
|
||||||
|
for (const definition of definitions) {
|
||||||
|
const provider = normalizeProvider(definition, {builtin: true});
|
||||||
|
if (providers.has(provider.id)) throw new Error(`Duplicate built-in provider: ${provider.id}`);
|
||||||
|
providers.set(provider.id, provider);
|
||||||
|
}
|
||||||
|
return providers;
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
# Runtime secrets
|
||||||
|
|
||||||
|
`../../homelab-emergency init-secrets` creates these untracked files:
|
||||||
|
|
||||||
|
- `vault_master_key`: 32-byte base64 key used only to wrap per-credential data keys.
|
||||||
|
- `portal_gateway_hmac`: 32-byte base64 key used to authenticate Portal-to-Gateway requests.
|
||||||
|
|
||||||
|
Back up both outside this repository. Never store either key in `vault.db`, Authentik, Docker labels,
|
||||||
|
or the Portal UI.
|
||||||
@@ -0,0 +1,183 @@
|
|||||||
|
import {timingSafeEqual} from "node:crypto";
|
||||||
|
import {readFileSync} from "node:fs";
|
||||||
|
import http from "node:http";
|
||||||
|
import {loadBuiltinProviders, normalizeProvider} from "./providers.mjs";
|
||||||
|
import {loadKeyFile, Vault} from "./vault.mjs";
|
||||||
|
|
||||||
|
const port = Number.parseInt(process.env.PORT || "8093", 10);
|
||||||
|
const databasePath = process.env.DATABASE_PATH || "/data/vault.db";
|
||||||
|
const masterKeyFile = process.env.MASTER_KEY_FILE || "/run/secrets/vault_master_key";
|
||||||
|
const serviceTokenFile = process.env.SERVICE_TOKEN_FILE || "/run/secrets/portal_gateway_hmac";
|
||||||
|
const providerCatalogFile = process.env.PROVIDER_CATALOG_FILE || "/app/providers.json";
|
||||||
|
const adminUsername = process.env.ADMIN_USERNAME || "liooil";
|
||||||
|
const serviceToken = readFileSync(serviceTokenFile, "utf8").trim();
|
||||||
|
const vault = new Vault({
|
||||||
|
databasePath,
|
||||||
|
masterKey: loadKeyFile(masterKeyFile),
|
||||||
|
builtinProviders: loadBuiltinProviders(providerCatalogFile)
|
||||||
|
});
|
||||||
|
|
||||||
|
const securityHeaders = {
|
||||||
|
"Cache-Control": "no-store",
|
||||||
|
"Content-Security-Policy": "default-src 'none'; frame-ancestors 'none'",
|
||||||
|
"X-Content-Type-Options": "nosniff",
|
||||||
|
"X-Frame-Options": "DENY"
|
||||||
|
};
|
||||||
|
|
||||||
|
function sendJson(response, statusCode, payload) {
|
||||||
|
const body = JSON.stringify(payload);
|
||||||
|
response.writeHead(statusCode, {
|
||||||
|
...securityHeaders,
|
||||||
|
"Content-Type": "application/json; charset=utf-8",
|
||||||
|
"Content-Length": Buffer.byteLength(body)
|
||||||
|
});
|
||||||
|
response.end(body);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function readBody(request) {
|
||||||
|
const chunks = [];
|
||||||
|
let size = 0;
|
||||||
|
for await (const chunk of request) {
|
||||||
|
size += chunk.length;
|
||||||
|
if (size > 1048576) {
|
||||||
|
const error = new Error("Request body too large");
|
||||||
|
error.statusCode = 413;
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
chunks.push(chunk);
|
||||||
|
}
|
||||||
|
return Buffer.concat(chunks);
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseJson(body) {
|
||||||
|
if (!body.length) return {};
|
||||||
|
try {
|
||||||
|
return JSON.parse(body.toString("utf8"));
|
||||||
|
} catch {
|
||||||
|
const error = new Error("Invalid JSON body");
|
||||||
|
error.statusCode = 400;
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function safeEqual(left, right) {
|
||||||
|
const a = Buffer.from(left || "");
|
||||||
|
const b = Buffer.from(right || "");
|
||||||
|
return a.length === b.length && a.length > 0 && timingSafeEqual(a, b);
|
||||||
|
}
|
||||||
|
|
||||||
|
function authenticateService(request) {
|
||||||
|
const authorization = request.headers.authorization || "";
|
||||||
|
const token = authorization.startsWith("Bearer ") ? authorization.slice(7).trim() : "";
|
||||||
|
if (!safeEqual(token, serviceToken)) return null;
|
||||||
|
const issuer = String(request.headers["x-key-vault-actor-issuer"] || "").trim();
|
||||||
|
const sub = String(request.headers["x-key-vault-actor-sub"] || "").trim();
|
||||||
|
const username = String(request.headers["x-key-vault-actor-username"] || "").trim();
|
||||||
|
if (!issuer || !sub || !username) return null;
|
||||||
|
return {issuer, sub, username, admin: username === adminUsername};
|
||||||
|
}
|
||||||
|
|
||||||
|
const server = http.createServer(async (request, response) => {
|
||||||
|
try {
|
||||||
|
if (!request.url) {
|
||||||
|
sendJson(response, 400, {error: "Bad request"});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const url = new URL(request.url, "http://localhost");
|
||||||
|
const pathname = url.pathname;
|
||||||
|
if (request.method === "GET" && pathname === "/healthz") {
|
||||||
|
sendJson(response, 200, {status: "ok", vault: "sealed-at-rest"});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const actor = authenticateService(request);
|
||||||
|
if (!actor) {
|
||||||
|
sendJson(response, 401, {error: "Trusted service authentication required"});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const body = ["POST", "PUT", "PATCH"].includes(request.method || "")
|
||||||
|
? await readBody(request)
|
||||||
|
: Buffer.alloc(0);
|
||||||
|
|
||||||
|
if (request.method === "GET" && pathname === "/v1/session") {
|
||||||
|
sendJson(response, 200, {actor});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (request.method === "GET" && pathname === "/v1/providers") {
|
||||||
|
sendJson(response, 200, {providers: vault.listProviders(actor)});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (request.method === "POST" && pathname === "/v1/providers") {
|
||||||
|
const provider = normalizeProvider(parseJson(body), {builtin: false});
|
||||||
|
sendJson(response, 200, {provider: vault.saveCustomProvider(actor, provider)});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const providerDelete = pathname.match(/^\/v1\/providers\/([a-z0-9._-]+)\/delete$/);
|
||||||
|
if (request.method === "POST" && providerDelete) {
|
||||||
|
sendJson(response, 200, vault.deleteCustomProvider(actor, providerDelete[1]));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (request.method === "GET" && pathname === "/v1/credentials") {
|
||||||
|
sendJson(response, 200, {
|
||||||
|
credentials: vault.listCredentials(actor, {all: actor.admin && url.searchParams.get("scope") === "all"})
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (request.method === "POST" && pathname === "/v1/credentials") {
|
||||||
|
sendJson(response, 201, {credential: vault.createCredential(actor, parseJson(body))});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const credentialAction = pathname.match(/^\/v1\/credentials\/([0-9a-f-]+)\/(replace|verify|delete)$/);
|
||||||
|
if (request.method === "POST" && credentialAction) {
|
||||||
|
const [, id, action] = credentialAction;
|
||||||
|
if (action === "replace") {
|
||||||
|
sendJson(response, 200, {credential: vault.replaceCredential(actor, id, parseJson(body))});
|
||||||
|
} else if (action === "verify") {
|
||||||
|
sendJson(response, 200, vault.verifyCredential(actor, id));
|
||||||
|
} else {
|
||||||
|
sendJson(response, 200, vault.deleteCredential(actor, id));
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (request.method === "POST" && pathname === "/v1/resolve") {
|
||||||
|
sendJson(response, 200, vault.resolve(actor, parseJson(body)));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (request.method === "GET" && pathname === "/v1/audit") {
|
||||||
|
sendJson(response, 200, {
|
||||||
|
events: vault.listAudit(actor, {
|
||||||
|
all: actor.admin && url.searchParams.get("scope") === "all",
|
||||||
|
limit: url.searchParams.get("limit")
|
||||||
|
})
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
sendJson(response, 404, {error: "Not found"});
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Key Vault request failed", error.message);
|
||||||
|
sendJson(response, error.statusCode || 400, {error: error.message || "Request failed"});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
server.listen(port, "0.0.0.0", () => {
|
||||||
|
console.log(`key-vault listening on :${port}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
function shutdown() {
|
||||||
|
server.close(() => {
|
||||||
|
vault.close();
|
||||||
|
process.exit(0);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
process.on("SIGTERM", shutdown);
|
||||||
|
process.on("SIGINT", shutdown);
|
||||||
@@ -0,0 +1,110 @@
|
|||||||
|
import assert from "node:assert/strict";
|
||||||
|
import {spawn} from "node:child_process";
|
||||||
|
import {randomBytes} from "node:crypto";
|
||||||
|
import {mkdtempSync, rmSync, writeFileSync} from "node:fs";
|
||||||
|
import http from "node:http";
|
||||||
|
import {tmpdir} from "node:os";
|
||||||
|
import {join} from "node:path";
|
||||||
|
|
||||||
|
const directory = mkdtempSync(join(tmpdir(), "xiteng-key-vault-api-test-"));
|
||||||
|
writeFileSync(join(directory, "master-key"), randomBytes(32).toString("base64"));
|
||||||
|
writeFileSync(join(directory, "service-token"), "integration-service-token");
|
||||||
|
|
||||||
|
const portProbe = http.createServer();
|
||||||
|
await new Promise((resolve) => portProbe.listen(0, "127.0.0.1", resolve));
|
||||||
|
const port = portProbe.address().port;
|
||||||
|
await new Promise((resolve) => portProbe.close(resolve));
|
||||||
|
|
||||||
|
const service = spawn(process.execPath, ["server.mjs"], {
|
||||||
|
cwd: new URL(".", import.meta.url),
|
||||||
|
env: {
|
||||||
|
...process.env,
|
||||||
|
PORT: String(port),
|
||||||
|
DATABASE_PATH: join(directory, "vault.db"),
|
||||||
|
MASTER_KEY_FILE: join(directory, "master-key"),
|
||||||
|
SERVICE_TOKEN_FILE: join(directory, "service-token"),
|
||||||
|
PROVIDER_CATALOG_FILE: new URL("providers.json", import.meta.url).pathname
|
||||||
|
},
|
||||||
|
stdio: ["ignore", "pipe", "pipe"]
|
||||||
|
});
|
||||||
|
let logs = "";
|
||||||
|
service.stdout.on("data", (chunk) => { logs += chunk; });
|
||||||
|
service.stderr.on("data", (chunk) => { logs += chunk; });
|
||||||
|
|
||||||
|
function request(pathname, {method = "GET", body} = {}) {
|
||||||
|
return fetch(`http://127.0.0.1:${port}${pathname}`, {
|
||||||
|
method,
|
||||||
|
headers: {
|
||||||
|
"Authorization": "Bearer integration-service-token",
|
||||||
|
"Accept": "application/json",
|
||||||
|
...(body ? {"Content-Type": "application/json"} : {}),
|
||||||
|
"X-Key-Vault-Actor-Issuer": "https://auth.xiteng.site",
|
||||||
|
"X-Key-Vault-Actor-Sub": "integration-user",
|
||||||
|
"X-Key-Vault-Actor-Username": "integration"
|
||||||
|
},
|
||||||
|
body: body ? JSON.stringify(body) : undefined
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
let ready = false;
|
||||||
|
for (let attempt = 0; attempt < 50; attempt += 1) {
|
||||||
|
try {
|
||||||
|
if ((await fetch(`http://127.0.0.1:${port}/healthz`)).ok) {
|
||||||
|
ready = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
} catch {}
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 50));
|
||||||
|
}
|
||||||
|
assert.equal(ready, true, logs || "Key Vault did not start");
|
||||||
|
|
||||||
|
const providersResponse = await request("/v1/providers");
|
||||||
|
assert.equal(providersResponse.status, 200);
|
||||||
|
const providers = await providersResponse.json();
|
||||||
|
assert.ok(providers.providers.some((provider) => provider.id === "openai"));
|
||||||
|
assert.ok(providers.providers.some((provider) => provider.id === "ollama" && provider.credentialState === "local"));
|
||||||
|
|
||||||
|
const customResponse = await request("/v1/providers", {
|
||||||
|
method: "POST",
|
||||||
|
body: {
|
||||||
|
id: "integration-provider",
|
||||||
|
name: "Integration Provider",
|
||||||
|
api: "openai-completions",
|
||||||
|
connection: {type: "backend", baseUrl: "https://example.com/v1", proxy: null},
|
||||||
|
auth: {type: "bearer"},
|
||||||
|
defaultModel: "test-model",
|
||||||
|
discovery: {type: "openai-models-list", url: "https://example.com/v1/models"}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
assert.equal(customResponse.status, 200);
|
||||||
|
const savedCustom = await customResponse.json();
|
||||||
|
assert.equal(savedCustom.provider.discovery.url, "https://example.com/v1/models");
|
||||||
|
assert.equal("models" in savedCustom.provider, false);
|
||||||
|
|
||||||
|
const createdResponse = await request("/v1/credentials", {
|
||||||
|
method: "POST",
|
||||||
|
body: {providerId: "integration-provider", name: "default", secret: {provider: {apiKey: "provider-secret"}}}
|
||||||
|
});
|
||||||
|
assert.equal(createdResponse.status, 201);
|
||||||
|
|
||||||
|
const resolvedResponse = await request("/v1/resolve", {
|
||||||
|
method: "POST",
|
||||||
|
body: {providerId: "integration-provider", credentialName: "default"}
|
||||||
|
});
|
||||||
|
assert.equal(resolvedResponse.status, 200);
|
||||||
|
const resolved = await resolvedResponse.json();
|
||||||
|
assert.equal(resolved.provider.connection.baseUrl, "https://example.com/v1");
|
||||||
|
assert.equal(resolved.credential.secret.provider.apiKey, "provider-secret");
|
||||||
|
|
||||||
|
const frontendCredential = await request("/v1/credentials", {
|
||||||
|
method: "POST",
|
||||||
|
body: {providerId: "ollama", name: "default", secret: "browser-only"}
|
||||||
|
});
|
||||||
|
assert.equal(frontendCredential.status, 409);
|
||||||
|
console.log("Key Vault API tests passed");
|
||||||
|
} finally {
|
||||||
|
service.kill("SIGTERM");
|
||||||
|
await new Promise((resolve) => service.once("exit", resolve));
|
||||||
|
rmSync(directory, {recursive: true, force: true});
|
||||||
|
}
|
||||||
@@ -0,0 +1,77 @@
|
|||||||
|
import assert from "node:assert/strict";
|
||||||
|
import {randomBytes} from "node:crypto";
|
||||||
|
import {mkdtempSync, rmSync} from "node:fs";
|
||||||
|
import {tmpdir} from "node:os";
|
||||||
|
import {join} from "node:path";
|
||||||
|
import {loadBuiltinProviders, normalizeProvider} from "./providers.mjs";
|
||||||
|
import {Vault} from "./vault.mjs";
|
||||||
|
|
||||||
|
const directory = mkdtempSync(join(tmpdir(), "xiteng-vault-test-"));
|
||||||
|
const vault = new Vault({
|
||||||
|
databasePath: join(directory, "vault.db"),
|
||||||
|
masterKey: randomBytes(32),
|
||||||
|
builtinProviders: loadBuiltinProviders(new URL("providers.json", import.meta.url))
|
||||||
|
});
|
||||||
|
const admin = {issuer: "https://id.xiteng.site", sub: "admin-sub", username: "liooil", admin: true};
|
||||||
|
const user = {issuer: "https://id.xiteng.site", sub: "user-sub", username: "ziyue", admin: false};
|
||||||
|
|
||||||
|
try {
|
||||||
|
const providers = vault.listProviders(user);
|
||||||
|
assert.ok(providers.length >= 25);
|
||||||
|
assert.equal(providers.find((provider) => provider.id === "ollama").connection.type, "frontend");
|
||||||
|
|
||||||
|
assert.throws(() => vault.createCredential(user, {
|
||||||
|
providerId: "ollama",
|
||||||
|
name: "default",
|
||||||
|
secret: "must-not-enter-server"
|
||||||
|
}), /remain in the browser/i);
|
||||||
|
|
||||||
|
const custom = normalizeProvider({
|
||||||
|
id: "team-proxy",
|
||||||
|
name: "Team Proxy",
|
||||||
|
api: "openai-completions",
|
||||||
|
connection: {
|
||||||
|
type: "backend",
|
||||||
|
baseUrl: "https://models.example.com/v1",
|
||||||
|
proxy: {type: "socks5", url: "socks5://proxy.example.com:1080"}
|
||||||
|
},
|
||||||
|
auth: {type: "bearer"},
|
||||||
|
defaultModel: "team-model",
|
||||||
|
discovery: {type: "openai-models-list", url: "https://models.example.com/v1/catalog"}
|
||||||
|
});
|
||||||
|
vault.saveCustomProvider(user, custom);
|
||||||
|
assert.equal(vault.getProvider(user, "team-proxy").connection.proxy.type, "socks5");
|
||||||
|
assert.equal(vault.getProvider(user, "team-proxy").discovery.url, "https://models.example.com/v1/catalog");
|
||||||
|
assert.equal("models" in vault.getProvider(user, "team-proxy"), false);
|
||||||
|
|
||||||
|
const credential = vault.createCredential(user, {
|
||||||
|
providerId: "team-proxy",
|
||||||
|
name: "default",
|
||||||
|
secret: {
|
||||||
|
provider: {apiKey: "secret-one"},
|
||||||
|
proxy: {username: "proxy-user", password: "proxy-pass"}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
assert.equal(vault.listCredentials(user).length, 1);
|
||||||
|
assert.equal(vault.listCredentials(admin, {all: true}).length, 1);
|
||||||
|
assert.equal(vault.verifyCredential(user, credential.id).verified, true);
|
||||||
|
|
||||||
|
const resolved = vault.resolve(user, {providerId: "team-proxy", credentialName: "default"});
|
||||||
|
assert.equal(resolved.provider.id, "team-proxy");
|
||||||
|
assert.equal(resolved.credential.secret.provider.apiKey, "secret-one");
|
||||||
|
assert.equal(resolved.credential.secret.proxy.username, "proxy-user");
|
||||||
|
|
||||||
|
vault.replaceCredential(user, credential.id, {secret: "secret-two"});
|
||||||
|
assert.equal(vault.resolve(user, {providerId: "team-proxy"}).credential.secret.provider.apiKey, "secret-two");
|
||||||
|
assert.throws(() => vault.resolve(admin, {providerId: "team-proxy"}), /provider not found/i);
|
||||||
|
|
||||||
|
vault.deleteCredential(user, credential.id);
|
||||||
|
assert.equal(vault.listCredentials(user).length, 0);
|
||||||
|
vault.deleteCustomProvider(user, "team-proxy");
|
||||||
|
assert.equal(vault.getProvider(user, "team-proxy"), null);
|
||||||
|
assert.ok(vault.listAudit(admin, {all: true}).length >= 7);
|
||||||
|
console.log("Vault tests passed");
|
||||||
|
} finally {
|
||||||
|
vault.close();
|
||||||
|
rmSync(directory, {recursive: true, force: true});
|
||||||
|
}
|
||||||
@@ -0,0 +1,552 @@
|
|||||||
|
import {
|
||||||
|
createCipheriv,
|
||||||
|
createDecipheriv,
|
||||||
|
createHash,
|
||||||
|
randomBytes,
|
||||||
|
randomUUID
|
||||||
|
} from "node:crypto";
|
||||||
|
import {mkdirSync, readFileSync} from "node:fs";
|
||||||
|
import {DatabaseSync} from "node:sqlite";
|
||||||
|
import {normalizeProvider} from "./providers.mjs";
|
||||||
|
|
||||||
|
function now() {
|
||||||
|
return new Date().toISOString();
|
||||||
|
}
|
||||||
|
|
||||||
|
function limitedString(value, field, maximum = 120) {
|
||||||
|
if (typeof value !== "string" || !value.trim()) throw new Error(`${field} is required`);
|
||||||
|
return value.trim().slice(0, maximum);
|
||||||
|
}
|
||||||
|
|
||||||
|
function secretBuffer(value) {
|
||||||
|
let normalized;
|
||||||
|
if (typeof value === "string") {
|
||||||
|
if (!value) throw new Error("secret is required");
|
||||||
|
normalized = {provider: {apiKey: value}};
|
||||||
|
} else if (value && typeof value === "object" && !Array.isArray(value)) {
|
||||||
|
normalized = value;
|
||||||
|
} else {
|
||||||
|
throw new Error("secret is required");
|
||||||
|
}
|
||||||
|
const buffer = Buffer.from(JSON.stringify(normalized));
|
||||||
|
if (buffer.length > 65536) throw new Error("secret is too large");
|
||||||
|
return buffer;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseSecret(buffer) {
|
||||||
|
const text = buffer.toString("utf8");
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(text);
|
||||||
|
if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) return parsed;
|
||||||
|
} catch {}
|
||||||
|
return {provider: {apiKey: text}};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function loadKeyFile(path) {
|
||||||
|
const value = readFileSync(path);
|
||||||
|
const text = value.toString("utf8").trim();
|
||||||
|
const decoded = /^[A-Fa-f0-9]{64}$/.test(text) ? Buffer.from(text, "hex") : Buffer.from(text, "base64");
|
||||||
|
if (decoded.length !== 32) {
|
||||||
|
throw new Error(`Key file ${path} must contain exactly 32 random bytes encoded as base64 or hex`);
|
||||||
|
}
|
||||||
|
return decoded;
|
||||||
|
}
|
||||||
|
|
||||||
|
function encrypt(key, plaintext, associatedData) {
|
||||||
|
const nonce = randomBytes(12);
|
||||||
|
const cipher = createCipheriv("aes-256-gcm", key, nonce);
|
||||||
|
cipher.setAAD(Buffer.from(associatedData));
|
||||||
|
const ciphertext = Buffer.concat([cipher.update(plaintext), cipher.final()]);
|
||||||
|
return {ciphertext, nonce, tag: cipher.getAuthTag()};
|
||||||
|
}
|
||||||
|
|
||||||
|
function decrypt(key, ciphertext, nonce, tag, associatedData) {
|
||||||
|
const decipher = createDecipheriv("aes-256-gcm", key, nonce);
|
||||||
|
decipher.setAAD(Buffer.from(associatedData));
|
||||||
|
decipher.setAuthTag(tag);
|
||||||
|
return Buffer.concat([decipher.update(ciphertext), decipher.final()]);
|
||||||
|
}
|
||||||
|
|
||||||
|
function credentialAad(record) {
|
||||||
|
return `credential:v1:${record.id}:${record.owner_issuer}:${record.owner_sub}:${record.provider}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function wrappedDekAad(record) {
|
||||||
|
return `wrapped-dek:v1:${record.id}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function fingerprint(secret) {
|
||||||
|
return createHash("sha256").update(secret).digest("hex").slice(0, 16);
|
||||||
|
}
|
||||||
|
|
||||||
|
function publicCredential(row) {
|
||||||
|
return {
|
||||||
|
id: row.id,
|
||||||
|
owner: {
|
||||||
|
issuer: row.owner_issuer,
|
||||||
|
sub: row.owner_sub,
|
||||||
|
username: row.owner_username
|
||||||
|
},
|
||||||
|
providerId: row.provider,
|
||||||
|
name: row.name,
|
||||||
|
fingerprint: row.fingerprint,
|
||||||
|
createdAt: row.created_at,
|
||||||
|
updatedAt: row.updated_at,
|
||||||
|
lastAccessedAt: row.last_used_at
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export class Vault {
|
||||||
|
constructor({databasePath, masterKey, builtinProviders}) {
|
||||||
|
this.masterKey = masterKey;
|
||||||
|
this.builtinProviders = builtinProviders;
|
||||||
|
mkdirSync(databasePath.slice(0, databasePath.lastIndexOf("/")) || ".", {recursive: true});
|
||||||
|
this.database = new DatabaseSync(databasePath);
|
||||||
|
this.database.exec(`
|
||||||
|
PRAGMA journal_mode = WAL;
|
||||||
|
PRAGMA foreign_keys = ON;
|
||||||
|
PRAGMA busy_timeout = 5000;
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS vault_credential (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
owner_issuer TEXT NOT NULL,
|
||||||
|
owner_sub TEXT NOT NULL,
|
||||||
|
owner_username TEXT NOT NULL,
|
||||||
|
owner_type TEXT NOT NULL DEFAULT 'user',
|
||||||
|
provider TEXT NOT NULL,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
ciphertext BLOB NOT NULL,
|
||||||
|
nonce BLOB NOT NULL,
|
||||||
|
auth_tag BLOB NOT NULL,
|
||||||
|
wrapped_dek BLOB NOT NULL,
|
||||||
|
wrap_nonce BLOB NOT NULL,
|
||||||
|
wrap_tag BLOB NOT NULL,
|
||||||
|
key_version INTEGER NOT NULL DEFAULT 1,
|
||||||
|
fingerprint TEXT NOT NULL,
|
||||||
|
status TEXT NOT NULL DEFAULT 'active',
|
||||||
|
created_at TEXT NOT NULL,
|
||||||
|
updated_at TEXT NOT NULL,
|
||||||
|
last_used_at TEXT,
|
||||||
|
revoked_at TEXT
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS vault_credential_owner
|
||||||
|
ON vault_credential(owner_issuer, owner_sub, provider, name);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS custom_provider (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
owner_issuer TEXT NOT NULL,
|
||||||
|
owner_sub TEXT NOT NULL,
|
||||||
|
owner_username TEXT NOT NULL,
|
||||||
|
provider_id TEXT NOT NULL,
|
||||||
|
definition_json TEXT NOT NULL,
|
||||||
|
created_at TEXT NOT NULL,
|
||||||
|
updated_at TEXT NOT NULL,
|
||||||
|
UNIQUE(owner_issuer, owner_sub, provider_id)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS custom_provider_owner
|
||||||
|
ON custom_provider(owner_issuer, owner_sub, provider_id);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS vault_audit_event (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
actor_issuer TEXT NOT NULL,
|
||||||
|
actor_sub TEXT NOT NULL,
|
||||||
|
actor_username TEXT NOT NULL,
|
||||||
|
action TEXT NOT NULL,
|
||||||
|
target_type TEXT NOT NULL,
|
||||||
|
target_id TEXT,
|
||||||
|
owner_issuer TEXT,
|
||||||
|
owner_sub TEXT,
|
||||||
|
result TEXT NOT NULL,
|
||||||
|
detail TEXT,
|
||||||
|
created_at TEXT NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS vault_audit_owner_time
|
||||||
|
ON vault_audit_event(owner_issuer, owner_sub, created_at DESC);
|
||||||
|
|
||||||
|
DROP TABLE IF EXISTS credential_grant;
|
||||||
|
UPDATE vault_credential SET status = 'active', revoked_at = NULL;
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
|
||||||
|
close() {
|
||||||
|
this.database.close();
|
||||||
|
}
|
||||||
|
|
||||||
|
audit(actor, action, targetType, targetId, owner, result = "success", detail = null) {
|
||||||
|
this.database.prepare(`
|
||||||
|
INSERT INTO vault_audit_event (
|
||||||
|
actor_issuer, actor_sub, actor_username, action, target_type, target_id,
|
||||||
|
owner_issuer, owner_sub, result, detail, created_at
|
||||||
|
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
|
`).run(
|
||||||
|
actor.issuer,
|
||||||
|
actor.sub,
|
||||||
|
actor.username,
|
||||||
|
action,
|
||||||
|
targetType,
|
||||||
|
targetId,
|
||||||
|
owner?.issuer || null,
|
||||||
|
owner?.sub || null,
|
||||||
|
result,
|
||||||
|
detail,
|
||||||
|
now()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
ownerFromInput(actor, input) {
|
||||||
|
return input.owner && actor.admin
|
||||||
|
? {
|
||||||
|
issuer: limitedString(input.owner.issuer, "owner.issuer", 300),
|
||||||
|
sub: limitedString(input.owner.sub, "owner.sub", 200),
|
||||||
|
username: limitedString(input.owner.username, "owner.username", 80)
|
||||||
|
}
|
||||||
|
: {issuer: actor.issuer, sub: actor.sub, username: actor.username};
|
||||||
|
}
|
||||||
|
|
||||||
|
customProviderDefinition(value) {
|
||||||
|
return normalizeProvider(JSON.parse(value));
|
||||||
|
}
|
||||||
|
|
||||||
|
listCustomProviders(actor) {
|
||||||
|
return this.database.prepare(`
|
||||||
|
SELECT * FROM custom_provider
|
||||||
|
WHERE owner_issuer = ? AND owner_sub = ?
|
||||||
|
ORDER BY provider_id COLLATE NOCASE
|
||||||
|
`).all(actor.issuer, actor.sub).map((row) => ({
|
||||||
|
...this.customProviderDefinition(row.definition_json),
|
||||||
|
createdAt: row.created_at,
|
||||||
|
updatedAt: row.updated_at
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
getProviderForOwner(owner, providerId) {
|
||||||
|
const id = limitedString(providerId, "providerId", 80).toLowerCase();
|
||||||
|
const custom = this.database.prepare(`
|
||||||
|
SELECT definition_json FROM custom_provider
|
||||||
|
WHERE owner_issuer = ? AND owner_sub = ? AND provider_id = ?
|
||||||
|
`).get(owner.issuer, owner.sub, id);
|
||||||
|
return custom ? this.customProviderDefinition(custom.definition_json) : this.builtinProviders.get(id) || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
getProvider(actor, providerId) {
|
||||||
|
return this.getProviderForOwner(actor, providerId);
|
||||||
|
}
|
||||||
|
|
||||||
|
listProviders(actor) {
|
||||||
|
const effective = new Map([...this.builtinProviders].map(([id, provider]) => [id, {...provider}]));
|
||||||
|
for (const provider of this.listCustomProviders(actor)) effective.set(provider.id, provider);
|
||||||
|
const credentials = this.listCredentials(actor);
|
||||||
|
return [...effective.values()]
|
||||||
|
.sort((left, right) => left.name.localeCompare(right.name))
|
||||||
|
.map((provider) => ({
|
||||||
|
...provider,
|
||||||
|
credentials: provider.connection.type === "backend"
|
||||||
|
? credentials.filter((credential) => credential.providerId === provider.id)
|
||||||
|
: [],
|
||||||
|
credentialState: provider.connection.type === "frontend"
|
||||||
|
? "local"
|
||||||
|
: credentials.some((credential) => credential.providerId === provider.id) ? "configured" : "missing"
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
saveCustomProvider(actor, provider) {
|
||||||
|
const timestamp = now();
|
||||||
|
const existing = this.database.prepare(`
|
||||||
|
SELECT id, created_at FROM custom_provider
|
||||||
|
WHERE owner_issuer = ? AND owner_sub = ? AND provider_id = ?
|
||||||
|
`).get(actor.issuer, actor.sub, provider.id);
|
||||||
|
const id = existing?.id || randomUUID();
|
||||||
|
this.database.prepare(`
|
||||||
|
INSERT INTO custom_provider (
|
||||||
|
id, owner_issuer, owner_sub, owner_username, provider_id, definition_json, created_at, updated_at
|
||||||
|
) VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
|
ON CONFLICT(owner_issuer, owner_sub, provider_id) DO UPDATE SET
|
||||||
|
owner_username = excluded.owner_username,
|
||||||
|
definition_json = excluded.definition_json,
|
||||||
|
updated_at = excluded.updated_at
|
||||||
|
`).run(
|
||||||
|
id,
|
||||||
|
actor.issuer,
|
||||||
|
actor.sub,
|
||||||
|
actor.username,
|
||||||
|
provider.id,
|
||||||
|
JSON.stringify({...provider, builtin: false}),
|
||||||
|
existing?.created_at || timestamp,
|
||||||
|
timestamp
|
||||||
|
);
|
||||||
|
this.audit(actor, existing ? "provider.update" : "provider.create", "provider", provider.id, actor, "success", provider.connection.type);
|
||||||
|
return this.getProvider(actor, provider.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
deleteCustomProvider(actor, providerId) {
|
||||||
|
const id = limitedString(providerId, "providerId", 80).toLowerCase();
|
||||||
|
const result = this.database.prepare(`
|
||||||
|
DELETE FROM custom_provider WHERE owner_issuer = ? AND owner_sub = ? AND provider_id = ?
|
||||||
|
`).run(actor.issuer, actor.sub, id);
|
||||||
|
if (!result.changes) {
|
||||||
|
const error = new Error("Custom provider not found");
|
||||||
|
error.statusCode = 404;
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
this.audit(actor, "provider.delete", "provider", id, actor);
|
||||||
|
return {deleted: true, id};
|
||||||
|
}
|
||||||
|
|
||||||
|
listCredentials(actor, {all = false} = {}) {
|
||||||
|
const rows = all && actor.admin
|
||||||
|
? this.database.prepare(`SELECT * FROM vault_credential ORDER BY owner_username, provider, name`).all()
|
||||||
|
: this.database.prepare(`
|
||||||
|
SELECT * FROM vault_credential
|
||||||
|
WHERE owner_issuer = ? AND owner_sub = ?
|
||||||
|
ORDER BY provider, name
|
||||||
|
`).all(actor.issuer, actor.sub);
|
||||||
|
return rows.map(publicCredential);
|
||||||
|
}
|
||||||
|
|
||||||
|
getCredential(id) {
|
||||||
|
return this.database.prepare("SELECT * FROM vault_credential WHERE id = ?").get(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
assertAccess(actor, row) {
|
||||||
|
if (!row || (!actor.admin && (row.owner_issuer !== actor.issuer || row.owner_sub !== actor.sub))) {
|
||||||
|
const error = new Error("Credential not found");
|
||||||
|
error.statusCode = 404;
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
createCredential(actor, input) {
|
||||||
|
const owner = this.ownerFromInput(actor, input);
|
||||||
|
const providerId = limitedString(input.providerId, "providerId", 80).toLowerCase();
|
||||||
|
const provider = this.getProviderForOwner(owner, providerId);
|
||||||
|
if (!provider) {
|
||||||
|
const error = new Error("Provider not found");
|
||||||
|
error.statusCode = 404;
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
if (provider.connection.type !== "backend") {
|
||||||
|
const error = new Error("Frontend credentials must remain in the browser");
|
||||||
|
error.statusCode = 409;
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
const name = limitedString(input.name || "default", "name", 120);
|
||||||
|
const duplicate = this.database.prepare(`
|
||||||
|
SELECT id FROM vault_credential WHERE owner_issuer = ? AND owner_sub = ? AND provider = ? AND name = ?
|
||||||
|
`).get(owner.issuer, owner.sub, providerId, name);
|
||||||
|
if (duplicate) {
|
||||||
|
const error = new Error("Credential already exists");
|
||||||
|
error.statusCode = 409;
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
const id = randomUUID();
|
||||||
|
const secret = secretBuffer(input.secret);
|
||||||
|
const record = {id, owner_issuer: owner.issuer, owner_sub: owner.sub, provider: providerId};
|
||||||
|
const dek = randomBytes(32);
|
||||||
|
const encryptedSecret = encrypt(dek, secret, credentialAad(record));
|
||||||
|
const encryptedDek = encrypt(this.masterKey, dek, wrappedDekAad(record));
|
||||||
|
const timestamp = now();
|
||||||
|
this.database.prepare(`
|
||||||
|
INSERT INTO vault_credential (
|
||||||
|
id, owner_issuer, owner_sub, owner_username, owner_type, provider, name,
|
||||||
|
ciphertext, nonce, auth_tag, wrapped_dek, wrap_nonce, wrap_tag,
|
||||||
|
fingerprint, status, created_at, updated_at
|
||||||
|
) VALUES (?, ?, ?, ?, 'user', ?, ?, ?, ?, ?, ?, ?, ?, ?, 'active', ?, ?)
|
||||||
|
`).run(
|
||||||
|
id, owner.issuer, owner.sub, owner.username, providerId, name,
|
||||||
|
encryptedSecret.ciphertext, encryptedSecret.nonce, encryptedSecret.tag,
|
||||||
|
encryptedDek.ciphertext, encryptedDek.nonce, encryptedDek.tag,
|
||||||
|
fingerprint(secret), timestamp, timestamp
|
||||||
|
);
|
||||||
|
secret.fill(0);
|
||||||
|
dek.fill(0);
|
||||||
|
this.audit(actor, "credential.create", "credential", id, owner, "success", providerId);
|
||||||
|
return publicCredential(this.getCredential(id));
|
||||||
|
}
|
||||||
|
|
||||||
|
decryptCredential(row) {
|
||||||
|
const dek = decrypt(this.masterKey, row.wrapped_dek, row.wrap_nonce, row.wrap_tag, wrappedDekAad(row));
|
||||||
|
try {
|
||||||
|
return decrypt(dek, row.ciphertext, row.nonce, row.auth_tag, credentialAad(row));
|
||||||
|
} finally {
|
||||||
|
dek.fill(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
replaceCredential(actor, id, input) {
|
||||||
|
const row = this.getCredential(id);
|
||||||
|
this.assertAccess(actor, row);
|
||||||
|
const secret = secretBuffer(input.secret);
|
||||||
|
const dek = randomBytes(32);
|
||||||
|
const encryptedSecret = encrypt(dek, secret, credentialAad(row));
|
||||||
|
const encryptedDek = encrypt(this.masterKey, dek, wrappedDekAad(row));
|
||||||
|
const timestamp = now();
|
||||||
|
this.database.prepare(`
|
||||||
|
UPDATE vault_credential
|
||||||
|
SET ciphertext = ?, nonce = ?, auth_tag = ?, wrapped_dek = ?, wrap_nonce = ?, wrap_tag = ?,
|
||||||
|
fingerprint = ?, updated_at = ?
|
||||||
|
WHERE id = ?
|
||||||
|
`).run(
|
||||||
|
encryptedSecret.ciphertext, encryptedSecret.nonce, encryptedSecret.tag,
|
||||||
|
encryptedDek.ciphertext, encryptedDek.nonce, encryptedDek.tag,
|
||||||
|
fingerprint(secret), timestamp, id
|
||||||
|
);
|
||||||
|
secret.fill(0);
|
||||||
|
dek.fill(0);
|
||||||
|
this.audit(actor, "credential.replace", "credential", id, {issuer: row.owner_issuer, sub: row.owner_sub});
|
||||||
|
return publicCredential(this.getCredential(id));
|
||||||
|
}
|
||||||
|
|
||||||
|
verifyCredential(actor, id) {
|
||||||
|
const row = this.getCredential(id);
|
||||||
|
this.assertAccess(actor, row);
|
||||||
|
const plaintext = this.decryptCredential(row);
|
||||||
|
const verified = fingerprint(plaintext) === row.fingerprint;
|
||||||
|
plaintext.fill(0);
|
||||||
|
this.audit(actor, "credential.verify", "credential", id, {issuer: row.owner_issuer, sub: row.owner_sub}, verified ? "success" : "failure");
|
||||||
|
return {verified, fingerprint: row.fingerprint};
|
||||||
|
}
|
||||||
|
|
||||||
|
deleteCredential(actor, id) {
|
||||||
|
const row = this.getCredential(id);
|
||||||
|
this.assertAccess(actor, row);
|
||||||
|
this.database.prepare("DELETE FROM vault_credential WHERE id = ?").run(id);
|
||||||
|
this.audit(actor, "credential.delete", "credential", id, {issuer: row.owner_issuer, sub: row.owner_sub});
|
||||||
|
return {deleted: true, id};
|
||||||
|
}
|
||||||
|
|
||||||
|
reassignCredential(actor, id, ownerInput) {
|
||||||
|
if (!actor.admin) {
|
||||||
|
const error = new Error("Administrator required");
|
||||||
|
error.statusCode = 403;
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
const row = this.getCredential(id);
|
||||||
|
this.assertAccess(actor, row);
|
||||||
|
const owner = {
|
||||||
|
issuer: limitedString(ownerInput.issuer, "owner.issuer", 300),
|
||||||
|
sub: limitedString(ownerInput.sub, "owner.sub", 200),
|
||||||
|
username: limitedString(ownerInput.username, "owner.username", 80)
|
||||||
|
};
|
||||||
|
const provider = this.getProviderForOwner(owner, row.provider);
|
||||||
|
if (!provider || provider.connection.type !== "backend") {
|
||||||
|
const error = new Error("Target owner has no matching Backend Provider");
|
||||||
|
error.statusCode = 409;
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
const plaintext = this.decryptCredential(row);
|
||||||
|
const next = {...row, owner_issuer: owner.issuer, owner_sub: owner.sub};
|
||||||
|
const dek = randomBytes(32);
|
||||||
|
const encryptedSecret = encrypt(dek, plaintext, credentialAad(next));
|
||||||
|
const encryptedDek = encrypt(this.masterKey, dek, wrappedDekAad(next));
|
||||||
|
this.database.prepare(`
|
||||||
|
UPDATE vault_credential
|
||||||
|
SET owner_issuer = ?, owner_sub = ?, owner_username = ?,
|
||||||
|
ciphertext = ?, nonce = ?, auth_tag = ?, wrapped_dek = ?, wrap_nonce = ?, wrap_tag = ?, updated_at = ?
|
||||||
|
WHERE id = ?
|
||||||
|
`).run(
|
||||||
|
owner.issuer, owner.sub, owner.username,
|
||||||
|
encryptedSecret.ciphertext, encryptedSecret.nonce, encryptedSecret.tag,
|
||||||
|
encryptedDek.ciphertext, encryptedDek.nonce, encryptedDek.tag, now(), id
|
||||||
|
);
|
||||||
|
plaintext.fill(0);
|
||||||
|
dek.fill(0);
|
||||||
|
this.audit(actor, "credential.reassign", "credential", id, owner, "success", `previous owner ${row.owner_username}`);
|
||||||
|
return publicCredential(this.getCredential(id));
|
||||||
|
}
|
||||||
|
|
||||||
|
resolve(actor, input) {
|
||||||
|
const providerId = limitedString(input.providerId, "providerId", 80).toLowerCase();
|
||||||
|
const provider = this.getProvider(actor, providerId);
|
||||||
|
if (!provider) {
|
||||||
|
const error = new Error("Provider not found");
|
||||||
|
error.statusCode = 404;
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
if (provider.connection.type !== "backend") {
|
||||||
|
const error = new Error("Frontend credentials are stored in the browser");
|
||||||
|
error.statusCode = 409;
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
const name = limitedString(input.credentialName || "default", "credentialName", 120);
|
||||||
|
const row = this.database.prepare(`
|
||||||
|
SELECT * FROM vault_credential
|
||||||
|
WHERE owner_issuer = ? AND owner_sub = ? AND provider = ? AND name = ?
|
||||||
|
`).get(actor.issuer, actor.sub, providerId, name);
|
||||||
|
if (!row) {
|
||||||
|
const error = new Error("Credential not found");
|
||||||
|
error.statusCode = 404;
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
const plaintext = this.decryptCredential(row);
|
||||||
|
const secret = parseSecret(plaintext);
|
||||||
|
plaintext.fill(0);
|
||||||
|
this.database.prepare("UPDATE vault_credential SET last_used_at = ? WHERE id = ?").run(now(), row.id);
|
||||||
|
this.audit(actor, "credential.resolve", "credential", row.id, actor, "success", providerId);
|
||||||
|
return {provider, credential: {id: row.id, name: row.name, secret}};
|
||||||
|
}
|
||||||
|
|
||||||
|
listAudit(actor, {all = false, limit = 100} = {}) {
|
||||||
|
const boundedLimit = Math.max(1, Math.min(500, Number.parseInt(limit, 10) || 100));
|
||||||
|
const rows = all && actor.admin
|
||||||
|
? this.database.prepare("SELECT * FROM vault_audit_event ORDER BY created_at DESC LIMIT ?").all(boundedLimit)
|
||||||
|
: this.database.prepare(`
|
||||||
|
SELECT * FROM vault_audit_event
|
||||||
|
WHERE owner_issuer = ? AND owner_sub = ?
|
||||||
|
ORDER BY created_at DESC LIMIT ?
|
||||||
|
`).all(actor.issuer, actor.sub, boundedLimit);
|
||||||
|
return rows.map((row) => ({
|
||||||
|
id: row.id,
|
||||||
|
actorUsername: row.actor_username,
|
||||||
|
action: row.action,
|
||||||
|
targetType: row.target_type,
|
||||||
|
targetId: row.target_id,
|
||||||
|
result: row.result,
|
||||||
|
detail: row.detail,
|
||||||
|
createdAt: row.created_at
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
rotateMasterKey(actor, newMasterKey) {
|
||||||
|
if (!actor.admin) throw new Error("Administrator required");
|
||||||
|
const rows = this.database.prepare("SELECT * FROM vault_credential").all();
|
||||||
|
this.database.exec("BEGIN IMMEDIATE");
|
||||||
|
try {
|
||||||
|
const update = this.database.prepare(`
|
||||||
|
UPDATE vault_credential
|
||||||
|
SET wrapped_dek = ?, wrap_nonce = ?, wrap_tag = ?, key_version = key_version + 1, updated_at = ?
|
||||||
|
WHERE id = ?
|
||||||
|
`);
|
||||||
|
for (const row of rows) {
|
||||||
|
const dek = decrypt(this.masterKey, row.wrapped_dek, row.wrap_nonce, row.wrap_tag, wrappedDekAad(row));
|
||||||
|
const wrapped = encrypt(newMasterKey, dek, wrappedDekAad(row));
|
||||||
|
dek.fill(0);
|
||||||
|
update.run(wrapped.ciphertext, wrapped.nonce, wrapped.tag, now(), row.id);
|
||||||
|
}
|
||||||
|
this.database.exec("COMMIT");
|
||||||
|
} catch (error) {
|
||||||
|
this.database.exec("ROLLBACK");
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
this.masterKey = newMasterKey;
|
||||||
|
this.audit(actor, "master-key.rotate", "vault", null, null, "success", `${rows.length} DEKs rewrapped`);
|
||||||
|
return {rewrapped: rows.length};
|
||||||
|
}
|
||||||
|
|
||||||
|
backup(destination) {
|
||||||
|
const escaped = destination.replaceAll("'", "''");
|
||||||
|
this.database.exec(`VACUUM INTO '${escaped}'`);
|
||||||
|
return {destination};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function breakGlassActor() {
|
||||||
|
return {
|
||||||
|
issuer: "urn:xiteng:break-glass",
|
||||||
|
sub: "local-emergency-script",
|
||||||
|
username: "liooil",
|
||||||
|
admin: true
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1600 1000" preserveAspectRatio="xMidYMid slice" role="img" aria-labelledby="title description">
|
||||||
|
<title id="title">Xiteng login background</title>
|
||||||
|
<desc id="description">A warm cream grid with simple red, yellow, blue and green geometric shapes.</desc>
|
||||||
|
<defs>
|
||||||
|
<pattern id="grid" width="40" height="40" patternUnits="userSpaceOnUse">
|
||||||
|
<path d="M40 0H0V40" fill="none" stroke="#1c1712" stroke-opacity=".09" stroke-width="2"/>
|
||||||
|
</pattern>
|
||||||
|
</defs>
|
||||||
|
<rect width="1600" height="1000" fill="#f7efe0"/>
|
||||||
|
<rect width="1600" height="1000" fill="url(#grid)"/>
|
||||||
|
<rect x="80" y="90" width="210" height="210" rx="32" fill="#f0c94b" stroke="#1c1712" stroke-width="8" transform="rotate(-6 185 195)"/>
|
||||||
|
<circle cx="1430" cy="170" r="110" fill="#93c9d7" stroke="#1c1712" stroke-width="8"/>
|
||||||
|
<path d="M1330 775h190v155h-190z" fill="#177a48" stroke="#1c1712" stroke-width="8" transform="rotate(5 1425 852)"/>
|
||||||
|
<path d="M92 760l160 76-124 112z" fill="#d83b21" stroke="#1c1712" stroke-width="8" stroke-linejoin="round"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 1.1 KiB |
@@ -0,0 +1,7 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512" role="img" aria-labelledby="title description">
|
||||||
|
<title id="title">Xiteng</title>
|
||||||
|
<desc id="description">A red geometric X on a cream tile with a yellow background.</desc>
|
||||||
|
<rect width="512" height="512" rx="104" fill="#f0c94b"/>
|
||||||
|
<rect x="48" y="48" width="416" height="416" rx="80" fill="#fffaf0" stroke="#1c1712" stroke-width="16"/>
|
||||||
|
<path d="M150 146h70l36 65 36-65h70l-66 110 66 110h-70l-36-65-36 65h-70l66-110z" fill="#d83b21" stroke="#1c1712" stroke-width="14" stroke-linejoin="round"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 569 B |
@@ -0,0 +1,9 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 520 112" role="img" aria-labelledby="title description">
|
||||||
|
<title id="title">Xiteng</title>
|
||||||
|
<desc id="description">Xiteng wordmark with the red X symbol.</desc>
|
||||||
|
<rect x="4" y="4" width="104" height="104" rx="22" fill="#f0c94b" stroke="#1c1712" stroke-width="8"/>
|
||||||
|
<rect x="20" y="20" width="72" height="72" rx="14" fill="#fffaf0" stroke="#1c1712" stroke-width="5"/>
|
||||||
|
<path d="M34 31h15l7 13 7-13h15L64 56l14 25H63l-7-13-7 13H34l14-25z" fill="#d83b21" stroke="#1c1712" stroke-width="3" stroke-linejoin="round"/>
|
||||||
|
<text x="132" y="70" fill="#1c1712" font-family="Inter, ui-sans-serif, system-ui, sans-serif" font-size="64" font-weight="850" letter-spacing="-2">Xiteng</text>
|
||||||
|
<text x="136" y="98" fill="#675f55" font-family="ui-monospace, SFMono-Regular, Menlo, monospace" font-size="15" font-weight="700" letter-spacing="4">HOME SERVICES</text>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 912 B |
@@ -0,0 +1,102 @@
|
|||||||
|
version: 1
|
||||||
|
metadata:
|
||||||
|
name: Xiteng - Brand and login flow
|
||||||
|
labels:
|
||||||
|
blueprints.goauthentik.io/instantiate: "true"
|
||||||
|
entries:
|
||||||
|
- model: authentik_brands.brand
|
||||||
|
state: present
|
||||||
|
identifiers:
|
||||||
|
domain: authentik-default
|
||||||
|
default: true
|
||||||
|
attrs:
|
||||||
|
branding_title: Xiteng
|
||||||
|
# Versioned repository assets live in Authentik's built-in static tree.
|
||||||
|
branding_logo: /static/dist/assets/xiteng/xiteng-wordmark-v1.svg
|
||||||
|
branding_favicon: /static/dist/assets/xiteng/xiteng-favicon-v1.svg
|
||||||
|
branding_default_flow_background: /static/dist/assets/xiteng/xiteng-background-v1.svg
|
||||||
|
attributes:
|
||||||
|
settings:
|
||||||
|
locale: zh-hans
|
||||||
|
branding_custom_css: |
|
||||||
|
:root {
|
||||||
|
--ak-font-family-sans-serif: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
||||||
|
--ak-font-family-heading: var(--ak-font-family-sans-serif);
|
||||||
|
--ak-global--background-color: #f7efe0;
|
||||||
|
--ak-c-login--MaxWidth: 31rem;
|
||||||
|
--ak-c-login__content-BoxShadow: none;
|
||||||
|
--ak-c-login__main--BackgroundColor: #fffaf0;
|
||||||
|
--ak-c-login__main--Color: #1c1712;
|
||||||
|
--ak-c-login__main--BoxShadow: 8px 8px 0 #1c1712;
|
||||||
|
--ak-c-login__footer--Color: #675f55;
|
||||||
|
--pf-global--primary-color--100: #d83b21;
|
||||||
|
--pf-global--primary-color--200: #a92a18;
|
||||||
|
--pf-global--Color--100: #1c1712;
|
||||||
|
--pf-global--Color--200: #675f55;
|
||||||
|
--pf-global--BorderColor--100: #1c1712;
|
||||||
|
--pf-global--BorderRadius--sm: 6px;
|
||||||
|
--pf-c-login__main--BackgroundColor: #fffaf0;
|
||||||
|
--pf-c-card--BackgroundColor: #fffaf0;
|
||||||
|
--pf-c-button--m-primary--BackgroundColor: #d83b21;
|
||||||
|
--pf-c-button--m-primary--hover--BackgroundColor: #bc321d;
|
||||||
|
--pf-c-button--m-primary--active--BackgroundColor: #a92a18;
|
||||||
|
--pf-c-button--m-secondary--Color: #1c1712;
|
||||||
|
--pf-c-button--m-secondary--BorderColor: #1c1712;
|
||||||
|
}
|
||||||
|
|
||||||
|
ak-flow-executor::part(locale-select) {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pf-c-login__main,
|
||||||
|
.pf-c-card {
|
||||||
|
border: 2px solid #1c1712;
|
||||||
|
border-radius: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pf-c-button.pf-m-primary {
|
||||||
|
border: 2px solid #1c1712;
|
||||||
|
border-radius: 6px;
|
||||||
|
box-shadow: 3px 3px 0 #1c1712;
|
||||||
|
font-weight: 800;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pf-c-button.pf-m-primary:hover {
|
||||||
|
box-shadow: 1px 1px 0 #1c1712;
|
||||||
|
transform: translate(2px, 2px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.pf-c-form-control {
|
||||||
|
border: 2px solid #1c1712;
|
||||||
|
border-radius: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 576px) {
|
||||||
|
:root {
|
||||||
|
--ak-c-login--MaxWidth: calc(100vw - 2rem);
|
||||||
|
--ak-c-login__main--BoxShadow: 5px 5px 0 #1c1712;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
- model: authentik_core.application
|
||||||
|
state: present
|
||||||
|
identifiers:
|
||||||
|
slug: xiteng-portal
|
||||||
|
attrs:
|
||||||
|
name: Xiteng Portal
|
||||||
|
meta_launch_url: https://xiteng.site/?focus=authentik#infrastructure
|
||||||
|
open_in_new_tab: false
|
||||||
|
meta_description: 返回 xiteng.site 服务目录
|
||||||
|
meta_publisher: Xiteng
|
||||||
|
meta_icon: https://xiteng.site/favicon.svg
|
||||||
|
- model: authentik_flows.flow
|
||||||
|
state: present
|
||||||
|
identifiers:
|
||||||
|
slug: default-authentication-flow
|
||||||
|
attrs:
|
||||||
|
name: Xiteng 登录
|
||||||
|
title: 欢迎回来
|
||||||
|
designation: authentication
|
||||||
|
authentication: none
|
||||||
|
layout: stacked
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
version: 1
|
||||||
|
# Managed ForwardAuth application and outpost assignment for chat.xiteng.site.
|
||||||
|
metadata:
|
||||||
|
name: Xiteng Chat ForwardAuth
|
||||||
|
labels:
|
||||||
|
blueprints.goauthentik.io/instantiate: "true"
|
||||||
|
entries:
|
||||||
|
- id: xiteng-chat-provider
|
||||||
|
model: authentik_providers_proxy.proxyprovider
|
||||||
|
state: present
|
||||||
|
identifiers:
|
||||||
|
name: Xiteng Chat
|
||||||
|
attrs:
|
||||||
|
authorization_flow: !Find [authentik_flows.flow, [slug, default-provider-authorization-implicit-consent]]
|
||||||
|
invalidation_flow: !Find [authentik_flows.flow, [slug, default-provider-invalidation-flow]]
|
||||||
|
external_host: https://chat.xiteng.site
|
||||||
|
mode: forward_single
|
||||||
|
|
||||||
|
- model: authentik_core.application
|
||||||
|
state: present
|
||||||
|
identifiers:
|
||||||
|
slug: xiteng-chat
|
||||||
|
attrs:
|
||||||
|
name: Xiteng Chat
|
||||||
|
provider: !KeyOf xiteng-chat-provider
|
||||||
|
meta_launch_url: https://chat.xiteng.site
|
||||||
|
open_in_new_tab: true
|
||||||
|
|
||||||
|
- model: authentik_outposts.outpost
|
||||||
|
state: present
|
||||||
|
identifiers:
|
||||||
|
name: uptime-kuma-outpost
|
||||||
|
attrs:
|
||||||
|
providers:
|
||||||
|
- !Find [authentik_core.provider, [name, Xiteng Portal Admin]]
|
||||||
|
- !Find [authentik_core.provider, [name, comfyui-proxy]]
|
||||||
|
- !Find [authentik_core.provider, [name, invokeai-proxy]]
|
||||||
|
- !Find [authentik_core.provider, [name, code-server-proxy]]
|
||||||
|
- !KeyOf xiteng-chat-provider
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
version: 1
|
||||||
|
metadata:
|
||||||
|
name: Xiteng - Passkey passwordless login
|
||||||
|
entries:
|
||||||
|
- model: authentik_stages_identification.identificationstage
|
||||||
|
state: present
|
||||||
|
identifiers:
|
||||||
|
name: default-authentication-identification
|
||||||
|
attrs:
|
||||||
|
user_fields:
|
||||||
|
- email
|
||||||
|
- username
|
||||||
|
password_stage: null
|
||||||
|
captcha_stage: null
|
||||||
|
webauthn_stage: !Find [authentik_stages_authenticator_validate.authenticatorvalidatestage, [name, default-authentication-mfa-validation]]
|
||||||
|
case_insensitive_matching: true
|
||||||
|
show_matched_user: true
|
||||||
|
pretend_user_exists: true
|
||||||
|
enable_remember_me: false
|
||||||
|
enrollment_flow: null
|
||||||
|
recovery_flow: null
|
||||||
|
passwordless_flow: null
|
||||||
|
show_source_labels: false
|
||||||
|
|
||||||
|
# Authentik 2026.5 ships the password/MFA skip policies in its default
|
||||||
|
# authentication blueprint. Remove the temporary local copies if present.
|
||||||
|
- model: authentik_policies_expression.expressionpolicy
|
||||||
|
state: absent
|
||||||
|
identifiers:
|
||||||
|
name: xiteng-authentication-password-optional
|
||||||
|
|
||||||
|
- model: authentik_policies_expression.expressionpolicy
|
||||||
|
state: absent
|
||||||
|
identifiers:
|
||||||
|
name: xiteng-authentication-mfa-after-passkey
|
||||||
@@ -0,0 +1,98 @@
|
|||||||
|
version: 1
|
||||||
|
metadata:
|
||||||
|
name: Xiteng password recovery
|
||||||
|
labels:
|
||||||
|
blueprints.goauthentik.io/instantiate: "true"
|
||||||
|
entries:
|
||||||
|
- id: recovery-flow
|
||||||
|
model: authentik_flows.flow
|
||||||
|
state: present
|
||||||
|
identifiers:
|
||||||
|
slug: xiteng-recovery
|
||||||
|
attrs:
|
||||||
|
name: Xiteng 密码恢复
|
||||||
|
title: 设置新密码
|
||||||
|
designation: recovery
|
||||||
|
authentication: require_unauthenticated
|
||||||
|
|
||||||
|
- id: password
|
||||||
|
model: authentik_stages_prompt.prompt
|
||||||
|
state: present
|
||||||
|
identifiers:
|
||||||
|
name: xiteng-recovery-password
|
||||||
|
attrs:
|
||||||
|
field_key: password
|
||||||
|
label: 新密码
|
||||||
|
type: password
|
||||||
|
required: true
|
||||||
|
order: 0
|
||||||
|
placeholder: 新密码
|
||||||
|
placeholder_expression: false
|
||||||
|
|
||||||
|
- id: password-repeat
|
||||||
|
model: authentik_stages_prompt.prompt
|
||||||
|
state: present
|
||||||
|
identifiers:
|
||||||
|
name: xiteng-recovery-password-repeat
|
||||||
|
attrs:
|
||||||
|
field_key: password_repeat
|
||||||
|
label: 再次输入新密码
|
||||||
|
type: password
|
||||||
|
required: true
|
||||||
|
order: 1
|
||||||
|
placeholder: 再次输入新密码
|
||||||
|
placeholder_expression: false
|
||||||
|
|
||||||
|
- id: password-stage
|
||||||
|
model: authentik_stages_prompt.promptstage
|
||||||
|
state: present
|
||||||
|
identifiers:
|
||||||
|
name: Xiteng 设置新密码
|
||||||
|
attrs:
|
||||||
|
fields:
|
||||||
|
- !KeyOf password
|
||||||
|
- !KeyOf password-repeat
|
||||||
|
validation_policies: []
|
||||||
|
|
||||||
|
- id: user-write
|
||||||
|
model: authentik_stages_user_write.userwritestage
|
||||||
|
state: present
|
||||||
|
identifiers:
|
||||||
|
name: xiteng-recovery-user-write
|
||||||
|
attrs:
|
||||||
|
user_creation_mode: never_create
|
||||||
|
|
||||||
|
- id: user-login
|
||||||
|
model: authentik_stages_user_login.userloginstage
|
||||||
|
state: present
|
||||||
|
identifiers:
|
||||||
|
name: xiteng-recovery-user-login
|
||||||
|
|
||||||
|
- model: authentik_flows.flowstagebinding
|
||||||
|
state: present
|
||||||
|
identifiers:
|
||||||
|
target: !KeyOf recovery-flow
|
||||||
|
stage: !KeyOf password-stage
|
||||||
|
order: 10
|
||||||
|
|
||||||
|
- model: authentik_flows.flowstagebinding
|
||||||
|
state: present
|
||||||
|
identifiers:
|
||||||
|
target: !KeyOf recovery-flow
|
||||||
|
stage: !KeyOf user-write
|
||||||
|
order: 20
|
||||||
|
|
||||||
|
- model: authentik_flows.flowstagebinding
|
||||||
|
state: present
|
||||||
|
identifiers:
|
||||||
|
target: !KeyOf recovery-flow
|
||||||
|
stage: !KeyOf user-login
|
||||||
|
order: 30
|
||||||
|
|
||||||
|
- model: authentik_brands.brand
|
||||||
|
state: present
|
||||||
|
identifiers:
|
||||||
|
domain: authentik-default
|
||||||
|
default: true
|
||||||
|
attrs:
|
||||||
|
flow_recovery: !KeyOf recovery-flow
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from authentik.core.models import Application, Group, Token, TokenIntents, User
|
||||||
|
from authentik.policies.models import PolicyBinding
|
||||||
|
|
||||||
|
ADMIN_USERNAME = "liooil"
|
||||||
|
FAMILY_GROUP = "liuhome"
|
||||||
|
TOKEN_IDENTIFIER = "xiteng-portal-admin"
|
||||||
|
TOKEN_PATH = Path("/run/secrets/portal_api_token")
|
||||||
|
MANAGED_APPLICATIONS = [
|
||||||
|
"xiteng-portal-admin",
|
||||||
|
"xiteng-portal",
|
||||||
|
"xiteng-chat",
|
||||||
|
"code-server",
|
||||||
|
"comfyui",
|
||||||
|
"invokeai",
|
||||||
|
"gitea",
|
||||||
|
"hedgedoc",
|
||||||
|
"hedgedoc2",
|
||||||
|
"minio",
|
||||||
|
"seaweedfs",
|
||||||
|
"remark42",
|
||||||
|
]
|
||||||
|
|
||||||
|
liooil = User.objects.get(username=ADMIN_USERNAME)
|
||||||
|
admin_group = Group.objects.get(name="authentik Admins")
|
||||||
|
admin_group.users.add(liooil)
|
||||||
|
|
||||||
|
User.objects.filter(username="akadmin").delete()
|
||||||
|
|
||||||
|
liuhome, _ = Group.objects.get_or_create(
|
||||||
|
name=FAMILY_GROUP,
|
||||||
|
defaults={"is_superuser": False},
|
||||||
|
)
|
||||||
|
if liuhome.is_superuser:
|
||||||
|
liuhome.is_superuser = False
|
||||||
|
liuhome.save(update_fields=["is_superuser"])
|
||||||
|
for username in ["liooil", "ziyue"]:
|
||||||
|
liuhome.users.add(User.objects.get(username=username))
|
||||||
|
|
||||||
|
for application in Application.objects.filter(slug__in=MANAGED_APPLICATIONS):
|
||||||
|
PolicyBinding.objects.filter(target=application).delete()
|
||||||
|
PolicyBinding.objects.create(
|
||||||
|
target=application,
|
||||||
|
group=liuhome,
|
||||||
|
order=0,
|
||||||
|
enabled=True,
|
||||||
|
negate=False,
|
||||||
|
failure_result=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
Token.objects.filter(identifier=TOKEN_IDENTIFIER).delete()
|
||||||
|
token = Token.objects.create(
|
||||||
|
identifier=TOKEN_IDENTIFIER,
|
||||||
|
intent=TokenIntents.INTENT_API,
|
||||||
|
user=liooil,
|
||||||
|
description="Xiteng Portal Authentik administration",
|
||||||
|
expiring=False,
|
||||||
|
)
|
||||||
|
TOKEN_PATH.write_text(token.key, encoding="utf-8")
|
||||||
|
TOKEN_PATH.chmod(0o600)
|
||||||
|
|
||||||
|
print(
|
||||||
|
f"Identity bootstrap complete: administrator={ADMIN_USERNAME}, "
|
||||||
|
f"group={FAMILY_GROUP}, applications={len(MANAGED_APPLICATIONS)}"
|
||||||
|
)
|
||||||
@@ -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 为 https(Tunnel 走 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
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
node_modules
|
||||||
|
.git
|
||||||
|
*.md
|
||||||
|
start-local-ai.bat
|
||||||
|
/tmp
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
# CAT-BODHI Sprite Segmentation API
|
||||||
|
# Node.js + Python + PyTorch in one container
|
||||||
|
#
|
||||||
|
# Build (CPU, default):
|
||||||
|
# docker build -t cat-bodhi .
|
||||||
|
# Build (GPU, requires nvidia-container-toolkit):
|
||||||
|
# docker build --build-arg TORCH_INDEX=https://download.pytorch.org/whl/cu126 -t cat-bodhi .
|
||||||
|
|
||||||
|
FROM python:3.11-slim
|
||||||
|
|
||||||
|
ARG NODE_MAJOR=22
|
||||||
|
ARG TORCH_INDEX=https://download.pytorch.org/whl/cpu
|
||||||
|
ARG PIP_MIRROR=https://pypi.tuna.tsinghua.edu.cn/simple
|
||||||
|
|
||||||
|
# 中科大 Debian 镜像源
|
||||||
|
RUN sed -i 's|http://deb.debian.org/debian|https://mirrors.ustc.edu.cn/debian|g' /etc/apt/sources.list.d/debian.sources \
|
||||||
|
&& sed -i 's|http://deb.debian.org/debian-security|https://mirrors.ustc.edu.cn/debian-security|g' /etc/apt/sources.list.d/debian.sources
|
||||||
|
|
||||||
|
# 安装 Node.js LTS
|
||||||
|
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||||
|
ca-certificates curl gnupg \
|
||||||
|
&& mkdir -p /etc/apt/keyrings \
|
||||||
|
&& curl -fsSL https://deb.nodesource.com/gpgkey/nodesource-repo.gpg.key | gpg --dearmor -o /etc/apt/keyrings/nodesource.gpg \
|
||||||
|
&& echo "deb [signed-by=/etc/apt/keyrings/nodesource.gpg] https://deb.nodesource.com/node_${NODE_MAJOR}.x nodistro main" > /etc/apt/sources.list.d/nodesource.list \
|
||||||
|
&& apt-get update && apt-get install -y --no-install-recommends nodejs \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
# 安装 PyTorch(从官方 index)+ 其他 Python 包(从清华镜像)
|
||||||
|
RUN pip install --no-cache-dir \
|
||||||
|
--index-url ${TORCH_INDEX} \
|
||||||
|
--extra-index-url ${PIP_MIRROR} \
|
||||||
|
torch torchvision \
|
||||||
|
opencv-python-headless \
|
||||||
|
numpy \
|
||||||
|
Pillow
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
COPY . .
|
||||||
|
RUN mkdir -p assets/ai/cats assets/ai/beads
|
||||||
|
|
||||||
|
EXPOSE 8080
|
||||||
|
ENV PORT=8080
|
||||||
|
CMD ["node", "server.mjs"]
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
services:
|
||||||
|
cat-bodhi:
|
||||||
|
build:
|
||||||
|
context: .
|
||||||
|
# GPU 加速:取消下面注释(需先安装 nvidia-container-toolkit)
|
||||||
|
# args:
|
||||||
|
# TORCH_INDEX: https://download.pytorch.org/whl/cu126
|
||||||
|
container_name: cat-bodhi
|
||||||
|
restart: unless-stopped
|
||||||
|
env_file:
|
||||||
|
- .env
|
||||||
|
environment:
|
||||||
|
- PORT=8080
|
||||||
|
- SPRITE_SEG_ROOT=/opt/sprite_alpha_seg_pytorch
|
||||||
|
- SPRITE_SEG_PYTHON=/usr/local/bin/python
|
||||||
|
- SPRITE_SEG_CHECKPOINT=/opt/sprite_alpha_seg_pytorch/checkpoints/unet_sprite_ft.pt
|
||||||
|
- SPRITE_SEG_OUT_DIR=/opt/sprite_alpha_seg_pytorch/outputs/cat_match
|
||||||
|
volumes:
|
||||||
|
# 模型文件目录(需要手动放入 infer_spritesheet_hybrid.py + checkpoints/)
|
||||||
|
- ./sprite_alpha_seg_pytorch:/opt/sprite_alpha_seg_pytorch:rw
|
||||||
|
# AI 生成图片持久化(可选,不挂载则容器重启后丢失)
|
||||||
|
- ./data/assets-ai:/app/assets/ai:rw
|
||||||
|
networks:
|
||||||
|
- homelab_net
|
||||||
|
# GPU 支持:取消下面注释(需先安装 nvidia-container-toolkit)
|
||||||
|
# deploy:
|
||||||
|
# resources:
|
||||||
|
# reservations:
|
||||||
|
# devices:
|
||||||
|
# - driver: nvidia
|
||||||
|
# count: 1
|
||||||
|
# capabilities: [gpu]
|
||||||
|
|
||||||
|
labels:
|
||||||
|
# ========== Traefik ==========
|
||||||
|
- "traefik.enable=true"
|
||||||
|
|
||||||
|
# HTTPS router (cert management)
|
||||||
|
- "traefik.http.routers.cat-bodhi.rule=Host(`cat-bodhi.xiteng.site`)"
|
||||||
|
- "traefik.http.routers.cat-bodhi.entrypoints=websecure"
|
||||||
|
- "traefik.http.routers.cat-bodhi.tls=true"
|
||||||
|
- "traefik.http.routers.cat-bodhi.tls.certresolver=cfresolver"
|
||||||
|
- "traefik.http.services.cat-bodhi.loadbalancer.server.port=8080"
|
||||||
|
|
||||||
|
# HTTP router (Cloudflare Tunnel ingress)
|
||||||
|
- "traefik.http.routers.cat-bodhi-http.rule=Host(`cat-bodhi.xiteng.site`)"
|
||||||
|
- "traefik.http.routers.cat-bodhi-http.service=cat-bodhi"
|
||||||
|
- "traefik.http.routers.cat-bodhi-http.entrypoints=web"
|
||||||
|
- "xiteng.site.cache.cat-bodhi.enabled=true"
|
||||||
|
- "xiteng.site.cache.cat-bodhi.routers=cat-bodhi,cat-bodhi-http"
|
||||||
|
- "xiteng.site.cache.cat-bodhi.paths=/assets/,/data/assets-ai/"
|
||||||
|
- "xiteng.site.cache.cat-bodhi.edge-ttl=604800"
|
||||||
|
- "xiteng.site.cache.cat-bodhi.stale-while-revalidate=86400"
|
||||||
|
|
||||||
|
- "xiteng.site.component.cat-bodhi.enabled=true"
|
||||||
|
- "xiteng.site.component.cat-bodhi.name=猫猫盘珠日记"
|
||||||
|
- "xiteng.site.component.cat-bodhi.description=文玩手串与猫咪主题游戏,以及 Sprite 抠图导入工具。"
|
||||||
|
- "xiteng.site.component.cat-bodhi.section=services"
|
||||||
|
- "xiteng.site.component.cat-bodhi.category=作品与实验"
|
||||||
|
- "xiteng.site.component.cat-bodhi.url=https://cat-bodhi.xiteng.site"
|
||||||
|
- "xiteng.site.component.cat-bodhi.access=public"
|
||||||
|
- "xiteng.site.component.cat-bodhi.access-label=无需登录"
|
||||||
|
- "xiteng.site.component.cat-bodhi.icon=猫"
|
||||||
|
- "xiteng.site.component.cat-bodhi.accent=yellow"
|
||||||
|
- "xiteng.site.component.cat-bodhi.order=300"
|
||||||
|
- "xiteng.site.component.cat-bodhi.navigation=new-tab"
|
||||||
|
- "xiteng.site.component.cat-bodhi.portal-link=embedded"
|
||||||
|
|
||||||
|
networks:
|
||||||
|
homelab_net:
|
||||||
|
external: true
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
node_modules
|
||||||
|
dist
|
||||||
|
.next
|
||||||
|
package-lock.json
|
||||||
|
npm-debug.log
|
||||||
|
data
|
||||||
|
run
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
FROM oven/bun:1.3.5-alpine AS dependencies
|
||||||
|
WORKDIR /app
|
||||||
|
COPY package.json bun.lock ./
|
||||||
|
RUN bun install --frozen-lockfile --production
|
||||||
|
|
||||||
|
FROM oven/bun:1.3.5-alpine AS builder
|
||||||
|
WORKDIR /app
|
||||||
|
COPY package.json bun.lock ./
|
||||||
|
RUN bun install --frozen-lockfile
|
||||||
|
COPY . .
|
||||||
|
RUN bun run build
|
||||||
|
|
||||||
|
FROM oven/bun:1.3.5-alpine AS runner
|
||||||
|
WORKDIR /app
|
||||||
|
ENV NODE_ENV=production
|
||||||
|
ENV PORT=3000
|
||||||
|
ENV STATIC_ROOT=/app/dist
|
||||||
|
RUN apk add --no-cache su-exec && addgroup -S app && adduser -S app -G app
|
||||||
|
COPY --from=dependencies --chown=app:app /app/node_modules ./node_modules
|
||||||
|
COPY --from=builder --chown=app:app /app/dist ./dist
|
||||||
|
COPY --from=builder --chown=app:app /app/src/server.ts ./src/server.ts
|
||||||
|
COPY --from=builder --chown=app:app /app/lib ./lib
|
||||||
|
COPY --chmod=755 entrypoint.sh /entrypoint.sh
|
||||||
|
EXPOSE 3000
|
||||||
|
ENTRYPOINT ["/entrypoint.sh"]
|
||||||
@@ -0,0 +1,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});
|
||||||
@@ -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=="],
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
set -eu
|
||||||
|
|
||||||
|
install -m 0400 -o app -g app "$KEY_VAULT_TOKEN_FILE" /tmp/key_vault_service_token
|
||||||
|
export KEY_VAULT_TOKEN_FILE=/tmp/key_vault_service_token
|
||||||
|
install -d -m 0700 -o app -g app /data
|
||||||
|
exec su-exec app bun src/server.ts
|
||||||
@@ -0,0 +1,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});
|
||||||
|
}
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
import type {ProviderDefinition, ProviderSecret} from "./provider-types";
|
||||||
|
|
||||||
|
export function createBrowserProviderFetch(provider: ProviderDefinition, secret: ProviderSecret) {
|
||||||
|
const proxy = provider.connection.proxy;
|
||||||
|
if (!proxy) return fetch;
|
||||||
|
if (proxy.type !== "relay") throw new Error(`Unsupported frontend proxy: ${proxy.type}`);
|
||||||
|
return async (input: RequestInfo | URL, init: RequestInit = {}) => {
|
||||||
|
const headers = new Headers(init.headers);
|
||||||
|
const relayHeaders: Record<string, string> = {"Content-Type": "application/json"};
|
||||||
|
if (secret.proxy?.token) relayHeaders.Authorization = `Bearer ${secret.proxy.token}`;
|
||||||
|
return fetch(proxy.url, {
|
||||||
|
method: "POST",
|
||||||
|
headers: relayHeaders,
|
||||||
|
body: JSON.stringify({
|
||||||
|
url: String(input),
|
||||||
|
method: init.method || "GET",
|
||||||
|
headers: Object.fromEntries(headers.entries()),
|
||||||
|
body: typeof init.body === "string" ? init.body : null
|
||||||
|
}),
|
||||||
|
signal: init.signal
|
||||||
|
});
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
import type {ProviderDefinition, ProviderSecret} from "./provider-types";
|
||||||
|
|
||||||
|
function inferredDiscoveryUrl(provider: ProviderDefinition, baseUrl: string) {
|
||||||
|
if (provider.discovery.type === "anthropic-models-list") return `${baseUrl.endsWith("/v1") ? baseUrl : `${baseUrl}/v1`}/models?limit=200`;
|
||||||
|
if (provider.discovery.type === "google-models-list") return `${baseUrl}/models?pageSize=200`;
|
||||||
|
return `${baseUrl}/models`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function applyBrowserProviderSettings(provider: ProviderDefinition, secret: ProviderSecret) {
|
||||||
|
const configuredBaseUrl = secret.provider?.baseUrl?.trim();
|
||||||
|
if (!configuredBaseUrl) return provider;
|
||||||
|
const url = new URL(configuredBaseUrl);
|
||||||
|
if (!["http:", "https:"].includes(url.protocol)) throw new Error("Provider Base URL 必须使用 http 或 https");
|
||||||
|
const baseUrl = configuredBaseUrl.replace(/\/+$/, "");
|
||||||
|
const originalBaseUrl = provider.connection.baseUrl.replace(/\/+$/, "");
|
||||||
|
const discoveryUrl = provider.discovery.url.startsWith(originalBaseUrl)
|
||||||
|
? inferredDiscoveryUrl(provider, baseUrl)
|
||||||
|
: provider.discovery.url;
|
||||||
|
return {
|
||||||
|
...provider,
|
||||||
|
connection: {...provider.connection, baseUrl},
|
||||||
|
discovery: {...provider.discovery, url: discoveryUrl}
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,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;
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
import {describe, expect, test} from "bun:test";
|
||||||
|
import {conversationHash, conversationIdFromHash} from "./conversation-hash.ts";
|
||||||
|
|
||||||
|
describe("conversation hash routing", () => {
|
||||||
|
test("round-trips a conversation id", () => {
|
||||||
|
const id = "f60dbe18-92ca-4a7d-9d5d-242d0ed4d042";
|
||||||
|
expect(conversationIdFromHash(conversationHash(id))).toBe(id);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("preserves URL-sensitive ids", () => {
|
||||||
|
expect(conversationIdFromHash(conversationHash("local/id + draft"))).toBe("local/id + draft");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("ignores unrelated or oversized hashes", () => {
|
||||||
|
expect(conversationIdFromHash("#services")).toBe("");
|
||||||
|
expect(conversationIdFromHash(`#conversation=${"x".repeat(121)}`)).toBe("");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
const conversationHashKey = "conversation";
|
||||||
|
|
||||||
|
export function conversationIdFromHash(hash: string) {
|
||||||
|
const input = hash.startsWith("#") ? hash.slice(1) : hash;
|
||||||
|
const id = new URLSearchParams(input).get(conversationHashKey)?.trim() || "";
|
||||||
|
return id && id.length <= 120 ? id : "";
|
||||||
|
}
|
||||||
|
|
||||||
|
export function conversationHash(id: string) {
|
||||||
|
const parameters = new URLSearchParams();
|
||||||
|
parameters.set(conversationHashKey, id);
|
||||||
|
return `#${parameters.toString()}`;
|
||||||
|
}
|
||||||
@@ -0,0 +1,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("");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
};
|
||||||
@@ -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)!)};
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
import {describe, expect, test} from "bun:test";
|
||||||
|
import {generationCallOptions, normalizeGenerationSettings} from "./generation-settings.ts";
|
||||||
|
|
||||||
|
function provider(api) {
|
||||||
|
return {
|
||||||
|
id: api,
|
||||||
|
name: api,
|
||||||
|
api,
|
||||||
|
connection: {type: "backend", baseUrl: "https://example.com/v1", proxy: null},
|
||||||
|
auth: {type: "bearer"},
|
||||||
|
headers: {},
|
||||||
|
defaultModel: "test-model",
|
||||||
|
discovery: {type: "openai-models-list", url: "https://example.com/v1/models"},
|
||||||
|
builtin: false,
|
||||||
|
credentialState: "configured",
|
||||||
|
credentials: []
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("generation settings", () => {
|
||||||
|
test("normalizes user-controlled values", () => {
|
||||||
|
expect(normalizeGenerationSettings({reasoning: "high", showReasoningSummary: true, temperature: 5, maxOutputTokens: 12.8})).toEqual({
|
||||||
|
reasoning: "high",
|
||||||
|
showReasoningSummary: true,
|
||||||
|
temperature: 2,
|
||||||
|
maxOutputTokens: 12
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test("maps OpenAI reasoning and summary", () => {
|
||||||
|
expect(generationCallOptions(provider("openai-responses"), {
|
||||||
|
reasoning: "high",
|
||||||
|
showReasoningSummary: true,
|
||||||
|
temperature: null,
|
||||||
|
maxOutputTokens: 4096
|
||||||
|
})).toEqual({
|
||||||
|
reasoning: "high",
|
||||||
|
maxOutputTokens: 4096,
|
||||||
|
providerOptions: {openai: {reasoningSummary: "auto"}}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test("maps Anthropic adaptive thinking", () => {
|
||||||
|
expect(generationCallOptions(provider("anthropic-messages"), {
|
||||||
|
reasoning: "medium",
|
||||||
|
showReasoningSummary: true,
|
||||||
|
temperature: 0.4,
|
||||||
|
maxOutputTokens: null
|
||||||
|
})).toEqual({
|
||||||
|
reasoning: "medium",
|
||||||
|
temperature: 0.4,
|
||||||
|
providerOptions: {anthropic: {thinking: {type: "adaptive", display: "summarized"}}}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test("keeps auto mode provider-default", () => {
|
||||||
|
expect(generationCallOptions(provider("openai-completions"), {
|
||||||
|
reasoning: "auto",
|
||||||
|
showReasoningSummary: false,
|
||||||
|
temperature: null,
|
||||||
|
maxOutputTokens: null
|
||||||
|
})).toEqual({});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
import type {ProviderDefinition} from "./provider-types";
|
||||||
|
|
||||||
|
type JsonValue = null | string | number | boolean | JsonValue[] | {[key: string]: JsonValue};
|
||||||
|
|
||||||
|
export type ReasoningLevel = "auto" | "none" | "low" | "medium" | "high";
|
||||||
|
|
||||||
|
export type GenerationSettings = {
|
||||||
|
reasoning: ReasoningLevel;
|
||||||
|
showReasoningSummary: boolean;
|
||||||
|
temperature: number | null;
|
||||||
|
maxOutputTokens: number | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const defaultGenerationSettings: GenerationSettings = {
|
||||||
|
reasoning: "auto",
|
||||||
|
showReasoningSummary: false,
|
||||||
|
temperature: null,
|
||||||
|
maxOutputTokens: null
|
||||||
|
};
|
||||||
|
|
||||||
|
export function normalizeGenerationSettings(value: unknown): GenerationSettings {
|
||||||
|
const input = value && typeof value === "object" && !Array.isArray(value) ? value as Record<string, unknown> : {};
|
||||||
|
const reasoning = ["auto", "none", "low", "medium", "high"].includes(String(input.reasoning))
|
||||||
|
? input.reasoning as ReasoningLevel
|
||||||
|
: "auto";
|
||||||
|
const temperature = typeof input.temperature === "number" && Number.isFinite(input.temperature)
|
||||||
|
? Math.min(2, Math.max(0, input.temperature))
|
||||||
|
: null;
|
||||||
|
const maxOutputTokens = typeof input.maxOutputTokens === "number" && Number.isFinite(input.maxOutputTokens)
|
||||||
|
? Math.min(1_000_000, Math.max(1, Math.floor(input.maxOutputTokens)))
|
||||||
|
: null;
|
||||||
|
return {
|
||||||
|
reasoning,
|
||||||
|
showReasoningSummary: input.showReasoningSummary === true,
|
||||||
|
temperature,
|
||||||
|
maxOutputTokens
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function generationCallOptions(provider: ProviderDefinition, settings: GenerationSettings) {
|
||||||
|
const providerOptions: Record<string, {[key: string]: JsonValue}> = {};
|
||||||
|
if (provider.api === "openai-responses") {
|
||||||
|
providerOptions.openai = {reasoningSummary: settings.showReasoningSummary ? "auto" : null};
|
||||||
|
} else if (provider.api === "anthropic-messages" && settings.showReasoningSummary && settings.reasoning !== "none") {
|
||||||
|
providerOptions.anthropic = {thinking: {type: "adaptive", display: "summarized"}};
|
||||||
|
} else if (provider.api === "google-generative-ai" && settings.showReasoningSummary) {
|
||||||
|
providerOptions.google = {thinkingConfig: {includeThoughts: true}};
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
...(settings.reasoning !== "auto" ? {reasoning: settings.reasoning} : {}),
|
||||||
|
...(settings.temperature !== null ? {temperature: settings.temperature} : {}),
|
||||||
|
...(settings.maxOutputTokens !== null ? {maxOutputTokens: settings.maxOutputTokens} : {}),
|
||||||
|
...(Object.keys(providerOptions).length ? {providerOptions} : {})
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,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");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
import {readFileSync} from "node:fs";
|
||||||
|
|
||||||
|
export type ChatIdentity = {issuer: string; sub: string; username: string; name: string; email: string};
|
||||||
|
type HeaderReader = Pick<Headers, "get">;
|
||||||
|
|
||||||
|
const keyVaultUrl = process.env.KEY_VAULT_URL || "http://ai-gateway:8093";
|
||||||
|
const authentikIssuer = process.env.AUTHENTIK_ISSUER || "https://auth.xiteng.site";
|
||||||
|
const serviceTokenFile = process.env.KEY_VAULT_TOKEN_FILE || "/run/secrets/portal_gateway_hmac";
|
||||||
|
let serviceToken: string | null = null;
|
||||||
|
|
||||||
|
function loadServiceToken() {
|
||||||
|
if (!serviceToken) serviceToken = readFileSync(/* turbopackIgnore: true */ serviceTokenFile, "utf8").trim();
|
||||||
|
return serviceToken;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function identityFromHeaders(headers: HeaderReader): ChatIdentity {
|
||||||
|
const username = headers.get("x-authentik-username")?.trim() || "";
|
||||||
|
const sub = headers.get("x-authentik-uid")?.trim() || "";
|
||||||
|
if (!username || !sub) {
|
||||||
|
const error = new Error("Authenticated user context is required");
|
||||||
|
Object.assign(error, {statusCode: 401});
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
issuer: authentikIssuer,
|
||||||
|
sub,
|
||||||
|
username,
|
||||||
|
name: headers.get("x-authentik-name")?.trim() || username,
|
||||||
|
email: headers.get("x-authentik-email")?.trim() || ""
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function keyVaultFetch(
|
||||||
|
pathname: string,
|
||||||
|
identity: ChatIdentity,
|
||||||
|
init: {method?: string; body?: string; headers?: HeadersInit} = {}
|
||||||
|
) {
|
||||||
|
const method = init.method || "GET";
|
||||||
|
return fetch(new URL(pathname, keyVaultUrl), {
|
||||||
|
method,
|
||||||
|
headers: {
|
||||||
|
"Accept": "application/json",
|
||||||
|
"Authorization": `Bearer ${loadServiceToken()}`,
|
||||||
|
...init.headers,
|
||||||
|
"X-Key-Vault-Actor-Issuer": identity.issuer,
|
||||||
|
"X-Key-Vault-Actor-Sub": identity.sub,
|
||||||
|
"X-Key-Vault-Actor-Username": identity.username
|
||||||
|
},
|
||||||
|
body: init.body,
|
||||||
|
cache: "no-store",
|
||||||
|
signal: AbortSignal.timeout(300000)
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
import {describe, expect, test} from "bun:test";
|
||||||
|
import {applyBrowserProviderSettings} from "./browser-provider-settings.ts";
|
||||||
|
import {discoverProviderModels} from "./provider-connectivity.ts";
|
||||||
|
|
||||||
|
function llamaProvider() {
|
||||||
|
return {
|
||||||
|
id: "llama.cpp",
|
||||||
|
name: "llama.cpp",
|
||||||
|
api: "openai-completions",
|
||||||
|
connection: {type: "frontend", baseUrl: "http://127.0.0.1:8080/v1", proxy: null},
|
||||||
|
auth: {type: "none"},
|
||||||
|
headers: {},
|
||||||
|
defaultModel: "local-model",
|
||||||
|
discovery: {type: "openai-models-list", url: "http://127.0.0.1:8080/v1/models"},
|
||||||
|
builtin: true,
|
||||||
|
credentialState: "local",
|
||||||
|
credentials: []
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("llama.cpp discovery", () => {
|
||||||
|
test("applies a browser-local endpoint override", () => {
|
||||||
|
const provider = applyBrowserProviderSettings(llamaProvider(), {provider: {baseUrl: "http://192.168.4.20:8081/v1/"}});
|
||||||
|
expect(provider.connection.baseUrl).toBe("http://192.168.4.20:8081/v1");
|
||||||
|
expect(provider.discovery.url).toBe("http://192.168.4.20:8081/v1/models");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("falls back to /props when /v1/models is unavailable", async () => {
|
||||||
|
const requests = [];
|
||||||
|
const result = await discoverProviderModels(llamaProvider(), {}, async (input) => {
|
||||||
|
requests.push(String(input));
|
||||||
|
if (String(input).endsWith("/v1/models")) {
|
||||||
|
return new Response(JSON.stringify({error: "Not Found"}), {status: 404, headers: {"Content-Type": "application/json"}});
|
||||||
|
}
|
||||||
|
return new Response(JSON.stringify({
|
||||||
|
model_path: "/models/Qwen3.5-9B-Q4_K_M.gguf",
|
||||||
|
default_generation_settings: {n_ctx: 32768}
|
||||||
|
}), {status: 200, headers: {"Content-Type": "application/json"}});
|
||||||
|
});
|
||||||
|
expect(requests).toEqual(["http://127.0.0.1:8080/v1/models", "http://127.0.0.1:8080/props"]);
|
||||||
|
expect(result.endpoint).toBe("http://127.0.0.1:8080/props");
|
||||||
|
expect(result.models).toEqual([{id: "Qwen3.5-9B-Q4_K_M.gguf", name: "Qwen3.5-9B-Q4_K_M.gguf", ownedBy: "llamacpp", contextWindow: 32768}]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
import type {ProviderSecret} from "./provider-types";
|
||||||
|
|
||||||
|
export type LocalCredential = {
|
||||||
|
id: string;
|
||||||
|
providerId: string;
|
||||||
|
name: string;
|
||||||
|
secret: ProviderSecret;
|
||||||
|
createdAt: string;
|
||||||
|
updatedAt: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
const databaseName = "xiteng-chat-local-vault";
|
||||||
|
const storeName = "credentials";
|
||||||
|
|
||||||
|
function openDatabase() {
|
||||||
|
return new Promise<IDBDatabase>((resolve, reject) => {
|
||||||
|
const request = indexedDB.open(databaseName, 1);
|
||||||
|
request.onupgradeneeded = () => {
|
||||||
|
const database = request.result;
|
||||||
|
if (!database.objectStoreNames.contains(storeName)) database.createObjectStore(storeName, {keyPath: "id"});
|
||||||
|
};
|
||||||
|
request.onsuccess = () => resolve(request.result);
|
||||||
|
request.onerror = () => reject(request.error || new Error("Unable to open local credential store"));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function transaction<T>(mode: IDBTransactionMode, operation: (store: IDBObjectStore) => IDBRequest<T>) {
|
||||||
|
const database = await openDatabase();
|
||||||
|
return new Promise<T>((resolve, reject) => {
|
||||||
|
const request = operation(database.transaction(storeName, mode).objectStore(storeName));
|
||||||
|
request.onsuccess = () => resolve(request.result);
|
||||||
|
request.onerror = () => reject(request.error || new Error("Local credential operation failed"));
|
||||||
|
}).finally(() => database.close());
|
||||||
|
}
|
||||||
|
|
||||||
|
export function localCredentialId(providerId: string, name = "default") {
|
||||||
|
return `${providerId}/${name}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getLocalCredential(providerId: string, name = "default") {
|
||||||
|
return transaction<LocalCredential | undefined>("readonly", (store) => store.get(localCredentialId(providerId, name)));
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function listLocalCredentials() {
|
||||||
|
return transaction<LocalCredential[]>("readonly", (store) => store.getAll());
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function saveLocalCredential(providerId: string, name: string, secret: ProviderSecret) {
|
||||||
|
const id = localCredentialId(providerId, name);
|
||||||
|
const existing = await getLocalCredential(providerId, name);
|
||||||
|
const timestamp = new Date().toISOString();
|
||||||
|
const credential: LocalCredential = {
|
||||||
|
id,
|
||||||
|
providerId,
|
||||||
|
name,
|
||||||
|
secret,
|
||||||
|
createdAt: existing?.createdAt || timestamp,
|
||||||
|
updatedAt: timestamp
|
||||||
|
};
|
||||||
|
await transaction("readwrite", (store) => store.put(credential));
|
||||||
|
return credential;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function deleteLocalCredential(providerId: string, name = "default") {
|
||||||
|
return transaction("readwrite", (store) => store.delete(localCredentialId(providerId, name)));
|
||||||
|
}
|
||||||
@@ -0,0 +1,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("<img");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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) => ({
|
||||||
|
"&": "&",
|
||||||
|
"<": "<",
|
||||||
|
">": ">",
|
||||||
|
'"': """,
|
||||||
|
"'": "'"
|
||||||
|
})[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>`;
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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"]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -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");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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));
|
||||||
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
export type ChatProfile = {
|
||||||
|
username: string;
|
||||||
|
name: string;
|
||||||
|
email: string;
|
||||||
|
};
|
||||||
@@ -0,0 +1,188 @@
|
|||||||
|
import {applyProviderAuthentication} from "./provider-model";
|
||||||
|
import type {ProviderDefinition, ProviderModel, ProviderSecret} from "./provider-types";
|
||||||
|
|
||||||
|
type ProviderFetch = (input: RequestInfo | URL, init?: RequestInit) => Promise<Response>;
|
||||||
|
type JsonRecord = Record<string, unknown>;
|
||||||
|
|
||||||
|
|
||||||
|
function accountEndpoints(provider: ProviderDefinition) {
|
||||||
|
const baseUrl = provider.connection.baseUrl.replace(/\/+$/, "");
|
||||||
|
const url = new URL(baseUrl);
|
||||||
|
if (url.hostname === "openrouter.ai") return ["https://openrouter.ai/api/v1/auth/key"];
|
||||||
|
if (url.hostname === "api.deepseek.com") return [`${url.origin}/user/balance`];
|
||||||
|
if (url.hostname === "api.moonshot.cn") return [`${baseUrl}/users/me/balance`];
|
||||||
|
if (url.hostname === "api.openai.com") return [`${baseUrl}/dashboard/billing/credit_grants`];
|
||||||
|
if (["openai-completions", "openai-responses"].includes(provider.api)) return [`${baseUrl}/dashboard/billing/credit_grants`];
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
async function responseText(response: Response, maximum = 4 * 1024 * 1024) {
|
||||||
|
const text = await response.text();
|
||||||
|
if (text.length > maximum) throw new Error("Provider response is too large");
|
||||||
|
return text;
|
||||||
|
}
|
||||||
|
|
||||||
|
function jsonRecord(value: unknown): JsonRecord | null {
|
||||||
|
return value && typeof value === "object" && !Array.isArray(value) ? value as JsonRecord : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function publicMetadata(value: unknown, depth = 0): unknown {
|
||||||
|
if (value === null || ["string", "number", "boolean"].includes(typeof value)) return value;
|
||||||
|
if (depth >= 3) return undefined;
|
||||||
|
if (Array.isArray(value)) return value.slice(0, 20).map((item) => publicMetadata(item, depth + 1)).filter((item) => item !== undefined);
|
||||||
|
const record = jsonRecord(value);
|
||||||
|
if (!record) return undefined;
|
||||||
|
return Object.fromEntries(Object.entries(record).slice(0, 50).map(([key, item]) => [key, publicMetadata(item, depth + 1)]).filter((entry) => entry[1] !== undefined));
|
||||||
|
}
|
||||||
|
|
||||||
|
function selectedMetadata(record: JsonRecord, pattern: RegExp) {
|
||||||
|
return Object.fromEntries(Object.entries(record).filter(([key]) => pattern.test(key)).map(([key, value]) => [key, publicMetadata(value)]).filter((entry) => entry[1] !== undefined));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function normalizeDiscoveredModels(payload: unknown): ProviderModel[] {
|
||||||
|
const root = jsonRecord(payload);
|
||||||
|
if (!root) return [];
|
||||||
|
const source = Array.isArray(root.data) ? root.data : Array.isArray(root.models) ? root.models : [];
|
||||||
|
return source.slice(0, 300).map((value) => {
|
||||||
|
const model = jsonRecord(value);
|
||||||
|
if (!model) return null;
|
||||||
|
const rawId = model.id || model.name || model.model;
|
||||||
|
if (typeof rawId !== "string" || !rawId.trim()) return null;
|
||||||
|
const id = rawId.replace(/^models\//, "");
|
||||||
|
const pricing = selectedMetadata(model, /price|pricing|cost|rate|token/i);
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
name: typeof model.displayName === "string" ? model.displayName : typeof model.name === "string" ? model.name.replace(/^models\//, "") : id,
|
||||||
|
...(typeof model.owned_by === "string" ? {ownedBy: model.owned_by} : {}),
|
||||||
|
...(Number.isFinite(model.context_length) ? {contextWindow: Number(model.context_length)} : {}),
|
||||||
|
...(Object.keys(pricing).length ? {pricing} : {})
|
||||||
|
};
|
||||||
|
}).filter((model): model is ProviderModel => model !== null);
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeLlamaCppProps(payload: unknown, fallbackModel: string): ProviderModel[] {
|
||||||
|
const root = jsonRecord(payload);
|
||||||
|
if (!root) return [];
|
||||||
|
const modelPath = typeof root.model_path === "string" ? root.model_path : "";
|
||||||
|
const modelAlias = typeof root.model_alias === "string" ? root.model_alias : "";
|
||||||
|
const id = modelAlias.trim() || modelPath.split(/[\\/]/).filter(Boolean).at(-1) || fallbackModel;
|
||||||
|
const generationSettings = jsonRecord(root.default_generation_settings);
|
||||||
|
const contextWindow = Number(generationSettings?.n_ctx);
|
||||||
|
return [{
|
||||||
|
id,
|
||||||
|
name: id,
|
||||||
|
ownedBy: "llamacpp",
|
||||||
|
...(Number.isFinite(contextWindow) && contextWindow > 0 ? {contextWindow} : {})
|
||||||
|
}];
|
||||||
|
}
|
||||||
|
|
||||||
|
function responseRateLimits(response: Response) {
|
||||||
|
return Object.fromEntries([...response.headers.entries()].filter(([name]) => /rate.?limit|retry-after|quota/i.test(name)));
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchJson(providerFetch: ProviderFetch, endpoint: string, headers: Headers, timeoutMs = 15000) {
|
||||||
|
const response = await providerFetch(endpoint, {
|
||||||
|
method: "GET",
|
||||||
|
headers,
|
||||||
|
signal: AbortSignal.timeout(timeoutMs)
|
||||||
|
});
|
||||||
|
const text = await responseText(response);
|
||||||
|
let payload: unknown = null;
|
||||||
|
try {
|
||||||
|
payload = text ? JSON.parse(text) : null;
|
||||||
|
} catch {
|
||||||
|
payload = {preview: text.slice(0, 1000)};
|
||||||
|
}
|
||||||
|
return {response, payload};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function modelProbe(provider: ProviderDefinition, secret: ProviderSecret, providerFetch: ProviderFetch) {
|
||||||
|
const endpoint = provider.discovery.url;
|
||||||
|
const headers = applyProviderAuthentication(provider, secret, {
|
||||||
|
"Accept": "application/json",
|
||||||
|
...(provider.discovery.type === "anthropic-models-list" ? {"anthropic-version": "2023-06-01"} : {})
|
||||||
|
});
|
||||||
|
const startedAt = performance.now();
|
||||||
|
const initial = await fetchJson(providerFetch, endpoint, headers);
|
||||||
|
let {response, payload} = initial;
|
||||||
|
let resolvedEndpoint = endpoint;
|
||||||
|
let models = response.ok ? normalizeDiscoveredModels(payload) : [];
|
||||||
|
if (provider.id === "llama.cpp" && models.length === 0) {
|
||||||
|
const propsEndpoint = `${provider.connection.baseUrl.replace(/\/v1\/?$/, "")}/props`;
|
||||||
|
try {
|
||||||
|
const props = await fetchJson(providerFetch, propsEndpoint, headers, 5000);
|
||||||
|
const propsModels = props.response.ok ? normalizeLlamaCppProps(props.payload, provider.defaultModel) : [];
|
||||||
|
if (propsModels.length > 0) {
|
||||||
|
response = props.response;
|
||||||
|
payload = props.payload;
|
||||||
|
resolvedEndpoint = propsEndpoint;
|
||||||
|
models = propsModels;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Preserve the primary /v1/models error when the compatibility probe also fails.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const latencyMs = Math.max(0, Math.round(performance.now() - startedAt));
|
||||||
|
if (!response.ok) {
|
||||||
|
const root = jsonRecord(payload);
|
||||||
|
const detail = root?.preview || root?.error || root?.message;
|
||||||
|
const error = new Error(typeof detail === "string" ? detail : `Provider HTTP ${response.status}`);
|
||||||
|
Object.assign(error, {statusCode: response.status});
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
return {endpoint: resolvedEndpoint, headers, response, payload, latencyMs, models};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function discoverProviderModels(
|
||||||
|
provider: ProviderDefinition,
|
||||||
|
secret: ProviderSecret,
|
||||||
|
providerFetch: ProviderFetch = fetch
|
||||||
|
) {
|
||||||
|
const probe = await modelProbe(provider, secret, providerFetch);
|
||||||
|
return {
|
||||||
|
endpoint: probe.endpoint,
|
||||||
|
status: probe.response.status,
|
||||||
|
latencyMs: probe.latencyMs,
|
||||||
|
models: probe.models
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function testProviderConnectivity(
|
||||||
|
provider: ProviderDefinition,
|
||||||
|
secret: ProviderSecret,
|
||||||
|
providerFetch: ProviderFetch = fetch
|
||||||
|
) {
|
||||||
|
const probe = await modelProbe(provider, secret, providerFetch);
|
||||||
|
const {endpoint, headers, response, payload, latencyMs, models} = probe;
|
||||||
|
const root = jsonRecord(payload) || {};
|
||||||
|
const account = selectedMetadata(root, /balance|credit|quota|usage|limit|billing|currency/i);
|
||||||
|
const rateLimits = responseRateLimits(response);
|
||||||
|
const accountUrls = accountEndpoints(provider);
|
||||||
|
let accountProbe: unknown = null;
|
||||||
|
for (const accountUrl of accountUrls) {
|
||||||
|
try {
|
||||||
|
const probe = await fetchJson(providerFetch, accountUrl, headers, 5000);
|
||||||
|
if (!probe.response.ok) continue;
|
||||||
|
accountProbe = {
|
||||||
|
endpoint: accountUrl,
|
||||||
|
status: probe.response.status,
|
||||||
|
data: publicMetadata(jsonRecord(probe.payload)?.data || probe.payload)
|
||||||
|
};
|
||||||
|
break;
|
||||||
|
} catch {
|
||||||
|
// Account metadata is best-effort and must not fail a successful model probe.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
ok: true,
|
||||||
|
status: response.status,
|
||||||
|
latencyMs,
|
||||||
|
endpoint,
|
||||||
|
modelCount: models.length,
|
||||||
|
models,
|
||||||
|
account: Object.keys(account).length ? account : null,
|
||||||
|
accountProbe,
|
||||||
|
rateLimits: Object.keys(rateLimits).length ? rateLimits : null
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
import type {ProviderDefinition, ProviderSecret} from "./provider-types";
|
||||||
|
|
||||||
|
type ProviderFetch = (input: RequestInfo | URL, init?: RequestInit) => Promise<Response>;
|
||||||
|
|
||||||
|
export function applyProviderAuthentication(
|
||||||
|
provider: ProviderDefinition,
|
||||||
|
secret: ProviderSecret,
|
||||||
|
inputHeaders?: HeadersInit
|
||||||
|
) {
|
||||||
|
const headers = new Headers(inputHeaders);
|
||||||
|
headers.delete("authorization");
|
||||||
|
headers.delete("x-api-key");
|
||||||
|
headers.delete("x-goog-api-key");
|
||||||
|
for (const [name, value] of Object.entries(provider.headers || {})) headers.set(name, value);
|
||||||
|
for (const [name, value] of Object.entries(secret.provider?.headers || {})) headers.set(name, value);
|
||||||
|
const apiKey = secret.provider?.apiKey || "";
|
||||||
|
if (provider.auth.type === "bearer" && apiKey) headers.set("Authorization", `Bearer ${apiKey}`);
|
||||||
|
if (provider.auth.type === "header" && provider.auth.header && apiKey) headers.set(provider.auth.header, apiKey);
|
||||||
|
return headers;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createProviderModel(
|
||||||
|
provider: ProviderDefinition,
|
||||||
|
secret: ProviderSecret,
|
||||||
|
modelId: string,
|
||||||
|
providerFetch: ProviderFetch
|
||||||
|
) {
|
||||||
|
const authenticatedFetch: ProviderFetch = (input, init = {}) => providerFetch(input, {
|
||||||
|
...init,
|
||||||
|
headers: applyProviderAuthentication(provider, secret, init.headers)
|
||||||
|
});
|
||||||
|
// Provider API comes from the runtime Registry; dynamic imports keep unused SDKs out of the initial client chunk.
|
||||||
|
if (provider.api === "openai-completions") {
|
||||||
|
const {createOpenAICompatible} = await import("@ai-sdk/openai-compatible");
|
||||||
|
return createOpenAICompatible({
|
||||||
|
name: provider.id,
|
||||||
|
baseURL: provider.connection.baseUrl,
|
||||||
|
fetch: authenticatedFetch as typeof fetch
|
||||||
|
}).chatModel(modelId);
|
||||||
|
}
|
||||||
|
if (provider.api === "openai-responses") {
|
||||||
|
const {createOpenAI} = await import("@ai-sdk/openai");
|
||||||
|
return createOpenAI({
|
||||||
|
name: provider.id,
|
||||||
|
baseURL: provider.connection.baseUrl,
|
||||||
|
apiKey: secret.provider?.apiKey || "browser-managed",
|
||||||
|
fetch: authenticatedFetch as typeof fetch
|
||||||
|
}).responses(modelId);
|
||||||
|
}
|
||||||
|
if (provider.api === "anthropic-messages") {
|
||||||
|
const {createAnthropic} = await import("@ai-sdk/anthropic");
|
||||||
|
return createAnthropic({
|
||||||
|
name: provider.id,
|
||||||
|
baseURL: provider.connection.baseUrl,
|
||||||
|
apiKey: secret.provider?.apiKey || "browser-managed",
|
||||||
|
fetch: authenticatedFetch as typeof fetch
|
||||||
|
}).messages(modelId);
|
||||||
|
}
|
||||||
|
const {createGoogleGenerativeAI} = await import("@ai-sdk/google");
|
||||||
|
return createGoogleGenerativeAI({
|
||||||
|
name: provider.id,
|
||||||
|
baseURL: provider.connection.baseUrl,
|
||||||
|
apiKey: secret.provider?.apiKey || "browser-managed",
|
||||||
|
fetch: authenticatedFetch as typeof fetch
|
||||||
|
}).chat(modelId);
|
||||||
|
}
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
export type ProviderApi = "openai-completions" | "openai-responses" | "anthropic-messages" | "google-generative-ai";
|
||||||
|
|
||||||
|
export type ProviderModel = {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
contextWindow?: number;
|
||||||
|
maxTokens?: number;
|
||||||
|
input?: string[];
|
||||||
|
reasoning?: boolean;
|
||||||
|
ownedBy?: string;
|
||||||
|
pricing?: Record<string, unknown>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ProviderDiscovery = {
|
||||||
|
type: "openai-models-list" | "anthropic-models-list" | "google-models-list";
|
||||||
|
url: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ProviderDefinition = {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
api: ProviderApi;
|
||||||
|
connection: {
|
||||||
|
type: "frontend" | "backend";
|
||||||
|
baseUrl: string;
|
||||||
|
proxy: null | {type: "relay" | "http" | "https" | "socks5"; url: string};
|
||||||
|
};
|
||||||
|
auth: {type: "bearer" | "header" | "none"; header?: string};
|
||||||
|
headers: Record<string, string>;
|
||||||
|
defaultModel: string;
|
||||||
|
discovery: ProviderDiscovery;
|
||||||
|
builtin: boolean;
|
||||||
|
credentialState: "configured" | "missing" | "local";
|
||||||
|
credentials: Array<{id: string; providerId: string; name: string; fingerprint: string}>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ProviderSecret = {
|
||||||
|
provider?: {apiKey?: string; headers?: Record<string, string>; baseUrl?: string};
|
||||||
|
proxy?: {username?: string; password?: string; token?: string};
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ResolvedBackendProvider = {
|
||||||
|
provider: ProviderDefinition;
|
||||||
|
credential: {id: string; name: string; secret: ProviderSecret};
|
||||||
|
};
|
||||||
@@ -0,0 +1,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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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];
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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};
|
||||||
|
}
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
import nodeFetch from "node-fetch";
|
||||||
|
import {ProxyAgent} from "proxy-agent";
|
||||||
|
import type {ProviderDefinition, ProviderSecret} from "./provider-types";
|
||||||
|
|
||||||
|
export function createServerProviderFetch(provider: ProviderDefinition, secret: ProviderSecret) {
|
||||||
|
const proxy = provider.connection.proxy;
|
||||||
|
if (!proxy) return fetch;
|
||||||
|
const proxyUrl = new URL(proxy.url);
|
||||||
|
if (secret.proxy?.username) proxyUrl.username = secret.proxy.username;
|
||||||
|
if (secret.proxy?.password) proxyUrl.password = secret.proxy.password;
|
||||||
|
const agent = new ProxyAgent({getProxyForUrl: () => proxyUrl.toString()});
|
||||||
|
return async (input: RequestInfo | URL, init?: RequestInit) => {
|
||||||
|
const response = await nodeFetch(String(input), {...init, agent} as never);
|
||||||
|
return response as unknown as Response;
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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} : {})}))
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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};
|
||||||
|
}
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64"><rect width="64" height="64" rx="18" fill="#171717"/><path d="M17 20h30v20H31l-9 8v-8h-5z" fill="#fff"/><circle cx="25" cy="30" r="2" fill="#171717"/><circle cx="32" cy="30" r="2" fill="#171717"/><circle cx="39" cy="30" r="2" fill="#171717"/></svg>
|
||||||
|
After Width: | Height: | Size: 309 B |
|
After Width: | Height: | Size: 14 KiB |
|
After Width: | Height: | Size: 14 KiB |
|
After Width: | Height: | Size: 80 KiB |
|
After Width: | Height: | Size: 80 KiB |
@@ -0,0 +1,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"}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
})));
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -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>
|
||||||
@@ -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}`);
|
||||||
@@ -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; }
|
||||||
|
}
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2022",
|
||||||
|
"lib": ["dom", "dom.iterable", "esnext"],
|
||||||
|
"types": ["bun"],
|
||||||
|
"strict": true,
|
||||||
|
"noEmit": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"allowJs": false,
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"module": "esnext",
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"resolveJsonModule": true,
|
||||||
|
"isolatedModules": true
|
||||||
|
},
|
||||||
|
"include": ["src/**/*.ts", "lib/**/*.ts", "build.ts"],
|
||||||
|
"exclude": ["node_modules", "dist"]
|
||||||
|
}
|
||||||
@@ -1,12 +1,25 @@
|
|||||||
services:
|
services:
|
||||||
cloudflared:
|
cloudflared:
|
||||||
image: cloudflare/cloudflared:latest
|
image: cloudflare/cloudflared:2026.7.3
|
||||||
container_name: cloudflared
|
container_name: cloudflared
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
command: tunnel --protocol http2 run
|
command: tunnel --protocol http2 run
|
||||||
volumes:
|
volumes:
|
||||||
- ./config.yml:/etc/cloudflared/config.yml:ro
|
- ./config.yml:/etc/cloudflared/config.yml:ro
|
||||||
- ./credentials.json:/etc/cloudflared/credentials.json:ro
|
- ./credentials.json:/etc/cloudflared/credentials.json:ro
|
||||||
|
labels:
|
||||||
|
- "xiteng.site.component.cloudflare-tunnel.enabled=true"
|
||||||
|
- "xiteng.site.component.cloudflare-tunnel.name=Cloudflare Tunnel"
|
||||||
|
- "xiteng.site.component.cloudflare-tunnel.description=将 xiteng.site 与通配子域的公网流量送入 Traefik。"
|
||||||
|
- "xiteng.site.component.cloudflare-tunnel.section=infrastructure"
|
||||||
|
- "xiteng.site.component.cloudflare-tunnel.category=边缘与网络"
|
||||||
|
- "xiteng.site.component.cloudflare-tunnel.endpoint=*.xiteng.site → Traefik"
|
||||||
|
- "xiteng.site.component.cloudflare-tunnel.access=internal"
|
||||||
|
- "xiteng.site.component.cloudflare-tunnel.access-label=基础设施"
|
||||||
|
- "xiteng.site.component.cloudflare-tunnel.icon=CF"
|
||||||
|
- "xiteng.site.component.cloudflare-tunnel.icon-url=https://cdn.simpleicons.org/cloudflare"
|
||||||
|
- "xiteng.site.component.cloudflare-tunnel.accent=yellow"
|
||||||
|
- "xiteng.site.component.cloudflare-tunnel.order=100"
|
||||||
networks:
|
networks:
|
||||||
- homelab_net
|
- homelab_net
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,62 @@
|
|||||||
|
services:
|
||||||
|
code-server:
|
||||||
|
image: codercom/code-server:4.132.0-39
|
||||||
|
container_name: code-server
|
||||||
|
restart: unless-stopped
|
||||||
|
user: "${CODE_SERVER_UID:-1000}:${CODE_SERVER_GID:-1000}"
|
||||||
|
environment:
|
||||||
|
DOCKER_USER: ${CODE_SERVER_USER:-coder}
|
||||||
|
command:
|
||||||
|
- --bind-addr
|
||||||
|
- 0.0.0.0:8080
|
||||||
|
- --auth
|
||||||
|
- none
|
||||||
|
- /home/coder/homelab
|
||||||
|
volumes:
|
||||||
|
- ./config:/home/coder/.config:rw
|
||||||
|
- ./local:/home/coder/.local:rw
|
||||||
|
- ../:/home/coder/homelab:rw
|
||||||
|
networks:
|
||||||
|
- homelab_net
|
||||||
|
labels:
|
||||||
|
# Traefik
|
||||||
|
- "traefik.enable=true"
|
||||||
|
- "traefik.http.routers.code-server.rule=Host(`code.xiteng.site`)"
|
||||||
|
- "traefik.http.routers.code-server.entrypoints=websecure"
|
||||||
|
- "traefik.http.routers.code-server.tls=true"
|
||||||
|
- "traefik.http.routers.code-server.tls.certresolver=cfresolver"
|
||||||
|
- "traefik.http.routers.code-server.service=code-server"
|
||||||
|
- "traefik.http.routers.code-server.middlewares=code-server-scheme,code-server-auth"
|
||||||
|
- "traefik.http.services.code-server.loadbalancer.server.port=8080"
|
||||||
|
- "traefik.http.routers.code-server-http.rule=Host(`code.xiteng.site`)"
|
||||||
|
- "traefik.http.routers.code-server-http.entrypoints=web"
|
||||||
|
- "traefik.http.routers.code-server-http.service=code-server"
|
||||||
|
- "traefik.http.routers.code-server-http.middlewares=code-server-scheme,code-server-auth"
|
||||||
|
- "xiteng.site.cache.code-server.enabled=true"
|
||||||
|
- "xiteng.site.cache.code-server.routers=code-server,code-server-http"
|
||||||
|
- "xiteng.site.cache.code-server.paths=/assets/,/static/"
|
||||||
|
- "xiteng.site.cache.code-server.edge-ttl=604800"
|
||||||
|
- "xiteng.site.cache.code-server.stale-while-revalidate=86400"
|
||||||
|
# Authentik ForwardAuth
|
||||||
|
- "traefik.http.middlewares.code-server-scheme.headers.customrequestheaders.X-Forwarded-Proto=https"
|
||||||
|
- "traefik.http.middlewares.code-server-auth.forwardauth.address=http://authentik-outpost:9000/outpost.goauthentik.io/auth/traefik"
|
||||||
|
- "traefik.http.middlewares.code-server-auth.forwardauth.trustForwardHeader=true"
|
||||||
|
- "traefik.http.middlewares.code-server-auth.forwardauth.authResponseHeaders=X-authentik-username,X-authentik-groups,X-authentik-email,X-authentik-name"
|
||||||
|
- "xiteng.site.component.code-server.enabled=true"
|
||||||
|
- "xiteng.site.component.code-server.name=Code Server"
|
||||||
|
- "xiteng.site.component.code-server.description=浏览器中的 VS Code 工作台,用于维护 Homelab 配置与脚本。"
|
||||||
|
- "xiteng.site.component.code-server.section=services"
|
||||||
|
- "xiteng.site.component.code-server.category=开发与协作"
|
||||||
|
- "xiteng.site.component.code-server.url=https://code.xiteng.site"
|
||||||
|
- "xiteng.site.component.code-server.access=sso"
|
||||||
|
- "xiteng.site.component.code-server.access-label=需要 Authentik"
|
||||||
|
- "xiteng.site.component.code-server.icon=CS"
|
||||||
|
- "xiteng.site.component.code-server.icon-url=https://cdn.simpleicons.org/coder"
|
||||||
|
- "xiteng.site.component.code-server.accent=blue"
|
||||||
|
- "xiteng.site.component.code-server.order=130"
|
||||||
|
- "xiteng.site.component.code-server.monitor.enabled=true"
|
||||||
|
- "xiteng.site.component.code-server.monitor.url=http://code-server:8080/healthz"
|
||||||
|
|
||||||
|
networks:
|
||||||
|
homelab_net:
|
||||||
|
external: true
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
FROM ghcr.nju.edu.cn/lecode-official/comfyui-docker:0.6.3-comfyui-0.8.2-comfyui-manager-4.0.5-pytorch-2.9.1-cuda-12.8-cudnn-9
|
||||||
|
|
||||||
|
USER root
|
||||||
|
WORKDIR /
|
||||||
|
|
||||||
|
|
||||||
|
RUN rm -rf /opt/comfyui /opt/comfyui-manager \
|
||||||
|
&& git clone --depth 1 --branch v0.31.0 https://github.com/Comfy-Org/ComfyUI.git /opt/comfyui \
|
||||||
|
&& git clone --depth 1 --branch 4.2.2 https://github.com/Comfy-Org/ComfyUI-Manager.git /opt/comfyui-manager
|
||||||
|
|
||||||
|
RUN /opt/conda/bin/pip install --index-url https://pypi.tuna.tsinghua.edu.cn/simple --requirement /opt/comfyui/requirements.txt \
|
||||||
|
&& if [ -f /opt/comfyui-manager/requirements.txt ]; then /opt/conda/bin/pip install --index-url https://pypi.tuna.tsinghua.edu.cn/simple --requirement /opt/comfyui-manager/requirements.txt; fi
|
||||||
|
|
||||||
|
WORKDIR /opt/comfyui
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
services:
|
||||||
|
comfyui:
|
||||||
|
command: ["--enable-manager"]
|
||||||
|
deploy:
|
||||||
|
resources:
|
||||||
|
reservations:
|
||||||
|
devices:
|
||||||
|
- driver: nvidia
|
||||||
|
count: all
|
||||||
|
capabilities: [gpu]
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
services:
|
||||||
|
comfyui:
|
||||||
|
build:
|
||||||
|
context: .
|
||||||
|
image: xiteng-comfyui:0.31.0-manager-4.2.2
|
||||||
|
container_name: comfyui
|
||||||
|
restart: unless-stopped
|
||||||
|
entrypoint: ["/bin/bash", "/entrypoint-patched.sh"]
|
||||||
|
environment:
|
||||||
|
USER_ID: ${COMFYUI_USER_ID:-1000}
|
||||||
|
GROUP_ID: ${COMFYUI_GROUP_ID:-1000}
|
||||||
|
PYTHONPATH: /opt/comfyui-manager
|
||||||
|
command: ["--cpu", "--enable-manager"]
|
||||||
|
volumes:
|
||||||
|
- ./entrypoint.sh:/entrypoint-patched.sh:ro
|
||||||
|
- ./models:/opt/comfyui/models:rw
|
||||||
|
- ./custom_nodes:/opt/comfyui/custom_nodes:rw
|
||||||
|
- ./output:/opt/comfyui/output:rw
|
||||||
|
networks:
|
||||||
|
- homelab_net
|
||||||
|
labels:
|
||||||
|
# Traefik
|
||||||
|
- "traefik.enable=true"
|
||||||
|
- "traefik.http.routers.comfyui.rule=Host(`comfy.xiteng.site`)"
|
||||||
|
- "traefik.http.routers.comfyui.entrypoints=websecure"
|
||||||
|
- "traefik.http.routers.comfyui.tls=true"
|
||||||
|
- "traefik.http.routers.comfyui.tls.certresolver=cfresolver"
|
||||||
|
- "traefik.http.routers.comfyui.service=comfyui"
|
||||||
|
- "traefik.http.routers.comfyui.middlewares=comfyui-scheme,comfyui-auth"
|
||||||
|
- "traefik.http.services.comfyui.loadbalancer.server.port=8188"
|
||||||
|
- "traefik.http.routers.comfyui-http.rule=Host(`comfy.xiteng.site`)"
|
||||||
|
- "traefik.http.routers.comfyui-http.entrypoints=web"
|
||||||
|
- "traefik.http.routers.comfyui-http.service=comfyui"
|
||||||
|
- "traefik.http.routers.comfyui-http.middlewares=comfyui-scheme,comfyui-auth"
|
||||||
|
- "xiteng.site.cache.comfyui.enabled=true"
|
||||||
|
- "xiteng.site.cache.comfyui.routers=comfyui,comfyui-http"
|
||||||
|
- "xiteng.site.cache.comfyui.paths=/assets/,/static/"
|
||||||
|
- "xiteng.site.cache.comfyui.edge-ttl=604800"
|
||||||
|
- "xiteng.site.cache.comfyui.stale-while-revalidate=86400"
|
||||||
|
# Authentik ForwardAuth
|
||||||
|
- "traefik.http.middlewares.comfyui-scheme.headers.customrequestheaders.X-Forwarded-Proto=https"
|
||||||
|
- "traefik.http.middlewares.comfyui-auth.forwardauth.address=http://authentik-outpost:9000/outpost.goauthentik.io/auth/traefik"
|
||||||
|
- "traefik.http.middlewares.comfyui-auth.forwardauth.trustForwardHeader=true"
|
||||||
|
- "traefik.http.middlewares.comfyui-auth.forwardauth.authResponseHeaders=X-authentik-username,X-authentik-groups,X-authentik-email,X-authentik-name"
|
||||||
|
- "xiteng.site.component.comfyui.enabled=true"
|
||||||
|
- "xiteng.site.component.comfyui.name=ComfyUI"
|
||||||
|
- "xiteng.site.component.comfyui.description=节点式图像生成、模型与工作流实验环境。"
|
||||||
|
- "xiteng.site.component.comfyui.section=services"
|
||||||
|
- "xiteng.site.component.comfyui.category=AI"
|
||||||
|
- "xiteng.site.component.comfyui.url=https://comfy.xiteng.site"
|
||||||
|
- "xiteng.site.component.comfyui.access=sso"
|
||||||
|
- "xiteng.site.component.comfyui.access-label=需要 Authentik"
|
||||||
|
- "xiteng.site.component.comfyui.icon=CU"
|
||||||
|
- "xiteng.site.component.comfyui.icon-url=https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/comfyui.svg"
|
||||||
|
- "xiteng.site.component.comfyui.accent=red"
|
||||||
|
- "xiteng.site.component.comfyui.order=210"
|
||||||
|
- "xiteng.site.component.comfyui.monitor.enabled=true"
|
||||||
|
- "xiteng.site.component.comfyui.monitor.url=http://comfyui:8188"
|
||||||
|
|
||||||
|
networks:
|
||||||
|
homelab_net:
|
||||||
|
external: true
|
||||||
@@ -0,0 +1,103 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
set -e
|
||||||
|
|
||||||
|
echo "Creating directories for models..."
|
||||||
|
MODEL_DIRECTORIES=(
|
||||||
|
"checkpoints"
|
||||||
|
"clip"
|
||||||
|
"clip_vision"
|
||||||
|
"configs"
|
||||||
|
"controlnet"
|
||||||
|
"diffusers"
|
||||||
|
"diffusion_models"
|
||||||
|
"embeddings"
|
||||||
|
"gligen"
|
||||||
|
"hypernetworks"
|
||||||
|
"loras"
|
||||||
|
"photomaker"
|
||||||
|
"style_models"
|
||||||
|
"text_encoders"
|
||||||
|
"unet"
|
||||||
|
"upscale_models"
|
||||||
|
"vae"
|
||||||
|
"vae_approx"
|
||||||
|
)
|
||||||
|
for MODEL_DIRECTORY in "${MODEL_DIRECTORIES[@]}"; do
|
||||||
|
mkdir -p "/opt/comfyui/models/$MODEL_DIRECTORY"
|
||||||
|
done
|
||||||
|
|
||||||
|
echo "Preparing ComfyUI Manager..."
|
||||||
|
export PYTHONPATH="/opt/comfyui-manager${PYTHONPATH:+:$PYTHONPATH}"
|
||||||
|
rm --force /opt/comfyui/custom_nodes/ComfyUI-Manager
|
||||||
|
ln -s /opt/comfyui-manager /opt/comfyui/custom_nodes/ComfyUI-Manager
|
||||||
|
mkdir -p /opt/comfyui/user/__manager
|
||||||
|
python - <<'PY'
|
||||||
|
from configparser import ConfigParser
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
config_path = Path("/opt/comfyui/user/__manager/config.ini")
|
||||||
|
config = ConfigParser(strict=False)
|
||||||
|
config.read(config_path)
|
||||||
|
if "default" not in config:
|
||||||
|
config["default"] = {}
|
||||||
|
|
||||||
|
defaults = {
|
||||||
|
"git_exe": "",
|
||||||
|
"use_uv": "True",
|
||||||
|
"channel_url": "https://raw.githubusercontent.com/ltdrdata/ComfyUI-Manager/main",
|
||||||
|
"share_option": "all",
|
||||||
|
"bypass_ssl": "False",
|
||||||
|
"file_logging": "True",
|
||||||
|
"update_policy": "stable-comfyui",
|
||||||
|
"windows_selector_event_loop_policy": "False",
|
||||||
|
"model_download_by_agent": "False",
|
||||||
|
"downgrade_blacklist": "",
|
||||||
|
"security_level": "normal",
|
||||||
|
"always_lazy_install": "False",
|
||||||
|
"verbose": "False",
|
||||||
|
}
|
||||||
|
for key, value in defaults.items():
|
||||||
|
config["default"].setdefault(key, value)
|
||||||
|
|
||||||
|
config["default"]["network_mode"] = "offline"
|
||||||
|
config["default"]["db_mode"] = "local"
|
||||||
|
|
||||||
|
with config_path.open("w", encoding="utf-8") as handle:
|
||||||
|
config.write(handle)
|
||||||
|
PY
|
||||||
|
|
||||||
|
echo "Installing requirements for custom nodes..."
|
||||||
|
for CUSTOM_NODE_DIRECTORY in /opt/comfyui/custom_nodes/*; do
|
||||||
|
if [ "$CUSTOM_NODE_DIRECTORY" != "/opt/comfyui/custom_nodes/ComfyUI-Manager" ]; then
|
||||||
|
if [ -f "$CUSTOM_NODE_DIRECTORY/requirements.txt" ]; then
|
||||||
|
CUSTOM_NODE_NAME=${CUSTOM_NODE_DIRECTORY##*/}
|
||||||
|
CUSTOM_NODE_NAME=${CUSTOM_NODE_NAME//[-_]/ }
|
||||||
|
echo "Installing requirements for $CUSTOM_NODE_NAME..."
|
||||||
|
pip install --requirement "$CUSTOM_NODE_DIRECTORY/requirements.txt"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
|
if [ -z "$USER_ID" ] || [ -z "$GROUP_ID" ]; then
|
||||||
|
echo "Running container as $USER..."
|
||||||
|
exec /opt/conda/bin/python main.py \
|
||||||
|
--port 8188 \
|
||||||
|
--listen 0.0.0.0 \
|
||||||
|
--disable-auto-launch \
|
||||||
|
"$@"
|
||||||
|
else
|
||||||
|
echo "Creating non-root user..."
|
||||||
|
getent group "$GROUP_ID" > /dev/null 2>&1 || groupadd --gid "$GROUP_ID" comfyui-user
|
||||||
|
id -u "$USER_ID" > /dev/null 2>&1 || useradd --uid "$USER_ID" --gid "$GROUP_ID" --create-home comfyui-user
|
||||||
|
chown --recursive "$USER_ID:$GROUP_ID" /opt/comfyui
|
||||||
|
chown --recursive "$USER_ID:$GROUP_ID" /opt/comfyui-manager
|
||||||
|
export PATH=$PATH:/home/comfyui-user/.local/bin
|
||||||
|
|
||||||
|
echo "Running container as comfyui-user ($USER_ID:$GROUP_ID)..."
|
||||||
|
sudo --set-home --preserve-env=PATH,PYTHONPATH --user "#$USER_ID" \
|
||||||
|
/opt/conda/bin/python main.py \
|
||||||
|
--port 8188 \
|
||||||
|
--listen 0.0.0.0 \
|
||||||
|
--disable-auto-launch \
|
||||||
|
"$@"
|
||||||
|
fi
|
||||||
@@ -2,6 +2,4 @@ networks:
|
|||||||
homelab_net:
|
homelab_net:
|
||||||
driver: bridge
|
driver: bridge
|
||||||
|
|
||||||
services:
|
services: {}
|
||||||
# 这里只定义网络和基础服务,具体服务在各自子目录
|
|
||||||
# 可扩展如 traefik/nginx-proxy-manager 等
|
|
||||||
|
|||||||
@@ -0,0 +1,45 @@
|
|||||||
|
services:
|
||||||
|
deck:
|
||||||
|
image: nginx:alpine@sha256:4a73073bd557c65b759505da037898b61f1be6cbcc3c2c3aeac22d2a470c1752
|
||||||
|
container_name: deck
|
||||||
|
restart: unless-stopped
|
||||||
|
volumes:
|
||||||
|
- ./index.html:/usr/share/nginx/html/index.html:ro
|
||||||
|
networks:
|
||||||
|
- homelab_net
|
||||||
|
labels:
|
||||||
|
# Traefik
|
||||||
|
- "traefik.enable=true"
|
||||||
|
- "traefik.http.routers.deck.rule=Host(`deck.xiteng.site`)"
|
||||||
|
- "traefik.http.routers.deck.entrypoints=websecure"
|
||||||
|
- "traefik.http.routers.deck.tls=true"
|
||||||
|
- "traefik.http.routers.deck.tls.certresolver=cfresolver"
|
||||||
|
- "traefik.http.routers.deck.service=deck"
|
||||||
|
- "traefik.http.services.deck.loadbalancer.server.port=80"
|
||||||
|
- "traefik.http.routers.deck-http.rule=Host(`deck.xiteng.site`)"
|
||||||
|
- "traefik.http.routers.deck-http.service=deck"
|
||||||
|
- "traefik.http.routers.deck-http.entrypoints=web"
|
||||||
|
- "xiteng.site.cache.deck.enabled=true"
|
||||||
|
- "xiteng.site.cache.deck.routers=deck,deck-http"
|
||||||
|
- "xiteng.site.cache.deck.paths=/assets/,/static/,/css/,/js/"
|
||||||
|
- "xiteng.site.cache.deck.edge-ttl=604800"
|
||||||
|
- "xiteng.site.cache.deck.stale-while-revalidate=86400"
|
||||||
|
- "xiteng.site.component.deck.enabled=true"
|
||||||
|
- "xiteng.site.component.deck.name=Xiteng Deck"
|
||||||
|
- "xiteng.site.component.deck.description=介绍个人基础设施与 xiteng.site 的公开演示文稿。"
|
||||||
|
- "xiteng.site.component.deck.section=services"
|
||||||
|
- "xiteng.site.component.deck.category=作品与实验"
|
||||||
|
- "xiteng.site.component.deck.url=https://deck.xiteng.site"
|
||||||
|
- "xiteng.site.component.deck.access=public"
|
||||||
|
- "xiteng.site.component.deck.access-label=无需登录"
|
||||||
|
- "xiteng.site.component.deck.icon=DK"
|
||||||
|
- "xiteng.site.component.deck.accent=red"
|
||||||
|
- "xiteng.site.component.deck.order=310"
|
||||||
|
- "xiteng.site.component.deck.navigation=new-tab"
|
||||||
|
- "xiteng.site.component.deck.portal-link=embedded"
|
||||||
|
- "xiteng.site.component.deck.monitor.enabled=true"
|
||||||
|
- "xiteng.site.component.deck.monitor.url=http://deck:80"
|
||||||
|
|
||||||
|
networks:
|
||||||
|
homelab_net:
|
||||||
|
external: true
|
||||||
@@ -0,0 +1,522 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="zh-CN">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>xiteng.site — Personal Infrastructure</title>
|
||||||
|
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&family=JetBrains+Mono:wght@400;500;700&display=swap" rel="stylesheet">
|
||||||
|
<style>
|
||||||
|
:root {
|
||||||
|
--bg: #050508;
|
||||||
|
--surface: #0a0a14;
|
||||||
|
--card: rgba(255,255,255,0.025);
|
||||||
|
--card-hover: rgba(255,255,255,0.05);
|
||||||
|
--text: #e4e4ec;
|
||||||
|
--text-dim: #8888a0;
|
||||||
|
--text-muted: #5a5a72;
|
||||||
|
--accent: #6366f1;
|
||||||
|
--accent-glow: rgba(99,102,241,0.35);
|
||||||
|
--green: #22c55e;
|
||||||
|
--green-glow: rgba(34,197,94,0.3);
|
||||||
|
--amber: #f59e0b;
|
||||||
|
--blue: #3b82f6;
|
||||||
|
--red: #ef4444;
|
||||||
|
--border: rgba(255,255,255,0.06);
|
||||||
|
--border-visible: rgba(255,255,255,0.10);
|
||||||
|
--grid: rgba(255,255,255,0.025);
|
||||||
|
}
|
||||||
|
*,*::before,*::after{box-sizing:border-box;margin:0;padding:0}
|
||||||
|
html{scroll-behavior:smooth;background:var(--bg);-webkit-text-size-adjust:100%}
|
||||||
|
body{
|
||||||
|
font-family:'Inter',system-ui,-apple-system,sans-serif;
|
||||||
|
background:var(--bg);color:var(--text);
|
||||||
|
overflow-x:hidden;line-height:1.6;
|
||||||
|
-webkit-font-smoothing:antialiased;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Canvas Background ── */
|
||||||
|
#gridCanvas{position:fixed;inset:0;z-index:0;pointer-events:none;opacity:0.6}
|
||||||
|
|
||||||
|
/* ── Status Bar ── */
|
||||||
|
.statusbar{
|
||||||
|
position:fixed;top:0;left:0;right:0;z-index:100;
|
||||||
|
height:36px;display:flex;align-items:center;justify-content:space-between;
|
||||||
|
padding:0 20px;font-family:'JetBrains Mono',monospace;
|
||||||
|
font-size:11px;color:var(--text-muted);
|
||||||
|
background:rgba(5,5,8,0.85);
|
||||||
|
backdrop-filter:blur(12px);
|
||||||
|
border-bottom:1px solid var(--border);
|
||||||
|
}
|
||||||
|
.statusbar a{display:inline-flex;align-items:center;color:var(--text-dim);text-decoration:none;font-weight:600}
|
||||||
|
.statusbar a:hover{color:var(--text)}
|
||||||
|
.statusbar .left{display:flex;align-items:center;gap:16px}
|
||||||
|
.statusbar .dot{
|
||||||
|
width:7px;height:7px;border-radius:50%;background:var(--green);
|
||||||
|
box-shadow:0 0 6px var(--green-glow);
|
||||||
|
margin-right:8px;display:inline-block;vertical-align:middle;
|
||||||
|
animation:pulse-dot 2s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
@keyframes pulse-dot{0%,100%{opacity:1}50%{opacity:0.5}}
|
||||||
|
.statusbar .right{display:flex;gap:16px}
|
||||||
|
|
||||||
|
/* ── Layout ── */
|
||||||
|
.container{position:relative;z-index:1;max-width:1100px;margin:0 auto;padding:0 24px}
|
||||||
|
section{padding:100px 0;position:relative}
|
||||||
|
section:first-of-type{padding-top:160px}
|
||||||
|
|
||||||
|
/* ── Typography ── */
|
||||||
|
.label{
|
||||||
|
font-family:'JetBrains Mono',monospace;
|
||||||
|
font-size:12px;font-weight:500;color:var(--accent);
|
||||||
|
text-transform:uppercase;letter-spacing:0.08em;margin-bottom:16px;display:block;
|
||||||
|
}
|
||||||
|
h1{
|
||||||
|
font-size:clamp(40px,6vw,80px);font-weight:700;
|
||||||
|
line-height:1.05;letter-spacing:-0.03em;margin-bottom:16px;
|
||||||
|
}
|
||||||
|
h1 .hl{color:var(--accent)}
|
||||||
|
h2{
|
||||||
|
font-size:clamp(28px,3.5vw,48px);font-weight:600;
|
||||||
|
line-height:1.15;letter-spacing:-0.02em;margin-bottom:12px;
|
||||||
|
}
|
||||||
|
.subtitle{
|
||||||
|
font-size:clamp(15px,1.4vw,20px);font-weight:300;color:var(--text-dim);
|
||||||
|
max-width:560px;line-height:1.7;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Hero ── */
|
||||||
|
.hero-grid{
|
||||||
|
display:grid;grid-template-columns:repeat(3,1fr);gap:12px;
|
||||||
|
margin-top:48px;max-width:600px;
|
||||||
|
}
|
||||||
|
.hero-stat{
|
||||||
|
background:var(--card);border:1px solid var(--border);
|
||||||
|
border-radius:8px;padding:16px 18px;
|
||||||
|
}
|
||||||
|
.hero-stat .val{
|
||||||
|
font-family:'JetBrains Mono',monospace;
|
||||||
|
font-size:22px;font-weight:700;color:var(--text);
|
||||||
|
}
|
||||||
|
.hero-stat .val.up{color:var(--green)}
|
||||||
|
.hero-stat .lbl{font-size:11px;color:var(--text-muted);margin-top:2px;text-transform:uppercase;letter-spacing:0.05em}
|
||||||
|
.hero-stat .val .unit{font-size:13px;color:var(--text-dim);font-weight:400}
|
||||||
|
|
||||||
|
/* ── Terminal Block ── */
|
||||||
|
.terminal{
|
||||||
|
background:rgba(0,0,0,0.5);border:1px solid var(--border-visible);
|
||||||
|
border-radius:10px;overflow:hidden;font-family:'JetBrains Mono',monospace;
|
||||||
|
font-size:clamp(11px,0.8vw,13px);line-height:1.8;
|
||||||
|
}
|
||||||
|
.terminal .bar{
|
||||||
|
height:28px;background:rgba(255,255,255,0.03);
|
||||||
|
display:flex;align-items:center;padding:0 12px;gap:6px;
|
||||||
|
border-bottom:1px solid var(--border);
|
||||||
|
}
|
||||||
|
.terminal .bar .tb{width:10px;height:10px;border-radius:50%}
|
||||||
|
.terminal .bar .tb.r{background:var(--red)}
|
||||||
|
.terminal .bar .tb.y{background:var(--amber)}
|
||||||
|
.terminal .bar .tb.g{background:var(--green)}
|
||||||
|
.terminal .bar .ttl{font-size:11px;color:var(--text-muted);margin-left:8px}
|
||||||
|
.terminal .body{padding:16px 18px;color:var(--text-dim)}
|
||||||
|
.terminal .body .prompt{color:var(--green)}
|
||||||
|
.terminal .body .cmd{color:var(--text)}
|
||||||
|
.terminal .body .out{color:var(--text-dim)}
|
||||||
|
.terminal .body .cursor{
|
||||||
|
display:inline-block;width:8px;height:15px;background:var(--accent);
|
||||||
|
vertical-align:text-bottom;margin-left:1px;
|
||||||
|
animation:blink 1s step-end infinite;
|
||||||
|
}
|
||||||
|
@keyframes blink{50%{opacity:0}}
|
||||||
|
|
||||||
|
/* ── Architecture Diagram ── */
|
||||||
|
.arch-diagram{
|
||||||
|
display:flex;align-items:center;gap:0;flex-wrap:wrap;
|
||||||
|
justify-content:center;padding:20px 0;
|
||||||
|
}
|
||||||
|
.arch-node{
|
||||||
|
background:var(--surface);border:1px solid var(--border-visible);
|
||||||
|
border-radius:12px;padding:20px 28px;text-align:center;
|
||||||
|
transition:all 0.3s;position:relative;
|
||||||
|
}
|
||||||
|
.arch-node:hover{
|
||||||
|
border-color:var(--accent);
|
||||||
|
box-shadow:0 0 24px var(--accent-glow);
|
||||||
|
transform:translateY(-2px);
|
||||||
|
}
|
||||||
|
.arch-node .n{font-size:18px;font-weight:600;color:var(--text)}
|
||||||
|
.arch-node .d{font-size:11px;color:var(--text-muted);margin-top:3px;font-family:'JetBrains Mono',monospace}
|
||||||
|
.arch-node.accent{border-color:var(--accent);background:rgba(99,102,241,0.04)}
|
||||||
|
.arch-node.accent .n{color:var(--accent)}
|
||||||
|
.arch-arrow{color:var(--text-muted);font-size:18px;padding:0 10px;font-family:'JetBrains Mono',monospace;flex-shrink:0}
|
||||||
|
.arch-pulse{animation:arch-glow 2.5s ease-in-out infinite}
|
||||||
|
.arch-pulse:nth-child(2){animation-delay:0.5s}
|
||||||
|
.arch-pulse:nth-child(3){animation-delay:1s}
|
||||||
|
@keyframes arch-glow{0%,100%{box-shadow:0 0 0 rgba(99,102,241,0)}50%{box-shadow:0 0 20px var(--accent-glow)}}
|
||||||
|
|
||||||
|
/* ── Service Cards ── */
|
||||||
|
.card-grid{
|
||||||
|
display:grid;grid-template-columns:repeat(auto-fill,minmax(280px,1fr));
|
||||||
|
gap:16px;margin-top:40px;
|
||||||
|
}
|
||||||
|
.svc-card{
|
||||||
|
background:var(--card);border:1px solid var(--border);
|
||||||
|
border-radius:12px;padding:28px;cursor:pointer;
|
||||||
|
transition:all 0.3s;position:relative;overflow:hidden;
|
||||||
|
}
|
||||||
|
.svc-card::before{
|
||||||
|
content:'';position:absolute;inset:0;border-radius:12px;
|
||||||
|
background:radial-gradient(600px circle at var(--mx,50%) var(--my,50%),rgba(99,102,241,0.06),transparent 40%);
|
||||||
|
opacity:0;transition:opacity 0.3s;
|
||||||
|
}
|
||||||
|
.svc-card:hover::before{opacity:1}
|
||||||
|
.svc-card:hover{
|
||||||
|
border-color:var(--border-visible);
|
||||||
|
background:var(--card-hover);
|
||||||
|
transform:translateY(-2px);
|
||||||
|
}
|
||||||
|
.svc-card .indicator{
|
||||||
|
width:8px;height:8px;border-radius:50%;background:var(--green);
|
||||||
|
box-shadow:0 0 8px var(--green-glow);
|
||||||
|
display:inline-block;margin-bottom:16px;
|
||||||
|
animation:pulse-dot 3s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
.svc-card .indicator:nth-child(odd){animation-delay:1.5s}
|
||||||
|
.svc-card h3{font-size:20px;font-weight:600;margin-bottom:4px;letter-spacing:-0.01em}
|
||||||
|
.svc-card .url{
|
||||||
|
font-family:'JetBrains Mono',monospace;font-size:11px;
|
||||||
|
color:var(--text-muted);margin-bottom:12px;
|
||||||
|
}
|
||||||
|
.svc-card p{font-size:13px;color:var(--text-dim);line-height:1.6}
|
||||||
|
.svc-card .tags{margin-top:14px;display:flex;gap:6px;flex-wrap:wrap}
|
||||||
|
.svc-card .tag{
|
||||||
|
font-size:10px;font-weight:600;text-transform:uppercase;
|
||||||
|
letter-spacing:0.04em;padding:3px 8px;border-radius:4px;
|
||||||
|
color:var(--accent);background:rgba(99,102,241,0.08);
|
||||||
|
border:1px solid rgba(99,102,241,0.12);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Stack Pills ── */
|
||||||
|
.stack-section{display:flex;flex-direction:column;gap:20px;margin-top:40px}
|
||||||
|
.stack-row{display:flex;align-items:flex-start;gap:16px;flex-wrap:wrap}
|
||||||
|
.stack-label{
|
||||||
|
font-family:'JetBrains Mono',monospace;font-size:12px;font-weight:700;
|
||||||
|
color:var(--green);min-width:80px;text-align:right;padding-top:5px;
|
||||||
|
}
|
||||||
|
.stack-pills{display:flex;gap:8px;flex-wrap:wrap}
|
||||||
|
.stack-pill{
|
||||||
|
padding:6px 14px;border-radius:20px;font-size:13px;font-weight:500;
|
||||||
|
background:var(--card);border:1px solid var(--border);
|
||||||
|
color:var(--text-dim);transition:all 0.2s;
|
||||||
|
}
|
||||||
|
.stack-pill:hover{border-color:var(--border-visible);color:var(--text)}
|
||||||
|
.stack-pill.hl{border-color:var(--accent);color:var(--accent);background:rgba(99,102,241,0.06)}
|
||||||
|
|
||||||
|
/* ── CTA / Command Prompt ── */
|
||||||
|
.cmd-prompt{
|
||||||
|
background:var(--surface);border:1px solid var(--border-visible);
|
||||||
|
border-radius:12px;padding:20px 24px;display:flex;align-items:center;
|
||||||
|
gap:12px;font-family:'JetBrains Mono',monospace;font-size:15px;
|
||||||
|
max-width:560px;margin:0 auto;margin-top:48px;transition:all 0.3s;
|
||||||
|
}
|
||||||
|
.cmd-prompt:focus-within{border-color:var(--accent);box-shadow:0 0 20px var(--accent-glow)}
|
||||||
|
.cmd-prompt .pr{color:var(--green);flex-shrink:0}
|
||||||
|
.cmd-prompt input{
|
||||||
|
flex:1;background:none;border:none;outline:none;
|
||||||
|
color:var(--text);font:inherit;caret-color:var(--accent);
|
||||||
|
}
|
||||||
|
.cmd-prompt input::placeholder{color:var(--text-muted)}
|
||||||
|
|
||||||
|
/* ── Footer ── */
|
||||||
|
footer{
|
||||||
|
text-align:center;padding:60px 24px;border-top:1px solid var(--border);
|
||||||
|
font-family:'JetBrains Mono',monospace;font-size:12px;color:var(--text-muted);
|
||||||
|
}
|
||||||
|
footer a{color:var(--accent);text-decoration:none}
|
||||||
|
footer a:hover{text-decoration:underline}
|
||||||
|
|
||||||
|
/* ── Scroll Reveal ── */
|
||||||
|
.reveal{opacity:0;transform:translateY(30px);transition:all 0.7s cubic-bezier(0.16,1,0.3,1)}
|
||||||
|
.reveal.visible{opacity:1;transform:translateY(0)}
|
||||||
|
|
||||||
|
/* ── Responsive ── */
|
||||||
|
@media(max-width:768px){
|
||||||
|
section{padding:60px 0}
|
||||||
|
section:first-of-type{padding-top:120px}
|
||||||
|
.arch-diagram{flex-direction:column;gap:4px}
|
||||||
|
.arch-arrow{display:none}
|
||||||
|
.hero-grid{grid-template-columns:repeat(2,1fr)}
|
||||||
|
.stack-row{flex-direction:column;gap:6px}
|
||||||
|
.stack-label{text-align:left;min-width:auto}
|
||||||
|
.card-grid{grid-template-columns:1fr}
|
||||||
|
.statusbar .right{display:none}
|
||||||
|
}
|
||||||
|
@media(max-width:400px){
|
||||||
|
.hero-grid{grid-template-columns:1fr 1fr;gap:6px}
|
||||||
|
.hero-stat{padding:10px 12px}
|
||||||
|
.hero-stat .val{font-size:18px}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
|
||||||
|
<canvas id="gridCanvas"></canvas>
|
||||||
|
|
||||||
|
<!-- ═══ Status Bar ═══ -->
|
||||||
|
<header class="statusbar">
|
||||||
|
<div class="left"><a href="https://xiteng.site/?focus=deck#services">← xiteng.site</a><span><span class="dot"></span> all systems operational</span></div>
|
||||||
|
<div class="right">UTC+8 · Arch Linux · Docker · <span style="color:var(--green)">UP 42d</span></div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<main class="container">
|
||||||
|
|
||||||
|
<!-- ═══ Hero ═══ -->
|
||||||
|
<section>
|
||||||
|
<div class="reveal">
|
||||||
|
<span class="label">// Personal Infrastructure</span>
|
||||||
|
<h1><span class="hl">xiteng</span>.site</h1>
|
||||||
|
<p class="subtitle">
|
||||||
|
自建开发基础设施。一台 Linux 机器,Docker Compose 驱动,
|
||||||
|
Cloudflare Tunnel 接入,Authentik 统一认证。
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div class="reveal hero-grid">
|
||||||
|
<div class="hero-stat"><div class="val up">8</div><div class="lbl">Services</div></div>
|
||||||
|
<div class="hero-stat"><div class="val">1<span class="unit"> host</span></div><div class="lbl">Arch Linux</div></div>
|
||||||
|
<div class="hero-stat"><div class="val up">99.9<span class="unit">%</span></div><div class="lbl">Uptime (30d)</div></div>
|
||||||
|
</div>
|
||||||
|
<div class="reveal" style="margin-top:40px">
|
||||||
|
<div class="terminal">
|
||||||
|
<div class="bar"><div class="tb r"></div><div class="tb y"></div><div class="tb g"></div><span class="ttl">ssh aliyun — bash — 80×24</span></div>
|
||||||
|
<div class="body">
|
||||||
|
<span class="prompt">$</span> <span class="cmd">docker compose ps</span><br>
|
||||||
|
<span class="out">NAME STATUS<br>
|
||||||
|
gitea Up 7 days<br>
|
||||||
|
hedgedoc Up 7 days<br>
|
||||||
|
seaweedfs Up 7 days<br>
|
||||||
|
uptime-kuma Up 7 days<br>
|
||||||
|
homepage Up 7 days<br>
|
||||||
|
authentik Up 7 days<br>
|
||||||
|
deck Up 5 minutes<br>
|
||||||
|
cloudflared Up 7 days</span><br>
|
||||||
|
<span class="prompt">$</span> <span class="cursor"></span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- ═══ Architecture ═══ -->
|
||||||
|
<section>
|
||||||
|
<div class="reveal"><span class="label">// How It Works</span></div>
|
||||||
|
<div class="reveal"><h2>Traffic Flow</h2></div>
|
||||||
|
<div class="reveal arch-diagram">
|
||||||
|
<div class="arch-node"><div class="n">Internet</div><div class="d">HTTPS</div></div>
|
||||||
|
<div class="arch-arrow">→</div>
|
||||||
|
<div class="arch-node accent arch-pulse"><div class="n">Cloudflare</div><div class="d">Tunnel · CDN</div></div>
|
||||||
|
<div class="arch-arrow">→</div>
|
||||||
|
<div class="arch-node arch-pulse"><div class="n">Traefik</div><div class="d">TLS · Router</div></div>
|
||||||
|
<div class="arch-arrow">→</div>
|
||||||
|
<div class="arch-node accent arch-pulse"><div class="n">Authentik</div><div class="d">SSO · Auth</div></div>
|
||||||
|
<div class="arch-arrow">→</div>
|
||||||
|
<div class="arch-node"><div class="n">Services</div><div class="d">8 containers</div></div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- ═══ Services ═══ -->
|
||||||
|
<section>
|
||||||
|
<div class="reveal"><span class="label">// Services</span></div>
|
||||||
|
<div class="reveal"><h2>What's Running</h2></div>
|
||||||
|
<div class="reveal card-grid">
|
||||||
|
<a href="https://git.xiteng.site" class="svc-card" style="text-decoration:none;color:inherit">
|
||||||
|
<div class="indicator"></div>
|
||||||
|
<h3>Gitea</h3>
|
||||||
|
<div class="url">git.xiteng.site</div>
|
||||||
|
<p>自建 Git 托管。Pull Request、CI/CD Actions、包仓库、OAuth2 登录。</p>
|
||||||
|
<div class="tags"><span class="tag">git</span><span class="tag">ci/cd</span><span class="tag">oauth2</span></div>
|
||||||
|
</a>
|
||||||
|
<a href="https://notes.xiteng.site" class="svc-card" style="text-decoration:none;color:inherit">
|
||||||
|
<div class="indicator"></div>
|
||||||
|
<h3>HedgeDoc</h3>
|
||||||
|
<div class="url">notes.xiteng.site</div>
|
||||||
|
<p>实时协作 Markdown。Mermaid 图表、MathJax 公式、OIDC 登录。</p>
|
||||||
|
<div class="tags"><span class="tag">docs</span><span class="tag">collab</span><span class="tag">oidc</span></div>
|
||||||
|
</a>
|
||||||
|
<a href="https://s3.xiteng.site" class="svc-card" style="text-decoration:none;color:inherit">
|
||||||
|
<div class="indicator"></div>
|
||||||
|
<h3>SeaweedFS</h3>
|
||||||
|
<div class="url">s3.xiteng.site</div>
|
||||||
|
<p>S3 兼容对象存储。60MB 镜像,替代 MinIO。Filer UI + REST API。</p>
|
||||||
|
<div class="tags"><span class="tag">s3</span><span class="tag">storage</span></div>
|
||||||
|
</a>
|
||||||
|
<a href="https://status.xiteng.site" class="svc-card" style="text-decoration:none;color:inherit">
|
||||||
|
<div class="indicator"></div>
|
||||||
|
<h3>Uptime Kuma</h3>
|
||||||
|
<div class="url">status.xiteng.site</div>
|
||||||
|
<p>健康监控。60s 间隔探测,飞书/邮件告警,公开状态页。</p>
|
||||||
|
<div class="tags"><span class="tag">monitor</span><span class="tag">alerts</span></div>
|
||||||
|
</a>
|
||||||
|
<a href="https://xiteng.site" class="svc-card" style="text-decoration:none;color:inherit">
|
||||||
|
<div class="indicator"></div>
|
||||||
|
<h3>Homepage</h3>
|
||||||
|
<div class="url">xiteng.site</div>
|
||||||
|
<p>统一仪表盘。Docker 自动发现,书签 & 搜索,OIDC 登录。</p>
|
||||||
|
<div class="tags"><span class="tag">dashboard</span><span class="tag">oidc</span></div>
|
||||||
|
</a>
|
||||||
|
<a href="https://auth.xiteng.site" class="svc-card" style="text-decoration:none;color:inherit">
|
||||||
|
<div class="indicator"></div>
|
||||||
|
<h3>Authentik</h3>
|
||||||
|
<div class="url">auth.xiteng.site</div>
|
||||||
|
<p>身份认证平台。OAuth2/OIDC Provider,ForwardAuth 代理保护。</p>
|
||||||
|
<div class="tags"><span class="tag">sso</span><span class="tag">security</span></div>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- ═══ Stack ═══ -->
|
||||||
|
<section>
|
||||||
|
<div class="reveal"><span class="label">// Stack</span></div>
|
||||||
|
<div class="reveal"><h2>Technology</h2></div>
|
||||||
|
<div class="reveal stack-section">
|
||||||
|
<div class="stack-row">
|
||||||
|
<div class="stack-label">Infra</div>
|
||||||
|
<div class="stack-pills">
|
||||||
|
<span class="stack-pill hl">Docker Compose</span>
|
||||||
|
<span class="stack-pill">Arch Linux</span>
|
||||||
|
<span class="stack-pill">Cloudflare Tunnel</span>
|
||||||
|
<span class="stack-pill">frp Transit</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="stack-row">
|
||||||
|
<div class="stack-label">Proxy</div>
|
||||||
|
<div class="stack-pills">
|
||||||
|
<span class="stack-pill hl">Traefik v3</span>
|
||||||
|
<span class="stack-pill">Let's Encrypt</span>
|
||||||
|
<span class="stack-pill">Cloudflare DNS</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="stack-row">
|
||||||
|
<div class="stack-label">Data</div>
|
||||||
|
<div class="stack-pills">
|
||||||
|
<span class="stack-pill hl">PostgreSQL</span>
|
||||||
|
<span class="stack-pill">Redis</span>
|
||||||
|
<span class="stack-pill">SeaweedFS</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="stack-row">
|
||||||
|
<div class="stack-label">Connect</div>
|
||||||
|
<div class="stack-pills">
|
||||||
|
<span class="stack-pill hl">Authentik</span>
|
||||||
|
<span class="stack-pill">Hermes Agent</span>
|
||||||
|
<span class="stack-pill">Feishu Bot</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- ═══ CTA ═══ -->
|
||||||
|
<section style="text-align:center">
|
||||||
|
<div class="reveal">
|
||||||
|
<span class="label">// Try It</span>
|
||||||
|
<h2>Open a Terminal</h2>
|
||||||
|
<p class="subtitle" style="margin:0 auto 0 auto">访问任意服务,Authentik 统一认证。或输入命令探索。</p>
|
||||||
|
</div>
|
||||||
|
<div class="reveal cmd-prompt">
|
||||||
|
<span class="pr">$</span>
|
||||||
|
<input type="text" id="cmdInput" placeholder="输入 help 查看可用命令…" autocomplete="off" autocorrect="off" autocapitalize="off" spellcheck="false">
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<footer>
|
||||||
|
<a href="https://git.xiteng.site">git.xiteng.site</a> ·
|
||||||
|
<a href="https://notes.xiteng.site">notes</a> ·
|
||||||
|
<a href="https://status.xiteng.site">status</a> ·
|
||||||
|
<a href="https://xiteng.site">home</a> ·
|
||||||
|
Built with Hermes Agent
|
||||||
|
</footer>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
// ═══ Grid Canvas ═══
|
||||||
|
(function(){
|
||||||
|
const c=document.getElementById('gridCanvas'),x=c.getContext('2d');
|
||||||
|
let W,H,mx=-100,my=-100;
|
||||||
|
function resize(){W=c.width=window.innerWidth;H=c.height=window.innerHeight}
|
||||||
|
resize();window.addEventListener('resize',resize);
|
||||||
|
document.addEventListener('mousemove',e=>{mx=e.clientX;my=e.clientY});
|
||||||
|
|
||||||
|
const dots=[];
|
||||||
|
for(let i=0;i<80;i++)dots.push({x:Math.random()*2000,y:Math.random()*2000,r:1+Math.random()*1.5,vx:(Math.random()-0.5)*0.3,vy:(Math.random()-0.5)*0.3});
|
||||||
|
|
||||||
|
function draw(){
|
||||||
|
x.clearRect(0,0,W,H);
|
||||||
|
// Grid
|
||||||
|
x.strokeStyle='rgba(255,255,255,0.018)';x.lineWidth=0.5;
|
||||||
|
const s=50;
|
||||||
|
for(let gx=s;gx<W;gx+=s){x.beginPath();x.moveTo(gx,0);x.lineTo(gx,H);x.stroke()}
|
||||||
|
for(let gy=s;gy<H;gy+=s){x.beginPath();x.moveTo(0,gy);x.lineTo(W,gy);x.stroke()}
|
||||||
|
// Dots
|
||||||
|
dots.forEach(d=>{
|
||||||
|
d.x+=d.vx;d.y+=d.vy;
|
||||||
|
if(d.x<0)d.x=2000;if(d.x>2000)d.x=0;
|
||||||
|
if(d.y<0)d.y=2000;if(d.y>2000)d.y=0;
|
||||||
|
const dx=d.x-mx,dy=d.y-my,dist=Math.sqrt(dx*dx+dy*dy);
|
||||||
|
const glow=Math.max(0,1-dist/200);
|
||||||
|
const r=d.r+glow*2;
|
||||||
|
x.beginPath();x.arc(d.x,d.y,r,0,Math.PI*2);
|
||||||
|
const a=0.15+glow*0.4;
|
||||||
|
x.fillStyle=`rgba(99,102,241,${a})`;x.fill();
|
||||||
|
});
|
||||||
|
requestAnimationFrame(draw);
|
||||||
|
}
|
||||||
|
draw();
|
||||||
|
})();
|
||||||
|
|
||||||
|
// ═══ Scroll Reveal ═══
|
||||||
|
(function(){
|
||||||
|
const obs=new IntersectionObserver(entries=>{
|
||||||
|
entries.forEach(e=>{if(e.isIntersecting)e.target.classList.add('visible')});
|
||||||
|
},{threshold:0.15});
|
||||||
|
document.querySelectorAll('.reveal').forEach(el=>obs.observe(el));
|
||||||
|
})();
|
||||||
|
|
||||||
|
// ═══ Card Glow Follow ═══
|
||||||
|
document.querySelectorAll('.svc-card').forEach(card=>{
|
||||||
|
card.addEventListener('mousemove',e=>{
|
||||||
|
const r=card.getBoundingClientRect();
|
||||||
|
card.style.setProperty('--mx',(e.clientX-r.left)+'px');
|
||||||
|
card.style.setProperty('--my',(e.clientY-r.top)+'px');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ═══ Command Prompt ═══
|
||||||
|
(function(){
|
||||||
|
const input=document.getElementById('cmdInput');
|
||||||
|
const routes={
|
||||||
|
'git':'https://git.xiteng.site',
|
||||||
|
'notes':'https://notes.xiteng.site',
|
||||||
|
's3':'https://s3.xiteng.site',
|
||||||
|
'status':'https://status.xiteng.site',
|
||||||
|
'home':'https://xiteng.site',
|
||||||
|
'auth':'https://auth.xiteng.site',
|
||||||
|
'deck':'https://deck.xiteng.site',
|
||||||
|
'file':'https://file.xiteng.site',
|
||||||
|
};
|
||||||
|
input.addEventListener('keydown',e=>{
|
||||||
|
if(e.key!=='Enter')return;
|
||||||
|
const v=input.value.trim().toLowerCase();
|
||||||
|
input.value='';
|
||||||
|
if(v==='help'||v==='?'){
|
||||||
|
let msg='Available: ';
|
||||||
|
Object.keys(routes).forEach(k=>msg+=k+' ');
|
||||||
|
msg+='\nType a service name to navigate, or "help" to see this.';
|
||||||
|
alert(msg);
|
||||||
|
}else if(routes[v]){
|
||||||
|
window.open(routes[v],'_blank');
|
||||||
|
}else if(v){
|
||||||
|
alert(`Unknown command: ${v}\nType "help" to see available commands.`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
dynamic/*
|
||||||
|
!dynamic/.gitkeep
|
||||||
|
state/*
|
||||||
|
!state/.gitkeep
|
||||||
|
secrets/*
|
||||||
|
!secrets/.gitkeep
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
# Label-managed edge cache
|
||||||
|
|
||||||
|
Services opt paths into the shared edge cache with Docker labels. The controller discovers the
|
||||||
|
labels, writes higher-priority Traefik routers, and maintains a single Cloudflare Cache Rule that
|
||||||
|
makes the declared host/path pairs eligible for edge caching.
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
labels:
|
||||||
|
- "xiteng.site.cache.frontend.enabled=true"
|
||||||
|
- "xiteng.site.cache.frontend.routers=example,example-http"
|
||||||
|
- "xiteng.site.cache.frontend.paths=/static/,/assets/"
|
||||||
|
- "xiteng.site.cache.frontend.edge-ttl=604800"
|
||||||
|
- "xiteng.site.cache.frontend.browser-ttl=0"
|
||||||
|
- "xiteng.site.cache.frontend.stale-while-revalidate=86400"
|
||||||
|
```
|
||||||
|
|
||||||
|
The policy id (`frontend`) only needs to be unique inside one Compose service. `routers` refers to
|
||||||
|
Traefik Docker router names declared on the same service. When omitted, all explicit HTTP routers on
|
||||||
|
the service are used. Each router must declare its entrypoints and service so it can be referenced
|
||||||
|
from the file provider. Existing router middlewares are inherited, so request headers and origin-side
|
||||||
|
authentication are not silently removed.
|
||||||
|
|
||||||
|
| Field | Default | Behavior |
|
||||||
|
|---|---:|---|
|
||||||
|
| `enabled` | required | Only the exact value `true` enables a policy. |
|
||||||
|
| `routers` | auto | Comma-separated Traefik router names on the same container. |
|
||||||
|
| `paths` | required | Comma-separated directory prefixes. Each must start and end with `/`; `/`, traversal and encoded paths are rejected. |
|
||||||
|
| `hosts` | from `Host()` | Optional exact hostnames for complex router rules; hosts must be inside `xiteng.site`. |
|
||||||
|
| `edge-ttl` | `604800` | Cloudflare TTL in seconds, bounded to 60 seconds–365 days. |
|
||||||
|
| `browser-ttl` | `0` | When non-zero, also overrides the browser `Cache-Control` TTL. |
|
||||||
|
| `stale-while-revalidate` | `86400` | Stale revalidation window in seconds. |
|
||||||
|
|
||||||
|
The generated router only matches `GET` and `HEAD`, has priority 1000 above its base router, and adds
|
||||||
|
`Cloudflare-CDN-Cache-Control`. Cloudflare's managed rule makes extensionless files eligible for cache,
|
||||||
|
respects the TTL header for successful responses, and gives 3xx–5xx responses a zero edge TTL.
|
||||||
|
|
||||||
|
Declaring a path static is a security decision: every response below the prefix must be public and
|
||||||
|
independent of users, cookies, authorization headers, and request-specific data. Never label HTML,
|
||||||
|
API, callback, admin, download-token, or user-content paths. A Cloudflare cache hit is served before
|
||||||
|
Traefik/ForwardAuth, even though cache misses still inherit the base router's middlewares.
|
||||||
|
Removing a policy does not revoke copies already held by browsers or edge locations; choose bounded
|
||||||
|
TTLs and purge Cloudflare explicitly when previously public content must be withdrawn immediately.
|
||||||
|
|
||||||
|
## Cloudflare token
|
||||||
|
|
||||||
|
Place an untracked API token at `secrets/cloudflare_cache_api_token`. It needs access to the
|
||||||
|
`xiteng.site` zone and the permissions required to edit Cache Rules. The existing DNS-01 token is
|
||||||
|
intentionally not reused. The controller remains healthy without this file and reports
|
||||||
|
`waiting-for-token`; in that state Traefik headers and default-extension caching work, but arbitrary
|
||||||
|
extensionless URLs are not guaranteed to be cached by Cloudflare.
|
||||||
|
|
||||||
|
Create the token in Cloudflare, then install it without exposing it in shell history or chat:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
./edge-cache/configure-token
|
||||||
|
```
|
||||||
|
|
||||||
|
Inspect the generated rule without contacting Cloudflare:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
docker compose -f edge-cache/compose.yml exec controller \
|
||||||
|
node /app/cloudflare.mjs plan
|
||||||
|
```
|
||||||
|
|
||||||
|
The live controller automatically creates or updates only the rule whose description is
|
||||||
|
`Xiteng label-managed static paths`; it preserves all unrelated Cache Rules.
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
import {readFileSync} from "node:fs";
|
||||||
|
import {cloudflareRuleFromSpec, syncCloudflareRule} from "./controller.mjs";
|
||||||
|
|
||||||
|
const command = process.argv[2] || "plan";
|
||||||
|
const specPath = process.env.CLOUDFLARE_SPEC_PATH || "/state/cloudflare-cache-rules.json";
|
||||||
|
const tokenPath = process.env.CLOUDFLARE_API_TOKEN_FILE || "/run/secrets/cloudflare_cache_api_token";
|
||||||
|
const zoneId = process.env.CLOUDFLARE_ZONE_ID || "";
|
||||||
|
const spec = JSON.parse(readFileSync(specPath, "utf8"));
|
||||||
|
|
||||||
|
if (command === "plan") {
|
||||||
|
console.log(JSON.stringify(cloudflareRuleFromSpec(spec), null, 2));
|
||||||
|
} else if (["check", "apply"].includes(command)) {
|
||||||
|
const token = readFileSync(tokenPath, "utf8").trim();
|
||||||
|
const result = await syncCloudflareRule(spec, {zoneId, token, dryRun: command === "check"});
|
||||||
|
console.log(JSON.stringify(result, null, 2));
|
||||||
|
} else {
|
||||||
|
console.error("Usage: node cloudflare.mjs [plan|check|apply]");
|
||||||
|
process.exit(2);
|
||||||
|
}
|
||||||