commit 76ba500417a2e4e7f70f161c2f1efe29f799ed8d Author: renjue Date: Tue Jun 23 21:46:16 2026 +0800 Initial commit: Luminary AI Gateway OpenAI-compatible AI gateway with Vue admin UI, multi-provider egress, ingress key governance, monitoring, and security controls. Co-authored-by: Cursor diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..fe13c4d --- /dev/null +++ b/.dockerignore @@ -0,0 +1,34 @@ +# VCS / CI +.git +.gitignore +.gitea + +# Docs (not needed in image) +*.md +web/docs + +# Local runtime / secrets +data/ +.env +.env.* + +# Build artifacts +luminary +luminary-recover +.docker-bin/ +*.db +*.test +coverage/ + +# Frontend (rebuilt in Dockerfile) +web/node_modules +web/dist + +# IDE / editor +.idea +.vscode +.cursor + +# OS +.DS_Store +Thumbs.db diff --git a/.gitea/README.md b/.gitea/README.md new file mode 100644 index 0000000..5ac4542 --- /dev/null +++ b/.gitea/README.md @@ -0,0 +1,182 @@ +# Gitea Actions CI 模板(Go + Vue) + +复制整个 `.gitea/` 目录到新仓库即可启用 CI:**可选 go test** + **Docker 构建/推送**(Go 编译与 Vue 构建在 Dockerfile 内完成)。 + +## 项目约定 + +| 路径 | 说明 | +|------|------| +| `go.mod` | 仓库根目录 | +| `Dockerfile` | 仓库根目录,多阶段构建(含前端 `web/`) | +| `web/` | 可选;由 Dockerfile 内 `npm run build` 处理,CI 不再单独构建 | + +## Dockerfile 要求 + +工作流的 **Build image** 步骤在仓库根目录(或 Variable `DOCKERFILE` 指定路径)执行 `docker buildx build`,**不会**在 CI 里单独装 Node / 跑 `npm`。因此 Dockerfile 必须能独立完成构建与(运行时)启动。 + +### 基本要求 + +| 项 | 要求 | +|----|------| +| 位置 | 默认仓库根目录 `Dockerfile`;其他路径用 Variable `DOCKERFILE` | +| 构建上下文 | 仓库根目录 `.`(整个仓库会被 `COPY` 进镜像) | +| Go 版本 | `go.mod` 里 `go` 行与 `golang` 基础镜像大版本一致 | +| 多架构 | `go build` 须使用 `GOARCH="$TARGETARCH"`(buildx 注入);推荐 `CGO_ENABLED=0` | +| Go 模块代理 | **必须**在镜像内显式设置 `GOPROXY` / `GOSUMDB`(见下);容器内不会自动读取 `go.env` | +| 基础镜像加速 | 支持构建参数 `IMAGE_PREFIX`(CI 默认 `docker.1panel.live/library/`) | +| 前端(可选) | 存在 `web/` 时在 Dockerfile 内完成 `npm ci` + `npm run build` | + +### CI 自动传入的构建参数 + +| 参数 | 默认 | 说明 | +|------|------|------| +| `IMAGE_PREFIX` | `docker.1panel.live/library/` | 基础镜像前缀;`hub` 表示 Docker Hub | +| `GOPROXY` | `https://goproxy.cn,direct` | 与 workflow / Variable 一致 | +| `GOSUMDB` | `sum.golang.google.cn` | checksum 数据库 | + +buildx 还会注入 `TARGETARCH`、`BUILDPLATFORM` 等,无需在 workflow 里写。 + +### 推荐:Go + Vue 多阶段模板 + +```dockerfile +# syntax=docker/dockerfile:1 +ARG IMAGE_PREFIX= + +# 1) 前端(无 web/ 时可删整个 stage,并去掉 go-builder 里 COPY dist) +FROM --platform=$BUILDPLATFORM ${IMAGE_PREFIX}node:20-alpine AS web-builder +WORKDIR /src/web +COPY web/package.json web/package-lock.json web/.npmrc ./ +RUN npm ci +COPY web/ ./ +RUN npm run build + +# 2) Go 编译 +FROM --platform=$BUILDPLATFORM ${IMAGE_PREFIX}golang:1.25-alpine AS go-builder +ARG TARGETARCH +ARG GOPROXY=https://goproxy.cn,direct +ARG GOSUMDB=sum.golang.google.cn +ENV GOPROXY=${GOPROXY} GOSUMDB=${GOSUMDB} + +WORKDIR /src +COPY go.mod go.sum ./ +RUN --mount=type=cache,target=/go/pkg/mod go mod download + +COPY cmd/ ./cmd/ +COPY internal/ ./internal/ +# 若静态资源嵌入 Go:COPY --from=web-builder /src/web/dist/ ./path/to/static/ + +RUN --mount=type=cache,target=/go/pkg/mod \ + --mount=type=cache,target=/root/.cache/go-build \ + CGO_ENABLED=0 GOOS=linux GOARCH="$TARGETARCH" \ + go build -ldflags="-w -s" -o /app ./cmd/yourapp + +# 3) 运行镜像 +FROM ${IMAGE_PREFIX}alpine:3.20 +RUN apk add --no-cache ca-certificates tzdata +COPY --from=go-builder /app /usr/local/bin/yourapp +ENTRYPOINT ["/usr/local/bin/yourapp"] +``` + +按项目调整:`./cmd/yourapp`、静态资源路径、运行用户、`EXPOSE` / `VOLUME` 等。 + +### 仅 Go(无前端) + +删除 `web-builder` stage;`go-builder` 中不要 `COPY` 前端产物;其余 `GOPROXY` / `TARGETARCH` / `IMAGE_PREFIX` 要求相同。 + +### 前端 npm 源(国内) + +在 `web/.npmrc` 配置 registry,并在 Dockerfile 里与 `package.json` 一并 `COPY`: + +```ini +registry=https://registry.npmmirror.com +``` + +### 本地验证(与 CI 一致) + +```bash +docker buildx build --platform linux/amd64 \ + --build-arg IMAGE_PREFIX=docker.1panel.live/library/ \ + --build-arg GOPROXY=https://goproxy.cn,direct \ + -f Dockerfile -t myapp:test . +``` + +### 常见 Dockerfile 构建错误 + +| 报错 | 原因 | 处理 | +|------|------|------| +| `proxy.golang.org` timeout | 镜像内未设 `ENV GOPROXY` | go-builder 阶段加 `ARG`/`ENV GOPROXY` | +| `node` / Vite 版本不符 | 基础镜像 Node 过旧 | 使用 `node:20-alpine` 及以上 | +| 某架构 build 失败 | 未使用 `TARGETARCH` | `GOARCH="$TARGETARCH"` | +| 拉基础镜像 timeout | Hub 不可达 | `--build-arg IMAGE_PREFIX=docker.1panel.live/library/` 或 1Panel 配镜像加速 | + +## 一次性配置 + +1. **Runner**:部署 act_runner,标签含 `ubuntu-latest`,并挂载 `docker.sock`(见 [act-runner/README.md](act-runner/README.md))。 +2. **Secret**:仓库 Settings → Actions → Secrets,添加 `REGISTRY_TOKEN`(PAT,`write:package` 权限)。 +3. **Variable(推荐)**:若 runner 与 Gitea 同机、或 `gitea.server_url` 为内网地址,必须设置 `REGISTRY=你的公网域名`(如 `git.example.com`,**不要**填 `172.17.0.1:13827`)。 +4. **Gitea Registry**:服务端 `[packages] ENABLED = true`,`ROOT_URL` 正确;穿透场景建议 `PUBLIC_URL_DETECTION = never`(Gitea 1.26+)。 + +`REGISTRY` 未设置时,会从 `GITEA_ROOT_URL` 或 `gitea.server_url` 推断;均为内网地址时 workflow 会提前失败并提示。 + +## 可选 Variables + +仓库 Settings → Actions → Variables(留空则用默认值): + +| 变量 | 默认 | 说明 | +|------|------|------| +| `REGISTRY` | 见下方推断顺序 | **公网** Registry 主机名,如 `git.example.com`(勿用内网 IP:13827) | +| `GITEA_ROOT_URL` | (空) | 当 `gitea.server_url` 为内网时,可设 `https://git.example.com/` | +| `IMAGE_NAME` | 仓库名小写 | 镜像名,非 owner/repo 全路径 | +| `DOCKERFILE` | `Dockerfile` | Dockerfile 路径 | +| `DOCKER_PLATFORMS` | `linux/amd64,linux/arm64` | push 时 buildx 平台 | +| `GO_TEST_SCOPE` | `./...` | `go test` 包路径 | +| `RUN_GO_TEST` | (空,即运行) | 设 `false` 跳过 go test,仅 Docker 构建 | +| `DOCKER_IMAGE_PREFIX` | `1panel` | 基础镜像前缀;可选 `hub` / `daocloud` | +| `GOPROXY` | `https://goproxy.cn,direct` | Go 模块代理 | +| `GOSUMDB` | `sum.golang.google.cn` | Go checksum 数据库 | + +## 触发与镜像 tag + +- **pull_request**:go test(可关)+ 单架构 `docker build` 验证(不推送) +- **push main/master**:go test + 多架构构建并推送 `:latest`、`:sha-xxxxxxx` +- **push tag v\***:额外推送 `:v1.2.3` 等 + +示例(仓库 `rose_cat707/Prism`,Gitea 在 `git.example.com`): + +```text +git.example.com/rose_cat707/prism:latest +git.example.com/rose_cat707/prism:sha-35b3b48 +``` + +## 目录结构 + +```text +.gitea/ +├── README.md # 本文件 +├── workflows/ +│ └── ci.yml # 主工作流 +└── act-runner/ # runner 部署参考(可选) + ├── README.md + ├── config.yaml + ├── docker-compose.yml + └── .env.example +``` + +## 本地验证 Registry + +```bash +host=$(echo "https://你的-gitea-地址/" | sed -e 's|^https://||' -e 's|/.*||') +curl -s -D - "https://${host}/v2/" -o /dev/null | grep -i www-authenticate +docker pull "${host}/owner/image:latest" # 公开 Registry 无需 login +``` + +推送镜像(CI)仍需仓库 Secret `REGISTRY_TOKEN`(`write:package`)。仅拉取公开包不需要登录。 + +`realm` 应指向公网 Gitea 域名,而非 `127.0.0.1`。 + +## 常见 Registry 错误 + +| 报错 | 原因 | 处理 | +|------|------|------| +| `Get "https://172.17.0.1:13827/v2/"` HTTP/HTTPS | Variable `REGISTRY` 或 `gitea.server_url` 为内网地址 | 设 `REGISTRY=git.example.com` | +| token 指向 `127.0.0.1` | Gitea `realm` 配置错误 | `PUBLIC_URL_DETECTION=never` + 正确 `ROOT_URL` | diff --git a/.gitea/act-runner/.env.example b/.gitea/act-runner/.env.example new file mode 100644 index 0000000..06b3c00 --- /dev/null +++ b/.gitea/act-runner/.env.example @@ -0,0 +1,3 @@ +GITEA_INSTANCE_URL=https://git.example.com +GITEA_RUNNER_REGISTRATION_TOKEN=从仓库_Settings_Actions_Runners_复制 +GITEA_RUNNER_NAME=go-vue-ci-runner diff --git a/.gitea/act-runner/README.md b/.gitea/act-runner/README.md new file mode 100644 index 0000000..eb068a4 --- /dev/null +++ b/.gitea/act-runner/README.md @@ -0,0 +1,177 @@ +# act_runner 配置参考 + +工作流 `runs-on: ubuntu-latest`;`docker` job 额外使用 `catthehacker/ubuntu:act-22.04` 容器(含 Docker CLI)。 + +## 构建镜像必须:挂载 Docker Socket + +`docker` job 需要访问宿主机 Docker 引擎。在 **runner 所在机器** 的 `config.yaml` 中配置: + +```yaml +container: + options: -v /var/run/docker.sock:/var/run/docker.sock + valid_volumes: + - /var/run/docker.sock +``` + +推荐 job 镜像(含 Node + Docker CLI): + +```yaml +runner: + labels: + - "ubuntu-latest:docker://catthehacker/ubuntu:act-22.04" + - "ubuntu-22.04:docker://catthehacker/ubuntu:act-22.04" +``` + +修改后 **重启 runner**(例如 `docker restart ` 或重启 Gitea Runner 服务)。 + +## 1Panel 宿主机:配置 Docker 镜像加速(推荐) + +Runner 通过 `docker.sock` 使用宿主机 Docker。在 **1Panel** 中配置加速器后,普通 `docker pull` 会走加速;**buildx 多架构构建**仍建议配合 workflow 内的 `IMAGE_PREFIX`(默认 `docker.1panel.live`)。 + +1. 登录 1Panel → **容器** → **配置** +2. **镜像加速地址** 填入: + ```text + https://docker.1panel.live + ``` +3. 保存并 **重启 Docker** + +验证(在 runner 宿主机): + +```bash +docker info | grep -A5 'Registry Mirrors' +docker pull alpine:3.20 +``` + +等价 `daemon.json`: + +```json +{ + "registry-mirrors": ["https://docker.1panel.live"] +} +``` + +CI 工作流默认 `IMAGE_PREFIX=docker.1panel.live/library/`(buildkit 拉基础镜像)。若 1Panel 加速不可用,可在仓库 Variables 设 `DOCKER_IMAGE_PREFIX=daocloud` 或 `hub`。 + +参考:[1Panel 容器配置文档](https://1panel.cn/docs/user_manual/containers/setting) + +## 验证 + +在 runner 宿主机执行: + +```bash +docker info +ls -l /var/run/docker.sock +``` + +## 从零部署 runner(可选) + +```bash +cd .gitea/act-runner +cp .env.example .env # 填入 Registration Token +docker compose up -d +``` + +## GitHub Actions 镜像(替代 ghfast) + +日志里出现 `git clone 'https://ghfast.top/https://github.com/actions/checkout'` 说明 **runner 宿主机** 的 `config.yaml` 配置了 `github_mirror`,与仓库 workflow 无关。 + +在 runner 的 `config.yaml` 中修改(修改后重启 runner): + +```yaml +runner: + github_mirror: 'https://gitea.com' # 推荐 +``` + +常用替代方案: + +| `github_mirror` 值 | 说明 | +|------------------|------| +| `''`(留空) | 直连 `github.com`,网络可达时最简单 | +| `https://gitea.com` | Gitea 官方 actions 镜像,国内较稳 | +| `https://gitclone.com/github.com` | 第三方 GitHub 克隆镜像 | +| `https://ghfast.top/https://github.com` | 部分环境需代理认证,易报 `Proxy Authentication Required` | + +前提:Gitea `app.ini` 中 `[actions] DEFAULT_ACTIONS_URL = github`(默认)。 + +**不依赖 runner 镜像** 的写法(workflow 内写绝对 URL,仅改写 `github.com` 的请求): + +```yaml +uses: https://gitea.com/actions/checkout@v4 +``` + +本模板 `ci.yml` 已采用此写法。若其他 workflow 仍写 `uses: actions/checkout@v4`,仍需配置 `github_mirror` 或改为绝对 URL。 + +修改 `github_mirror` 后建议清理 runner 缓存目录(如 `/root/.cache/act`),否则旧缓存可能仍指向 ghfast。 + +## 常见错误 + +| 报错 | 原因 | 处理 | +|------|------|------| +| `registry-1.docker.io` i/o timeout | Docker Hub 不可达 | 1Panel 配 `docker.1panel.live`;Variable `DOCKER_IMAGE_PREFIX=1panel` | +| `ghfast.top` Proxy Authentication Required | runner `github_mirror` 指向 ghfast 且需代理认证 | 改 `github_mirror` 或删掉;见上文「GitHub Actions 镜像」 | +| `docker: command not found` | job 容器无 Docker CLI | 工作流已指定 act 镜像;或 runner 改用 catthehacker/ubuntu | +| `Cannot connect to Docker daemon` | 未挂载 docker.sock | 按上文修改 config.yaml 并重启 runner | +| `node not in PATH` | job 镜像无 Node | 标签映射改用 catthehacker/ubuntu:act-22.04 | +| `http: server gave HTTP response to HTTPS client` 且 token 指向 `127.0.0.1` | **Gitea Registry 配置/反代错误** | 见下文「Registry 登录失败」 | + +## Registry 登录失败(127.0.0.1 / HTTP vs HTTPS) + +若 `docker login git.rc707blog.top` 报错类似: + +```text +Get "https://127.0.0.1:xxxxx/v2/token?...": http: server gave HTTP response to HTTPS client +``` + +说明 Gitea 把 **Docker 认证 token 地址** 配成了本机内网地址,CI runner 访问不到。需在 **Gitea 服务器** 修复,而非改 workflow。 + +### 1. 检查 `app.ini` + +```ini +[server] +ROOT_URL = https://git.example.com/ +LOCAL_ROOT_URL = http://127.0.0.1:3000/ +; 内网穿透 / 错误 Host 时(Gitea 1.26+): +; PUBLIC_URL_DETECTION = never + +[packages] +ENABLED = true +``` + +`ROOT_URL` 必须与浏览器访问 Gitea 的 **HTTPS 外网地址** 完全一致(含末尾 `/`)。 + +### 2. 反向代理必须转发 `/v2` 并带上头 + +Container Registry 固定使用根路径 `/v2`。Nginx 示例: + +```nginx +location / { + client_max_body_size 0; + proxy_pass http://127.0.0.1:3000; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; +} +``` + +关键:`X-Forwarded-Proto: https` 和正确的 `Host`(与 Gitea `ROOT_URL` 域名一致)。 + +### 3. 在 runner 宿主机验证(修复后) + +```bash +GITEA_HOST=git.example.com # 改成你的 Gitea 域名 +curl -s -D - "https://${GITEA_HOST}/v2/" -o /dev/null | grep -i www-authenticate +echo "$REGISTRY_TOKEN" | docker login "${GITEA_HOST}" -u 你的用户名 --password-stdin +``` + +应返回 `401 Unauthorized`(正常,表示 registry 可达)且 `docker login` 显示 **Login Succeeded**。 + +参考:[Gitea 反向代理文档](https://docs.gitea.com/administration/reverse-proxies) + +## Gitea Runner v0.6.x(个人 runner) + +1. 找到 runner 的配置文件或环境(安装目录 / docker compose) +2. 确保 runner 进程能访问宿主机 `/var/run/docker.sock` +3. Runners 页标签含 `ubuntu-latest` 且状态 **空闲/在线** + +若使用 Gitea 网页注册的个人 runner(docker 模式),通常需在 runner 启动参数或 `config.yaml` 里加入 socket 挂载,具体路径取决于你的安装方式。 diff --git a/.gitea/act-runner/config.yaml b/.gitea/act-runner/config.yaml new file mode 100644 index 0000000..797596b --- /dev/null +++ b/.gitea/act-runner/config.yaml @@ -0,0 +1,34 @@ +# act_runner 配置(可选参考部署) +# 工作流 runs-on: ubuntu-latest + +log: + level: info + +runner: + file: .runner + capacity: 2 + timeout: 3h + insecure: false + fetch_timeout: 5s + fetch_interval: 2s + # 拉取 uses: actions/checkout@v4 等 GitHub Action 时的镜像(替换 https://github.com) + # 需 Gitea app.ini 中 [actions] DEFAULT_ACTIONS_URL = github + # 留空则直连 github.com;第三方镜像不稳定时可改用 workflow 绝对 URL(见 .gitea/README.md) + # + # github_mirror: '' # 直连 GitHub + # github_mirror: 'https://gitea.com' # 推荐:Gitea 官方 actions 镜像 + # github_mirror: 'https://gitclone.com/github.com' # 第三方 GitHub 克隆镜像 + # github_mirror: 'https://ghfast.top/https://github.com' # 需代理认证时易失败,不推荐 + labels: + - "ubuntu-latest:docker://catthehacker/ubuntu:act-22.04" + - "ubuntu-22.04:docker://catthehacker/ubuntu:act-22.04" + +cache: + enabled: false + +container: + network: bridge + privileged: false + options: -v /var/run/docker.sock:/var/run/docker.sock + valid_volumes: + - /var/run/docker.sock diff --git a/.gitea/act-runner/docker-compose.yml b/.gitea/act-runner/docker-compose.yml new file mode 100644 index 0000000..cd192f1 --- /dev/null +++ b/.gitea/act-runner/docker-compose.yml @@ -0,0 +1,22 @@ +# Gitea act_runner — 可选参考部署(标签 default,与工作流一致) +# +# 若已在 Gitea 注册个人/仓库 runner 且标签为 default,无需使用本目录。 + +services: + act-runner: + image: docker.io/gitea/act_runner:0.2.12 + container_name: prism-act-runner + restart: unless-stopped + environment: + CONFIG_FILE: /config.yaml + GITEA_INSTANCE_URL: ${GITEA_INSTANCE_URL:-https://git.rc707blog.top} + GITEA_RUNNER_REGISTRATION_TOKEN: ${GITEA_RUNNER_REGISTRATION_TOKEN} + GITEA_RUNNER_NAME: ${GITEA_RUNNER_NAME:-prism-ci-runner} + volumes: + - ./config.yaml:/config.yaml:ro + - act-runner-data:/data + - /var/run/docker.sock:/var/run/docker.sock + working_dir: /data + +volumes: + act-runner-data: diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml new file mode 100644 index 0000000..1500876 --- /dev/null +++ b/.gitea/workflows/ci.yml @@ -0,0 +1,236 @@ +# 通用 Go + Vue 项目 CI(Gitea Actions) +# +# 单 job:可选 go test + Docker 多架构构建/推送 +# 约定:go.mod、Dockerfile(详见 .gitea/README.md「Dockerfile 要求」);前端(可选)在 web/ +# +# 前置:act_runner(ubuntu-latest + docker.sock)、Secret REGISTRY_TOKEN +# Variables:REGISTRY、IMAGE_NAME、DOCKER_IMAGE_PREFIX、RUN_GO_TEST 等 + +name: CI + +on: + push: + branches: [main, master] + tags: ['v*'] + pull_request: + +env: + REGISTRY: ${{ vars.REGISTRY }} + GITEA_ROOT_URL: ${{ vars.GITEA_ROOT_URL }} + IMAGE_NAME: ${{ vars.IMAGE_NAME }} + DOCKERFILE: ${{ vars.DOCKERFILE }} + DOCKER_PLATFORMS: ${{ vars.DOCKER_PLATFORMS }} + GO_TEST_SCOPE: ${{ vars.GO_TEST_SCOPE }} + RUN_GO_TEST: ${{ vars.RUN_GO_TEST }} + DOCKER_IMAGE_PREFIX: ${{ vars.DOCKER_IMAGE_PREFIX }} + GOPROXY: ${{ vars.GOPROXY }} + GOSUMDB: ${{ vars.GOSUMDB }} + +jobs: + docker: + runs-on: ubuntu-latest + container: + image: catthehacker/ubuntu:act-22.04 + steps: + - name: Checkout + uses: https://gitea.com/actions/checkout@v4 + + - name: Run Go tests + if: vars.RUN_GO_TEST != 'false' + shell: bash + run: | + set -euo pipefail + export GOPROXY="${GOPROXY:-https://goproxy.cn,direct}" + export GOSUMDB="${GOSUMDB:-sum.golang.google.cn}" + + GO_VERSION=$(grep '^go ' go.mod | awk '{print $2}') + ARCH=$(uname -m | sed 's/x86_64/amd64/; s/aarch64/arm64/') + TAR="go${GO_VERSION}.linux-${ARCH}.tar.gz" + + if ! command -v go >/dev/null 2>&1 || ! go version | grep -q "go${GO_VERSION} "; then + downloaded=0 + for url in \ + "https://mirrors.aliyun.com/golang/${TAR}" \ + "https://golang.google.cn/dl/${TAR}" \ + "https://go.dev/dl/${TAR}"; do + echo "trying ${url}" + if curl -fsSL --connect-timeout 20 --retry 3 --retry-delay 5 --max-time 600 \ + "$url" -o /tmp/go.tgz; then + downloaded=1 + break + fi + done + [ "$downloaded" -eq 1 ] || { echo "failed to download Go ${GO_VERSION}" >&2; exit 1; } + rm -rf /usr/local/go + tar -C /usr/local -xzf /tmp/go.tgz + export PATH="/usr/local/go/bin:${PATH}" + fi + go version + go test "${GO_TEST_SCOPE:-./...}" + + - name: Setup Docker + run: | + set -eux + if ! command -v docker >/dev/null 2>&1; then + apt-get update + DEBIAN_FRONTEND=noninteractive apt-get install -y docker.io + fi + if [ ! -S /var/run/docker.sock ]; then + echo "ERROR: /var/run/docker.sock 未挂载到 job 容器。" >&2 + echo "请在 act_runner config.yaml 中配置:" >&2 + echo " container.options: -v /var/run/docker.sock:/var/run/docker.sock" >&2 + echo " container.valid_volumes: [/var/run/docker.sock]" >&2 + exit 1 + fi + docker info + + - name: Resolve registry + id: reg + if: gitea.event_name == 'push' + shell: bash + env: + GITEA_SERVER_URL: ${{ gitea.server_url }} + run: | + set -euo pipefail + + url_to_host() { + echo "$1" | sed -e 's|^https://||' -e 's|^http://||' -e 's|/.*||' + } + + is_internal_host() { + local host="${1%%:*}" + case "$host" in + localhost|127.*|10.*|192.168.*) return 0 ;; + 172.*) + local second + second=$(echo "$host" | cut -d. -f2) + [ "$second" -ge 16 ] && [ "$second" -le 31 ] + return + ;; + *) return 1 ;; + esac + } + + registry="${REGISTRY:-}" + if [ -z "$registry" ] && [ -n "${GITEA_ROOT_URL:-}" ]; then + registry=$(url_to_host "${GITEA_ROOT_URL}") + fi + if [ -z "$registry" ]; then + registry=$(url_to_host "${GITEA_SERVER_URL}") + fi + + if is_internal_host "$registry"; then + echo "ERROR: Registry 主机 '${registry}' 是内网地址。" >&2 + echo "请设置 Variable REGISTRY=公网 Gitea 域名(如 git.example.com)。" >&2 + exit 1 + fi + + echo "registry host: ${registry}" + + auth_header="" + if auth_header=$(curl -fsS -D - "https://${registry}/v2/" -o /dev/null 2>&1 | grep -i '^www-authenticate:'); then + if echo "$auth_header" | grep -qiE '127\.0\.0\.1|172\.(1[6-9]|2[0-9]|3[01])\.|localhost|:13827'; then + echo "ERROR: Registry token realm 指向内网地址:${auth_header}" >&2 + exit 1 + fi + fi + + echo "host=${registry}" >> "${GITHUB_OUTPUT}" + + - name: Log in to Container Registry + if: gitea.event_name == 'push' + shell: bash + run: | + set -euo pipefail + echo "${{ secrets.REGISTRY_TOKEN }}" | \ + docker login "${{ steps.reg.outputs.host }}" -u "${{ gitea.actor }}" --password-stdin + + - name: Build image + shell: bash + env: + GITEA_SHA: ${{ gitea.sha }} + GITEA_REF: ${{ gitea.ref }} + GITEA_REPOSITORY: ${{ gitea.repository }} + GITEA_REPOSITORY_OWNER: ${{ gitea.repository_owner }} + GITEA_EVENT_NAME: ${{ gitea.event_name }} + REGISTRY_HOST: ${{ steps.reg.outputs.host }} + run: | + set -euo pipefail + + dockerfile="${DOCKERFILE:-Dockerfile}" + + if [ "${DOCKER_IMAGE_PREFIX:-}" = "hub" ] || [ "${DOCKER_IMAGE_PREFIX:-}" = "docker.io" ]; then + image_prefix="" + elif [ -z "${DOCKER_IMAGE_PREFIX:-}" ] || [ "${DOCKER_IMAGE_PREFIX}" = "1panel" ]; then + image_prefix="docker.1panel.live/library/" + elif [ "${DOCKER_IMAGE_PREFIX}" = "daocloud" ]; then + image_prefix="docker.m.daocloud.io/library/" + else + image_prefix="${DOCKER_IMAGE_PREFIX}" + [[ "${image_prefix}" == */ ]] || image_prefix="${image_prefix}/" + fi + echo "using IMAGE_PREFIX=${image_prefix:-}" + build_args=( + --build-arg "IMAGE_PREFIX=${image_prefix}" + --build-arg "GOPROXY=${GOPROXY:-https://goproxy.cn,direct}" + --build-arg "GOSUMDB=${GOSUMDB:-sum.golang.google.cn}" + ) + + builder_id="gitea-buildx-${GITEA_REPOSITORY//\//-}" + + if [ "${GITEA_EVENT_NAME}" = "push" ]; then + install_binfmt() { + for img in \ + "docker.1panel.live/tonistiigi/binfmt:latest" \ + "docker.m.daocloud.io/tonistiigi/binfmt:latest" \ + "tonistiigi/binfmt:latest"; do + echo "trying binfmt image: ${img}" + if docker run --privileged --rm "${img}" --install all; then + return 0 + fi + done + return 1 + } + install_binfmt || true + + registry="${REGISTRY_HOST}" + image_name="${IMAGE_NAME:-$(echo "${GITEA_REPOSITORY##*/}" | tr '[:upper:]' '[:lower:]')}" + platforms="${DOCKER_PLATFORMS:-linux/amd64,linux/arm64}" + image="${registry}/${GITEA_REPOSITORY_OWNER}/${image_name}" + tags="${image}:sha-${GITEA_SHA:0:7}" + case "${GITEA_REF}" in + refs/heads/main|refs/heads/master) + tags="${tags},${image}:latest" + ;; + refs/tags/v*) + tags="${tags},${image}:${GITEA_REF#refs/tags/}" + ;; + esac + + docker buildx create --name "${builder_id}" --use 2>/dev/null || docker buildx use "${builder_id}" + docker buildx inspect --bootstrap + + tag_args=() + IFS=',' read -r -a tag_list <<< "$tags" + for t in "${tag_list[@]}"; do + tag_args+=(-t "$t") + done + + docker buildx build \ + --platform "${platforms}" \ + -f "${dockerfile}" \ + "${build_args[@]}" \ + "${tag_args[@]}" \ + --push \ + . + else + # PR:仅单架构构建验证 Dockerfile,不推送 + docker buildx create --name "${builder_id}" --use 2>/dev/null || docker buildx use "${builder_id}" + docker buildx inspect --bootstrap + docker buildx build \ + --platform linux/amd64 \ + -f "${dockerfile}" \ + "${build_args[@]}" \ + --load \ + . + fi diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..f6a427b --- /dev/null +++ b/.gitignore @@ -0,0 +1,43 @@ +# Binaries +/luminary +/luminary-recover +*.exe +*.exe~ +*.dll +*.so +*.dylib +*.test + +# Go +go.work +go.work.sum +*.out +coverage/ +*.coverprofile + +# Luminary runtime data +data/ +/data/ +*.db + +# Docker local prebuilt +.docker-bin/ + +# Frontend build output (keep placeholder for go:embed in CI / go test) +web/dist/* +!web/dist/index.html +web/node_modules/ + +# Environment +.env +.env.* +!.env.example + +# IDE / editor +.idea/ +.vscode/ +.cursor/ + +# OS +.DS_Store +Thumbs.db diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..b6d3d64 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,71 @@ +# Luminary AI Gateway(Go + Vue 管理台) +# +# 构建: +# docker build -t luminary:latest . +# +# 运行: +# docker run -d --name luminary \ +# -p 8293:8293 \ +# -v luminary-data:/data \ +# luminary:latest +# +# 生产镜像由 Gitea Actions 构建,见 README 与 .gitea/README.md + +# syntax=docker/dockerfile:1 + +# 基础镜像默认 Docker Hub;CI 默认 1Panel 加速(docker.1panel.live),本地可传: +# docker build --build-arg IMAGE_PREFIX=docker.1panel.live/library/ -t luminary:latest . +ARG IMAGE_PREFIX= + +FROM --platform=$BUILDPLATFORM ${IMAGE_PREFIX}node:20-alpine AS web-builder + +WORKDIR /src/web +COPY web/package.json web/package-lock.json web/.npmrc ./ +RUN npm ci +COPY web/src ./src +COPY web/public ./public +COPY web/index.html web/tsconfig.json web/vite.config.ts ./ +RUN npm run build + +FROM --platform=$BUILDPLATFORM ${IMAGE_PREFIX}golang:1.25-alpine AS go-builder + +ARG TARGETARCH=amd64 +ARG GOPROXY=https://goproxy.cn,direct +ARG GOSUMDB=sum.golang.google.cn +ENV CGO_ENABLED=0 GOPROXY=${GOPROXY} GOSUMDB=${GOSUMDB} + +WORKDIR /src +COPY go.mod go.sum ./ +RUN --mount=type=cache,target=/go/pkg/mod \ + go mod download + +COPY cmd/ ./cmd/ +COPY internal/ ./internal/ +COPY web/static_embed.go ./web/ +COPY --from=web-builder /src/web/dist ./web/dist + +RUN --mount=type=cache,target=/go/pkg/mod \ + --mount=type=cache,target=/root/.cache/go-build \ + GOOS=linux GOARCH="$TARGETARCH" \ + go build -trimpath -ldflags="-w -s" -o /luminary ./cmd/luminary \ + && go build -trimpath -ldflags="-w -s" -o /luminary-recover ./cmd/luminary-recover + +ARG IMAGE_PREFIX= +FROM ${IMAGE_PREFIX}alpine:3.20 + +ARG APK_MIRROR=mirrors.aliyun.com +RUN sed -i "s/dl-cdn.alpinelinux.org/${APK_MIRROR}/g" /etc/apk/repositories \ + && apk add --no-cache ca-certificates tzdata tini + +ENV LUMINARY_DATA=/data \ + LUMINARY_ADDR=:8293 + +COPY --from=go-builder /luminary /usr/local/bin/luminary +COPY --from=go-builder /luminary-recover /usr/local/bin/luminary-recover +COPY docker/entrypoint.sh /entrypoint.sh +COPY scripts/luminary-recover.sh /usr/local/bin/luminary-recover.sh +RUN chmod +x /entrypoint.sh /usr/local/bin/luminary-recover.sh + +VOLUME ["/data"] +EXPOSE 8293 +ENTRYPOINT ["/sbin/tini", "--", "/entrypoint.sh"] diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..22f4732 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 Luminary Contributors + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..795bca8 --- /dev/null +++ b/Makefile @@ -0,0 +1,63 @@ +.PHONY: dev build build-web run tidy test recover docker-build docker-push docker-push-prebuilt + +export GOPROXY ?= https://goproxy.cn,direct +export GOSUMDB ?= sum.golang.google.cn +export GOPRIVATE ?= git.rc707blog.top +export IMAGE_PREFIX ?= docker.1panel.live/library/ +export APK_MIRROR ?= mirrors.aliyun.com +export DOCKER_PLATFORM ?= linux/amd64 +export LUMINARY_IMAGE ?= registry.rc707blog.top/rose_cat707/luminary:latest + +dev: + @echo "Start backend: go run ./cmd/luminary" + @echo "Start frontend: cd web && npm run dev" + +build-web: + cd web && npm install --registry=https://registry.npmmirror.com && npm run build + +build: build-web + go build -o luminary ./cmd/luminary + +recover: + go build -o luminary-recover ./cmd/luminary-recover + +run: build + ./luminary + +tidy: + go mod tidy + +test: + go test ./... + +docker-build: + docker buildx build --platform $(DOCKER_PLATFORM) \ + -t $(LUMINARY_IMAGE) \ + --build-arg IMAGE_PREFIX=$(IMAGE_PREFIX) \ + --build-arg APK_MIRROR=$(APK_MIRROR) \ + --build-arg GOPROXY=$(GOPROXY) \ + --build-arg GOSUMDB=$(GOSUMDB) \ + --load \ + . + +docker-push: + docker buildx build --platform $(DOCKER_PLATFORM) \ + -t $(LUMINARY_IMAGE) \ + --build-arg IMAGE_PREFIX=$(IMAGE_PREFIX) \ + --build-arg APK_MIRROR=$(APK_MIRROR) \ + --build-arg GOPROXY=$(GOPROXY) \ + --build-arg GOSUMDB=$(GOSUMDB) \ + --push \ + . + +docker-push-prebuilt: build-web + mkdir -p .docker-bin + GOOS=linux GOARCH=amd64 CGO_ENABLED=0 go build -trimpath -ldflags="-s -w" -o .docker-bin/luminary ./cmd/luminary + GOOS=linux GOARCH=amd64 CGO_ENABLED=0 go build -trimpath -ldflags="-s -w" -o .docker-bin/luminary-recover ./cmd/luminary-recover + docker buildx build --platform $(DOCKER_PLATFORM) \ + -f docker/Dockerfile.prebuilt \ + -t $(LUMINARY_IMAGE) \ + --build-arg IMAGE_PREFIX=$(IMAGE_PREFIX) \ + --build-arg APK_MIRROR=$(APK_MIRROR) \ + --push \ + . diff --git a/README.md b/README.md new file mode 100644 index 0000000..103de80 --- /dev/null +++ b/README.md @@ -0,0 +1,357 @@ +# Luminary + +OpenAI 兼容 AI 网关,带 Web 管理台。支持多 Provider 出口、入口密钥治理、看板监控与安全管控。控制面自研(Go + SQLite),数据面提供 OpenAI 兼容 `/v1/*` 推理 API。 + +所有运行参数在 Web 管理台完成配置,**无需手写配置文件**。管理台支持**简体中文、英文**两种界面语言,可在顶栏随时切换。 + +## 界面预览 + +以下为 Web 管理台主要页面(点击可查看原图)。 + +| 登录 | 看板监控 | +|:---:|:---:| +| ![登录页](docs/image/登陆页.png) | ![看板监控](docs/image/看板监控.png) | +| 默认密码 `admin123`,首次登录后请修改 | Provider 健康、24h 请求/Token 与入口用量排行 | + +| 出口管理 | 入口管理 | +|:---:|:---:| +| ![出口管理](docs/image/出口管理.png) | ![入口管理](docs/image/入口管理.png) | +| 多 Provider、多 Key 负载均衡与故障转移 | 入口密钥(`sk-lum-*`)、路由规则与预算限流 | + +| 请求日志 | IP 规则 | +|:---:|:---:| +| ![请求日志](docs/image/请求日志.png) | ![IP 规则](docs/image/IP规则.png) | +| 全量请求记录与成本估算 | 精确 / CIDR 白黑名单(admin / proxy 范围) | + +| 系统设置 | | +|:---:|:---:| +| ![系统设置](docs/image/系统设置.png) | | +| 加密密钥、会话、登录限流、健康检查与重试策略 | | + +## 功能概览 + +| 模块 | 说明 | +|------|------| +| 出口管理 | OpenAI / Anthropic / Azure OpenAI / OpenRouter / Ollama / Custom,多 Key 加权负载均衡与故障转移 | +| 入口管理 | OpenAI 兼容 API,入口密钥(`sk-lum-*`),Virtual Key 路由、预算与 RPM/TPM 限流 | +| 看板监控 | Provider 健康检查、24h 请求/Token 统计、入口用量排行 | +| 请求日志 | 全量请求记录、按模型定价的成本估算 | +| IP 安全 | 管理员与推理入口分范围的白名单 / 黑名单(精确、CIDR) | +| 系统设置 | 加密密钥、信任代理、会话、登录限流、用量刷盘、网关重试 | + +## 架构 + +``` +客户端 SDK / curl + → /v1/*(入口密钥 sk-lum-*) + → Luminary Gateway(路由、限流、故障转移) + → Provider 出口(多 Key 加权负载均衡) + ↑ + 管理台 REST API + Vue UI (:8293) + ↓ + SQLite + 内存缓存 +``` + +## 快速开始(开发) + +### 依赖 + +- Go 1.25+(见 `go.mod`) +- Node.js 20+(仅开发/构建前端) + +依赖镜像(已写入 `Makefile`、`web/.npmrc`,国内网络友好): + +- Go:`GOPROXY=https://goproxy.cn,direct` +- npm:`registry=https://registry.npmmirror.com` + +海外用户可将 `GOPROXY` 改为 `https://proxy.golang.org,direct`,npm 使用默认 registry 即可。 + +### 启动 + +```bash +cd web && npm install # 安装前端依赖 + +# 终端 1:后端 +go run ./cmd/luminary + +# 终端 2:前端(Vite 代理 /api、/v1 → :8293) +cd web && npm run dev +``` + +访问 http://localhost:5173 ,默认账号 `admin` / `admin123`(**首次登录后请立即修改**)。 + +```bash +go test ./... # 运行单元测试 +make build # 构建 luminary 二进制(含嵌入前端) +``` + +## 部署 + +镜像由 Gitea Actions 自动构建并发布到 Container Registry,**无需自行编译**。直接拉取运行即可。 + +```bash +docker pull git.rc707blog.top/rose_cat707/luminary:latest +``` + +| Tag | 说明 | +|-----|------| +| `latest` | `main` / `master` 分支最新构建 | +| `sha-xxxxxxx` | 对应 commit 短 SHA | +| `v1.2.3` | 推送 Git tag `v1.2.3` 时发布 | + +### docker run + +```bash +docker pull git.rc707blog.top/rose_cat707/luminary:latest + +docker run -d --name luminary \ + --restart unless-stopped \ + -p 8293:8293 \ + -v luminary-data:/data \ + git.rc707blog.top/rose_cat707/luminary:latest +``` + +或使用项目内 `docker compose up -d`(通过环境变量 `LUMINARY_IMAGE` 指定镜像)。 + +### 本地自行构建 + +```bash +make docker-build +# 或 +docker build \ + --build-arg IMAGE_PREFIX=docker.1panel.live/library/ \ + --build-arg GOPROXY=https://goproxy.cn,direct \ + -t luminary:local . +``` + +**端口(默认)** + +| 服务 | 端口 | 说明 | +|------|------|------| +| 管理 API / Web UI / 推理入口 | 8293 | 管理台与 `/v1/*` 共用同一监听地址 | + +监听地址可通过启动参数 `-addr` 或环境变量 `LUMINARY_ADDR` 修改。 + +## 数据目录 + +数据默认存储在 `./data/luminary.db`(SQLite),Docker 部署时挂载为 `/data`: + +``` +data/ +└── luminary.db # Provider、入口密钥、规则、日志、系统设置 +``` + +首次启动自动创建数据库与默认系统设置(含随机加密密钥)。默认管理账号 `admin` / `admin123`(仅当数据库中尚无管理员时创建)。 + +## 入口 API(推理) + +客户端使用在管理台「入口管理」创建的**入口密钥**(`sk-lum-*`)调用,与 OpenAI SDK 兼容。 + +**鉴权** + +```http +Authorization: Bearer sk-lum- +``` + +**Base URL**:`http://:8293/v1` + +| 方法 | 路径 | 说明 | 状态 | +|------|------|------|------| +| `GET` | `/v1/models` | 列出当前入口密钥可用的模型 | 已支持 | +| `POST` | `/v1/chat/completions` | 对话补全;`stream: true` 时返回 SSE 流式 | 已支持 | +| `POST` | `/v1/responses` | OpenAI Responses API;`stream: true` 时返回 SSE 流式 | 已支持 | +| `POST` | `/v1/completions` | 文本补全(Legacy) | 已支持 | +| `POST` | `/v1/embeddings` | 向量嵌入 | 已支持 | +| `POST` | `/v1/rerank` | Cohere 兼容重排序 | 已支持 | +| `POST` | `/v1/audio/speech` | 语音合成 | 预留 | +| `POST` | `/v1/audio/transcriptions` | 语音转写 | 预留 | +| `POST` | `/v1/images/generations` | 图像生成 | 预留 | + +> 实际可访问的接口取决于上游 Provider 在「出口管理」中启用的 endpoint;未启用的接口网关不会转发。 +> +> **Responses 适配**:若出口仅启用 `chat_completion` 而未启用 `responses`,网关会将 `/v1/responses` 自动转换为 `/v1/chat/completions` 请求,并将响应转回 Responses API 格式(含流式)。 + +**示例:对话补全** + +```bash +curl http://localhost:8293/v1/chat/completions \ + -H "Authorization: Bearer sk-lum-..." \ + -H "Content-Type: application/json" \ + -d '{"model":"gpt-4o-mini","messages":[{"role":"user","content":"Hello"}]}' +``` + +**示例:流式对话** + +```bash +curl http://localhost:8293/v1/chat/completions \ + -H "Authorization: Bearer sk-lum-..." \ + -H "Content-Type: application/json" \ + -d '{"model":"gpt-4o-mini","stream":true,"messages":[{"role":"user","content":"Hello"}]}' +``` + +**示例:Embeddings** + +```bash +curl http://localhost:8293/v1/embeddings \ + -H "Authorization: Bearer sk-lum-..." \ + -H "Content-Type: application/json" \ + -d '{"model":"text-embedding-3-small","input":"hello"}' +``` + +**示例:Rerank(Cohere 兼容)** + +```bash +curl http://localhost:8293/v1/rerank \ + -H "Authorization: Bearer sk-lum-..." \ + -H "Content-Type: application/json" \ + -d '{"model":"bge-reranker-v2-m3","query":"What is the capital of France?","documents":["Paris is the capital of France.","Berlin is in Germany."],"top_n":2}' +``` + +**示例:列出模型** + +```bash +curl http://localhost:8293/v1/models \ + -H "Authorization: Bearer sk-lum-..." +``` + +入口密钥的 Provider / Model 白名单、预算、RPM/TPM 限流与路由规则均在管理台「入口管理」配置;白名单为空表示不限制。 + +## 使用攻略(简要) + +### 1. 首次登录 + +1. 打开 `http://<主机>:8293` +2. 默认账号 `admin` / `admin123`,登录后进入「系统设置」修改密码 +3. 顶栏切换界面语言(中文 / English) + +### 2. 配置出口 + +1. 「出口管理」→ 新建 Provider,选择类型并填写 Base URL +2. 添加 API Key,配置加权与故障转移 +3. 启用所需 endpoint 分类(语言模型 / 向量嵌入 / 重排序 / 图像等) + +### 3. 配置入口 + +1. 「入口管理」→ 新建入口密钥(`sk-lum-*`) +2. 配置 Provider / Model 白名单、预算与 RPM/TPM 限流 +3. 按需设置 Virtual Key 路由规则 + +### 4. IP 安全 + +1. 「IP 规则」添加白名单 / 黑名单(支持 CIDR) +2. 规则可按 `admin`(管理台)或 `proxy`(推理入口)范围生效 + +### 5. 日常运维 + +| 操作 | 位置 | +|------|------| +| 查看流量与健康 | 看板监控 | +| 管理 Provider 与 Key | 出口管理 | +| 管理入口密钥 | 入口管理 | +| 查看请求与成本 | 请求日志 | +| 修改运行参数 | 系统设置 | + +## 系统设置 + +所有运行参数在管理台 **系统设置** 页面维护,存入 SQLite。 + +| 设置项 | 说明 | +|------|------| +| 加密密钥 | Provider API Key 加密密钥(首次启动自动生成) | +| 信任代理 | 信任的反向代理 IP/CIDR;仅此时才读取 `X-Forwarded-For` | +| 会话 / 登录限制 | 管理员 Session 有效期、登录尝试次数与锁定时长 | +| 用量刷盘 / 健康检查 | 后台任务间隔 | +| 网关重试 | 上游故障时的重试次数与退避 | + +启动参数(非阻塞,仅决定进程如何监听与数据存放位置): + +| 参数 / 环境变量 | 说明 | 默认 | +|------|------|------| +| `-data-dir` / `LUMINARY_DATA` | SQLite 数据目录 | `./data` | +| `-addr` / `LUMINARY_ADDR` | HTTP 监听地址 | `:8293` | + +可选环境变量 `LUMINARY_ENCRYPTION_KEY`:仅在首次初始化系统设置时写入加密密钥(一般无需设置,管理台可改)。 + +## 应急恢复(容器内) + +当 IP 白名单或登录冷却导致无法进入管理台时,可在容器内执行恢复工具(已内置在镜像中): + +```bash +# 一键:重置密码为 admin123、清空 IP 规则与会话,并重启进程(清除内存中的登录冷却) +docker exec luminary luminary-recover.sh --all + +# 指定新密码 +docker exec luminary luminary-recover.sh --all --password 'your-new-password' + +# 分项执行(不重启) +docker exec luminary luminary-recover.sh \ + --reset-password --clear-ip --clear-sessions --password 'your-new-password' + +# 仅清除登录冷却(需重启进程;登录冷却存在内存中) +docker exec luminary luminary-recover.sh --restart +``` + +本地非容器环境: + +```bash +chmod +x scripts/luminary-recover.sh +./scripts/luminary-recover.sh --all +# 或 +go run ./cmd/luminary-recover --all +``` + +| 参数 | 说明 | +|------|------| +| `--all` | 重置密码 + 清空 IP 规则 + 清空会话 + 重启进程 | +| `--reset-password` | 重置管理员密码 | +| `--clear-ip` | 删除全部 IP 规则 | +| `--clear-sessions` | 删除全部管理员 Session | +| `--restart` | 向 PID 1 发送 SIGTERM,由容器重启策略拉起新进程 | +| `--password` | 新密码(默认 `admin123`) | +| `--username` | 管理员用户名(默认 `admin`) | +| `-data-dir` / `LUMINARY_DATA` | 数据目录(默认 `./data`,容器内为 `/data`) | + +## 命令行 + +| 命令 | 说明 | +|------|------| +| `luminary` | 启动网关(默认 `-data-dir ./data`、`-addr :8293`) | +| `luminary-recover` | 应急恢复 CLI(容器内 `luminary-recover.sh` 封装) | + +## 项目结构 + +``` +cmd/luminary/ # 主服务入口 +cmd/luminary-recover/ # 应急恢复 CLI +internal/ + gateway/ # 路由、故障转移、健康检查 + handler/ # 管理 API、推理代理、静态资源 + provider/ # 各 Provider 客户端与适配 + store/ # SQLite 与内存缓存 + auth/ # 密码、登录限流 + middleware/ # IP 过滤、客户端 IP + monitor/ # 监控统计 + pricing/ # 成本估算 +web/ # Vue 3 管理台 + src/i18n/ # 多语言资源 + src/views/ # 页面 +docs/ + image/ # README 界面截图 + UI-DESIGN-SYSTEM.md +.gitea/workflows/ # Gitea Actions CI +``` + +## CI / 镜像发布 + +推送 `main` / `master` 或 tag `v*` 时,Gitea Actions 构建多架构镜像并推送到 Registry。工作流与 Dockerfile 约定见 [.gitea/README.md](.gitea/README.md);act_runner 部署参考见 [.gitea/act-runner/](.gitea/act-runner/)。 + +**一次性配置**(仓库 Settings → Actions): + +| 类型 | 名称 | 说明 | +|------|------|------| +| Secret | `REGISTRY_TOKEN` | Gitea PAT,需 `write:package` 权限 | +| Variable | `REGISTRY` | 公网 Gitea 域名,如 `git.rc707blog.top` | + +## 许可 + +[Luminary 采用 MIT License](LICENSE). diff --git a/cmd/luminary-recover/main.go b/cmd/luminary-recover/main.go new file mode 100644 index 0000000..8969f6e --- /dev/null +++ b/cmd/luminary-recover/main.go @@ -0,0 +1,156 @@ +// luminary-recover: emergency recovery CLI (reset password, clear IP rules, restart). +// Intended for docker exec when admin login is locked out. +package main + +import ( + "flag" + "fmt" + "os" + "path/filepath" + "syscall" + "time" + + "github.com/glebarez/sqlite" + "github.com/rose_cat707/luminary/internal/auth" + "github.com/rose_cat707/luminary/internal/model" + "github.com/rose_cat707/luminary/internal/settings" + "gorm.io/gorm" + "gorm.io/gorm/logger" +) + +func main() { + os.Exit(run()) +} + +func run() int { + dataDir := flag.String("data-dir", envOr("LUMINARY_DATA", "./data"), "directory containing luminary.db") + deprecatedConfig := flag.String("config", "", "deprecated: ignored; use -data-dir / LUMINARY_DATA") + username := flag.String("username", "", "admin username (default: admin)") + password := flag.String("password", "", "new admin password (default: admin123)") + doAll := flag.Bool("all", false, "reset password, clear IP rules and sessions, then restart") + doPassword := flag.Bool("reset-password", false, "reset admin password") + doIP := flag.Bool("clear-ip", false, "delete all IP rules") + doSessions := flag.Bool("clear-sessions", false, "delete all admin sessions") + doRestart := flag.Bool("restart", false, "send SIGTERM to PID 1 to restart server (clears login cooldown)") + flag.Parse() + if *deprecatedConfig != "" { + fmt.Fprintln(os.Stderr, "warning: -config is deprecated and ignored; use -data-dir / LUMINARY_DATA") + } + + if !*doAll && !*doPassword && !*doIP && !*doSessions && !*doRestart { + fmt.Fprintln(os.Stderr, "usage: luminary-recover --all [--password NEW] [--restart]") + fmt.Fprintln(os.Stderr, " luminary-recover --reset-password --clear-ip --clear-sessions [--restart]") + flag.PrintDefaults() + return 2 + } + + user := *username + pass := *password + if user == "" || pass == "" { + legacyUser, legacyPass := settings.LegacyAdminCredentials() + if user == "" { + user = legacyUser + } + if pass == "" { + pass = legacyPass + } + } + + if *doAll { + *doPassword = true + *doIP = true + *doSessions = true + *doRestart = true + } + + dbPath := filepath.Join(*dataDir, "luminary.db") + db, err := openDB(dbPath) + if err != nil { + fmt.Fprintf(os.Stderr, "open database: %v\n", err) + return 1 + } + + if *doPassword { + if pass == "" { + fmt.Fprintln(os.Stderr, "password is required (use --password)") + return 1 + } + if err := resetPassword(db, user, pass); err != nil { + fmt.Fprintf(os.Stderr, "reset password: %v\n", err) + return 1 + } + fmt.Printf("admin password reset for user %q\n", user) + } + + if *doIP { + res := db.Exec("DELETE FROM ip_rules") + if res.Error != nil { + fmt.Fprintf(os.Stderr, "clear IP rules: %v\n", res.Error) + return 1 + } + fmt.Printf("cleared %d IP rule(s)\n", res.RowsAffected) + } + + if *doSessions { + res := db.Exec("DELETE FROM admin_sessions") + if res.Error != nil { + fmt.Fprintf(os.Stderr, "clear sessions: %v\n", res.Error) + return 1 + } + fmt.Printf("cleared %d admin session(s)\n", res.RowsAffected) + } + + if *doRestart { + fmt.Println("sending SIGTERM to PID 1 (restart server to clear in-memory login cooldown)...") + if err := syscall.Kill(1, syscall.SIGTERM); err != nil { + fmt.Fprintf(os.Stderr, "restart: %v (you may need to restart the container manually)\n", err) + return 1 + } + } + + fmt.Println("done") + return 0 +} + +func openDB(path string) (*gorm.DB, error) { + dsn := path + "?_pragma=journal_mode(WAL)&_pragma=busy_timeout(5000)" + db, err := gorm.Open(sqlite.Open(dsn), &gorm.Config{ + Logger: logger.Default.LogMode(logger.Silent), + }) + if err != nil { + return nil, err + } + sqlDB, err := db.DB() + if err != nil { + return nil, err + } + sqlDB.SetMaxOpenConns(1) + return db, nil +} + +func resetPassword(db *gorm.DB, username, password string) error { + hash, err := auth.HashPassword(password) + if err != nil { + return err + } + var user model.AdminUser + err = db.Where("username = ?", username).First(&user).Error + if err == gorm.ErrRecordNotFound { + return db.Create(&model.AdminUser{ + Username: username, + PasswordHash: hash, + CreatedAt: time.Now(), + }).Error + } + if err != nil { + return err + } + return db.Model(&user).Update("password_hash", hash).Error +} + +func envOr(key, fallback string) string { + if v := os.Getenv(key); v != "" { + return v + } + return fallback +} diff --git a/cmd/luminary/main.go b/cmd/luminary/main.go new file mode 100644 index 0000000..4ff4c1f --- /dev/null +++ b/cmd/luminary/main.go @@ -0,0 +1,129 @@ +package main + +import ( + "flag" + "log" + "os" + "os/signal" + "path/filepath" + "strings" + "syscall" + + "github.com/gin-gonic/gin" + webembed "github.com/rose_cat707/luminary/web" + "github.com/rose_cat707/luminary/internal/gateway" + "github.com/rose_cat707/luminary/internal/handler/admin" + "github.com/rose_cat707/luminary/internal/handler/files" + "github.com/rose_cat707/luminary/internal/handler/proxy" + "github.com/rose_cat707/luminary/internal/handler/replicate" + "github.com/rose_cat707/luminary/internal/handler/static" + "github.com/rose_cat707/luminary/internal/middleware" + "github.com/rose_cat707/luminary/internal/model" + "github.com/rose_cat707/luminary/internal/monitor" + "github.com/rose_cat707/luminary/internal/prediction" + "github.com/rose_cat707/luminary/internal/settings" + "github.com/rose_cat707/luminary/internal/storage/imagestore" + "github.com/rose_cat707/luminary/internal/store/cache" + sqlitestore "github.com/rose_cat707/luminary/internal/store/sqlite" +) + +func main() { + dataDir := flag.String("data-dir", envOr("LUMINARY_DATA", "./data"), "directory for SQLite database") + addr := flag.String("addr", envOr("LUMINARY_ADDR", ":8293"), "HTTP listen address") + deprecatedConfig := flag.String("config", "", "deprecated: ignored; settings are stored in the database") + flag.Parse() + if *deprecatedConfig != "" { + log.Printf("warning: -config is deprecated and ignored; use -data-dir / LUMINARY_DATA and admin UI settings") + } + + if err := os.MkdirAll(*dataDir, 0o755); err != nil { + log.Fatalf("data dir: %v", err) + } + dbPath := filepath.Join(*dataDir, "luminary.db") + + db, err := sqlitestore.New(dbPath) + if err != nil { + log.Fatalf("database: %v", err) + } + + rt := settings.NewRuntime(db.DB()) + if err := rt.Load(); err != nil { + log.Fatalf("settings: %v", err) + } + + memCache := cache.New() + repo := sqlitestore.NewRepository(db, memCache) + if err := repo.EnsureAdmin(); err != nil { + log.Fatalf("admin bootstrap: %v", err) + } + if err := repo.BootstrapFromDB(); err != nil { + log.Fatalf("cache bootstrap: %v", err) + } + rt.AfterLoad() + + imageDir := filepath.Join(*dataDir, rt.ImageStoragePath()) + publicURL := func() string { + if u := rt.PublicBaseURL(); u != "" { + return u + } + a := *addr + if strings.HasPrefix(a, ":") { + a = "localhost" + a + } + return "http://" + a + } + imgStore := imagestore.New(db.DB(), imageDir, publicURL(), rt.EncryptionKey(), rt.SignedURLTTL()) + _ = imgStore.EnsureDir() + + predSvc := prediction.NewService(db.DB(), memCache, imgStore, publicURL(), rt.PredictionWaitSeconds()) + + gw := gateway.New(memCache, rt) + mon := monitor.New(gw, memCache, db, rt) + mon.Start(repo.FlushUsage) + + gin.SetMode(gin.ReleaseMode) + r := gin.New() + r.Use(middleware.Logger(), gin.Recovery()) + + r.GET("/health", func(c *gin.Context) { + c.JSON(200, gin.H{"status": "ok"}) + }) + + adminHandler := admin.New(repo, db, memCache, gw, rt, mon, predSvc, imgStore, publicURL) + adminGroup := r.Group("/api/admin", middleware.IPFilter(memCache, model.IPScopeAdmin)) + adminHandler.Register(adminGroup) + + proxyHandler := proxy.New(gw) + v1 := r.Group("/v1", middleware.IPFilter(memCache, model.IPScopeProxy), middleware.IngressAuth(gw)) + proxyHandler.Register(v1) + + replicateHandler := replicate.New(predSvc, publicURL) + replicateHandler.Register(v1) + + filesHandler := files.New(imgStore) + filesHandler.Register(r) + + staticHandler := static.New(webembed.WebFS) + staticHandler.Register(r) + + go func() { + log.Printf("Luminary listening on %s (data=%s)", *addr, *dataDir) + if err := r.Run(*addr); err != nil { + log.Fatalf("server: %v", err) + } + }() + + quit := make(chan os.Signal, 1) + signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM) + <-quit + log.Println("shutting down...") + mon.Stop() + repo.FlushUsage(memCache.DrainUsageDeltas()) +} + +func envOr(key, fallback string) string { + if v := os.Getenv(key); v != "" { + return v + } + return fallback +} diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..7b0697e --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,23 @@ +services: + luminary: + build: + context: . + dockerfile: Dockerfile + args: + IMAGE_PREFIX: docker.1panel.live/library/ + APK_MIRROR: mirrors.aliyun.com + GOPROXY: https://goproxy.cn,direct + GOSUMDB: sum.golang.google.cn + image: ${LUMINARY_IMAGE:-registry.rc707blog.top/rose_cat707/luminary:latest} + container_name: luminary + restart: unless-stopped + ports: + - "8293:8293" + environment: + LUMINARY_DATA: /data + LUMINARY_ADDR: ":8293" + volumes: + - luminary-data:/data + +volumes: + luminary-data: diff --git a/docker/Dockerfile.prebuilt b/docker/Dockerfile.prebuilt new file mode 100644 index 0000000..b53f361 --- /dev/null +++ b/docker/Dockerfile.prebuilt @@ -0,0 +1,20 @@ +# Runtime image using locally cross-compiled linux/amd64 binaries (avoids QEMU segfault in buildx). +ARG IMAGE_PREFIX=docker.1panel.live/library/ +ARG APK_MIRROR=mirrors.aliyun.com +FROM ${IMAGE_PREFIX}alpine:3.20 AS runtime +ARG APK_MIRROR=mirrors.aliyun.com +RUN sed -i "s/dl-cdn.alpinelinux.org/${APK_MIRROR}/g" /etc/apk/repositories \ + && apk add --no-cache ca-certificates tini + +ENV LUMINARY_DATA=/data \ + LUMINARY_ADDR=:8293 + +COPY .docker-bin/luminary /usr/local/bin/luminary +COPY .docker-bin/luminary-recover /usr/local/bin/luminary-recover +COPY docker/entrypoint.sh /entrypoint.sh +COPY scripts/luminary-recover.sh /usr/local/bin/luminary-recover.sh +RUN chmod +x /entrypoint.sh /usr/local/bin/luminary-recover.sh + +VOLUME ["/data"] +EXPOSE 8293 +ENTRYPOINT ["/sbin/tini", "--", "/entrypoint.sh"] diff --git a/docker/entrypoint.sh b/docker/entrypoint.sh new file mode 100644 index 0000000..873eab4 --- /dev/null +++ b/docker/entrypoint.sh @@ -0,0 +1,10 @@ +#!/bin/sh +set -eu + +DATA_DIR="${LUMINARY_DATA:-/data}" +ADDR="${LUMINARY_ADDR:-:8293}" + +mkdir -p "$DATA_DIR" + +echo "[luminary] $(date '+%Y-%m-%d %H:%M:%S') starting (addr=${ADDR}, data=${DATA_DIR})" +exec /usr/local/bin/luminary -data-dir "$DATA_DIR" -addr "$ADDR" diff --git a/docs/UI-DESIGN-SYSTEM.md b/docs/UI-DESIGN-SYSTEM.md new file mode 100644 index 0000000..c6adb10 --- /dev/null +++ b/docs/UI-DESIGN-SYSTEM.md @@ -0,0 +1,2013 @@ +# UI 设计规范 + +> 适用范围:基于 Vue 3 + Element Plus 2.x 的后台管理系统 Web 页面 +> **本文档为唯一设计规范**:色彩、间距、布局、组件样式、全局 CSS 均定义于本文件(§6 分模块说明 + §8.9 完整 CSS)。项目实施时从本文档复制代码即可,不依赖其他设计资产文件。 + +--- + +## 目录 + +1. [页面整体风格](#1-页面整体风格) +2. [布局标准](#2-布局标准) +3. [字体标准](#3-字体标准) +4. [颜色规范](#4-颜色规范) +5. [图标使用规范](#5-图标使用规范) +6. [组件与页面标准样式](#6-组件与页面标准样式) +7. [交互原则](#7-交互原则) +8. [附录:复刻指南](#8-附录复刻指南) + +--- + +## 1. 页面整体风格 + +### 1.1 设计定位 + +本规范定义一套**浅色、克制、数据导向**的后台界面风格。视觉语言强调可读性与操作效率,避免装饰性元素喧宾夺主。 + +| 维度 | 规范 | +|------|------| +| 整体调性 | 专业、冷静、工具感;类似现代 SaaS 控制台 | +| 色彩策略 | 大面积中性灰白底 + 青绿品牌色点缀 | +| 层次表达 | 靠**字号阶梯**与**灰度阶梯**区分,不靠重阴影 | +| 容器形态 | 白底细边框卡片(`1px` + `8px` 圆角),无浮起阴影 | +| 信息密度 | 列表/表格偏紧凑(`size="small"`),表单区留白充分 | +| 技术底座 | Element Plus 组件 + 全局 CSS 变量覆盖 | + +### 1.2 核心设计原则 + +| 原则 | 说明 | +|------|------| +| **浅色优先** | 页面背景 `#f4f4f5`,内容区白卡片;禁止深色全屏侧栏 | +| **克制用色** | 主色青绿仅用于品牌、主按钮、导航激活态、关键正向状态 | +| **细边框卡片** | 白底 + `1px` 浅灰边框 + `8px` 圆角,禁止大面积 box-shadow | +| **8px 间距节奏** | 所有间距使用间距 Token(4px 仅用于极紧凑场景) | +| **层次靠字号与灰度** | 标题 `#18181b`,正文 `#3f3f46`,辅助 `#71717a` | +| **组件复用优先** | 使用 `PageHeader`、`AppCard`、`StatCard` 等标准壳层,禁止页面内自建容器体系 | +| **国际化友好** | 文案走 i18n;布局预留中英文长度差异(按钮组 `flex-wrap`) | + +### 1.3 视觉示意 + +``` +┌──────────────┬────────────────────────────────────┐ +│ Sidebar │ Topbar(52px,白底,底部分割线) │ +│ 240px ├────────────────────────────────────┤ +│ 半透明浅灰 │ Main Content(padding 24px) │ +│ 品牌区+导航 │ .page(max-width 1400px) │ +│ │ PageHeader → StatGrid → AppCard │ +└──────────────┴────────────────────────────────────┘ +``` + +登录页为独立全屏居中布局,无侧栏与顶栏。 + +--- + +## 2. 布局标准 + +### 2.1 栅格系统 + +#### PC 端(≥1025px) + +| 项目 | 规范 | +|------|------| +| 基准栅格 | **12 列**逻辑栅格(内容区 `max-width: 1400px` 内划分) | +| 列间距(gutter) | `16px`(`--space-4`) | +| 页面水平边距 | 主内容区内边距 `24px`(`--space-6`) | +| 典型列分配 | 统计卡 4×3 列;双栏内容 `1.4fr : 1fr` 或 `1fr : 1fr`;三栏等分 `1fr × 3` | + +常用网格类: + +| 类名 | PC 列数 | 说明 | +|------|---------|------| +| `.stat-grid` | 4 列 | 概览指标卡 | +| `.content-grid--2` | 2 列等宽 | 并列卡片 | +| `.content-grid--2-1` | 2 列(约 7:5) | 主内容 + 侧信息 | +| `.content-grid--3` | 3 列等宽 | 三列面板(按需扩展) | + +#### 平板(641px – 1024px) + +| 项目 | 规范 | +|------|------| +| 有效栅格 | **8 列**逻辑栅格 | +| `.stat-grid` | 2 列 | +| `.content-grid--*` | 全部折叠为 **1 列** | +| 主内容 padding | 保持 `24px` | + +#### 移动端(≤640px) + +| 项目 | 规范 | +|------|------| +| 有效栅格 | **4 列**逻辑栅格 | +| `.stat-grid` | 1 列 | +| `.page-header` | 纵向堆叠(标题在上,操作在下) | +| 主内容 padding | `16px`(`--space-4`) | +| 底部 Tab(若启用) | 固定底部,高度 `56px`,图标 `20px` + 文字 `10px` | + +> 默认以 PC 为主、移动端通过响应式折叠适配;若启用底部 Tab 导航,须使用本节尺寸,不得自定义高度。 + +### 2.2 间距体系(8px 基准) + +**硬性规则:页面级、组件级外边距与内边距必须使用间距 Token,禁止出现 5px、7px、10px、15px 等非体系数值。** + +| Token | 值 | 是否 8 的倍数 | 典型用途 | +|-------|-----|---------------|----------| +| `--space-1` | 4px | ½×8,仅紧凑场景 | 标题与副标题间距、行内 code 水平 padding | +| `--space-2` | 8px | ✓ | 按钮组 gap、导航项内边距、表格单元格垂直 padding | +| `--space-3` | 12px | — | 列表项间距、卡片底栏提示 | +| `--space-4` | 16px | ✓ | 卡片间距、工具栏下边距、移动端页面 padding | +| `--space-5` | 20px | — | 卡片水平内边距、统计卡内边距 | +| `--space-6` | 24px | ✓ | 页头下边距、主内容 padding、区块分隔 | +| `--space-8` | 32px | ✓ | 登录卡片内边距、大区块分隔 | + +**垂直节奏(页面级)** + +- `.page-header` 下方:`24px` +- `.stat-grid`、`.content-grid` 下方:`24px` +- 相邻 `.app-card` 间距:`16px`;页面最后一个卡片无底边距 + +**已知例外(保留,勿改)** + +| 位置 | 值 | 原因 | +|------|-----|------| +| `.form-hint` 的 `margin-top` | `6px` | 紧贴表单项控件底部,视觉上等同 8px 体系 | + +`--primary-hover` 已定义但未写独立 CSS 规则,主按钮 hover 由 Element Plus 通过 `--el-color-primary-dark-2` 自动处理。 + +### 2.3 布局组件尺寸 + +| 组件 | 尺寸 | 其他规则 | +|------|------|----------| +| **侧栏** `.sidebar` | 宽 `240px`(`15rem`) | 右边框 `1px`;背景半透明 + `backdrop-filter: blur(8px)` | +| **侧栏品牌区** `.sidebar-brand` | 高随内容;padding `20px 16px` | 品牌图标 `32×32px` | +| **导航项** `.nav-item` | 高随内容;padding `8px 12px`;margin 左右 `8px` | 圆角 `8px`;图标 `16px` | +| **导航分组标签** `.nav-group-label` | padding `16px 16px 8px` | 字号 `12px`,全大写 | +| **顶栏** `.topbar` | 高 **`52px`** | 左右 padding `24px`;白底 + 底边框 | +| **主内容** `.main-content` | flex 填满剩余高度 | padding `24px`(移动端 `16px`);纵向滚动 | +| **页面容器** `.page` | max-width **`1400px`** | 无水平居中强制(随主内容左对齐) | +| **底部 Tab**(预留) | 高 **`56px`** | 图标 `20px`,标签 `10px`,最多 5 项 | +| **登录卡片** `.login-card` | max-width 外层 `400px`;内边距 `32px` | 品牌图标 `48×48px`,圆角 `12px` | + +### 2.4 断点 + +| 断点 | 媒体查询 | 主要变化 | +|------|----------|----------| +| 平板 | `max-width: 1024px` | 统计 2 列;内容网格单列 | +| 手机 | `max-width: 640px` | 统计 1 列;页头纵向;主内容 16px padding | + +### 2.5 布局结构(ASCII) + +``` +PC (≥1025px) +┌─ sidebar 240px ─┬──────────── main-area ────────────────────┐ +│ [brand 32px] │ topbar 52px │ +│ [nav × N] ├───────────────────────────────────────────┤ +│ │ padding 24px │ +│ │ ┌─ page max 1400px ─────────────────┐ │ +│ │ │ page-header │ │ +│ │ │ stat-grid (4 col, gap 16) │ │ +│ │ │ app-card │ │ +│ │ └────────────────────────────────────┘ │ +└─────────────────┴───────────────────────────────────────────┘ + +Mobile (≤640px) +┌──────────────── main-area 100% ────────────────┐ +│ topbar 52px │ +├────────────────────────────────────────────────┤ +│ padding 16px │ +│ page-header (column) │ +│ stat-grid (1 col) │ +│ app-card │ +├────────────────────────────────────────────────┤ +│ [optional bottom-tab 56px] │ +└────────────────────────────────────────────────┘ +``` + +--- + +## 3. 字体标准 + +### 3.1 字体族 + +| 场景 | 字体栈 | +|------|--------| +| **中文界面** | `"PingFang SC"`, `"Microsoft YaHei"`, `"Noto Sans SC"`, `"Helvetica Neue"`, `Arial`, `sans-serif` | +| **英文界面** | `ui-sans-serif`, `system-ui`, `-apple-system`, `"Segoe UI"`, `Roboto`, `"Helvetica Neue"`, `Arial`, `sans-serif` | +| **合并栈(推荐默认)** | `ui-sans-serif`, `system-ui`, `-apple-system`, "Segoe UI", Roboto, "Helvetica Neue", Arial, "PingFang SC", "Microsoft YaHei", "Noto Sans SC", sans-serif` | +| **等宽(代码/文件名)** | `ui-monospace`, `SFMono-Regular`, `Menlo`, `Monaco`, `Consolas`, monospace` | + +> 中英文共用同一 sans 栈:西文优先 system 字体,CJK 由系统自动 fallback 至苹方/微软雅黑。禁止为中文单独引入 Web Font,保持加载性能。 + +### 3.2 字号阶梯 + +根元素 `font-size: 14px`(`1rem = 14px`)。所有字号使用 `rem` 相对根字号。 + +| 层级 | 类名 / 场景 | 字号 | 字重 | 行高 | 颜色 | +|------|-------------|------|------|------|------| +| 页面标题 | `.page-title` | `1.5rem`(21px) | 600 | 默认 | `--foreground` | +| 登录品牌标题 | `.page-title`(登录页) | 同上 | 600 | 默认 | `--foreground` | +| 卡片标题 | `.app-card__title` | `0.9375rem`(13px) | 600 | 默认 | `--foreground` | +| 品牌名 | `.brand-name` | `1.125rem`(15.75px) | 600 | 默认 | `--foreground` | +| 统计数值 | `.stat-card__value` | `1.75rem`(24.5px) | 600 | 默认 | `--foreground` | +| 正文 / 导航 / 顶栏 | `.nav-item`, `.topbar-title`, 表格正文 | `0.875rem`(12.25px) | 400–500 | 1.4(表格) | `--el-text-color-regular` 或 `--muted-foreground` | +| 描述 / 标签说明 | `.page-desc`, `.stat-card__label` | `0.875rem` | 400 | 默认 | `--muted-foreground` | +| 表头 / 辅助 | 表头 `th`, `.form-hint`, `.nav-group-label` | `0.75rem`(10.5px) | 600(表头)/ 400 | 默认 | `--muted-foreground` | +| 极小说明 | `.form-hint`, `code.inline-code` | `0.75rem` | 400 | 默认 | `--muted-foreground` | +| 等宽数据 | `.kv-block code`, `.meta-list` | `0.8125rem` | 400 | 默认 | 继承 | + +### 3.3 字重与样式 + +| 样式 | 使用场景 | 禁止场景 | +|------|----------|----------| +| **600(Semibold)** | 页面标题、卡片标题、表头、导航激活态、分组标题 `.section-title` | 大段正文 | +| **500(Medium)** | 导航默认态、顶栏次要文字 | — | +| **400(Regular)** | 正文、描述、表单标签 | — | +| **加粗 ``** | 行内强调少量关键词 | 整段文字 | +| **斜体 ``** | 极少使用;仅引用或术语首次出现 | 按钮、导航、表头 | +| **`letter-spacing: -0.02em`** | 仅 `.page-title` | 其他文字 | +| **`text-transform: uppercase`** | 仅 `.nav-group-label` | 其他文字 | + +### 3.4 Element Plus 表单标签 + +| 场景 | `label-width` | +|------|---------------| +| 简单 CRUD 对话框 | `100px` | +| 字段名较长 | `110px` | +| 系统设置长表单 | `120px` | +| 嵌套子表单 | `90px` | +| 登录页 | `size="large"`(EP 默认大号控件) | + +--- + +## 4. 颜色规范 + +### 4.1 色彩角色一览 + +``` +品牌层:primary 系列(操作引导) +中性层:background / card / muted / border(结构) +语义层:success / warning / danger / info(状态) +文本层:foreground / regular / muted-foreground(可读性) +``` + +### 4.2 完整色板 + +#### 品牌色 + +| Token | 色值 | 用途 | +|-------|------|------| +| `--primary` | `#0f7a62` | 主按钮、品牌图标底、导航激活文字、链接强调 | +| `--primary-hover` | `#0d6b56` | 主按钮 hover | +| `--primary-foreground` | `#fafafa` | 主色背景上的文字/图标 | +| `--el-color-primary-light-3` | `#4da08c` | EP 组件浅色变体 | +| `--el-color-primary-light-5` | `#76b8a8` | — | +| `--el-color-primary-light-7` | `#a0d0c4` | — | +| `--el-color-primary-light-8` | `#b8dcd2` | — | +| `--el-color-primary-light-9` | `#d0e8e1` | 浅色背景提示 | +| `--el-color-primary-dark-2` | `#0c624e` | 主色按下/深色态 | + +#### 中性色 + +| Token | 色值 | 用途 | +|-------|------|------| +| `--background` | `#f4f4f5` | 页面底色、登录页背景 | +| `--foreground` | `#18181b` | 主标题、强强调正文 | +| `--card` | `#ffffff` | 卡片、顶栏、弹窗内容区背景 | +| `--card-foreground` | `#18181b` | 卡片上主文字 | +| `--muted` | `#f4f4f5` | 表头背景、内嵌块、行内 code 底 | +| `--muted-foreground` | `#71717a` | 描述、表头文字、顶栏标题、占位性文字 | +| `--border` | `#e4e4e7` | 卡片边框、分割线、顶栏底边 | +| `--input` | `#e4e4e7` | 输入框边框(与 border 一致) | + +#### 侧栏专用 + +| Token | 色值 | 用途 | +|-------|------|------| +| `--sidebar` | `rgba(250, 250, 250, 0.85)` | 侧栏背景(半透明) | +| `--sidebar-foreground` | `#18181b` | 侧栏主文字 | +| `--sidebar-accent` | `#f4f4f5` | 导航 hover / active 背景 | +| `--sidebar-border` | `#e4e4e7` | 侧栏右边框 | + +#### 语义色 + +| Token / 类名 | 色值 | 用途 | +|--------------|------|------| +| `--success` | `#047857` | 成功状态(与 EP success 同步) | +| `--warning` | `#b45309` | 警告状态 | +| `--danger` / `--destructive` | `#dc2626` | 危险、错误、删除 | +| `--info` | `#a1a1aa` | 中性、禁用、分类标签 | +| `--el-color-success` | `#047857` | `el-tag type="success"` | +| `--el-color-warning` | `#b45309` | `el-tag type="warning"` | +| `--el-color-danger` | `#dc2626` | `el-tag type="danger"` | +| `--el-color-info` | `#a1a1aa` | `el-tag type="info"` | +| `.status-good` | `#047857` | 数值/状态优良 | +| `.status-warn` | `#b45309` | 数值/状态偏高 | +| `.status-bad` | `#dc2626` | 数值/状态很差 | +| `.status-muted` | `--muted-foreground` | 无数据/超时/不可用 | +| `.meta-list__ok` | `var(--success)` | 列表项就绪 | +| `.meta-list__warn` | `var(--warning)` | 列表项缺失/警告 | + +#### 文本色(Element Plus 对齐) + +| Token | 色值 | 用途 | +|-------|------|------| +| `--el-text-color-primary` | `#18181b` | EP 主要文字 | +| `--el-text-color-regular` | `#3f3f46` | 表格正文 | +| `--el-text-color-secondary` | `#71717a` | EP 次要文字 | + +### 4.3 颜色使用规范 + +| 规则 | 说明 | +|------|------| +| **禁止硬编码色值** | 新样式必须使用 CSS 变量 | +| **主色克制** | 单屏主色面积不超过 15%;除主按钮与激活导航外不大面积铺主色 | +| **状态色统一** | 成功/失败/禁用一律用 `el-tag` 语义 type,不自创色块 | +| **边框优先于阴影** | 容器分隔用 `1px solid var(--border)`,不用阴影 | +| **危险操作** | 破坏性操作用 `type="danger"` + `link`(表格行内)或二次确认弹框 | +| **图表** | 遵循 [§6.16](#616-图表规范echarts) 配色与 grid 配置 | + +### 4.4 圆角 + +| Token | 值 | 用途 | +|-------|-----|------| +| `--radius` | `0.5rem`(8px) | 卡片、按钮、输入框、导航项、内嵌块 | +| 登录品牌图标 | `12px` | 仅 `.login-brand-icon`,略大以强化品牌 | + +--- + +## 5. 图标使用规范 + +### 5.1 图标体系 + +| 类型 | 来源 | 说明 | +|------|------|------| +| **品牌图标** | 项目自有 Logo(自行实现) | 产品标识;仅用于侧栏 `.brand-icon`、登录页 `.login-brand-icon`;**各项目图形不同,本文档不提供参考实现** | +| **功能图标** | `@element-plus/icons-vue` | 导航、操作提示;通过 `` 包裹 | +| **状态** | 优先 `el-tag` 文字 | 不以图标替代明确状态文案 | + +### 5.2 尺寸规范 + +| 场景 | 尺寸 | +|------|------| +| 侧栏品牌区 Logo | `20px` | +| 登录页品牌 Logo | `24px` | +| 侧栏导航图标 | `16px`(``) | +| 按钮内图标(若使用) | `14px` | +| 底部 Tab(预留) | `20px` | + +### 5.3 常见场景图标推荐 + +图标库统一使用 `@element-plus/icons-vue`(Outlined 线型)。下表为常见导航/功能场景的推荐映射,各项目可按业务替换,但须保持风格一致。 + +| 场景 | 推荐图标 | 含义 | +|------|----------|------| +| 首页 / 仪表盘 | `Odometer`、`HomeFilled` | 总览、监控 | +| 列表 / 规则 / 配置项 | `List`、`Menu` | 条目化管理 | +| 连接 / 网络 | `Connection`、`Share` | 链路、拓扑 | +| 外部关联 / 链接 | `Link` | 外部数据源 | +| 切换 / 路由 / 策略 | `Switch` | 分流、调度 | +| 日志 / 文档 | `Document`、`Tickets` | 记录、流水 | +| 用户 / 权限 | `User`、`Lock` | 身份、安全 | +| 系统设置 | `Setting` | 全局配置 | +| 新建 | `Plus` | 创建资源 | +| 编辑 | `Edit` | 修改 | +| 删除 | `Delete` | 移除(配合 danger 按钮,不单用图标) | +| 刷新 | `Refresh` | 重新加载 | +| 导入 / 导出 | `Upload`、`Download` | 数据交换 | + +### 5.4 使用原则 + +| 原则 | 说明 | +|------|------| +| **导航必带图标** | 侧栏每项左侧固定 `16px` 图标,增强扫读性 | +| **颜色跟随文字** | 图标 `currentColor`,不单独设色 | +| **禁止混用风格** | 统一 Outlined 线型图标(Element Plus Icons) | +| **操作按钮** | 主操作优先文字按钮;图标-only 按钮需 `aria-label` | +| **品牌图标不外借** | Logo 仅用于品牌区,不用于普通操作按钮 | + +--- + +## 6. 组件与页面标准样式 + +> 本节 6.1–6.14 为**分模块说明**(便于查阅);**合并后的完整全局 CSS 见 [§8.9](#89-完整全局样式一键复制)**。两处内容须保持一致。 + +### 6.0 技术栈与引入 + +| 项目 | 要求 | +|------|------| +| Vue | `^3.4` | +| Element Plus | `^2.4`(2.x 均可,须使用 CSS 变量主题) | +| 图标 | `@element-plus/icons-vue` | +| 图表(可选) | `echarts` `^5` | + +**`main.ts` 引入顺序(顺序不可颠倒):** + +```ts +import { createApp } from 'vue' +import ElementPlus from 'element-plus' +import 'element-plus/dist/index.css' // ① 先 Element Plus 基础样式 +import './styles/global.css' // ② 再将本文档 §8.9 复制为全局样式文件后引入 + +const app = createApp(App) +app.use(ElementPlus) // ③ 全量注册(或按需,但变量须生效) +app.mount('#app') +``` + +**依赖说明:** + +- 全局样式**仅来源于本文档 §8.9**,文件名与存放路径由项目自定(如 `global.css`、`app.css`)。 +- 按钮、输入框、弹框、Tabs 等**无需逐组件写 CSS**,通过 `:root` 中的 `--el-*` 变量与品牌色对齐。 +- 表格、卡片容器、布局壳层使用本文档自定义 class,须与 §8.1 壳层 DOM 结构配合。 + +### 6.1 设计 Token 与基础重置 + +```css +:root { + --background: #f4f4f5; + --foreground: #18181b; + --card: #ffffff; + --card-foreground: #18181b; + --primary: #0f7a62; + --primary-hover: #0d6b56; + --primary-foreground: #fafafa; + --muted: #f4f4f5; + --muted-foreground: #71717a; + --border: #e4e4e7; + --input: #e4e4e7; + --destructive: #dc2626; + --success: #047857; + --warning: #b45309; + --danger: #dc2626; + --info: #a1a1aa; + --sidebar: rgba(250, 250, 250, 0.85); + --sidebar-foreground: #18181b; + --sidebar-accent: #f4f4f5; + --sidebar-border: #e4e4e7; + --sidebar-width: 15rem; + --radius: 0.5rem; + --space-1: 4px; + --space-2: 8px; + --space-3: 12px; + --space-4: 16px; + --space-5: 20px; + --space-6: 24px; + --space-8: 32px; + + --el-color-primary: #0f7a62; + --el-color-primary-light-3: #4da08c; + --el-color-primary-light-5: #76b8a8; + --el-color-primary-light-7: #a0d0c4; + --el-color-primary-light-8: #b8dcd2; + --el-color-primary-light-9: #d0e8e1; + --el-color-primary-dark-2: #0c624e; + --el-color-success: #047857; + --el-color-warning: #b45309; + --el-color-danger: #dc2626; + --el-color-info: #a1a1aa; + --el-border-radius-base: 0.5rem; + --el-border-color: #e4e4e7; + --el-bg-color-page: #f4f4f5; + --el-text-color-primary: #18181b; + --el-text-color-regular: #3f3f46; + --el-text-color-secondary: #71717a; + --el-font-family: ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto, "Helvetica Neue", Arial, "PingFang SC", "Microsoft YaHei", "Noto Sans SC", sans-serif; +} + +* { box-sizing: border-box; } + +html, body, #app { + margin: 0; + padding: 0; + height: 100%; + font-size: 14px; + font-family: var(--el-font-family); + background: var(--background); + color: var(--foreground); +} +``` + +### 6.2 布局壳层(侧栏 / 顶栏 / 主内容) + +```css +.layout { + display: flex; + height: 100vh; +} + +.sidebar { + width: var(--sidebar-width); + flex-shrink: 0; + background: var(--sidebar); + border-right: 1px solid var(--sidebar-border); + backdrop-filter: blur(8px); + display: flex; + flex-direction: column; + overflow-y: auto; +} + +.sidebar-brand { + display: flex; + align-items: center; + gap: var(--space-3); + padding: var(--space-5) var(--space-4); +} + +.brand-icon { + width: 32px; + height: 32px; + border-radius: var(--radius); + background: var(--primary); + color: var(--primary-foreground); + display: flex; + align-items: center; + justify-content: center; +} + +.brand-name { + font-size: 1.125rem; + font-weight: 600; +} + +.nav-group-label { + font-size: 0.75rem; + text-transform: uppercase; + letter-spacing: 0.04em; + color: var(--muted-foreground); + padding: var(--space-4) var(--space-4) var(--space-2); +} + +.nav-item { + display: flex; + align-items: center; + gap: var(--space-2); + padding: var(--space-2) var(--space-3); + margin: 0 var(--space-2); + border-radius: var(--radius); + color: var(--muted-foreground); + text-decoration: none; + font-size: 0.875rem; + font-weight: 500; +} + +.nav-item:hover { background: var(--sidebar-accent); } + +.nav-item.active { + background: var(--sidebar-accent); + color: var(--primary); + font-weight: 600; +} + +.main-area { + flex: 1; + display: flex; + flex-direction: column; + min-width: 0; +} + +.topbar { + height: 52px; + display: flex; + align-items: center; + justify-content: space-between; + padding: 0 var(--space-6); + background: var(--card); + border-bottom: 1px solid var(--border); +} + +.topbar-title { + font-size: 0.875rem; + color: var(--muted-foreground); +} + +.main-content { + flex: 1; + overflow-y: auto; + padding: var(--space-6); +} +``` + +### 6.3 页面头与网格 + +```css +.page { + max-width: 1400px; +} + +.page-header { + display: flex; + align-items: flex-start; + justify-content: space-between; + margin-bottom: var(--space-6); + gap: var(--space-4); +} + +.page-actions { + display: flex; + align-items: center; + gap: var(--space-2); + flex-shrink: 0; + flex-wrap: wrap; +} + +.page-title { + font-size: 1.5rem; + font-weight: 600; + letter-spacing: -0.02em; + margin: 0 0 var(--space-1); +} + +.page-desc { + font-size: 0.875rem; + color: var(--muted-foreground); + margin: 0; +} + +.stat-grid { + display: grid; + grid-template-columns: repeat(4, 1fr); + gap: var(--space-4); + margin-bottom: var(--space-6); +} + +.stat-card { + background: var(--card); + border: 1px solid var(--border); + border-radius: var(--radius); + padding: var(--space-5); +} + +.stat-card__label { + font-size: 0.875rem; + color: var(--muted-foreground); + margin-bottom: var(--space-2); +} + +.stat-card__value { + font-size: 1.75rem; + font-weight: 600; +} + +.content-grid { + display: grid; + gap: var(--space-4); + margin-bottom: var(--space-6); +} + +.content-grid--2-1 { grid-template-columns: 1.4fr 1fr; } +.content-grid--2 { grid-template-columns: 1fr 1fr; } +.content-grid--3 { grid-template-columns: repeat(3, 1fr); } +``` + +### 6.4 卡片与工具栏 + +```css +.app-card { + background: var(--card); + border: 1px solid var(--border); + border-radius: var(--radius); + margin-bottom: var(--space-4); +} + +.page > .app-card:last-child, +.page > .content-grid:last-child { + margin-bottom: 0; +} + +.app-card__header { + padding: var(--space-4) var(--space-5); + border-bottom: 1px solid var(--border); +} + +.app-card__title { + font-size: 0.9375rem; + font-weight: 600; + margin: 0; +} + +.app-card__body--padded { padding: var(--space-5); } + +.card-toolbar { + display: flex; + align-items: center; + gap: var(--space-2); + padding: var(--space-4) var(--space-5) 0; + margin-bottom: var(--space-4); + flex-wrap: wrap; +} + +.card-toolbar--desc { + align-items: flex-start; +} + +.card-pager { + padding: var(--space-4) var(--space-5); + display: flex; + justify-content: flex-end; +} + +.card-footer-hint { + padding: 0 var(--space-5) var(--space-3); + margin: 0; +} + +.card-footer-hint + .card-pager { + padding-top: var(--space-3); +} + +.card-inset-block { + margin: 0 var(--space-5) var(--space-4); + padding: var(--space-3); + background: var(--muted); + border-radius: var(--radius); + font-size: 0.875rem; +} + +.tabs-bar { + padding: 0 var(--space-5); + border-bottom: 1px solid var(--border); +} + +.tabs-bar .el-tabs__header { margin-bottom: 0; } + +.page-toolbar { + display: flex; + align-items: center; + gap: var(--space-2); + margin-bottom: var(--space-4); + flex-wrap: wrap; +} + +.inline-actions { + display: flex; + align-items: center; + gap: var(--space-2); + flex-wrap: wrap; +} + +.card-header-row { + display: flex; + align-items: center; + justify-content: space-between; + gap: var(--space-4); +} +``` + +### 6.5 表单、间距工具类 + +```css +.form-hint { + margin-top: 6px; + font-size: 0.75rem; + color: var(--muted-foreground); +} + +.form-hint--flush { margin-top: 0; } + +.u-mt-3 { margin-top: var(--space-3); } +.u-mt-4 { margin-top: var(--space-4); } +.u-mb-3 { margin-bottom: var(--space-3); } +.u-mb-4 { margin-bottom: var(--space-4); } +.u-mb-6 { margin-bottom: var(--space-6); } +.text-center { text-align: center; } +.w-full { width: 100%; } + +code.inline-code { + font-size: 0.75rem; + padding: 0 var(--space-1); + background: var(--muted); + border-radius: var(--radius); +} + +.section-title { + font-size: 0.875rem; + font-weight: 600; + margin-bottom: var(--space-4); +} + +.section-title:not(:first-child) { margin-top: var(--space-6); } + +.el-form .el-select { width: 100%; } +``` + +### 6.6 登录页 + +```css +.login-page { + min-height: 100vh; + display: flex; + align-items: center; + justify-content: center; + background: var(--background); + padding: var(--space-6); +} + +.login-wrap { max-width: 400px; width: 100%; text-align: center; } + +.login-brand-icon { + width: 48px; + height: 48px; + border-radius: 12px; + background: var(--primary); + color: var(--primary-foreground); + display: inline-flex; + align-items: center; + justify-content: center; + margin-bottom: var(--space-4); +} + +.login-card { + background: var(--card); + border: 1px solid var(--border); + border-radius: var(--radius); + padding: var(--space-8); + margin-top: var(--space-6); + text-align: left; +} +``` + +### 6.7 表格 + +```css +.app-card .el-table { + --el-table-header-bg-color: var(--muted); + font-size: 0.875rem; +} + +.app-card .el-table--small .el-table__cell { + padding: var(--space-2) var(--space-3); +} + +.app-card .el-table--small th.el-table__cell { + font-size: 0.75rem; + font-weight: 600; + color: var(--muted-foreground); +} + +.app-card .el-table--small td.el-table__cell { + color: var(--el-text-color-regular); +} + +.app-card .el-table--small .el-table__row { + height: 40px; +} + +.app-card .el-table .el-button.is-link { + padding: 0; + height: auto; +} + +.app-card .el-table .cell { + line-height: 1.4; +} + +.table-scroll--sm { max-height: 320px; } +.table-scroll--md { max-height: 360px; } +.table-scroll--lg { max-height: 480px; } + +.card-pager .el-pagination { + font-size: 0.875rem; +} +``` + +**表格列宽约定** + +| 列类型 | 宽度 | +|--------|------| +| 时间 | `168px` | +| 状态 / 布尔 | `80–100px` | +| 操作 | `120–160px`,`fixed="right"` | +| 主文本 | `min-width`,不设固定宽 | + +### 6.8 工具栏控件宽度 + +```css +.toolbar-select { width: 120px; } +.toolbar-select--country { width: 160px; } +.toolbar-select--wide { width: 280px; } +.toolbar-input { width: 200px; } +.toolbar-input--wide { width: 260px; } +.toolbar-input--grow { flex: 1; min-width: 200px; } +.table-cell-select { width: 100%; min-width: 200px; } + +.card-toolbar .toolbar-input--grow { + flex: 1; + min-width: 200px; +} +``` + +### 6.9 按钮规范(Element Plus 约定) + +| 场景 | 写法 | +|------|------| +| 页面主操作 | `` | +| 页头次要操作 | 默认按钮(无 type) | +| 表格行内操作 | `` | +| 表格行内删除 | `` | +| 顶栏退出 | `` | +| 登录提交 | `` | +| 加载中 | `:loading="true"`,禁止重复点击 | + +### 6.10 弹框与抽屉 + +| 类型 | 宽度 | 属性 | +|------|------|------| +| 简单 CRUD | `480–520px` | `destroy-on-close` | +| 导入/中等表单 | `560–640px` | 同上 | +| 复杂列表弹窗 | `720px` | 同上 | +| 二级编辑 | 抽屉 `50%` | — | + +**底部按钮顺序**:取消(默认)在左,确认(`type="primary"`)在右。 + +### 6.11 标签(Tag) + +统一 `size="small"`: + +| 语义 | type | +|------|------| +| 启用 / 成功 / 存活 | `success` | +| 禁用 / 中性 | `info` | +| 失败 / 错误 | `danger` | +| 警告 / 未就绪 | `warning` | +| 可选项高亮 | `primary` | + +### 6.12 状态色与辅助块 + +```css +.status-good { color: #047857; } +.status-warn { color: #b45309; } +.status-bad { color: #dc2626; } +.status-muted { color: var(--muted-foreground); } + +.kv-block p { + margin: 0 0 var(--space-2); +} + +.kv-block p:last-child { + margin-bottom: 0; +} + +.kv-block .label { + display: inline-block; + width: 5rem; + color: var(--muted-foreground); +} + +.kv-block code { + font-size: 0.8125rem; +} + +.cell-stack { + display: flex; + flex-direction: column; + gap: var(--space-2); + width: 100%; +} + +.meta-list { + margin: 0; + padding: 0; + list-style: none; + font-size: 0.8125rem; + color: var(--muted-foreground); +} + +.meta-list li { + display: flex; + justify-content: space-between; + gap: var(--space-3); + padding: var(--space-1) 0; +} + +.meta-list__mono { + font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace; +} + +.meta-list__ok { color: var(--el-color-success); } +.meta-list__warn { color: var(--el-color-warning); } + +.chart-box { + height: 280px; +} +``` + +### 6.13 滚动条与响应式 + +```css +.custom-scrollbar { + scrollbar-width: thin; + scrollbar-color: #e4e4e7 transparent; +} + +.custom-scrollbar::-webkit-scrollbar { + width: 6px; + height: 6px; +} + +.custom-scrollbar::-webkit-scrollbar-thumb { + background: #e4e4e7; + border-radius: 3px; +} + +.custom-scrollbar::-webkit-scrollbar-track { + background: transparent; +} + +@media (max-width: 1024px) { + .stat-grid { grid-template-columns: repeat(2, 1fr); } + .content-grid--2-1, + .content-grid--2, + .content-grid--3 { grid-template-columns: 1fr; } +} + +@media (max-width: 640px) { + .stat-grid { grid-template-columns: 1fr; } + .main-content { padding: var(--space-4); } + .page-header { flex-direction: column; } +} +``` + +### 6.14 全局工具类速查 + +| 类名 | 用途 | +|------|------| +| `.card-toolbar` | 卡片内筛选行 | +| `.card-pager` | 卡片内分页(右对齐) | +| `.card-header-row` | 卡片头标题 + 右侧操作 | +| `.form-hint` | 表单项下方说明 | +| `.section-title` | 长表单分组标题 | +| `.page-toolbar` | 页内独立工具条 | +| `.tabs-bar` | 卡片内 Tab 容器 | +| `.toolbar-select` / `.toolbar-input` | 工具栏控件宽度 | +| `.table-scroll--sm/md/lg` | 表格最大高度 | +| `.w-full` | 宽度 100% | +| `.u-mb-4` / `.u-mt-3` 等 | 标准外边距 | +| `.status-good` / `.status-warn` / `.status-bad` | 行内数值/状态语义色 | +| `.kv-block` | 键值对结果展示(常配合 `.card-inset-block`) | +| `.cell-stack` | 表格单元格内纵向堆叠 | +| `.meta-list` | 元数据清单列表 | +| `.custom-scrollbar` | 细滚动条(Firefox + WebKit) | + +### 6.15 Element Plus 适配说明 + +以下组件**依靠 EP 默认样式 + `:root` 变量**即可,禁止重复覆盖(除非本节另有说明): + +| 组件 | 使用约定 | 自定义 CSS | +|------|----------|------------| +| `el-button` | 主操作 `type="primary"`;行内 `size="small" link` | 无(颜色来自 `--el-color-primary*`) | +| `el-input` / `el-select` | 表单内 select 宽度 `100%` | 仅 `.el-form .el-select { width: 100% }` | +| `el-dialog` | `destroy-on-close`;宽度见 §6.10 | 无 | +| `el-drawer` | 二级编辑 `size="50%"` | 无 | +| `el-tabs` | 放在 `.tabs-bar` 内 | 仅 `.tabs-bar .el-tabs__header { margin-bottom: 0 }`;激活色来自 `--el-color-primary` | +| `el-pagination` | 放在 `.card-pager` 内 | 仅 `font-size: 0.875rem` | +| `el-tag` | 统一 `size="small"` | 语义色来自 `--el-color-success` 等 | +| `el-message` / `el-message-box` | 操作反馈 | 无 | +| `el-form` | `label-width` 见 §3.4 | 无 | + +**Tabs 推荐结构:** + +```vue + +
+ + + + +
+ +
+``` + +**Dialog 推荐结构:** + +```vue + + + + +``` + +### 6.16 图表规范(ECharts) + +含折线图的统计页使用 `.chart-box` 容器(高度 `280px`)。图表配置须与品牌色一致: + +| 配置项 | 值 | +|--------|-----| +| 容器 class | `chart-box`(`height: 280px`) | +| 主系列色 | `#0f7a62`(`var(--primary)`) | +| 次系列色 | `#71717a`(`var(--muted-foreground)`) | +| 第三系列及以后 | `#4da08c`、`#76b8a8`、`#a0d0c4`(primary-light 阶梯) | +| `grid` | `{ left: 40, right: 16, top: 16, bottom: 24 }` | +| 坐标轴字号 | `12`(与 `0.857rem` 接近) | +| 坐标轴颜色 | `#71717a` | +| 分割线 | `#e4e4e7` | +| 折线 | `smooth: true`;线宽 `2` | +| tooltip | `trigger: 'axis'` | +| 图例 | 顶部或底部,字号 `12` | + +```ts +chart.setOption({ + grid: { left: 40, right: 16, top: 16, bottom: 24 }, + xAxis: { + type: 'category', + data: labels, + axisLabel: { color: '#71717a', fontSize: 12 }, + axisLine: { lineStyle: { color: '#e4e4e7' } }, + }, + yAxis: { + type: 'value', + axisLabel: { color: '#71717a', fontSize: 12 }, + splitLine: { lineStyle: { color: '#e4e4e7' } }, + }, + series: [ + { name: '主指标', type: 'line', smooth: true, data: a, color: '#0f7a62' }, + { name: '次指标', type: 'line', smooth: true, data: b, color: '#71717a' }, + ], + tooltip: { trigger: 'axis' }, +}) +``` + +--- + +## 7. 交互原则 + +### 7.1 反馈与状态 + +| 原则 | 规范 | +|------|------| +| **操作反馈** | 成功/失败使用消息提示(如 `ElMessage`);破坏性操作须二次确认(如 `ElMessageBox.confirm`) | +| **加载态** | 列表 `v-loading`;按钮 `:loading`;全页加载用卡片级 loading | +| **空状态** | 表格无数据依赖 EP 默认 empty;需文案时走 i18n | +| **禁用态** | 批量任务进行中禁用与之互斥的行内操作 | +| **乐观更新** | 不做乐观更新;等 API 成功后再刷新列表 | + +### 7.2 导航与层级 + +| 原则 | 规范 | +|------|------| +| **路由即导航** | 侧栏链接与当前路由同步,激活态高亮 | +| **顶栏标题** | 显示当前一级导航名称,非重复页面 H1 | +| **页内标题** | `PageHeader` 的 `title` 为页面 H1,描述可选 | +| **退出登录** | 顶栏右侧 `text` 按钮,清除会话后跳转登录页 | + +### 7.3 表单与对话框 + +| 原则 | 规范 | +|------|------| +| **关闭销毁** | 对话框统一 `destroy-on-close`,避免脏数据残留 | +| **提交防重** | 提交按钮 `loading` 期间禁止重复提交 | +| **取消不保存** | 取消仅关闭弹层,不触发 API | +| **密码字段** | `type="password"` + `show-password` | +| **字段说明** | 用 `.form-hint`,不用 placeholder 替代说明 | + +### 7.4 列表与表格 + +| 原则 | 规范 | +|------|------| +| **主操作位置** | 新建/导入放 `PageHeader#actions`;筛选放 `.card-toolbar` | +| **行内操作** | 链接式小按钮,删除放最右 | +| **分页** | 放 `.card-pager`,右对齐 | +| **刷新** | 提供显式「刷新」按钮;除监控大屏等实时场景外,不依赖隐式自动刷新 | +| **固定列** | 操作列 `fixed="right"` | + +### 7.5 动效与过渡 + +| 原则 | 规范 | +|------|------| +| **克制动效** | 仅使用 EP 默认过渡(对话框、下拉、Message) | +| **禁止花哨动画** | 不加自定义 page transition、弹跳、视差 | +| **侧栏** | 桌面端固定展开,不用折叠动画(移动端待扩展) | + +### 7.6 无障碍与可用性 + +| 原则 | 规范 | +|------|------| +| **对比度** | 正文与背景对比度 ≥ 4.5:1 | +| **焦点** | 依赖 EP 默认 focus ring,不自行移除 | +| **点击目标** | 按钮最小高度遵循 EP(默认 32px,large 40px) | +| **错误信息** | 接口错误映射为用户可读的本地化文案(如限流、权限不足) | + +### 7.7 国际化 + +| 原则 | 规范 | +|------|------| +| **文案** | 所有可见文字走国际化方案(如 i18n),禁止硬编码用户可见字符串 | +| **时间** | 显示格式 `YYYY-MM-DD HH:mm:ss`(ISO 替换 `T` 并截断) | +| **布局弹性** | 按钮组、工具栏 `flex-wrap: wrap` 适配长文案 | + +--- + +## 8. 附录:复刻指南 + +按以下顺序实施,可在新项目中复刻本文档定义的全部视觉(Vue 3 + Element Plus 2.x)。 + +### 8.1 壳层布局完整模板 + +**DOM 层级(class 名称不可改):** + +``` +.layout +├── .sidebar.custom-scrollbar +│ ├── .sidebar-brand +│ │ ├── .brand-icon → 项目自有 Logo(20px) +│ │ └── .brand-name +│ ├── .nav-group-label(可选分组) +│ └── a.nav-item[.active] × N(含 el-icon 16px) +└── .main-area + ├── header.topbar + │ ├── .topbar-title + │ └── 顶栏操作(如 el-button text) + └── main.main-content.custom-scrollbar + └── router-view → 各页面 .page +``` + +```vue + + + + +``` + +### 8.2 登录页完整模板 + +```vue + + + + +``` + +### 8.3 公共组件完整源码 + +```vue + + + + +``` + +```vue + + + + +``` + +```vue + + + + +``` + +品牌 Logo 由各项目自行提供,放入 `.brand-icon`(20px)与 `.login-brand-icon`(24px)容器即可;须使用 `currentColor` 或适配主色底上的 `--primary-foreground`。 + +### 8.4 标准页面模板 + +```vue + +``` + +### 8.5 带 Tab 的列表页 + +```vue + +
+ + + + +
+
+ +
+
+``` + +### 8.6 新页面 Checklist + +1. 将 §8.9 复制为项目全局样式文件,按 §6.0 顺序在 `main.ts` 中引入 +2. 复制 §8.1 壳层布局、§8.3 公共组件(文件名自定) +3. 页面根节点 `
` +4. `PageHeader`(标题 + 描述 + actions) +5. 主内容 `AppCard`;表单页加 `padded` +6. 列表筛选用 `.card-toolbar`,分页用 `.card-pager` +7. 间距与颜色仅使用 CSS 变量与全局类 +8. 在主布局中注册导航项与图标(§5.3) + +### 8.7 反模式 + +| 不要 | 应该 | +|------|------| +| 深色侧栏 | 浅色半透明侧栏 | +| 页面内裸 `el-card` 作主容器 | `AppCard` | +| 无标题直接上表格 | 先 `PageHeader` | +| 硬编码色值 | CSS 变量 | +| 重阴影 | 细边框 | +| 每页自定义 `.toolbar` / `.pager` | `.card-toolbar` / `.card-pager` | +| 表单卡片不加 `padded` | `AppCard padded` | +| `style="margin-top: 12px"` | `.u-mt-3` 等工具类 | +| 非 8px 倍数的随意间距 | 间距 Token | +| 在 EP 样式之前引入全局样式 | 先 `element-plus/dist/index.css`,再引入 §8.9 全局样式 | + +### 8.8 推荐目录结构(示例) + +``` +styles/global.css ← 由本文档 §8.9 复制生成,路径与文件名自定 +components/PageHeader.vue +components/StatCard.vue +components/AppCard.vue +layouts/MainLayout.vue ← 见 §8.1,文件名自定 +views/Login.vue ← 见 §8.2,文件名自定 +``` + +样式变更时,须同时更新本文档 **§6.1–6.14** 与 **§8.9** 两处 CSS,保持内容一致。 + +### 8.9 完整全局样式(一键复制) + +以下为**设计规范定义的完整全局 CSS**,直接复制到项目全局样式文件即可(§6.1–6.14 为分模块对照版,内容须与本节一致)。 + +```css +:root { + --background: #f4f4f5; + --foreground: #18181b; + --card: #ffffff; + --card-foreground: #18181b; + --primary: #0f7a62; + --primary-hover: #0d6b56; + --primary-foreground: #fafafa; + --muted: #f4f4f5; + --muted-foreground: #71717a; + --border: #e4e4e7; + --input: #e4e4e7; + --destructive: #dc2626; + --success: #047857; + --warning: #b45309; + --danger: #dc2626; + --info: #a1a1aa; + --sidebar: rgba(250, 250, 250, 0.85); + --sidebar-foreground: #18181b; + --sidebar-accent: #f4f4f5; + --sidebar-border: #e4e4e7; + --sidebar-width: 15rem; + --radius: 0.5rem; + --space-1: 4px; + --space-2: 8px; + --space-3: 12px; + --space-4: 16px; + --space-5: 20px; + --space-6: 24px; + --space-8: 32px; + + --el-color-primary: #0f7a62; + --el-color-primary-light-3: #4da08c; + --el-color-primary-light-5: #76b8a8; + --el-color-primary-light-7: #a0d0c4; + --el-color-primary-light-8: #b8dcd2; + --el-color-primary-light-9: #d0e8e1; + --el-color-primary-dark-2: #0c624e; + --el-color-success: #047857; + --el-color-warning: #b45309; + --el-color-danger: #dc2626; + --el-color-info: #a1a1aa; + --el-border-radius-base: 0.5rem; + --el-border-color: #e4e4e7; + --el-bg-color-page: #f4f4f5; + --el-text-color-primary: #18181b; + --el-text-color-regular: #3f3f46; + --el-text-color-secondary: #71717a; + --el-font-family: ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto, "Helvetica Neue", Arial, "PingFang SC", "Microsoft YaHei", "Noto Sans SC", sans-serif; +} + +* { box-sizing: border-box; } + +html, body, #app { + margin: 0; + padding: 0; + height: 100%; + font-size: 14px; + font-family: var(--el-font-family); + background: var(--background); + color: var(--foreground); +} + +.layout { + display: flex; + height: 100vh; +} + +.sidebar { + width: var(--sidebar-width); + flex-shrink: 0; + background: var(--sidebar); + border-right: 1px solid var(--sidebar-border); + backdrop-filter: blur(8px); + display: flex; + flex-direction: column; + overflow-y: auto; +} + +.sidebar-brand { + display: flex; + align-items: center; + gap: var(--space-3); + padding: var(--space-5) var(--space-4); +} + +.brand-icon { + width: 32px; + height: 32px; + border-radius: var(--radius); + background: var(--primary); + color: var(--primary-foreground); + display: flex; + align-items: center; + justify-content: center; +} + +.brand-name { + font-size: 1.125rem; + font-weight: 600; +} + +.nav-group-label { + font-size: 0.75rem; + text-transform: uppercase; + letter-spacing: 0.04em; + color: var(--muted-foreground); + padding: var(--space-4) var(--space-4) var(--space-2); +} + +.nav-item { + display: flex; + align-items: center; + gap: var(--space-2); + padding: var(--space-2) var(--space-3); + margin: 0 var(--space-2); + border-radius: var(--radius); + color: var(--muted-foreground); + text-decoration: none; + font-size: 0.875rem; + font-weight: 500; +} + +.nav-item:hover { background: var(--sidebar-accent); } + +.nav-item.active { + background: var(--sidebar-accent); + color: var(--primary); + font-weight: 600; +} + +.main-area { + flex: 1; + display: flex; + flex-direction: column; + min-width: 0; +} + +.topbar { + height: 52px; + display: flex; + align-items: center; + justify-content: space-between; + padding: 0 var(--space-6); + background: var(--card); + border-bottom: 1px solid var(--border); +} + +.topbar-title { + font-size: 0.875rem; + color: var(--muted-foreground); +} + +.main-content { + flex: 1; + overflow-y: auto; + padding: var(--space-6); +} + +.page { + max-width: 1400px; +} + +.page-header { + display: flex; + align-items: flex-start; + justify-content: space-between; + margin-bottom: var(--space-6); + gap: var(--space-4); +} + +.page-actions { + display: flex; + align-items: center; + gap: var(--space-2); + flex-shrink: 0; + flex-wrap: wrap; +} + +.page-title { + font-size: 1.5rem; + font-weight: 600; + letter-spacing: -0.02em; + margin: 0 0 var(--space-1); +} + +.page-desc { + font-size: 0.875rem; + color: var(--muted-foreground); + margin: 0; +} + +.stat-grid { + display: grid; + grid-template-columns: repeat(4, 1fr); + gap: var(--space-4); + margin-bottom: var(--space-6); +} + +.stat-card { + background: var(--card); + border: 1px solid var(--border); + border-radius: var(--radius); + padding: var(--space-5); +} + +.stat-card__label { + font-size: 0.875rem; + color: var(--muted-foreground); + margin-bottom: var(--space-2); +} + +.stat-card__value { + font-size: 1.75rem; + font-weight: 600; +} + +.app-card { + background: var(--card); + border: 1px solid var(--border); + border-radius: var(--radius); + margin-bottom: var(--space-4); +} + +.page > .app-card:last-child, +.page > .content-grid:last-child { + margin-bottom: 0; +} + +.app-card__header { + padding: var(--space-4) var(--space-5); + border-bottom: 1px solid var(--border); +} + +.app-card__title { + font-size: 0.9375rem; + font-weight: 600; + margin: 0; +} + +.app-card__body--padded { padding: var(--space-5); } + +.content-grid { + display: grid; + gap: var(--space-4); + margin-bottom: var(--space-6); +} + +.content-grid--2-1 { grid-template-columns: 1.4fr 1fr; } +.content-grid--2 { grid-template-columns: 1fr 1fr; } +.content-grid--3 { grid-template-columns: repeat(3, 1fr); } + +.card-toolbar { + display: flex; + align-items: center; + gap: var(--space-2); + padding: var(--space-4) var(--space-5) 0; + margin-bottom: var(--space-4); + flex-wrap: wrap; +} + +.card-toolbar--desc { + align-items: flex-start; +} + +.card-pager { + padding: var(--space-4) var(--space-5); + display: flex; + justify-content: flex-end; +} + +.card-footer-hint { + padding: 0 var(--space-5) var(--space-3); + margin: 0; +} + +.card-footer-hint + .card-pager { + padding-top: var(--space-3); +} + +.card-inset-block { + margin: 0 var(--space-5) var(--space-4); + padding: var(--space-3); + background: var(--muted); + border-radius: var(--radius); + font-size: 0.875rem; +} + +.tabs-bar { + padding: 0 var(--space-5); + border-bottom: 1px solid var(--border); +} + +.tabs-bar .el-tabs__header { margin-bottom: 0; } + +.page-toolbar { + display: flex; + align-items: center; + gap: var(--space-2); + margin-bottom: var(--space-4); + flex-wrap: wrap; +} + +.inline-actions { + display: flex; + align-items: center; + gap: var(--space-2); + flex-wrap: wrap; +} + +.card-header-row { + display: flex; + align-items: center; + justify-content: space-between; + gap: var(--space-4); +} + +.form-hint { + margin-top: 6px; + font-size: 0.75rem; + color: var(--muted-foreground); +} + +.form-hint--flush { margin-top: 0; } + +.u-mt-3 { margin-top: var(--space-3); } +.u-mt-4 { margin-top: var(--space-4); } +.u-mb-3 { margin-bottom: var(--space-3); } +.u-mb-4 { margin-bottom: var(--space-4); } +.u-mb-6 { margin-bottom: var(--space-6); } +.text-center { text-align: center; } +.w-full { width: 100%; } + +code.inline-code { + font-size: 0.75rem; + padding: 0 var(--space-1); + background: var(--muted); + border-radius: var(--radius); +} + +.section-title { + font-size: 0.875rem; + font-weight: 600; + margin-bottom: var(--space-4); +} + +.section-title:not(:first-child) { margin-top: var(--space-6); } + +.login-page { + min-height: 100vh; + display: flex; + align-items: center; + justify-content: center; + background: var(--background); + padding: var(--space-6); +} + +.login-wrap { max-width: 400px; width: 100%; text-align: center; } + +.login-brand-icon { + width: 48px; + height: 48px; + border-radius: 12px; + background: var(--primary); + color: var(--primary-foreground); + display: inline-flex; + align-items: center; + justify-content: center; + margin-bottom: var(--space-4); +} + +.login-card { + background: var(--card); + border: 1px solid var(--border); + border-radius: var(--radius); + padding: var(--space-8); + margin-top: var(--space-6); + text-align: left; +} + +.status-good { color: #047857; } +.status-warn { color: #b45309; } +.status-bad { color: #dc2626; } +.status-muted { color: var(--muted-foreground); } + +.toolbar-select { width: 120px; } +.toolbar-select--country { width: 160px; } +.toolbar-select--wide { width: 280px; } +.toolbar-input { width: 200px; } +.toolbar-input--wide { width: 260px; } +.toolbar-input--grow { flex: 1; min-width: 200px; } +.table-cell-select { width: 100%; min-width: 200px; } + +.el-form .el-select { width: 100%; } + +.app-card .el-table { + --el-table-header-bg-color: var(--muted); + font-size: 0.875rem; +} + +.app-card .el-table--small .el-table__cell { + padding: var(--space-2) var(--space-3); +} + +.app-card .el-table--small th.el-table__cell { + font-size: 0.75rem; + font-weight: 600; + color: var(--muted-foreground); +} + +.app-card .el-table--small td.el-table__cell { + color: var(--el-text-color-regular); +} + +.app-card .el-table--small .el-table__row { + height: 40px; +} + +.app-card .el-table .el-button.is-link { + padding: 0; + height: auto; +} + +.app-card .el-table .cell { + line-height: 1.4; +} + +.table-scroll--sm { max-height: 320px; } +.table-scroll--md { max-height: 360px; } +.table-scroll--lg { max-height: 480px; } + +.card-toolbar .toolbar-input--grow { + flex: 1; + min-width: 200px; +} + +.card-pager .el-pagination { + font-size: 0.875rem; +} + +.kv-block p { + margin: 0 0 var(--space-2); +} + +.kv-block p:last-child { + margin-bottom: 0; +} + +.kv-block .label { + display: inline-block; + width: 5rem; + color: var(--muted-foreground); +} + +.kv-block code { + font-size: 0.8125rem; +} + +.cell-stack { + display: flex; + flex-direction: column; + gap: var(--space-2); + width: 100%; +} + +.meta-list { + margin: 0; + padding: 0; + list-style: none; + font-size: 0.8125rem; + color: var(--muted-foreground); +} + +.meta-list li { + display: flex; + justify-content: space-between; + gap: var(--space-3); + padding: var(--space-1) 0; +} + +.meta-list__mono { + font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace; +} + +.meta-list__ok { color: var(--el-color-success); } +.meta-list__warn { color: var(--el-color-warning); } + +.chart-box { + height: 280px; +} + +.custom-scrollbar { + scrollbar-width: thin; + scrollbar-color: #e4e4e7 transparent; +} + +.custom-scrollbar::-webkit-scrollbar { + width: 6px; + height: 6px; +} + +.custom-scrollbar::-webkit-scrollbar-thumb { + background: #e4e4e7; + border-radius: 3px; +} + +.custom-scrollbar::-webkit-scrollbar-track { + background: transparent; +} + +@media (max-width: 1024px) { + .stat-grid { grid-template-columns: repeat(2, 1fr); } + .content-grid--2-1, + .content-grid--2, + .content-grid--3 { grid-template-columns: 1fr; } +} + +@media (max-width: 640px) { + .stat-grid { grid-template-columns: 1fr; } + .main-content { padding: var(--space-4); } + .page-header { flex-direction: column; } +} +``` + +--- + +*浅色后台管理系统 UI 设计规范* diff --git a/docs/image/IP规则.png b/docs/image/IP规则.png new file mode 100644 index 0000000..1584b05 Binary files /dev/null and b/docs/image/IP规则.png differ diff --git a/docs/image/入口管理.png b/docs/image/入口管理.png new file mode 100644 index 0000000..2950d97 Binary files /dev/null and b/docs/image/入口管理.png differ diff --git a/docs/image/出口管理.png b/docs/image/出口管理.png new file mode 100644 index 0000000..3b1067d Binary files /dev/null and b/docs/image/出口管理.png differ diff --git a/docs/image/登陆页.png b/docs/image/登陆页.png new file mode 100644 index 0000000..68ad57f Binary files /dev/null and b/docs/image/登陆页.png differ diff --git a/docs/image/看板监控.png b/docs/image/看板监控.png new file mode 100644 index 0000000..79b1ff6 Binary files /dev/null and b/docs/image/看板监控.png differ diff --git a/docs/image/系统设置.png b/docs/image/系统设置.png new file mode 100644 index 0000000..6a6eeee Binary files /dev/null and b/docs/image/系统设置.png differ diff --git a/docs/image/请求日志.png b/docs/image/请求日志.png new file mode 100644 index 0000000..3a2e100 Binary files /dev/null and b/docs/image/请求日志.png differ diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..4a388c5 --- /dev/null +++ b/go.mod @@ -0,0 +1,52 @@ +module github.com/rose_cat707/luminary + +go 1.25.0 + +require ( + github.com/gin-gonic/gin v1.12.0 + github.com/glebarez/sqlite v1.11.0 + github.com/google/uuid v1.3.0 + github.com/gorilla/websocket v1.5.3 + golang.org/x/crypto v0.53.0 + gorm.io/gorm v1.31.1 +) + +require ( + github.com/bytedance/gopkg v0.1.3 // indirect + github.com/bytedance/sonic v1.15.0 // indirect + github.com/bytedance/sonic/loader v0.5.0 // indirect + github.com/cloudwego/base64x v0.1.6 // indirect + github.com/dustin/go-humanize v1.0.1 // indirect + github.com/gabriel-vasile/mimetype v1.4.12 // indirect + github.com/gin-contrib/sse v1.1.0 // indirect + github.com/glebarez/go-sqlite v1.21.2 // indirect + github.com/go-playground/locales v0.14.1 // indirect + github.com/go-playground/universal-translator v0.18.1 // indirect + github.com/go-playground/validator/v10 v10.30.1 // indirect + github.com/goccy/go-json v0.10.5 // indirect + github.com/goccy/go-yaml v1.19.2 // indirect + github.com/jinzhu/inflection v1.0.0 // indirect + github.com/jinzhu/now v1.1.5 // indirect + github.com/json-iterator/go v1.1.12 // indirect + github.com/klauspost/cpuid/v2 v2.3.0 // indirect + github.com/leodido/go-urn v1.4.0 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect + github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect + github.com/modern-go/reflect2 v1.0.2 // indirect + github.com/pelletier/go-toml/v2 v2.2.4 // indirect + github.com/quic-go/qpack v0.6.0 // indirect + github.com/quic-go/quic-go v0.59.0 // indirect + github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect + github.com/twitchyliquid64/golang-asm v0.15.1 // indirect + github.com/ugorji/go/codec v1.3.1 // indirect + go.mongodb.org/mongo-driver/v2 v2.5.0 // indirect + golang.org/x/arch v0.22.0 // indirect + golang.org/x/net v0.55.0 // indirect + golang.org/x/sys v0.46.0 // indirect + golang.org/x/text v0.38.0 // indirect + google.golang.org/protobuf v1.36.10 // indirect + modernc.org/libc v1.22.5 // indirect + modernc.org/mathutil v1.5.0 // indirect + modernc.org/memory v1.5.0 // indirect + modernc.org/sqlite v1.23.1 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..47e3498 --- /dev/null +++ b/go.sum @@ -0,0 +1,118 @@ +github.com/bytedance/gopkg v0.1.3 h1:TPBSwH8RsouGCBcMBktLt1AymVo2TVsBVCY4b6TnZ/M= +github.com/bytedance/gopkg v0.1.3/go.mod h1:576VvJ+eJgyCzdjS+c4+77QF3p7ubbtiKARP3TxducM= +github.com/bytedance/sonic v1.15.0 h1:/PXeWFaR5ElNcVE84U0dOHjiMHQOwNIx3K4ymzh/uSE= +github.com/bytedance/sonic v1.15.0/go.mod h1:tFkWrPz0/CUCLEF4ri4UkHekCIcdnkqXw9VduqpJh0k= +github.com/bytedance/sonic/loader v0.5.0 h1:gXH3KVnatgY7loH5/TkeVyXPfESoqSBSBEiDd5VjlgE= +github.com/bytedance/sonic/loader v0.5.0/go.mod h1:AR4NYCk5DdzZizZ5djGqQ92eEhCCcdf5x77udYiSJRo= +github.com/cloudwego/base64x v0.1.6 h1:t11wG9AECkCDk5fMSoxmufanudBtJ+/HemLstXDLI2M= +github.com/cloudwego/base64x v0.1.6/go.mod h1:OFcloc187FXDaYHvrNIjxSe8ncn0OOM8gEHfghB2IPU= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= +github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= +github.com/gabriel-vasile/mimetype v1.4.12 h1:e9hWvmLYvtp846tLHam2o++qitpguFiYCKbn0w9jyqw= +github.com/gabriel-vasile/mimetype v1.4.12/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s= +github.com/gin-contrib/sse v1.1.0 h1:n0w2GMuUpWDVp7qSpvze6fAu9iRxJY4Hmj6AmBOU05w= +github.com/gin-contrib/sse v1.1.0/go.mod h1:hxRZ5gVpWMT7Z0B0gSNYqqsSCNIJMjzvm6fqCz9vjwM= +github.com/gin-gonic/gin v1.12.0 h1:b3YAbrZtnf8N//yjKeU2+MQsh2mY5htkZidOM7O0wG8= +github.com/gin-gonic/gin v1.12.0/go.mod h1:VxccKfsSllpKshkBWgVgRniFFAzFb9csfngsqANjnLc= +github.com/glebarez/go-sqlite v1.21.2 h1:3a6LFC4sKahUunAmynQKLZceZCOzUthkRkEAl9gAXWo= +github.com/glebarez/go-sqlite v1.21.2/go.mod h1:sfxdZyhQjTM2Wry3gVYWaW072Ri1WMdWJi0k6+3382k= +github.com/glebarez/sqlite v1.11.0 h1:wSG0irqzP6VurnMEpFGer5Li19RpIRi2qvQz++w0GMw= +github.com/glebarez/sqlite v1.11.0/go.mod h1:h8/o8j5wiAsqSPoWELDUdJXhjAhsVliSn7bWZjOhrgQ= +github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s= +github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4= +github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA= +github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY= +github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY= +github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY= +github.com/go-playground/validator/v10 v10.30.1 h1:f3zDSN/zOma+w6+1Wswgd9fLkdwy06ntQJp0BBvFG0w= +github.com/go-playground/validator/v10 v10.30.1/go.mod h1:oSuBIQzuJxL//3MelwSLD5hc2Tu889bF0Idm9Dg26cM= +github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4= +github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M= +github.com/goccy/go-yaml v1.19.2 h1:PmFC1S6h8ljIz6gMRBopkjP1TVT7xuwrButHID66PoM= +github.com/goccy/go-yaml v1.19.2/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA= +github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= +github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= +github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= +github.com/google/pprof v0.0.0-20221118152302-e6195bd50e26 h1:Xim43kblpZXfIBQsbuBVKCudVG457BR2GZFIz3uw3hQ= +github.com/google/pprof v0.0.0-20221118152302-e6195bd50e26/go.mod h1:dDKJzRmX4S37WGHujM7tX//fmj1uioxKzKxz3lo4HJo= +github.com/google/uuid v1.3.0 h1:t6JiXgmwXMjEs8VusXIJk2BXHsn+wx8BZdTaoZ5fu7I= +github.com/google/uuid v1.3.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg= +github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= +github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E= +github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc= +github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ= +github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8= +github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= +github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= +github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y= +github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0= +github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ= +github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI= +github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= +github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg= +github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= +github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M= +github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= +github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4= +github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/quic-go/qpack v0.6.0 h1:g7W+BMYynC1LbYLSqRt8PBg5Tgwxn214ZZR34VIOjz8= +github.com/quic-go/qpack v0.6.0/go.mod h1:lUpLKChi8njB4ty2bFLX2x4gzDqXwUpaO1DP9qMDZII= +github.com/quic-go/quic-go v0.59.0 h1:OLJkp1Mlm/aS7dpKgTc6cnpynnD2Xg7C1pwL6vy/SAw= +github.com/quic-go/quic-go v0.59.0/go.mod h1:upnsH4Ju1YkqpLXC305eW3yDZ4NfnNbmQRCMWS58IKU= +github.com/remyoudompheng/bigfft v0.0.0-20200410134404-eec4a21b6bb0/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= +github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= +github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= +github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= +github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= +github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= +github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= +github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= +github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI= +github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08= +github.com/ugorji/go/codec v1.3.1 h1:waO7eEiFDwidsBN6agj1vJQ4AG7lh2yqXyOXqhgQuyY= +github.com/ugorji/go/codec v1.3.1/go.mod h1:pRBVtBSKl77K30Bv8R2P+cLSGaTtex6fsA2Wjqmfxj4= +go.mongodb.org/mongo-driver/v2 v2.5.0 h1:yXUhImUjjAInNcpTcAlPHiT7bIXhshCTL3jVBkF3xaE= +go.mongodb.org/mongo-driver/v2 v2.5.0/go.mod h1:yOI9kBsufol30iFsl1slpdq1I0eHPzybRWdyYUs8K/0= +go.uber.org/mock v0.6.0 h1:hyF9dfmbgIX5EfOdasqLsWD6xqpNZlXblLB/Dbnwv3Y= +go.uber.org/mock v0.6.0/go.mod h1:KiVJ4BqZJaMj4svdfmHM0AUx4NJYO8ZNpPnZn1Z+BBU= +golang.org/x/arch v0.22.0 h1:c/Zle32i5ttqRXjdLyyHZESLD/bB90DCU1g9l/0YBDI= +golang.org/x/arch v0.22.0/go.mod h1:dNHoOeKiyja7GTvF9NJS1l3Z2yntpQNzgrjh1cU103A= +golang.org/x/crypto v0.53.0 h1:QZ4Muo8THX6CizN2vPPd5fBGHyogrdK9fG4wLPFUsto= +golang.org/x/crypto v0.53.0/go.mod h1:DNLU434OwVakk9PzuwV8w62mAJpRJL3vsgcfp4Qnsio= +golang.org/x/net v0.55.0 h1:bcvxaJn3e1U6InsFWt1JUq1aSjnRxLzT2rtD2KfkDF8= +golang.org/x/net v0.55.0/go.mod h1:L5U2KuzuOe1lY7Z+aWVIKK6qEeJXnXV9yzGA+WCHJww= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.46.0 h1:noSf2Fq6F8DBgS+LysIkx7rIExoNHJsxOAtPp4rthXw= +golang.org/x/sys v0.46.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/text v0.38.0 h1:sXmwo9DwP3OK9EZ7PqAdaooSGozfl/3a6/xJcbzPRhE= +golang.org/x/text v0.38.0/go.mod h1:YXZt3QhHUKYT53r2lLKFIVi6Ao1jdzrTR/KQ09qyxF4= +google.golang.org/protobuf v1.36.10 h1:AYd7cD/uASjIL6Q9LiTjz8JLcrh/88q5UObnmY3aOOE= +google.golang.org/protobuf v1.36.10/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gorm.io/gorm v1.31.1 h1:7CA8FTFz/gRfgqgpeKIBcervUn3xSyPUmr6B2WXJ7kg= +gorm.io/gorm v1.31.1/go.mod h1:XyQVbO2k6YkOis7C2437jSit3SsDK72s7n7rsSHd+Gs= +modernc.org/libc v1.22.5 h1:91BNch/e5B0uPbJFgqbxXuOnxBQjlS//icfQEGmvyjE= +modernc.org/libc v1.22.5/go.mod h1:jj+Z7dTNX8fBScMVNRAYZ/jF91K8fdT2hYMThc3YjBY= +modernc.org/mathutil v1.5.0 h1:rV0Ko/6SfM+8G+yKiyI830l3Wuz1zRutdslNoQ0kfiQ= +modernc.org/mathutil v1.5.0/go.mod h1:mZW8CKdRPY1v87qxC/wUdX5O1qDzXMP5TH3wjfpga6E= +modernc.org/memory v1.5.0 h1:N+/8c5rE6EqugZwHii4IFsaJ7MUhoWX07J5tC/iI5Ds= +modernc.org/memory v1.5.0/go.mod h1:PkUhL0Mugw21sHPeskwZW4D6VscE/GQJOnIpCnW6pSU= +modernc.org/sqlite v1.23.1 h1:nrSBg4aRQQwq59JpvGEQ15tNxoO5pX/kUjcRNwSAGQM= +modernc.org/sqlite v1.23.1/go.mod h1:OrDj17Mggn6MhE+iPbBNf7RGKODDE9NFT0f3EwDzJqk= diff --git a/internal/auth/crypto.go b/internal/auth/crypto.go new file mode 100644 index 0000000..8c27de7 --- /dev/null +++ b/internal/auth/crypto.go @@ -0,0 +1,74 @@ +package auth + +import ( + "crypto/aes" + "crypto/cipher" + "crypto/rand" + "crypto/sha256" + "encoding/base64" + "errors" + "fmt" + "io" +) + +func deriveKey(secret string) []byte { + h := sha256.Sum256([]byte(secret)) + return h[:] +} + +func Encrypt(plaintext, secret string) (string, error) { + key := deriveKey(secret) + block, err := aes.NewCipher(key) + if err != nil { + return "", err + } + gcm, err := cipher.NewGCM(block) + if err != nil { + return "", err + } + nonce := make([]byte, gcm.NonceSize()) + if _, err := io.ReadFull(rand.Reader, nonce); err != nil { + return "", err + } + ciphertext := gcm.Seal(nonce, nonce, []byte(plaintext), nil) + return base64.StdEncoding.EncodeToString(ciphertext), nil +} + +func Decrypt(encoded, secret string) (string, error) { + data, err := base64.StdEncoding.DecodeString(encoded) + if err != nil { + return "", err + } + key := deriveKey(secret) + block, err := aes.NewCipher(key) + if err != nil { + return "", err + } + gcm, err := cipher.NewGCM(block) + if err != nil { + return "", err + } + nonceSize := gcm.NonceSize() + if len(data) < nonceSize { + return "", errors.New("ciphertext too short") + } + nonce, ciphertext := data[:nonceSize], data[nonceSize:] + plain, err := gcm.Open(nil, nonce, ciphertext, nil) + if err != nil { + return "", err + } + return string(plain), nil +} + +func HashToken(token string) string { + h := sha256.Sum256([]byte(token)) + return fmt.Sprintf("%x", h) +} + +func GenerateToken(n int) (string, error) { + b := make([]byte, n) + if _, err := rand.Read(b); err != nil { + return "", err + } + return base64.URLEncoding.EncodeToString(b)[:n], nil +} diff --git a/internal/auth/loginlimit/limit.go b/internal/auth/loginlimit/limit.go new file mode 100644 index 0000000..0e4c6d0 --- /dev/null +++ b/internal/auth/loginlimit/limit.go @@ -0,0 +1,93 @@ +package loginlimit + +import ( + "sync" + "time" +) + +// Limiter tracks failed admin login attempts per client IP. +type Limiter struct { + mu sync.Mutex + attempts map[string]*record + maxAttempts int + lockout time.Duration +} + +type record struct { + failures int + lockedUntil time.Time +} + +func New(maxAttempts int, lockout time.Duration) *Limiter { + if maxAttempts <= 0 { + maxAttempts = 5 + } + if lockout <= 0 { + lockout = 15 * time.Minute + } + return &Limiter{ + attempts: make(map[string]*record), + maxAttempts: maxAttempts, + lockout: lockout, + } +} + +// Check reports whether the IP is locked and how long to wait. +func (l *Limiter) Check(ip string) (locked bool, retryAfter time.Duration) { + l.mu.Lock() + defer l.mu.Unlock() + r := l.attempts[ip] + if r == nil { + return false, 0 + } + now := time.Now() + if now.Before(r.lockedUntil) { + return true, r.lockedUntil.Sub(now) + } + if r.failures >= l.maxAttempts { + delete(l.attempts, ip) + } + return false, 0 +} + +// RecordFailure increments the failure count and locks the IP when the limit is reached. +func (l *Limiter) RecordFailure(ip string) { + l.mu.Lock() + defer l.mu.Unlock() + now := time.Now() + r := l.attempts[ip] + if r == nil { + r = &record{} + l.attempts[ip] = r + } + if now.Before(r.lockedUntil) { + return + } + if r.failures >= l.maxAttempts && !r.lockedUntil.IsZero() && now.After(r.lockedUntil) { + r.failures = 0 + r.lockedUntil = time.Time{} + } + r.failures++ + if r.failures >= l.maxAttempts { + r.lockedUntil = now.Add(l.lockout) + } +} + +// Configure updates rate limit parameters at runtime. +func (l *Limiter) Configure(maxAttempts int, lockout time.Duration) { + l.mu.Lock() + defer l.mu.Unlock() + if maxAttempts > 0 { + l.maxAttempts = maxAttempts + } + if lockout > 0 { + l.lockout = lockout + } +} + +// Reset clears failure state after a successful login. +func (l *Limiter) Reset(ip string) { + l.mu.Lock() + defer l.mu.Unlock() + delete(l.attempts, ip) +} diff --git a/internal/auth/loginlimit/limit_test.go b/internal/auth/loginlimit/limit_test.go new file mode 100644 index 0000000..17b5a88 --- /dev/null +++ b/internal/auth/loginlimit/limit_test.go @@ -0,0 +1,36 @@ +package loginlimit + +import ( + "testing" + "time" +) + +func TestLimiterLockout(t *testing.T) { + l := New(3, time.Minute) + ip := "192.168.1.1" + + for i := 0; i < 2; i++ { + if locked, _ := l.Check(ip); locked { + t.Fatalf("unexpected lock at attempt %d", i) + } + l.RecordFailure(ip) + } + + if locked, _ := l.Check(ip); locked { + t.Fatal("should not lock before max attempts") + } + l.RecordFailure(ip) + + locked, retry := l.Check(ip) + if !locked { + t.Fatal("expected lock after max failures") + } + if retry <= 0 { + t.Fatal("expected positive retry duration") + } + + l.Reset(ip) + if locked, _ := l.Check(ip); locked { + t.Fatal("expected unlock after reset") + } +} diff --git a/internal/auth/password.go b/internal/auth/password.go new file mode 100644 index 0000000..f1eb40e --- /dev/null +++ b/internal/auth/password.go @@ -0,0 +1,30 @@ +package auth + +import ( + "strings" + + "golang.org/x/crypto/bcrypt" +) + +func HashPassword(password string) (string, error) { + b, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost) + return string(b), err +} + +func CheckPassword(hash, password string) bool { + return bcrypt.CompareHashAndPassword([]byte(hash), []byte(password)) == nil +} + +// HashIngressKey stores a SHA-256 hex digest for O(1) cache lookup. +func HashIngressKey(key string) string { + return HashToken(key) +} + +func IsLegacyIngressHash(hash string) bool { + return strings.HasPrefix(hash, "$2a$") || strings.HasPrefix(hash, "$2b$") +} + +// CheckIngressKey verifies legacy bcrypt-hashed ingress keys. +func CheckIngressKey(hash, key string) bool { + return bcrypt.CompareHashAndPassword([]byte(hash), []byte(key)) == nil +} diff --git a/internal/balancer/weighted.go b/internal/balancer/weighted.go new file mode 100644 index 0000000..747a263 --- /dev/null +++ b/internal/balancer/weighted.go @@ -0,0 +1,49 @@ +package balancer + +import ( + "math/rand" + + "github.com/rose_cat707/luminary/internal/model" +) + +type KeyCandidate struct { + Key model.ProviderKey + Weight float64 +} + +func SelectWeighted(candidates []KeyCandidate) *model.ProviderKey { + if len(candidates) == 0 { + return nil + } + if len(candidates) == 1 { + k := candidates[0].Key + return &k + } + var total float64 + for _, c := range candidates { + w := c.Weight + if w <= 0 { + w = 1 + } + total += w + } + if total <= 0 { + k := candidates[0].Key + return &k + } + r := rand.Float64() * total + var acc float64 + for _, c := range candidates { + w := c.Weight + if w <= 0 { + w = 1 + } + acc += w + if r <= acc { + k := c.Key + return &k + } + } + k := candidates[len(candidates)-1].Key + return &k +} diff --git a/internal/balancer/weighted_test.go b/internal/balancer/weighted_test.go new file mode 100644 index 0000000..a4fd5c8 --- /dev/null +++ b/internal/balancer/weighted_test.go @@ -0,0 +1,32 @@ +package balancer_test + +import ( + "testing" + + "github.com/rose_cat707/luminary/internal/balancer" + "github.com/rose_cat707/luminary/internal/model" +) + +func TestSelectWeighted(t *testing.T) { + candidates := []balancer.KeyCandidate{ + {Key: model.ProviderKey{ID: 1, Name: "a"}, Weight: 1}, + {Key: model.ProviderKey{ID: 2, Name: "b"}, Weight: 3}, + } + counts := map[uint]int{} + for i := 0; i < 1000; i++ { + k := balancer.SelectWeighted(candidates) + if k == nil { + t.Fatal("nil key") + } + counts[k.ID]++ + } + if counts[2] <= counts[1] { + t.Fatalf("expected heavier weight key selected more often: %v", counts) + } +} + +func TestSelectWeightedEmpty(t *testing.T) { + if k := balancer.SelectWeighted(nil); k != nil { + t.Fatal("expected nil") + } +} diff --git a/internal/gateway/errors.go b/internal/gateway/errors.go new file mode 100644 index 0000000..7508093 --- /dev/null +++ b/internal/gateway/errors.go @@ -0,0 +1,11 @@ +package gateway + +import "errors" + +var ( + ErrModelNotAllowed = errors.New("model not allowed") + ErrBudgetExceeded = errors.New("budget exceeded") + ErrRateLimitExceeded = errors.New("rate limit exceeded") + ErrInvalidJSON = errors.New("invalid json") + ErrModelRequired = errors.New("model is required") +) diff --git a/internal/gateway/failover.go b/internal/gateway/failover.go new file mode 100644 index 0000000..be4ca14 --- /dev/null +++ b/internal/gateway/failover.go @@ -0,0 +1,160 @@ +package gateway + +import ( + "context" + "fmt" + "sort" + "time" + + "github.com/rose_cat707/luminary/internal/balancer" + "github.com/rose_cat707/luminary/internal/limiter" + "github.com/rose_cat707/luminary/internal/model" + "github.com/rose_cat707/luminary/internal/provider" + "github.com/rose_cat707/luminary/internal/store/cache" +) + +type attempt struct { + provider *model.Provider + key *model.ProviderKey + apiKey string +} + +func (s *Service) buildAttempts(providers []model.Provider, modelName string, route *routeTarget) ([]attempt, error) { + var attempts []attempt + var decryptFailures int + var eligibleKeys int + for i := range providers { + p := providers[i] + keys := s.listEligibleKeys(p.ID, modelName, route) + eligibleKeys += len(keys) + for _, k := range keys { + plain, err := s.DecryptAPIKey(k.APIKeyEnc) + if err != nil { + decryptFailures++ + continue + } + attempts = append(attempts, attempt{provider: &p, key: &k, apiKey: plain}) + } + } + if len(attempts) == 0 { + if decryptFailures > 0 { + return nil, fmt.Errorf("no available provider keys: %d key(s) failed to decrypt (check encryption_key in system settings)", decryptFailures) + } + if eligibleKeys == 0 { + return nil, fmt.Errorf("no available provider keys: none match model %q or are disabled/rate-limited", modelName) + } + return nil, fmt.Errorf("no available provider keys") + } + sort.SliceStable(attempts, func(i, j int) bool { + return attempts[i].key.Weight > attempts[j].key.Weight + }) + return attempts, nil +} + +func (s *Service) listEligibleKeys(providerID uint, modelName string, route *routeTarget) []model.ProviderKey { + if route != nil && route.ProviderKeyID > 0 && route.ProviderID == providerID { + if k, ok := s.cache.GetProviderKey(route.ProviderKeyID); ok && k.Enabled && limiter.IsProviderKeyAvailable(k) { + return []model.ProviderKey{k} + } + } + var candidates []balancer.KeyCandidate + for _, k := range s.cache.ListProviderKeys(providerID) { + if !limiter.IsProviderKeyAvailable(k) { + continue + } + allowed := cache.ParseJSONList(k.ModelsJSON) + if modelName != "" && !cache.MatchesList(allowed, "*") { + upstream := s.resolveUpstreamModelID(providerID, modelName) + if !cache.MatchesList(allowed, modelName) && !cache.MatchesList(allowed, upstream) { + continue + } + } + candidates = append(candidates, balancer.KeyCandidate{Key: k, Weight: k.Weight}) + } + // collect unique keys by weighted random sampling order + seen := map[uint]bool{} + var out []model.ProviderKey + for len(out) < len(candidates) { + selected := balancer.SelectWeighted(candidates) + if selected == nil { + break + } + if seen[selected.ID] { + // remove from candidates + filtered := candidates[:0] + for _, c := range candidates { + if c.Key.ID != selected.ID { + filtered = append(filtered, c) + } + } + candidates = filtered + if len(candidates) == 0 { + break + } + continue + } + seen[selected.ID] = true + out = append(out, *selected) + filtered := candidates[:0] + for _, c := range candidates { + if c.Key.ID != selected.ID { + filtered = append(filtered, c) + } + } + candidates = filtered + } + return out +} + +func (s *Service) forwardWithFailover(ctx context.Context, attempts []attempt, endpoint provider.EndpointType, body []byte) (*provider.ChatResponse, *attempt, error) { + var lastErr error + var lastAtt *attempt + var lastResp *provider.ChatResponse + maxRetries := s.settings.GatewayMaxRetries() + if maxRetries < 1 { + maxRetries = 1 + } + backoffMs := s.settings.GatewayRetryBackoffMs() + for _, att := range attempts { + if !s.canServeEndpoint(att.provider, endpoint) { + continue + } + for try := 0; try < maxRetries; try++ { + if try > 0 { + time.Sleep(time.Duration(backoffMs) * time.Millisecond) + } + resp, err := s.forward(ctx, att.provider, att.apiKey, endpoint, body) + cur := att + lastAtt = &cur + if resp != nil { + lastResp = resp + } + if err != nil { + lastErr = err + continue + } + if resp == nil { + lastErr = fmt.Errorf("empty upstream response") + continue + } + if resp.StatusCode < 400 { + return resp, &att, nil + } + lastErr = fmt.Errorf("%s", describeForwardError(nil, resp)) + if passThroughUpstreamStatus(resp.StatusCode) { + return resp, &att, nil + } + if tryNextKeyOnUpstreamStatus(resp.StatusCode) { + break // try next provider key + } + if retryableUpstreamStatus(resp.StatusCode) { + continue // retry same key + } + return resp, &att, nil + } + } + if lastErr == nil { + lastErr = fmt.Errorf("all providers failed") + } + return lastResp, lastAtt, lastErr +} diff --git a/internal/gateway/health_check.go b/internal/gateway/health_check.go new file mode 100644 index 0000000..00b9c7e --- /dev/null +++ b/internal/gateway/health_check.go @@ -0,0 +1,190 @@ +package gateway + +import ( + "context" + "encoding/json" + "fmt" + "time" + + "github.com/rose_cat707/luminary/internal/model" + "github.com/rose_cat707/luminary/internal/provider" +) + +func (s *Service) healthCheckEndpoints(p *model.Provider) []provider.EndpointType { + var out []provider.EndpointType + if s.providerAllowsEndpoint(p, provider.EndpointListModels) { + out = append(out, provider.EndpointListModels) + } + switch p.Category { + case model.CategoryEmbedding: + if s.providerAllowsEndpoint(p, provider.EndpointEmbeddings) { + out = append(out, provider.EndpointEmbeddings) + } + case model.CategoryRerank: + if s.providerAllowsEndpoint(p, provider.EndpointRerank) { + out = append(out, provider.EndpointRerank) + } + } + return out +} + +func (s *Service) runHealthChecks( + ctx context.Context, + p *model.Provider, + keys []model.ProviderKey, + cfg provider.ProviderConfig, +) (model.ProviderStatus, int64, string) { + endpoints := s.healthCheckEndpoints(p) + if len(endpoints) == 0 { + return model.ProviderUnhealthy, 0, "no health check endpoint enabled" + } + start := time.Now() + var lastErr string + for _, ep := range endpoints { + status, _, msg := s.runHealthCheck(ctx, p, keys, cfg, ep) + if status == model.ProviderHealthy { + return model.ProviderHealthy, time.Since(start).Milliseconds(), "" + } + if msg != "" { + lastErr = msg + } + } + if lastErr == "" { + lastErr = "health check failed" + } + return model.ProviderUnhealthy, time.Since(start).Milliseconds(), lastErr +} + +func (s *Service) runHealthCheck( + ctx context.Context, + p *model.Provider, + keys []model.ProviderKey, + cfg provider.ProviderConfig, + endpoint provider.EndpointType, +) (model.ProviderStatus, int64, string) { + start := time.Now() + latency := func() int64 { return time.Since(start).Milliseconds() } + + switch endpoint { + case provider.EndpointListModels: + if len(keys) == 0 && p.Type == model.ProviderOllama { + if err := s.openai.HealthCheckWithConfig(ctx, "", cfg); err == nil { + return model.ProviderHealthy, latency(), "" + } + return model.ProviderUnhealthy, latency(), "health check failed" + } + for _, k := range keys { + if !k.Enabled { + continue + } + apiKey, err := s.DecryptAPIKey(k.APIKeyEnc) + if err != nil { + continue + } + var checkErr error + if p.Type == model.ProviderAnthropic { + checkErr = s.anthropic.HealthCheckWithConfig(ctx, apiKey, cfg) + } else { + checkErr = s.openai.HealthCheckWithConfig(ctx, apiKey, cfg) + } + if checkErr == nil { + return model.ProviderHealthy, latency(), "" + } + } + return model.ProviderUnhealthy, latency(), "health check failed" + + case provider.EndpointEmbeddings, provider.EndpointRerank: + body, err := healthProbeBody(s, p, endpoint) + if err != nil { + return model.ProviderUnhealthy, latency(), err.Error() + } + if len(keys) == 0 && p.Type == model.ProviderOllama { + ok, msg := s.probeEndpoint(ctx, "", cfg, endpoint, body) + if ok { + return model.ProviderHealthy, latency(), "" + } + return model.ProviderUnhealthy, latency(), msg + } + var lastErr string + for _, k := range keys { + if !k.Enabled { + continue + } + apiKey, err := s.DecryptAPIKey(k.APIKeyEnc) + if err != nil { + continue + } + if ok, msg := s.probeEndpoint(ctx, apiKey, cfg, endpoint, body); ok { + return model.ProviderHealthy, latency(), "" + } else if msg != "" { + lastErr = msg + } + } + if lastErr == "" { + lastErr = "health check failed" + } + return model.ProviderUnhealthy, latency(), lastErr + default: + return model.ProviderUnhealthy, latency(), "unsupported health check endpoint" + } +} + +func (s *Service) probeEndpoint( + ctx context.Context, + apiKey string, + cfg provider.ProviderConfig, + endpoint provider.EndpointType, + body []byte, +) (bool, string) { + resp, err := s.openai.ForwardWithConfig(ctx, apiKey, cfg, endpoint, body) + if err != nil { + return false, err.Error() + } + if resp == nil { + return false, "empty response" + } + if isHealthCheckHTTPStatus(resp.StatusCode) { + return true, "" + } + return false, fmt.Sprintf("%s: HTTP %d", endpoint, resp.StatusCode) +} + +func healthProbeBody(s *Service, p *model.Provider, endpoint provider.EndpointType) ([]byte, error) { + modelID := probeModelID(s, p.ID, endpoint) + switch endpoint { + case provider.EndpointEmbeddings: + return json.Marshal(map[string]string{"model": modelID, "input": "."}) + case provider.EndpointRerank: + return json.Marshal(map[string]any{ + "model": modelID, + "query": "ping", + "documents": []string{"ping"}, + }) + default: + return nil, fmt.Errorf("unsupported probe endpoint %s", endpoint) + } +} + +func probeModelID(s *Service, providerID uint, endpoint provider.EndpointType) string { + for _, m := range s.cache.GetModels(providerID) { + if m.Enabled && m.ModelID != "" { + return m.ModelID + } + } + switch endpoint { + case provider.EndpointEmbeddings: + return "text-embedding-3-small" + case provider.EndpointRerank: + return "rerank-multilingual-v3.0" + default: + return "health-check" + } +} + +func isHealthCheckHTTPStatus(code int) bool { + if code >= 200 && code < 300 { + return true + } + // Upstream reachable but rejected payload/model — still counts as healthy. + return code == 400 || code == 422 +} diff --git a/internal/gateway/health_check_test.go b/internal/gateway/health_check_test.go new file mode 100644 index 0000000..84173dc --- /dev/null +++ b/internal/gateway/health_check_test.go @@ -0,0 +1,51 @@ +package gateway + +import ( + "testing" + + "github.com/rose_cat707/luminary/internal/model" + "github.com/rose_cat707/luminary/internal/provider" +) + +func TestIsHealthCheckHTTPStatus(t *testing.T) { + cases := map[int]bool{ + 200: true, + 204: true, + 400: true, + 422: true, + 401: false, + 404: false, + 500: false, + } + for code, want := range cases { + if got := isHealthCheckHTTPStatus(code); got != want { + t.Fatalf("code %d: got %v want %v", code, got, want) + } + } +} + +func TestHealthCheckEndpointsEmbedding(t *testing.T) { + s := &Service{} + p := &model.Provider{ + Category: model.CategoryEmbedding, + Type: model.ProviderOpenAI, + AllowedEndpointsJSON: `["embeddings"]`, + } + eps := s.healthCheckEndpoints(p) + if len(eps) != 1 || eps[0] != provider.EndpointEmbeddings { + t.Fatalf("got %v", eps) + } +} + +func TestHealthCheckEndpointsEmbeddingWithListModels(t *testing.T) { + s := &Service{} + p := &model.Provider{ + Category: model.CategoryEmbedding, + Type: model.ProviderOpenAI, + AllowedEndpointsJSON: `["list_models","embeddings"]`, + } + eps := s.healthCheckEndpoints(p) + if len(eps) != 2 || eps[0] != provider.EndpointListModels || eps[1] != provider.EndpointEmbeddings { + t.Fatalf("got %v", eps) + } +} diff --git a/internal/gateway/logbody.go b/internal/gateway/logbody.go new file mode 100644 index 0000000..8867482 --- /dev/null +++ b/internal/gateway/logbody.go @@ -0,0 +1,90 @@ +package gateway + +import ( + "encoding/json" + "fmt" + "strings" + + "github.com/rose_cat707/luminary/internal/provider" +) + +const maxLogBodyBytes = 16384 + +func truncateLogBody(b []byte) string { + if len(b) == 0 { + return "" + } + if len(b) <= maxLogBodyBytes { + return string(b) + } + return string(b[:maxLogBodyBytes]) + "\n...(truncated)" +} + +func extractModelName(body []byte) string { + if len(body) == 0 { + return "" + } + var payload struct { + Model string `json:"model"` + } + if err := json.Unmarshal(body, &payload); err != nil { + return "" + } + return payload.Model +} + +func describeForwardError(err error, resp *provider.ChatResponse) string { + if err != nil { + return err.Error() + } + if resp == nil { + return "unknown error" + } + if resp.StatusCode < 400 { + return "" + } + if msg := extractAPIErrorMessage(resp.Body); msg != "" { + return fmt.Sprintf("status %d: %s", resp.StatusCode, msg) + } + if len(resp.Body) > 0 { + return fmt.Sprintf("status %d: %s", resp.StatusCode, truncateLogBody(resp.Body)) + } + return fmt.Sprintf("status %d", resp.StatusCode) +} + +func extractAPIErrorMessage(body []byte) string { + if len(body) == 0 { + return "" + } + var openAI struct { + Error struct { + Message string `json:"message"` + Type string `json:"type"` + } `json:"error"` + } + if err := json.Unmarshal(body, &openAI); err == nil { + if openAI.Error.Message != "" { + if openAI.Error.Type != "" { + return openAI.Error.Type + ": " + openAI.Error.Message + } + return openAI.Error.Message + } + } + var simple struct { + Message string `json:"message"` + Error string `json:"error"` + } + if err := json.Unmarshal(body, &simple); err == nil { + if simple.Message != "" { + return simple.Message + } + if simple.Error != "" { + return simple.Error + } + } + s := strings.TrimSpace(string(body)) + if len(s) > 240 { + return s[:240] + "..." + } + return s +} diff --git a/internal/gateway/logbody_test.go b/internal/gateway/logbody_test.go new file mode 100644 index 0000000..60de370 --- /dev/null +++ b/internal/gateway/logbody_test.go @@ -0,0 +1,25 @@ +package gateway + +import ( + "testing" + + "github.com/rose_cat707/luminary/internal/provider" +) + +func TestExtractAPIErrorMessage_OpenAI(t *testing.T) { + body := []byte(`{"error":{"message":"Invalid API key","type":"invalid_request_error"}}`) + got := extractAPIErrorMessage(body) + if got != "invalid_request_error: Invalid API key" { + t.Fatalf("got %q", got) + } +} + +func TestDescribeForwardError_WithBody(t *testing.T) { + got := describeForwardError(nil, &provider.ChatResponse{ + StatusCode: 400, + Body: []byte(`{"error":{"message":"model not found"}}`), + }) + if got != "status 400: model not found" { + t.Fatalf("got %q", got) + } +} diff --git a/internal/gateway/model_resolve.go b/internal/gateway/model_resolve.go new file mode 100644 index 0000000..810b313 --- /dev/null +++ b/internal/gateway/model_resolve.go @@ -0,0 +1,53 @@ +package gateway + +import ( + "encoding/json" + + "github.com/rose_cat707/luminary/internal/model" +) + +// IngressModelID returns the model identifier exposed to ingress clients. +func IngressModelID(m model.ModelEntry) string { + return m.IngressID() +} + +func modelEntryMatches(m model.ModelEntry, name string) bool { + return m.MatchesIngressName(name) +} + +func (s *Service) resolveUpstreamModelID(providerID uint, ingressModel string) string { + if ingressModel == "" { + return "" + } + for _, m := range s.cache.GetModels(providerID) { + if modelEntryMatches(m, ingressModel) { + return m.ModelID + } + } + return ingressModel +} + +func rewriteModelInBody(body []byte, upstreamModel string) ([]byte, error) { + if upstreamModel == "" || len(body) == 0 { + return body, nil + } + var payload map[string]any + if err := json.Unmarshal(body, &payload); err != nil { + return body, nil + } + current, _ := payload["model"].(string) + if current == "" || current == upstreamModel { + return body, nil + } + payload["model"] = upstreamModel + return json.Marshal(payload) +} + +func (s *Service) rewriteBodyForProvider(providerID uint, body []byte) ([]byte, error) { + ingressModel := extractModelName(body) + if ingressModel == "" { + return body, nil + } + upstream := s.resolveUpstreamModelID(providerID, ingressModel) + return rewriteModelInBody(body, upstream) +} diff --git a/internal/gateway/model_resolve_test.go b/internal/gateway/model_resolve_test.go new file mode 100644 index 0000000..7ee3676 --- /dev/null +++ b/internal/gateway/model_resolve_test.go @@ -0,0 +1,32 @@ +package gateway + +import ( + "encoding/json" + "testing" + + "github.com/rose_cat707/luminary/internal/model" +) + +func TestIngressModelID(t *testing.T) { + if got := IngressModelID(model.ModelEntry{ModelID: "text-embedding-3-small", Alias: "my-embed"}); got != "my-embed" { + t.Fatalf("got %q", got) + } + if got := (model.ModelEntry{ModelID: "gpt-4o"}).IngressID(); got != "gpt-4o" { + t.Fatalf("got %q", got) + } +} + +func TestRewriteModelInBody(t *testing.T) { + body := []byte(`{"model":"my-embed","input":"hi"}`) + out, err := rewriteModelInBody(body, "text-embedding-3-small") + if err != nil { + t.Fatal(err) + } + var payload map[string]string + if err := json.Unmarshal(out, &payload); err != nil { + t.Fatal(err) + } + if payload["model"] != "text-embedding-3-small" { + t.Fatalf("got %q", payload["model"]) + } +} diff --git a/internal/gateway/routing.go b/internal/gateway/routing.go new file mode 100644 index 0000000..dc2b0ef --- /dev/null +++ b/internal/gateway/routing.go @@ -0,0 +1,104 @@ +package gateway + +import ( + "strings" + + "github.com/rose_cat707/luminary/internal/model" + "github.com/rose_cat707/luminary/internal/store/cache" +) + +type routeTarget struct { + ProviderID uint + ProviderKeyID uint // 0 = auto load balance +} + +func (s *Service) resolveRoute(ingressID uint, modelName string) *routeTarget { + rules := s.cache.ListRoutingRules(ingressID) + var best *model.RoutingRule + for i := range rules { + r := rules[i] + if !r.Enabled { + continue + } + if !matchPattern(r.ModelPattern, modelName) { + continue + } + if best == nil || r.Priority > best.Priority { + best = &r + } + } + if best == nil { + return nil + } + return &routeTarget{ProviderID: best.ProviderID, ProviderKeyID: best.ProviderKeyID} +} + +func matchPattern(pattern, model string) bool { + if pattern == "" || pattern == "*" { + return true + } + if strings.HasSuffix(pattern, "*") { + return strings.HasPrefix(model, strings.TrimSuffix(pattern, "*")) + } + return pattern == model +} + +func (s *Service) findProvidersForModel(modelName string, allowed []string, category model.ProviderCategory, route *routeTarget) ([]model.Provider, error) { + if route != nil { + if p, ok := s.cache.GetProvider(route.ProviderID); ok && p.Category == category && p.Enabled { + if cache.MatchesList(allowed, p.Name) || cache.MatchesList(allowed, string(p.Type)) || cache.MatchesList(allowed, "*") { + return []model.Provider{p}, nil + } + } + } + var matched []model.Provider + for _, p := range s.cache.ListProviders() { + if p.Category != category || !model.IsCategorySupported(p.Category) || !p.Enabled { + continue + } + if !cache.MatchesList(allowed, p.Name) && !cache.MatchesList(allowed, string(p.Type)) { + continue + } + if s.providerSupportsModel(&p, modelName) { + matched = append(matched, p) + } + } + if len(matched) == 0 { + if parts := strings.SplitN(modelName, "/", 2); len(parts) == 2 { + if p, ok := s.cache.GetProviderByName(parts[0]); ok && p.Category == category && p.Enabled { + if s.providerSupportsModel(&p, parts[1]) { + return []model.Provider{p}, nil + } + } + } + return nil, errNoProvider(modelName) + } + return matched, nil +} + +func (s *Service) providerSupportsModel(p *model.Provider, modelName string) bool { + models := s.cache.GetModels(p.ID) + if len(models) == 0 { + for _, k := range s.cache.ListProviderKeys(p.ID) { + allowed := cache.ParseJSONList(k.ModelsJSON) + if cache.MatchesList(allowed, modelName) || cache.MatchesList(allowed, "*") { + return true + } + } + return false + } + for _, m := range models { + if m.Enabled && modelEntryMatches(m, modelName) { + return true + } + } + return false +} + +func errNoProvider(model string) error { + return &gatewayError{msg: "no provider found for model " + model} +} + +type gatewayError struct{ msg string } + +func (e *gatewayError) Error() string { return e.msg } diff --git a/internal/gateway/service.go b/internal/gateway/service.go new file mode 100644 index 0000000..5bef403 --- /dev/null +++ b/internal/gateway/service.go @@ -0,0 +1,475 @@ +package gateway + +import ( + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "time" + + "github.com/rose_cat707/luminary/internal/auth" + "github.com/rose_cat707/luminary/internal/limiter" + "github.com/rose_cat707/luminary/internal/model" + "github.com/rose_cat707/luminary/internal/pricing" + "github.com/rose_cat707/luminary/internal/provider" + "github.com/rose_cat707/luminary/internal/provider/anthropic" + "github.com/rose_cat707/luminary/internal/provider/comfyui" + "github.com/rose_cat707/luminary/internal/provider/openai" + "github.com/rose_cat707/luminary/internal/settings" + "github.com/rose_cat707/luminary/internal/store/cache" +) + +type Service struct { + cache *cache.Store + settings *settings.Runtime + openai *openai.Client + anthropic *anthropic.Client + streamClient *provider.HTTPClientWrapper +} + +func New(c *cache.Store, rt *settings.Runtime) *Service { + return &Service{ + cache: c, + settings: rt, + openai: openai.New(), + anthropic: anthropic.New(), + streamClient: &provider.HTTPClientWrapper{Client: provider.NewStreamHTTPClient()}, + } +} + +func (s *Service) ResolveIngressKey(plainKey string) (*model.IngressKey, error) { + k, ok := s.cache.LookupIngressKey(plainKey) + if !ok { + return nil, fmt.Errorf("invalid api key") + } + if k.ExpiresAt != nil && k.ExpiresAt.Before(time.Now()) { + return nil, fmt.Errorf("api key expired") + } + return k, nil +} + +func (s *Service) providerConfig(p *model.Provider) provider.ProviderConfig { + return provider.ProviderConfig{ + Type: p.Type, + Category: p.Category, + BaseURL: p.BaseURL, + EndpointPaths: provider.ParseEndpointPaths(p.EndpointPathsJSON), + } +} + +func (s *Service) DecryptAPIKey(enc string) (string, error) { + return auth.Decrypt(enc, s.settings.EncryptionKey()) +} + +func (s *Service) providerAllowsEndpoint(p *model.Provider, endpoint provider.EndpointType) bool { + allowed := provider.ParseAllowedEndpoints(p.AllowedEndpointsJSON, p.Category) + return provider.IsEndpointAllowed(allowed, endpoint) +} + +func (s *Service) canServeEndpoint(p *model.Provider, endpoint provider.EndpointType) bool { + if s.providerAllowsEndpoint(p, endpoint) { + return true + } + return endpoint == provider.EndpointResponses && s.providerAllowsEndpoint(p, provider.EndpointChatCompletion) +} + +func (s *Service) shouldAdaptResponses(p *model.Provider) bool { + return !s.providerAllowsEndpoint(p, provider.EndpointResponses) && s.providerAllowsEndpoint(p, provider.EndpointChatCompletion) +} + +func (s *Service) forward(ctx context.Context, p *model.Provider, apiKey string, endpoint provider.EndpointType, body []byte) (*provider.ChatResponse, error) { + var err error + body, err = s.rewriteBodyForProvider(p.ID, body) + if err != nil { + return nil, err + } + actualEndpoint := endpoint + actualBody := body + adaptResponses := false + + if endpoint == provider.EndpointResponses { + if s.shouldAdaptResponses(p) { + adapted, err := provider.ResponsesToChatCompletion(body) + if err != nil { + return nil, err + } + actualBody = adapted + actualEndpoint = provider.EndpointChatCompletion + adaptResponses = true + } else if !s.providerAllowsEndpoint(p, endpoint) { + return nil, fmt.Errorf("endpoint %s not enabled on provider %s", endpoint, p.Name) + } + } else if !s.providerAllowsEndpoint(p, endpoint) { + return nil, fmt.Errorf("endpoint %s not enabled on provider %s", endpoint, p.Name) + } + + resp, err := s.forwardRaw(ctx, p, apiKey, actualEndpoint, actualBody) + if err != nil { + return nil, err + } + + if endpoint == provider.EndpointResponses && !adaptResponses && resp != nil && resp.StatusCode == http.StatusNotFound && s.providerAllowsEndpoint(p, provider.EndpointChatCompletion) { + adaptedBody, adaptErr := provider.ResponsesToChatCompletion(body) + if adaptErr == nil { + if resp2, err2 := s.forwardRaw(ctx, p, apiKey, provider.EndpointChatCompletion, adaptedBody); err2 == nil && resp2 != nil { + resp = resp2 + adaptResponses = true + } + } + } + + if adaptResponses && resp != nil && resp.StatusCode < 400 { + converted, convErr := provider.ChatCompletionToResponses(resp.Body) + if convErr == nil { + resp.Body = converted + } + } + return resp, nil +} + +func (s *Service) forwardRaw(ctx context.Context, p *model.Provider, apiKey string, endpoint provider.EndpointType, body []byte) (*provider.ChatResponse, error) { + cfg := s.providerConfig(p) + if p.Type == model.ProviderAnthropic && endpoint == provider.EndpointChatCompletion { + return s.anthropic.ForwardWithConfig(ctx, apiKey, cfg, endpoint, body) + } + return s.openai.ForwardWithConfig(ctx, apiKey, cfg, endpoint, body) +} + +func (s *Service) forwardStream(ctx context.Context, p *model.Provider, apiKey string, endpoint provider.EndpointType, body []byte, w io.Writer, flusher http.Flusher) (*provider.StreamResult, error) { + var err error + body, err = s.rewriteBodyForProvider(p.ID, body) + if err != nil { + return nil, err + } + actualEndpoint := endpoint + actualBody := body + adaptResponses := false + + if endpoint == provider.EndpointResponses { + if s.shouldAdaptResponses(p) { + adapted, err := provider.ResponsesToChatCompletion(body) + if err != nil { + return nil, err + } + actualBody = adapted + actualEndpoint = provider.EndpointChatCompletion + adaptResponses = true + } else if !s.providerAllowsEndpoint(p, endpoint) { + return nil, fmt.Errorf("endpoint %s not enabled", endpoint) + } + } else if !s.providerAllowsEndpoint(p, endpoint) { + return nil, fmt.Errorf("endpoint %s not enabled", endpoint) + } + + cfg := s.providerConfig(p) + url := cfg.ResolveEndpointURL(actualEndpoint) + + if adaptResponses { + pr, pw := io.Pipe() + errCh := make(chan error, 1) + go func() { + _, err := provider.ForwardStreamHTTP(ctx, s.streamClient.Client, "POST", url, apiKey, p.Type, actualBody, pw, nil) + _ = pw.Close() + errCh <- err + }() + result, err := provider.AdaptResponsesStream(body, pr, w, flusher) + if streamErr := <-errCh; err == nil && streamErr != nil { + err = streamErr + } + return result, err + } + + return provider.ForwardStreamHTTP(ctx, s.streamClient.Client, "POST", url, apiKey, p.Type, actualBody, w, flusher) +} + +type forwardParams struct { + ingress *model.IngressKey + endpoint provider.EndpointType + category model.ProviderCategory + body []byte + stream bool +} + +func (s *Service) prepareForward(params forwardParams) (modelName string, providers []model.Provider, route *routeTarget, err error) { + if len(params.body) > 0 { + var payload map[string]any + if err := json.Unmarshal(params.body, &payload); err != nil { + return "", nil, nil, fmt.Errorf("%w: %v", ErrInvalidJSON, err) + } + modelName, _ = payload["model"].(string) + } + if params.endpoint != provider.EndpointListModels && modelName == "" { + return "", nil, nil, ErrModelRequired + } + allowedProviders := cache.ParseJSONList(params.ingress.AllowedProvidersJSON) + if modelName != "" { + allowedModels := cache.ParseJSONList(params.ingress.AllowedModelsJSON) + if !cache.MatchesList(allowedModels, modelName) && !cache.MatchesList(allowedModels, "*") { + return "", nil, nil, ErrModelNotAllowed + } + } + if !limiter.CheckIngressBudget(params.ingress, 0) { + return "", nil, nil, ErrBudgetExceeded + } + if !limiter.CheckRateLimit(s.cache, params.ingress.ID, params.ingress.RateLimitRPM, params.ingress.RateLimitTPM, 1000) { + return "", nil, nil, ErrRateLimitExceeded + } + route = s.resolveRoute(params.ingress.ID, modelName) + providers, err = s.findProvidersForModel(modelName, allowedProviders, params.category, route) + return modelName, providers, route, err +} + +func (s *Service) recordUsage(ingress *model.IngressKey, att *attempt, clientIP, modelName, endpoint string, stream bool, tokensIn, tokensOut int, latency int64, status, errMsg, requestBody, responseBody string) { + var providerKeyID, providerID uint + if att != nil { + providerKeyID = att.key.ID + providerID = att.provider.ID + } + cost := pricing.Estimate(modelName, tokensIn, tokensOut, endpoint) + s.cache.RecordUsage(cache.UsageDelta{ + IngressKeyID: ingress.ID, + ProviderKeyID: providerKeyID, + ProviderID: providerID, + ClientIP: clientIP, + Endpoint: endpoint, + Model: modelName, + TokensIn: tokensIn, + TokensOut: tokensOut, + Cost: cost, + LatencyMs: latency, + Status: status, + Stream: stream, + ErrorMsg: errMsg, + RequestBody: requestBody, + ResponseBody: responseBody, + BudgetUsed: cost, + Requests: 1, + Tokens: int64(tokensIn + tokensOut), + }) +} + +func (s *Service) ForwardEndpoint(ctx context.Context, ingress *model.IngressKey, endpoint provider.EndpointType, rawBody []byte, clientIP string) (*provider.ChatResponse, error) { + category := categoryForEndpoint(endpoint) + modelName, providers, route, err := s.prepareForward(forwardParams{ + ingress: ingress, endpoint: endpoint, category: category, body: rawBody, + }) + if err != nil { + s.recordUsage(ingress, nil, clientIP, modelNameOrBody(rawBody, modelName), string(endpoint), false, 0, 0, 0, "error", err.Error(), truncateLogBody(rawBody), "") + return nil, err + } + attempts, err := s.buildAttempts(providers, modelName, route) + if err != nil { + s.recordUsage(ingress, nil, clientIP, modelName, string(endpoint), false, 0, 0, 0, "error", err.Error(), truncateLogBody(rawBody), "") + return nil, err + } + start := time.Now() + resp, att, err := s.forwardWithFailover(ctx, attempts, endpoint, rawBody) + latency := time.Since(start).Milliseconds() + status := "success" + if err != nil || (resp != nil && resp.StatusCode >= 400) { + status = "error" + } + errMsg := describeForwardError(err, resp) + tokensIn, tokensOut := 0, 0 + var respBody []byte + if resp != nil { + tokensIn, tokensOut = resp.TokensIn, resp.TokensOut + respBody = resp.Body + } + s.recordUsage(ingress, att, clientIP, modelName, string(endpoint), false, tokensIn, tokensOut, latency, status, errMsg, truncateLogBody(rawBody), truncateLogBody(respBody)) + if err != nil && resp != nil && resp.StatusCode >= 400 { + return resp, nil + } + return resp, err +} + +func modelNameOrBody(body []byte, modelName string) string { + if modelName != "" { + return modelName + } + return extractModelName(body) +} + +func (s *Service) ForwardStream(ctx context.Context, ingress *model.IngressKey, endpoint provider.EndpointType, rawBody []byte, clientIP string, w io.Writer, flusher http.Flusher) error { + category := categoryForEndpoint(endpoint) + modelName, providers, route, err := s.prepareForward(forwardParams{ + ingress: ingress, endpoint: endpoint, category: category, body: rawBody, stream: true, + }) + if err != nil { + s.recordUsage(ingress, nil, clientIP, modelNameOrBody(rawBody, modelName), string(endpoint), true, 0, 0, 0, "error", err.Error(), truncateLogBody(rawBody), "") + return err + } + attempts, err := s.buildAttempts(providers, modelName, route) + if err != nil { + s.recordUsage(ingress, nil, clientIP, modelName, string(endpoint), true, 0, 0, 0, "error", err.Error(), truncateLogBody(rawBody), "") + return err + } + var lastErr error + var lastAtt *attempt + maxRetries := s.settings.GatewayMaxRetries() + if maxRetries < 1 { + maxRetries = 1 + } + backoffMs := s.settings.GatewayRetryBackoffMs() + for _, att := range attempts { + if !s.canServeEndpoint(att.provider, endpoint) { + continue + } + for try := 0; try < maxRetries; try++ { + if try > 0 { + time.Sleep(time.Duration(backoffMs) * time.Millisecond) + } + start := time.Now() + result, err := s.forwardStream(ctx, att.provider, att.apiKey, endpoint, rawBody, w, flusher) + latency := time.Since(start).Milliseconds() + lastAtt = &att + if err != nil { + lastErr = err + continue + } + if result == nil { + lastErr = fmt.Errorf("empty upstream stream response") + continue + } + if result.StatusCode < 400 { + s.recordUsage(ingress, &att, clientIP, modelName, string(endpoint), true, result.TokensIn, result.TokensOut, latency, "success", "", truncateLogBody(rawBody), "[SSE stream]") + return nil + } + errMsg := describeForwardError(nil, &provider.ChatResponse{StatusCode: result.StatusCode, Body: result.Body}) + lastErr = fmt.Errorf("%s", errMsg) + respBody := "[SSE stream]" + if len(result.Body) > 0 { + respBody = truncateLogBody(result.Body) + } + if passThroughUpstreamStatus(result.StatusCode) { + s.recordUsage(ingress, &att, clientIP, modelName, string(endpoint), true, result.TokensIn, result.TokensOut, latency, "error", errMsg, truncateLogBody(rawBody), respBody) + return nil + } + if tryNextKeyOnUpstreamStatus(result.StatusCode) { + break + } + if retryableUpstreamStatus(result.StatusCode) { + continue + } + s.recordUsage(ingress, &att, clientIP, modelName, string(endpoint), true, result.TokensIn, result.TokensOut, latency, "error", errMsg, truncateLogBody(rawBody), respBody) + return nil + } + } + if lastErr == nil { + lastErr = fmt.Errorf("all providers failed") + } + s.recordUsage(ingress, lastAtt, clientIP, modelName, string(endpoint), true, 0, 0, 0, "error", lastErr.Error(), truncateLogBody(rawBody), "[SSE stream]") + return lastErr +} + +func categoryForEndpoint(ep provider.EndpointType) model.ProviderCategory { + switch ep { + case provider.EndpointEmbeddings: + return model.CategoryEmbedding + case provider.EndpointRerank: + return model.CategoryRerank + default: + return model.CategoryLLM + } +} + +func listModelsCategories() []model.ProviderCategory { + return []model.ProviderCategory{ + model.CategoryLLM, + model.CategoryEmbedding, + model.CategoryRerank, + } +} + +func providerInListModelsCategory(cat model.ProviderCategory) bool { + for _, c := range listModelsCategories() { + if c == cat { + return true + } + } + return false +} + +func (s *Service) ListModels(ctx context.Context, ingress *model.IngressKey) ([]map[string]any, error) { + allowedProviders := cache.ParseJSONList(ingress.AllowedProvidersJSON) + var result []map[string]any + var hasLLM bool + for _, p := range s.cache.ListProviders() { + if !providerInListModelsCategory(p.Category) { + continue + } + if !cache.MatchesList(allowedProviders, p.Name) && !cache.MatchesList(allowedProviders, string(p.Type)) { + continue + } + if !s.providerAllowsEndpoint(&p, provider.EndpointListModels) { + continue + } + if p.Category == model.CategoryLLM { + hasLLM = true + } + for _, m := range s.cache.GetModels(p.ID) { + if m.Enabled { + result = append(result, map[string]any{ + "id": IngressModelID(m), "object": "model", "owned_by": p.Name, + }) + } + } + } + if len(result) == 0 && hasLLM { + result = append(result, map[string]any{"id": "gpt-4o-mini", "object": "model", "owned_by": "luminary"}) + } + return result, nil +} + +func (s *Service) FetchModels(ctx context.Context, providerID uint) ([]provider.ModelInfo, error) { + p, ok := s.cache.GetProvider(providerID) + if !ok { + return nil, fmt.Errorf("provider not found") + } + if !s.providerAllowsEndpoint(&p, provider.EndpointListModels) { + return nil, fmt.Errorf("list_models endpoint not enabled") + } + keys := s.cache.ListProviderKeys(providerID) + var firstKey string + for _, k := range keys { + if k.Enabled { + plain, err := s.DecryptAPIKey(k.APIKeyEnc) + if err == nil { + firstKey = plain + break + } + } + } + if firstKey == "" && p.Type != model.ProviderOllama { + return nil, fmt.Errorf("no enabled keys") + } + cfg := s.providerConfig(&p) + if p.Type == model.ProviderAnthropic { + return s.anthropic.ListModelsWithConfig(ctx, firstKey, cfg) + } + return s.openai.ListModelsWithConfig(ctx, firstKey, cfg) +} + +func (s *Service) HealthCheckProvider(ctx context.Context, providerID uint) (model.ProviderStatus, int64, string) { + p, ok := s.cache.GetProvider(providerID) + if !ok { + return model.ProviderUnhealthy, 0, "not found" + } + if !model.IsCategorySupported(p.Category) { + return model.ProviderUnhealthy, 0, "category not supported" + } + if p.Type == model.ProviderComfyUI { + start := time.Now() + if err := comfyui.New(p.BaseURL).HealthCheck(ctx); err != nil { + return model.ProviderUnhealthy, time.Since(start).Milliseconds(), err.Error() + } + return model.ProviderHealthy, time.Since(start).Milliseconds(), "" + } + keys := s.cache.ListProviderKeys(providerID) + if len(keys) == 0 && p.Type != model.ProviderOllama { + return model.ProviderUnhealthy, 0, "no provider keys configured" + } + cfg := s.providerConfig(&p) + return s.runHealthChecks(ctx, &p, keys, cfg) +} diff --git a/internal/gateway/upstream.go b/internal/gateway/upstream.go new file mode 100644 index 0000000..54d3dd2 --- /dev/null +++ b/internal/gateway/upstream.go @@ -0,0 +1,43 @@ +package gateway + +import "net/http" + +// retryableUpstreamStatus reports HTTP codes that should trigger retry / failover. +func retryableUpstreamStatus(code int) bool { + switch code { + case http.StatusTooManyRequests, + http.StatusRequestTimeout, + http.StatusBadGateway, + http.StatusServiceUnavailable, + http.StatusGatewayTimeout: + return true + default: + return code >= 500 + } +} + +// tryNextKeyOnUpstreamStatus reports auth/rate errors where another provider key may work. +func tryNextKeyOnUpstreamStatus(code int) bool { + switch code { + case http.StatusUnauthorized, + http.StatusForbidden, + http.StatusTooManyRequests, + http.StatusPaymentRequired: + return true + default: + return retryableUpstreamStatus(code) + } +} + +// passThroughUpstreamStatus reports client errors that should be returned as-is. +func passThroughUpstreamStatus(code int) bool { + switch code { + case http.StatusBadRequest, + http.StatusNotFound, + http.StatusUnprocessableEntity, + http.StatusUnsupportedMediaType: + return true + default: + return false + } +} diff --git a/internal/gateway/upstream_test.go b/internal/gateway/upstream_test.go new file mode 100644 index 0000000..e48d9ec --- /dev/null +++ b/internal/gateway/upstream_test.go @@ -0,0 +1,27 @@ +package gateway + +import ( + "net/http" + "testing" +) + +func TestRetryableUpstreamStatus(t *testing.T) { + if !retryableUpstreamStatus(http.StatusTooManyRequests) { + t.Fatal("429 should be retryable") + } + if !retryableUpstreamStatus(http.StatusServiceUnavailable) { + t.Fatal("503 should be retryable") + } + if retryableUpstreamStatus(http.StatusBadRequest) { + t.Fatal("400 should not be retryable") + } +} + +func TestTryNextKeyOnUpstreamStatus(t *testing.T) { + if !tryNextKeyOnUpstreamStatus(http.StatusTooManyRequests) { + t.Fatal("429 should try next key") + } + if !tryNextKeyOnUpstreamStatus(http.StatusUnauthorized) { + t.Fatal("401 should try next key") + } +} diff --git a/internal/handler/admin/auth.go b/internal/handler/admin/auth.go new file mode 100644 index 0000000..0fc9831 --- /dev/null +++ b/internal/handler/admin/auth.go @@ -0,0 +1,273 @@ +package admin + +import ( + "crypto/rand" + "encoding/hex" + "encoding/json" + "fmt" + "net/http" + "strconv" + "time" + + "github.com/gin-gonic/gin" + "github.com/rose_cat707/luminary/internal/auth" + "github.com/rose_cat707/luminary/internal/auth/loginlimit" + "github.com/rose_cat707/luminary/internal/gateway" + "github.com/rose_cat707/luminary/internal/middleware" + "github.com/rose_cat707/luminary/internal/model" + "github.com/rose_cat707/luminary/internal/monitor" + "github.com/rose_cat707/luminary/internal/prediction" + "github.com/rose_cat707/luminary/internal/settings" + "github.com/rose_cat707/luminary/internal/storage/imagestore" + "github.com/rose_cat707/luminary/internal/store/cache" + sqlitestore "github.com/rose_cat707/luminary/internal/store/sqlite" +) + +type Handler struct { + repo *sqlitestore.Repository + db *sqlitestore.Store + cache *cache.Store + gateway *gateway.Service + settings *settings.Runtime + monitor *monitor.Service + loginLimit *loginlimit.Limiter + predictions *prediction.Service + images *imagestore.Store + publicURL func() string +} + +func New(repo *sqlitestore.Repository, db *sqlitestore.Store, c *cache.Store, gw *gateway.Service, rt *settings.Runtime, mon *monitor.Service, pred *prediction.Service, imgs *imagestore.Store, publicURL func() string) *Handler { + return &Handler{ + repo: repo, + db: db, + cache: c, + gateway: gw, + settings: rt, + monitor: mon, + loginLimit: loginlimit.New(rt.LoginMaxAttempts(), rt.LoginLockout()), + predictions: pred, + images: imgs, + publicURL: publicURL, + } +} + +func (h *Handler) publicBaseURL() string { + if h.publicURL != nil { + return h.publicURL() + } + return "http://localhost:8293" +} + +func (h *Handler) Register(r *gin.RouterGroup) { + r.POST("/login", h.Login) + r.GET("/auth/status", h.AuthStatus) + + authGroup := r.Group("", middleware.AdminAuth(h.validateSession)) + authGroup.POST("/logout", h.Logout) + authGroup.PUT("/password", h.ChangePassword) + + authGroup.GET("/endpoint-meta", h.ListEndpointMeta) + authGroup.GET("/providers", h.ListProviders) + authGroup.POST("/providers", h.CreateProvider) + authGroup.GET("/providers/:id", h.GetProvider) + authGroup.PUT("/providers/:id", h.UpdateProvider) + authGroup.DELETE("/providers/:id", h.DeleteProvider) + authGroup.POST("/providers/:id/sync-models", h.SyncModels) + + authGroup.GET("/providers/:id/keys", h.ListProviderKeys) + authGroup.POST("/providers/:id/keys", h.CreateProviderKey) + authGroup.PUT("/provider-keys/:keyId", h.UpdateProviderKey) + authGroup.DELETE("/provider-keys/:keyId", h.DeleteProviderKey) + + authGroup.GET("/providers/:id/models", h.ListModels) + authGroup.POST("/providers/:id/models", h.CreateModel) + authGroup.PUT("/providers/:id/models/:modelId", h.UpdateModel) + authGroup.DELETE("/providers/:id/models/:modelId", h.DeleteModel) + authGroup.PUT("/models/:modelId", h.UpdateModel) + authGroup.DELETE("/models/:modelId", h.DeleteModel) + + authGroup.GET("/ingress-keys", h.ListIngressKeys) + authGroup.POST("/ingress-keys", h.CreateIngressKey) + authGroup.GET("/ingress-keys/:id", h.GetIngressKey) + authGroup.PUT("/ingress-keys/:id", h.UpdateIngressKey) + authGroup.DELETE("/ingress-keys/:id", h.DeleteIngressKey) + authGroup.GET("/ingress-keys/:id/usage", h.IngressUsage) + + authGroup.GET("/ingress-keys/:id/routing-rules", h.ListRoutingRules) + authGroup.POST("/ingress-keys/:id/routing-rules", h.CreateRoutingRule) + authGroup.PUT("/routing-rules/:ruleId", h.UpdateRoutingRule) + authGroup.DELETE("/routing-rules/:ruleId", h.DeleteRoutingRule) + + authGroup.GET("/workflows/param-meta", h.GetWorkflowParamMeta) + authGroup.POST("/workflows/analyze", h.AnalyzeWorkflow) + authGroup.GET("/predictions/:id", h.GetPrediction) + authGroup.GET("/images/:id", h.ServeAdminImage) + + authGroup.GET("/request-logs", h.ListRequestLogs) + authGroup.GET("/request-logs/:id", h.GetRequestLog) + authGroup.GET("/pricing/catalog", h.PricingCatalog) + + authGroup.GET("/ip-rules", h.ListIPRules) + authGroup.POST("/ip-rules", h.CreateIPRule) + authGroup.PUT("/ip-rules/:id", h.UpdateIPRule) + authGroup.DELETE("/ip-rules/:id", h.DeleteIPRule) + + authGroup.GET("/settings", h.GetSettings) + authGroup.PUT("/settings", h.UpdateSettings) + + authGroup.GET("/dashboard/overview", h.DashboardOverview) + authGroup.GET("/dashboard/providers", h.DashboardProviders) + authGroup.GET("/dashboard/ingress-usage", h.DashboardIngressUsage) + authGroup.GET("/dashboard/egress-usage", h.DashboardEgressUsage) + authGroup.GET("/dashboard/provider-model-usage", h.DashboardProviderModelUsage) + authGroup.GET("/dashboard/client-ip-usage", h.DashboardClientIPUsage) + authGroup.GET("/dashboard/request-trend", h.DashboardRequestTrend) +} + +func (h *Handler) validateSession(token string) (bool, time.Time) { + hash := auth.HashToken(token) + var session model.AdminSession + if err := h.db.DB().Where("token_hash = ?", hash).First(&session).Error; err != nil { + return false, time.Time{} + } + return session.ExpiresAt.After(time.Now()), session.ExpiresAt +} + +func (h *Handler) Login(c *gin.Context) { + ip := middleware.ClientIP(c) + if locked, retryAfter := h.loginLimit.Check(ip); locked { + sec := int(retryAfter.Seconds()) + 1 + c.Header("Retry-After", strconv.Itoa(sec)) + h.recordLoginLog(ip, "", "error", "登录尝试次数过多", "", fmt.Sprintf(`{"retry_after_sec":%d}`, sec)) + c.JSON(http.StatusTooManyRequests, gin.H{ + "error": "登录尝试次数过多,请稍后再试", + "retry_after_sec": sec, + }) + return + } + + var req struct { + Username string `json:"username"` + Password string `json:"password"` + } + if err := c.ShouldBindJSON(&req); err != nil { + h.recordLoginLog(ip, "", "error", "invalid request", "", "") + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid request"}) + return + } + reqBody := loginRequestBody(req.Username) + + var user model.AdminUser + if err := h.db.DB().Where("username = ?", req.Username).First(&user).Error; err != nil { + h.loginLimit.RecordFailure(ip) + h.recordLoginLog(ip, req.Username, "error", "invalid credentials", reqBody, "") + c.JSON(http.StatusUnauthorized, gin.H{"error": "invalid credentials"}) + return + } + if !auth.CheckPassword(user.PasswordHash, req.Password) { + h.loginLimit.RecordFailure(ip) + h.recordLoginLog(ip, req.Username, "error", "invalid credentials", reqBody, "") + c.JSON(http.StatusUnauthorized, gin.H{"error": "invalid credentials"}) + return + } + h.loginLimit.Reset(ip) + token, err := generateSessionToken() + if err != nil { + h.recordLoginLog(ip, req.Username, "error", "session error", reqBody, "") + c.JSON(http.StatusInternalServerError, gin.H{"error": "session error"}) + return + } + session := model.AdminSession{ + TokenHash: auth.HashToken(token), + ExpiresAt: time.Now().Add(h.settings.SessionTTL()), + } + if err := h.db.DB().Create(&session).Error; err != nil { + h.recordLoginLog(ip, req.Username, "error", "session error", reqBody, "") + c.JSON(http.StatusInternalServerError, gin.H{"error": "session error"}) + return + } + respBody := fmt.Sprintf(`{"expires_at":%q}`, session.ExpiresAt.Format(time.RFC3339)) + h.recordLoginLog(ip, req.Username, "success", "", reqBody, respBody) + c.SetSameSite(http.SameSiteStrictMode) + c.SetCookie(middleware.SessionCookieName(), token, int(h.settings.SessionTTL().Seconds()), "/", "", false, true) + c.JSON(http.StatusOK, gin.H{"token": token, "expires_at": session.ExpiresAt}) +} + +func loginRequestBody(username string) string { + b, _ := json.Marshal(map[string]string{"username": username}) + return string(b) +} + +func (h *Handler) recordLoginLog(ip, username, status, errMsg, requestBody, responseBody string) { + h.repo.RecordRequestLog(model.UsageRecord{ + ClientIP: ip, + Endpoint: model.EndpointAdminLogin, + Model: username, + Status: status, + ErrorMsg: errMsg, + RequestBody: requestBody, + ResponseBody: responseBody, + }) +} + +func (h *Handler) Logout(c *gin.Context) { + token, _ := c.Get("session_token") + if t, ok := token.(string); ok { + h.db.DB().Where("token_hash = ?", auth.HashToken(t)).Delete(&model.AdminSession{}) + } + c.SetCookie(middleware.SessionCookieName(), "", -1, "/", "", false, true) + c.JSON(http.StatusOK, gin.H{"ok": true}) +} + +func (h *Handler) AuthStatus(c *gin.Context) { + token := c.GetHeader("Authorization") + if len(token) > 7 { + token = token[7:] + } + if token == "" { + if cookie, err := c.Cookie(middleware.SessionCookieName()); err == nil { + token = cookie + } + } + valid := false + if token != "" { + valid, _ = h.validateSession(token) + } + c.JSON(http.StatusOK, gin.H{"authenticated": valid}) +} + +func (h *Handler) ChangePassword(c *gin.Context) { + var req struct { + OldPassword string `json:"old_password"` + NewPassword string `json:"new_password"` + } + if err := c.ShouldBindJSON(&req); err != nil || req.NewPassword == "" { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid request"}) + return + } + var user model.AdminUser + if err := h.db.DB().First(&user).Error; err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "user not found"}) + return + } + if !auth.CheckPassword(user.PasswordHash, req.OldPassword) { + c.JSON(http.StatusUnauthorized, gin.H{"error": "invalid old password"}) + return + } + hash, err := auth.HashPassword(req.NewPassword) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "hash error"}) + return + } + user.PasswordHash = hash + h.db.DB().Save(&user) + c.JSON(http.StatusOK, gin.H{"ok": true}) +} + +func generateSessionToken() (string, error) { + b := make([]byte, 32) + if _, err := rand.Read(b); err != nil { + return "", err + } + return hex.EncodeToString(b), nil +} diff --git a/internal/handler/admin/helpers.go b/internal/handler/admin/helpers.go new file mode 100644 index 0000000..2ab63a3 --- /dev/null +++ b/internal/handler/admin/helpers.go @@ -0,0 +1,29 @@ +package admin + +import ( + "fmt" + "strconv" + "github.com/rose_cat707/luminary/internal/model" + "github.com/rose_cat707/luminary/internal/provider" + "github.com/rose_cat707/luminary/internal/provider/anthropic" + "github.com/rose_cat707/luminary/internal/provider/custom" + "github.com/rose_cat707/luminary/internal/provider/openai" +) + +func parseUint(s string) uint { + v, _ := strconv.ParseUint(s, 10, 64) + return uint(v) +} + +func (h *Handler) gatewayClient(t model.ProviderType) (provider.Client, error) { + switch t { + case model.ProviderOpenAI, model.ProviderAzureOpenAI, model.ProviderOpenRouter, model.ProviderOllama: + return openai.New(), nil + case model.ProviderAnthropic: + return anthropic.New(), nil + case model.ProviderCustom: + return custom.New(), nil + default: + return nil, fmt.Errorf("unsupported provider type: %s", t) + } +} diff --git a/internal/handler/admin/ingress.go b/internal/handler/admin/ingress.go new file mode 100644 index 0000000..57b7557 --- /dev/null +++ b/internal/handler/admin/ingress.go @@ -0,0 +1,163 @@ +package admin + +import ( + "crypto/rand" + "encoding/hex" + "net/http" + "time" + + "github.com/gin-gonic/gin" + "github.com/rose_cat707/luminary/internal/auth" + "github.com/rose_cat707/luminary/internal/model" + "github.com/rose_cat707/luminary/internal/store/cache" +) + +func (h *Handler) ListIngressKeys(c *gin.Context) { + keys := h.cache.ListIngressKeys() + c.JSON(http.StatusOK, gin.H{"data": keys}) +} + +func (h *Handler) CreateIngressKey(c *gin.Context) { + var req struct { + Name string `json:"name"` + BudgetLimit float64 `json:"budget_limit"` + RateLimitRPM int `json:"rate_limit_rpm"` + RateLimitTPM int `json:"rate_limit_tpm"` + AllowedProviders []string `json:"allowed_providers"` + AllowedModels []string `json:"allowed_models"` + ExpiresAt *time.Time `json:"expires_at"` + } + if err := c.ShouldBindJSON(&req); err != nil || req.Name == "" { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid request"}) + return + } + plainKey, err := generateIngressKey() + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "key generation failed"}) + return + } + hash := auth.HashIngressKey(plainKey) + providersJSON := `["*"]` + modelsJSON := `["*"]` + if req.AllowedProviders != nil { + if len(req.AllowedProviders) == 0 { + providersJSON = `["*"]` + } else { + providersJSON = cache.ModelsToJSON(req.AllowedProviders) + } + } + if req.AllowedModels != nil { + if len(req.AllowedModels) == 0 { + modelsJSON = `["*"]` + } else { + modelsJSON = cache.ModelsToJSON(req.AllowedModels) + } + } + k := model.IngressKey{ + Name: req.Name, + KeyHash: hash, + Prefix: plainKey[:12] + "...", + Enabled: true, + BudgetLimit: req.BudgetLimit, + RateLimitRPM: req.RateLimitRPM, + RateLimitTPM: req.RateLimitTPM, + AllowedProvidersJSON: providersJSON, + AllowedModelsJSON: modelsJSON, + ExpiresAt: req.ExpiresAt, + } + if err := h.db.DB().Create(&k).Error; err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + h.cache.SetIngressKey(k) + c.JSON(http.StatusCreated, gin.H{"key": k, "plain_key": plainKey}) +} + +func (h *Handler) GetIngressKey(c *gin.Context) { + id := parseUint(c.Param("id")) + k, ok := h.cache.GetIngressKey(id) + if !ok { + c.JSON(http.StatusNotFound, gin.H{"error": "not found"}) + return + } + c.JSON(http.StatusOK, k) +} + +func (h *Handler) UpdateIngressKey(c *gin.Context) { + id := parseUint(c.Param("id")) + k, ok := h.cache.GetIngressKey(id) + if !ok { + c.JSON(http.StatusNotFound, gin.H{"error": "not found"}) + return + } + var req struct { + Name string `json:"name"` + Enabled *bool `json:"enabled"` + BudgetLimit *float64 `json:"budget_limit"` + RateLimitRPM *int `json:"rate_limit_rpm"` + RateLimitTPM *int `json:"rate_limit_tpm"` + AllowedProviders []string `json:"allowed_providers"` + AllowedModels []string `json:"allowed_models"` + } + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid request"}) + return + } + if req.Name != "" { + k.Name = req.Name + } + if req.Enabled != nil { + k.Enabled = *req.Enabled + } + if req.BudgetLimit != nil { + k.BudgetLimit = *req.BudgetLimit + } + if req.RateLimitRPM != nil { + k.RateLimitRPM = *req.RateLimitRPM + } + if req.RateLimitTPM != nil { + k.RateLimitTPM = *req.RateLimitTPM + } + if req.AllowedProviders != nil { + if len(req.AllowedProviders) == 0 { + k.AllowedProvidersJSON = `["*"]` + } else { + k.AllowedProvidersJSON = cache.ModelsToJSON(req.AllowedProviders) + } + } + if req.AllowedModels != nil { + if len(req.AllowedModels) == 0 { + k.AllowedModelsJSON = `["*"]` + } else { + k.AllowedModelsJSON = cache.ModelsToJSON(req.AllowedModels) + } + } + h.db.DB().Save(&k) + h.cache.SetIngressKey(k) + c.JSON(http.StatusOK, k) +} + +func (h *Handler) DeleteIngressKey(c *gin.Context) { + id := parseUint(c.Param("id")) + h.db.DB().Where("ingress_key_id = ?", id).Delete(&model.RoutingRule{}) + h.db.DB().Delete(&model.IngressKey{}, id) + h.cache.DeleteIngressKey(id) + h.cache.DeleteRoutingRulesForIngress(id) + c.JSON(http.StatusOK, gin.H{"ok": true}) +} + +func (h *Handler) IngressUsage(c *gin.Context) { + id := parseUint(c.Param("id")) + var records []model.UsageRecord + h.db.DB().Where("ingress_key_id = ?", id).Order("created_at desc").Limit(50).Find(&records) + k, _ := h.cache.GetIngressKey(id) + c.JSON(http.StatusOK, gin.H{"key": k, "recent": records}) +} + +func generateIngressKey() (string, error) { + b := make([]byte, 24) + if _, err := rand.Read(b); err != nil { + return "", err + } + return "sk-lum-" + hex.EncodeToString(b), nil +} diff --git a/internal/handler/admin/ip_dashboard.go b/internal/handler/admin/ip_dashboard.go new file mode 100644 index 0000000..8412863 --- /dev/null +++ b/internal/handler/admin/ip_dashboard.go @@ -0,0 +1,243 @@ +package admin + +import ( + "net/http" + "time" + + "github.com/gin-gonic/gin" + "github.com/rose_cat707/luminary/internal/model" +) + +func (h *Handler) ListIPRules(c *gin.Context) { + c.JSON(http.StatusOK, gin.H{"data": h.cache.ListIPRules()}) +} + +func (h *Handler) CreateIPRule(c *gin.Context) { + var req struct { + CIDR string `json:"cidr"` + Type model.IPRuleType `json:"type"` + Scope model.IPRuleScope `json:"scope"` + Enabled *bool `json:"enabled"` + } + if err := c.ShouldBindJSON(&req); err != nil || req.CIDR == "" { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid request"}) + return + } + if req.Type == "" { + req.Type = model.IPRuleAllow + } + if req.Scope == "" { + req.Scope = model.IPScopeProxy + } + rule := model.IPRule{CIDR: req.CIDR, Type: req.Type, Scope: req.Scope, Enabled: true} + if req.Enabled != nil { + rule.Enabled = *req.Enabled + } + if err := h.db.DB().Create(&rule).Error; err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + rules := h.cache.ListIPRules() + rules = append(rules, rule) + h.cache.SetIPRules(rules) + c.JSON(http.StatusCreated, rule) +} + +func (h *Handler) UpdateIPRule(c *gin.Context) { + id := parseUint(c.Param("id")) + var rule model.IPRule + if err := h.db.DB().First(&rule, id).Error; err != nil { + c.JSON(http.StatusNotFound, gin.H{"error": "not found"}) + return + } + var req struct { + CIDR string `json:"cidr"` + Type model.IPRuleType `json:"type"` + Scope model.IPRuleScope `json:"scope"` + Enabled *bool `json:"enabled"` + } + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid request"}) + return + } + if req.CIDR != "" { + rule.CIDR = req.CIDR + } + if req.Type != "" { + rule.Type = req.Type + } + if req.Scope != "" { + rule.Scope = req.Scope + } + if req.Enabled != nil { + rule.Enabled = *req.Enabled + } + h.db.DB().Save(&rule) + h.reloadIPRules() + c.JSON(http.StatusOK, rule) +} + +func (h *Handler) DeleteIPRule(c *gin.Context) { + id := parseUint(c.Param("id")) + h.db.DB().Delete(&model.IPRule{}, id) + h.reloadIPRules() + c.JSON(http.StatusOK, gin.H{"ok": true}) +} + +func (h *Handler) reloadIPRules() { + var rules []model.IPRule + h.db.DB().Find(&rules) + h.cache.SetIPRules(rules) +} + +func (h *Handler) DashboardOverview(c *gin.Context) { + since := time.Now().Add(-24 * time.Hour) + var totalRequests int64 + var totalTokens int64 + var errorCount int64 + h.db.DB().Model(&model.UsageRecord{}).Where("created_at >= ?", since).Count(&totalRequests) + h.db.DB().Model(&model.UsageRecord{}).Where("created_at >= ?", since).Select("COALESCE(SUM(tokens_in + tokens_out), 0)").Scan(&totalTokens) + h.db.DB().Model(&model.UsageRecord{}).Where("created_at >= ? AND status = ?", since, "error").Count(&errorCount) + activeKeys := len(h.cache.ListIngressKeys()) + errorRate := 0.0 + if totalRequests > 0 { + errorRate = float64(errorCount) / float64(totalRequests) + } + c.JSON(http.StatusOK, gin.H{ + "total_requests_24h": totalRequests, + "total_tokens_24h": totalTokens, + "error_rate": errorRate, + "active_ingress_keys": activeKeys, + "providers": len(h.cache.ListProviders()), + }) +} + +func (h *Handler) DashboardProviders(c *gin.Context) { + providers := h.cache.ListProviders() + health := h.cache.GetHealthStatus() + type item struct { + model.Provider + Health model.HealthCheckRecord `json:"health"` + } + var out []item + for _, p := range providers { + out = append(out, item{Provider: p, Health: health[p.ID]}) + } + c.JSON(http.StatusOK, gin.H{"data": out}) +} + +func (h *Handler) DashboardIngressUsage(c *gin.Context) { + keys := h.cache.ListIngressKeys() + c.JSON(http.StatusOK, gin.H{"data": keys}) +} + +func (h *Handler) DashboardEgressUsage(c *gin.Context) { + keys := h.cache.ListAllProviderKeys() + c.JSON(http.StatusOK, gin.H{"data": keys}) +} + +func (h *Handler) DashboardProviderModelUsage(c *gin.Context) { + since := time.Now().Add(-24 * time.Hour) + type row struct { + ProviderID uint `json:"provider_id"` + Model string `json:"model"` + RequestCount int64 `json:"request_count"` + TokenCount int64 `json:"token_count"` + Cost float64 `json:"cost"` + } + var rows []row + h.db.DB().Model(&model.UsageRecord{}). + Select("provider_id, model, COUNT(*) as request_count, COALESCE(SUM(tokens_in + tokens_out), 0) as token_count, COALESCE(SUM(cost), 0) as cost"). + Where("created_at >= ? AND provider_id > 0 AND model != ''", since). + Group("provider_id, model"). + Order("request_count desc"). + Limit(50). + Scan(&rows) + + type item struct { + ProviderID uint `json:"provider_id"` + ProviderName string `json:"provider_name"` + Model string `json:"model"` + RequestCount int64 `json:"request_count"` + TokenCount int64 `json:"token_count"` + Cost float64 `json:"cost"` + } + out := make([]item, 0, len(rows)) + for _, r := range rows { + name := "" + if p, ok := h.cache.GetProvider(r.ProviderID); ok { + name = p.Name + } + out = append(out, item{ + ProviderID: r.ProviderID, + ProviderName: name, + Model: r.Model, + RequestCount: r.RequestCount, + TokenCount: r.TokenCount, + Cost: r.Cost, + }) + } + c.JSON(http.StatusOK, gin.H{"data": out}) +} + +func (h *Handler) DashboardClientIPUsage(c *gin.Context) { + since := time.Now().Add(-24 * time.Hour) + type item struct { + ClientIP string `json:"client_ip"` + RequestCount int64 `json:"request_count"` + TokenCount int64 `json:"token_count"` + Cost float64 `json:"cost"` + } + var out []item + h.db.DB().Model(&model.UsageRecord{}). + Select("client_ip, COUNT(*) as request_count, COALESCE(SUM(tokens_in + tokens_out), 0) as token_count, COALESCE(SUM(cost), 0) as cost"). + Where("created_at >= ? AND client_ip != ''", since). + Group("client_ip"). + Order("request_count desc"). + Limit(50). + Scan(&out) + c.JSON(http.StatusOK, gin.H{"data": out}) +} + +func (h *Handler) DashboardRequestTrend(c *gin.Context) { + since := time.Now().Add(-23 * time.Hour).Truncate(time.Hour) + type aggRow struct { + Hour string `gorm:"column:hour"` + Requests int64 `gorm:"column:requests"` + Errors int64 `gorm:"column:errors"` + Tokens int64 `gorm:"column:tokens"` + } + var rows []aggRow + h.db.DB().Model(&model.UsageRecord{}). + Select(`strftime('%Y-%m-%d %H:00', created_at) as hour, + COUNT(*) as requests, + SUM(CASE WHEN status = 'error' THEN 1 ELSE 0 END) as errors, + COALESCE(SUM(tokens_in + tokens_out), 0) as tokens`). + Where("created_at >= ?", since). + Group("hour"). + Order("hour"). + Scan(&rows) + + byHour := make(map[string]aggRow, len(rows)) + for _, r := range rows { + byHour[r.Hour] = r + } + + type bucket struct { + Hour string `json:"hour"` + Requests int64 `json:"requests"` + Errors int64 `json:"errors"` + Tokens int64 `json:"tokens"` + } + out := make([]bucket, 24) + for i := 0; i < 24; i++ { + t := since.Add(time.Duration(i) * time.Hour) + key := t.Format("2006-01-02 15:00") + if r, ok := byHour[key]; ok { + out[i] = bucket{Hour: key, Requests: r.Requests, Errors: r.Errors, Tokens: r.Tokens} + } else { + out[i] = bucket{Hour: key} + } + } + c.JSON(http.StatusOK, gin.H{"data": out}) +} diff --git a/internal/handler/admin/providers.go b/internal/handler/admin/providers.go new file mode 100644 index 0000000..fe0e806 --- /dev/null +++ b/internal/handler/admin/providers.go @@ -0,0 +1,196 @@ +package admin + +import ( + "encoding/json" + "net/http" + + "github.com/gin-gonic/gin" + "github.com/rose_cat707/luminary/internal/model" + "github.com/rose_cat707/luminary/internal/provider" + "github.com/rose_cat707/luminary/internal/store/cache" +) + +func (h *Handler) ListEndpointMeta(c *gin.Context) { + category := model.ProviderCategory(c.Query("category")) + if category == "" { + category = model.CategoryLLM + } + providerType := model.ProviderType(c.Query("type")) + if providerType == "" { + providerType = provider.DefaultProviderType(category) + } + endpoints := provider.EndpointsForCategory(category) + providerTypes := provider.TypesForCategory(category) + type item struct { + provider.EndpointMeta + DefaultPath string `json:"default_path"` + } + out := make([]item, 0, len(endpoints)) + for _, e := range endpoints { + out = append(out, item{ + EndpointMeta: e, + DefaultPath: provider.DefaultPath(providerType, e.Key), + }) + } + c.JSON(http.StatusOK, gin.H{ + "categories": []gin.H{ + {"key": model.CategoryLLM, "label": provider.CategoryLabel(model.CategoryLLM), "supported": true}, + {"key": model.CategoryEmbedding, "label": provider.CategoryLabel(model.CategoryEmbedding), "supported": true}, + {"key": model.CategoryRerank, "label": provider.CategoryLabel(model.CategoryRerank), "supported": true}, + {"key": model.CategorySpeech, "label": provider.CategoryLabel(model.CategorySpeech), "supported": false}, + {"key": model.CategoryImage, "label": provider.CategoryLabel(model.CategoryImage), "supported": true}, + {"key": model.CategoryAudio, "label": provider.CategoryLabel(model.CategoryAudio), "supported": false}, + }, + "provider_types": providerTypes, + "all_provider_types": provider.AllProviderTypes(), + "endpoints": out, + }) +} + +func (h *Handler) ListProviders(c *gin.Context) { + category := c.Query("category") + providers := h.cache.ListProviders() + if category != "" { + filtered := providers[:0] + for _, p := range providers { + if string(p.Category) == category { + filtered = append(filtered, p) + } + } + providers = filtered + } + out := make([]model.Provider, 0, len(providers)) + for _, p := range providers { + p.Models = h.cache.GetModels(p.ID) + out = append(out, p) + } + c.JSON(http.StatusOK, gin.H{"data": out}) +} + +func (h *Handler) CreateProvider(c *gin.Context) { + var req struct { + Name string `json:"name"` + Type model.ProviderType `json:"type"` + Category model.ProviderCategory `json:"category"` + BaseURL string `json:"base_url"` + AllowedEndpoints []string `json:"allowed_endpoints"` + EndpointPaths map[string]string `json:"endpoint_paths"` + } + if err := c.ShouldBindJSON(&req); err != nil || req.Name == "" || req.Type == "" { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid request"}) + return + } + if req.Category == "" { + req.Category = model.CategoryLLM + } + if !model.IsCategorySupported(req.Category) { + c.JSON(http.StatusBadRequest, gin.H{"error": "category not supported"}) + return + } + if !provider.IsTypeValidForCategory(req.Category, req.Type) { + c.JSON(http.StatusBadRequest, gin.H{"error": "protocol type not supported for this category"}) + return + } + if h.providerNameTaken(req.Name, 0) { + c.JSON(http.StatusBadRequest, gin.H{"error": "provider name already exists"}) + return + } + allowed := req.AllowedEndpoints + if len(allowed) == 0 { + allowed = provider.DefaultEndpointsForCategory(req.Category) + } + if err := provider.ValidateCategoryEndpoints(req.Category, allowed); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + pathsJSON, _ := json.Marshal(req.EndpointPaths) + if req.EndpointPaths == nil { + pathsJSON = []byte("{}") + } + p := model.Provider{ + Name: req.Name, + Type: req.Type, + Category: req.Category, + BaseURL: req.BaseURL, + AllowedEndpointsJSON: cache.ModelsToJSON(allowed), + EndpointPathsJSON: string(pathsJSON), + Status: model.ProviderUnknown, + } + if err := h.db.DB().Create(&p).Error; err != nil { + if h.providerNameTaken(req.Name, 0) { + c.JSON(http.StatusBadRequest, gin.H{"error": "provider name already exists"}) + return + } + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + h.cache.SetProvider(p) + c.JSON(http.StatusCreated, p) +} + +func (h *Handler) GetProvider(c *gin.Context) { + id := parseUint(c.Param("id")) + p, ok := h.cache.GetProvider(id) + if !ok { + c.JSON(http.StatusNotFound, gin.H{"error": "not found"}) + return + } + p.Keys = h.cache.ListProviderKeys(id) + p.Models = h.cache.GetModels(id) + c.JSON(http.StatusOK, p) +} + +func (h *Handler) UpdateProvider(c *gin.Context) { + id := parseUint(c.Param("id")) + p, ok := h.cache.GetProvider(id) + if !ok { + c.JSON(http.StatusNotFound, gin.H{"error": "not found"}) + return + } + var req struct { + Name string `json:"name"` + BaseURL string `json:"base_url"` + Category model.ProviderCategory `json:"category"` + AllowedEndpoints []string `json:"allowed_endpoints"` + EndpointPaths map[string]string `json:"endpoint_paths"` + Enabled *bool `json:"enabled"` + } + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid request"}) + return + } + if req.Name != "" && req.Name != p.Name { + if h.providerNameTaken(req.Name, p.ID) { + c.JSON(http.StatusBadRequest, gin.H{"error": "provider name already exists"}) + return + } + p.Name = req.Name + } + if req.BaseURL != "" { + p.BaseURL = req.BaseURL + } + if req.Category != "" { + if !model.IsCategorySupported(req.Category) { + c.JSON(http.StatusBadRequest, gin.H{"error": "category not supported yet"}) + return + } + p.Category = req.Category + } + if req.AllowedEndpoints != nil { + if err := provider.ValidateCategoryEndpoints(p.Category, req.AllowedEndpoints); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + p.AllowedEndpointsJSON = cache.ModelsToJSON(req.AllowedEndpoints) + } + if req.EndpointPaths != nil { + b, _ := json.Marshal(req.EndpointPaths) + p.EndpointPathsJSON = string(b) + } + if req.Enabled != nil { + p.Enabled = *req.Enabled + } + h.db.DB().Save(&p) + h.cache.SetProvider(p) + c.JSON(http.StatusOK, p) +} diff --git a/internal/handler/admin/providers_keys.go b/internal/handler/admin/providers_keys.go new file mode 100644 index 0000000..52007c7 --- /dev/null +++ b/internal/handler/admin/providers_keys.go @@ -0,0 +1,418 @@ +package admin + +import ( + "encoding/json" + "net/http" + "slices" + + "github.com/gin-gonic/gin" + "github.com/rose_cat707/luminary/internal/auth" + "github.com/rose_cat707/luminary/internal/model" + "github.com/rose_cat707/luminary/internal/prediction" + "github.com/rose_cat707/luminary/internal/provider" + "github.com/rose_cat707/luminary/internal/store/cache" +) + +func (h *Handler) DeleteProvider(c *gin.Context) { + id := parseUint(c.Param("id")) + h.db.DB().Where("provider_id = ?", id).Delete(&model.ProviderKey{}) + h.db.DB().Where("provider_id = ?", id).Delete(&model.ModelEntry{}) + h.db.DB().Delete(&model.Provider{}, id) + h.cache.DeleteProvider(id) + c.JSON(http.StatusOK, gin.H{"ok": true}) +} + +func (h *Handler) SyncModels(c *gin.Context) { + id := parseUint(c.Param("id")) + p, ok := h.cache.GetProvider(id) + if !ok { + c.JSON(http.StatusNotFound, gin.H{"error": "provider not found"}) + return + } + allowed := provider.ParseAllowedEndpoints(p.AllowedEndpointsJSON, p.Category) + if !slices.Contains(allowed, string(provider.EndpointListModels)) { + c.JSON(http.StatusBadRequest, gin.H{"error": "list_models endpoint not enabled"}) + return + } + models, err := h.gateway.FetchModels(c.Request.Context(), id) + if err != nil { + c.JSON(http.StatusBadGateway, gin.H{"error": err.Error()}) + return + } + existing := h.cache.GetModels(id) + existingByModelID := make(map[string]model.ModelEntry, len(existing)) + for _, e := range existing { + existingByModelID[e.ModelID] = e + } + upstreamIDs := make(map[string]bool, len(models)) + var entries []model.ModelEntry + for _, m := range models { + upstreamIDs[m.ID] = true + if old, ok := existingByModelID[m.ID]; ok { + old.DisplayName = m.DisplayName + if old.DisplayName == "" { + old.DisplayName = m.ID + } + if err := h.db.DB().Save(&old).Error; err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + entries = append(entries, old) + continue + } + displayName := m.DisplayName + if displayName == "" { + displayName = m.ID + } + entry := model.ModelEntry{ + ProviderID: id, + ModelID: m.ID, + DisplayName: displayName, + Enabled: true, + } + if err := h.db.DB().Create(&entry).Error; err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + entries = append(entries, entry) + } + for _, e := range existing { + if !upstreamIDs[e.ModelID] { + entries = append(entries, e) + } + } + h.cache.SetModels(id, entries) + c.JSON(http.StatusOK, gin.H{"synced": len(models), "models": entries}) +} + +func (h *Handler) ListProviderKeys(c *gin.Context) { + id := parseUint(c.Param("id")) + c.JSON(http.StatusOK, gin.H{"data": h.cache.ListProviderKeys(id)}) +} + +func (h *Handler) CreateProviderKey(c *gin.Context) { + providerID := parseUint(c.Param("id")) + var req struct { + Name string `json:"name"` + APIKey string `json:"api_key"` + Weight float64 `json:"weight"` + Models []string `json:"models"` + Enabled *bool `json:"enabled"` + MaxRequestsPerDay int `json:"max_requests_per_day"` + MaxTokensPerDay int64 `json:"max_tokens_per_day"` + } + if err := c.ShouldBindJSON(&req); err != nil || req.Name == "" { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid request"}) + return + } + if req.APIKey == "" { + var p model.Provider + h.db.DB().First(&p, providerID) + if p.Type != model.ProviderOllama { + c.JSON(http.StatusBadRequest, gin.H{"error": "api_key required"}) + return + } + req.APIKey = "ollama" + } + enc, err := auth.Encrypt(req.APIKey, h.settings.EncryptionKey()) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "encrypt failed"}) + return + } + modelsJSON := cache.ModelsToJSON(req.Models) + if req.Models == nil { + modelsJSON = `["*"]` + } + k := model.ProviderKey{ + ProviderID: providerID, + Name: req.Name, + APIKeyEnc: enc, + Weight: req.Weight, + ModelsJSON: modelsJSON, + Enabled: true, + MaxRequestsPerDay: req.MaxRequestsPerDay, + MaxTokensPerDay: req.MaxTokensPerDay, + } + if req.Weight <= 0 { + k.Weight = 1 + } + if req.Enabled != nil { + k.Enabled = *req.Enabled + } + if err := h.db.DB().Create(&k).Error; err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + h.cache.SetProviderKey(k) + c.JSON(http.StatusCreated, k) +} + +func (h *Handler) UpdateProviderKey(c *gin.Context) { + keyID := parseUint(c.Param("keyId")) + k, ok := h.cache.GetProviderKey(keyID) + if !ok { + c.JSON(http.StatusNotFound, gin.H{"error": "not found"}) + return + } + var req struct { + Name string `json:"name"` + APIKey string `json:"api_key"` + Weight float64 `json:"weight"` + Models []string `json:"models"` + Enabled *bool `json:"enabled"` + MaxRequestsPerDay int `json:"max_requests_per_day"` + MaxTokensPerDay int64 `json:"max_tokens_per_day"` + } + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid request"}) + return + } + if req.Name != "" { + k.Name = req.Name + } + if req.APIKey != "" { + enc, err := auth.Encrypt(req.APIKey, h.settings.EncryptionKey()) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "encrypt failed"}) + return + } + k.APIKeyEnc = enc + } + if req.Weight > 0 { + k.Weight = req.Weight + } + if req.Models != nil { + k.ModelsJSON = cache.ModelsToJSON(req.Models) + } + if req.Enabled != nil { + k.Enabled = *req.Enabled + } + if req.MaxRequestsPerDay >= 0 { + k.MaxRequestsPerDay = req.MaxRequestsPerDay + } + if req.MaxTokensPerDay >= 0 { + k.MaxTokensPerDay = req.MaxTokensPerDay + } + h.db.DB().Save(&k) + h.cache.SetProviderKey(k) + c.JSON(http.StatusOK, k) +} + +func (h *Handler) DeleteProviderKey(c *gin.Context) { + keyID := parseUint(c.Param("keyId")) + h.db.DB().Delete(&model.ProviderKey{}, keyID) + h.cache.DeleteProviderKey(keyID) + c.JSON(http.StatusOK, gin.H{"ok": true}) +} + +func (h *Handler) ListModels(c *gin.Context) { + id := parseUint(c.Param("id")) + if _, ok := h.cache.GetProvider(id); !ok { + c.JSON(http.StatusNotFound, gin.H{"error": "provider not found"}) + return + } + c.JSON(http.StatusOK, gin.H{"data": h.cache.GetModels(id)}) +} + +func (h *Handler) CreateModel(c *gin.Context) { + providerID := parseUint(c.Param("id")) + prov, ok := h.cache.GetProvider(providerID) + if !ok { + c.JSON(http.StatusNotFound, gin.H{"error": "provider not found"}) + return + } + var req struct { + ModelID string `json:"model_id"` + Alias string `json:"alias"` + DisplayName string `json:"display_name"` + WorkflowType model.WorkflowType `json:"workflow_type"` + WorkflowJSON string `json:"workflow_json"` + InputBindingJSON string `json:"input_binding_json"` + InputBinding map[string]*prediction.NodeField `json:"input_binding"` + } + if err := c.ShouldBindJSON(&req); err != nil || req.ModelID == "" { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid request"}) + return + } + for _, existing := range h.cache.GetModels(providerID) { + if existing.ModelID == req.ModelID { + c.JSON(http.StatusBadRequest, gin.H{"error": "model already exists"}) + return + } + } + if msg := validateModelAlias(h, req.Alias, 0); msg != "" { + c.JSON(http.StatusBadRequest, gin.H{"error": msg}) + return + } + if prov.Category == model.CategoryImage { + if err := validateImageModelCreate(struct { + ModelID, DisplayName string + WorkflowType model.WorkflowType + WorkflowJSON, InputBindingJSON string + InputBinding map[string]*prediction.NodeField + }{req.ModelID, req.DisplayName, req.WorkflowType, req.WorkflowJSON, req.InputBindingJSON, req.InputBinding}); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + } + bindingJSON := req.InputBindingJSON + if bindingJSON == "" && req.InputBinding != nil { + b, _ := json.Marshal(req.InputBinding) + bindingJSON = string(b) + } + wt := req.WorkflowType + if wt == "" { + wt = model.WorkflowTxt2Img + } + m := model.ModelEntry{ + ProviderID: providerID, + ModelID: req.ModelID, + Alias: normalizeAlias(req.Alias), + DisplayName: req.DisplayName, + WorkflowType: wt, + WorkflowJSON: req.WorkflowJSON, + InputBindingJSON: bindingJSON, + VersionHash: prediction.HashWorkflowJSON(req.WorkflowJSON), + Enabled: true, + } + if m.DisplayName == "" { + m.DisplayName = req.ModelID + } + if err := h.db.DB().Create(&m).Error; err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + models := h.cache.GetModels(providerID) + models = append(models, m) + h.cache.SetModels(providerID, models) + c.JSON(http.StatusCreated, m) +} + +func (h *Handler) UpdateModel(c *gin.Context) { + providerID := parseUint(c.Param("id")) + modelID := parseUint(c.Param("modelId")) + var req struct { + Enabled *bool `json:"enabled"` + DisplayName string `json:"display_name"` + Alias *string `json:"alias"` + ModelID string `json:"model_id"` + WorkflowType model.WorkflowType `json:"workflow_type"` + WorkflowJSON string `json:"workflow_json"` + InputBindingJSON string `json:"input_binding_json"` + InputBinding map[string]*prediction.NodeField `json:"input_binding"` + } + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid request"}) + return + } + m, ok := h.findModelEntry(providerID, modelID, req.ModelID) + if !ok { + c.JSON(http.StatusNotFound, gin.H{"error": "model not found"}) + return + } + if req.Enabled != nil { + m.Enabled = *req.Enabled + } + if req.DisplayName != "" { + m.DisplayName = req.DisplayName + } + if req.Alias != nil { + normalized := normalizeAlias(*req.Alias) + if msg := validateModelAlias(h, normalized, m.ID); msg != "" { + c.JSON(http.StatusBadRequest, gin.H{"error": msg}) + return + } + m.Alias = normalized + } + if req.ModelID != "" && req.ModelID != m.ModelID { + for _, existing := range h.cache.GetModels(m.ProviderID) { + if existing.ID != m.ID && existing.ModelID == req.ModelID { + c.JSON(http.StatusBadRequest, gin.H{"error": "model already exists"}) + return + } + } + m.ModelID = req.ModelID + } + if req.WorkflowType != "" { + m.WorkflowType = req.WorkflowType + } + if req.WorkflowJSON != "" { + m.WorkflowJSON = req.WorkflowJSON + m.VersionHash = prediction.HashWorkflowJSON(req.WorkflowJSON) + } + if req.InputBindingJSON != "" { + m.InputBindingJSON = req.InputBindingJSON + } else if req.InputBinding != nil { + b, _ := json.Marshal(req.InputBinding) + m.InputBindingJSON = string(b) + } + prov, _ := h.cache.GetProvider(m.ProviderID) + if prov.Category == model.CategoryImage && m.WorkflowJSON != "" { + if err := validateImageModelCreate(struct { + ModelID, DisplayName string + WorkflowType model.WorkflowType + WorkflowJSON, InputBindingJSON string + InputBinding map[string]*prediction.NodeField + }{m.ModelID, m.DisplayName, m.WorkflowType, m.WorkflowJSON, m.InputBindingJSON, nil}); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + } + if err := h.db.DB().Save(&m).Error; err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + models := h.cache.GetModels(m.ProviderID) + for i := range models { + if models[i].ID == m.ID { + models[i] = m + break + } + } + h.cache.SetModels(m.ProviderID, models) + c.JSON(http.StatusOK, m) +} + +func (h *Handler) findModelEntry(providerID, entryID uint, modelID string) (model.ModelEntry, bool) { + var m model.ModelEntry + if entryID > 0 { + if err := h.db.DB().First(&m, entryID).Error; err == nil { + if providerID > 0 && m.ProviderID != providerID { + return model.ModelEntry{}, false + } + return m, true + } + } + if providerID > 0 && modelID != "" { + if err := h.db.DB().Where("provider_id = ? AND model_id = ?", providerID, modelID).First(&m).Error; err == nil { + return m, true + } + } + return model.ModelEntry{}, false +} + +func (h *Handler) DeleteModel(c *gin.Context) { + modelID := parseUint(c.Param("modelId")) + var m model.ModelEntry + if err := h.db.DB().First(&m, modelID).Error; err != nil { + c.JSON(http.StatusNotFound, gin.H{"error": "not found"}) + return + } + if providerID := parseUint(c.Param("id")); providerID > 0 && m.ProviderID != providerID { + c.JSON(http.StatusNotFound, gin.H{"error": "not found"}) + return + } + if err := h.db.DB().Delete(&m).Error; err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + models := h.cache.GetModels(m.ProviderID) + filtered := models[:0] + for _, item := range models { + if item.ID != modelID { + filtered = append(filtered, item) + } + } + h.cache.SetModels(m.ProviderID, filtered) + c.JSON(http.StatusOK, gin.H{"ok": true}) +} diff --git a/internal/handler/admin/providers_util.go b/internal/handler/admin/providers_util.go new file mode 100644 index 0000000..298230f --- /dev/null +++ b/internal/handler/admin/providers_util.go @@ -0,0 +1,51 @@ +package admin + +import ( + "strings" +) + +func (h *Handler) providerNameTaken(name string, excludeID uint) bool { + name = strings.TrimSpace(name) + if name == "" { + return false + } + for _, p := range h.cache.ListProviders() { + if p.ID != excludeID && p.Name == name { + return true + } + } + return false +} + +func (h *Handler) modelAliasTaken(alias string, excludeModelID uint) bool { + alias = strings.TrimSpace(alias) + if alias == "" { + return false + } + for _, p := range h.cache.ListProviders() { + for _, m := range h.cache.GetModels(p.ID) { + if m.ID == excludeModelID { + continue + } + if m.Alias == alias { + return true + } + } + } + return false +} + +func normalizeAlias(alias string) string { + return strings.TrimSpace(alias) +} + +func validateModelAlias(h *Handler, alias string, excludeModelID uint) string { + alias = normalizeAlias(alias) + if alias == "" { + return "" + } + if h.modelAliasTaken(alias, excludeModelID) { + return "model alias already exists" + } + return "" +} diff --git a/internal/handler/admin/routing_logs.go b/internal/handler/admin/routing_logs.go new file mode 100644 index 0000000..da23947 --- /dev/null +++ b/internal/handler/admin/routing_logs.go @@ -0,0 +1,220 @@ +package admin + +import ( + "net/http" + "strconv" + "time" + + "github.com/gin-gonic/gin" + "github.com/rose_cat707/luminary/internal/model" + "github.com/rose_cat707/luminary/internal/pricing" +) + +func (h *Handler) ListRoutingRules(c *gin.Context) { + ingressID := parseUint(c.Param("id")) + c.JSON(http.StatusOK, gin.H{"data": h.cache.ListRoutingRules(ingressID)}) +} + +func (h *Handler) CreateRoutingRule(c *gin.Context) { + ingressID := parseUint(c.Param("id")) + var req struct { + ModelPattern string `json:"model_pattern"` + ProviderID uint `json:"provider_id"` + ProviderKeyID uint `json:"provider_key_id"` + Priority int `json:"priority"` + Enabled *bool `json:"enabled"` + } + if err := c.ShouldBindJSON(&req); err != nil || req.ModelPattern == "" || req.ProviderID == 0 { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid request"}) + return + } + rule := model.RoutingRule{ + IngressKeyID: ingressID, + ModelPattern: req.ModelPattern, + ProviderID: req.ProviderID, + ProviderKeyID: req.ProviderKeyID, + Priority: req.Priority, + Enabled: true, + } + if req.Enabled != nil { + rule.Enabled = *req.Enabled + } + if err := h.db.DB().Create(&rule).Error; err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + rules := h.cache.ListRoutingRules(ingressID) + rules = append(rules, rule) + h.cache.SetRoutingRules(ingressID, rules) + c.JSON(http.StatusCreated, rule) +} + +func (h *Handler) UpdateRoutingRule(c *gin.Context) { + ruleID := parseUint(c.Param("ruleId")) + var rule model.RoutingRule + if err := h.db.DB().First(&rule, ruleID).Error; err != nil { + c.JSON(http.StatusNotFound, gin.H{"error": "not found"}) + return + } + var req struct { + ModelPattern string `json:"model_pattern"` + ProviderID uint `json:"provider_id"` + ProviderKeyID uint `json:"provider_key_id"` + Priority int `json:"priority"` + Enabled *bool `json:"enabled"` + } + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid request"}) + return + } + if req.ModelPattern != "" { + rule.ModelPattern = req.ModelPattern + } + if req.ProviderID > 0 { + rule.ProviderID = req.ProviderID + } + if req.ProviderKeyID >= 0 { + rule.ProviderKeyID = req.ProviderKeyID + } + rule.Priority = req.Priority + if req.Enabled != nil { + rule.Enabled = *req.Enabled + } + h.db.DB().Save(&rule) + h.reloadRoutingRules(rule.IngressKeyID) + c.JSON(http.StatusOK, rule) +} + +func (h *Handler) DeleteRoutingRule(c *gin.Context) { + ruleID := parseUint(c.Param("ruleId")) + var rule model.RoutingRule + if err := h.db.DB().First(&rule, ruleID).Error; err != nil { + c.JSON(http.StatusNotFound, gin.H{"error": "not found"}) + return + } + h.db.DB().Delete(&rule) + h.reloadRoutingRules(rule.IngressKeyID) + c.JSON(http.StatusOK, gin.H{"ok": true}) +} + +func (h *Handler) reloadRoutingRules(ingressID uint) { + var rules []model.RoutingRule + h.db.DB().Where("ingress_key_id = ?", ingressID).Find(&rules) + h.cache.SetRoutingRules(ingressID, rules) +} + +func (h *Handler) ListRequestLogs(c *gin.Context) { + page, _ := strconv.Atoi(c.DefaultQuery("page", "1")) + pageSize, _ := strconv.Atoi(c.DefaultQuery("page_size", "20")) + if page < 1 { + page = 1 + } + if pageSize <= 0 || pageSize > 50 { + pageSize = 20 + } + + q := h.db.DB().Model(&model.UsageRecord{}) + if ingressID := c.Query("ingress_key_id"); ingressID != "" { + q = q.Where("ingress_key_id = ?", ingressID) + } + if status := c.Query("status"); status != "" { + q = q.Where("status = ?", status) + } + if endpoint := c.Query("endpoint"); endpoint != "" { + q = q.Where("endpoint = ?", endpoint) + } + if clientIP := c.Query("client_ip"); clientIP != "" { + q = q.Where("client_ip LIKE ?", "%"+clientIP+"%") + } + if modelName := c.Query("model"); modelName != "" { + q = q.Where("model LIKE ?", "%"+modelName+"%") + } + + var total int64 + q.Count(&total) + + var logs []model.UsageRecord + q.Order("created_at desc"). + Offset((page - 1) * pageSize). + Limit(pageSize). + Find(&logs) + + type item struct { + ID uint `json:"id"` + IngressKeyID uint `json:"ingress_key_id"` + IngressName string `json:"ingress_name"` + ClientIP string `json:"client_ip"` + Endpoint string `json:"endpoint"` + Model string `json:"model"` + LatencyMs int64 `json:"latency_ms"` + Status string `json:"status"` + Stream bool `json:"stream"` + ErrorMsg string `json:"error_msg"` + CreatedAt time.Time `json:"created_at"` + } + out := make([]item, 0, len(logs)) + for _, log := range logs { + name := "" + if log.Endpoint == model.EndpointAdminLogin { + name = "管理台" + } else if k, ok := h.cache.GetIngressKey(log.IngressKeyID); ok { + name = k.Name + } + out = append(out, item{ + ID: log.ID, + IngressKeyID: log.IngressKeyID, + IngressName: name, + ClientIP: log.ClientIP, + Endpoint: log.Endpoint, + Model: log.Model, + LatencyMs: log.LatencyMs, + Status: log.Status, + Stream: log.Stream, + ErrorMsg: log.ErrorMsg, + CreatedAt: log.CreatedAt, + }) + } + c.JSON(http.StatusOK, gin.H{ + "data": out, + "total": total, + "page": page, + "page_size": pageSize, + "max_kept": model.MaxUsageLogRecords, + }) +} + +func (h *Handler) GetRequestLog(c *gin.Context) { + id := parseUint(c.Param("id")) + var log model.UsageRecord + if err := h.db.DB().First(&log, id).Error; err != nil { + c.JSON(http.StatusNotFound, gin.H{"error": "not found"}) + return + } + name := "" + if log.Endpoint == model.EndpointAdminLogin { + name = "管理台" + } else if k, ok := h.cache.GetIngressKey(log.IngressKeyID); ok { + name = k.Name + } + c.JSON(http.StatusOK, gin.H{ + "data": gin.H{ + "id": log.ID, + "ingress_key_id": log.IngressKeyID, + "ingress_name": name, + "client_ip": log.ClientIP, + "endpoint": log.Endpoint, + "model": log.Model, + "latency_ms": log.LatencyMs, + "status": log.Status, + "stream": log.Stream, + "error_msg": log.ErrorMsg, + "request_body": log.RequestBody, + "response_body": log.ResponseBody, + "created_at": log.CreatedAt, + }, + }) +} + +func (h *Handler) PricingCatalog(c *gin.Context) { + c.JSON(http.StatusOK, gin.H{"data": pricing.ListCatalog()}) +} diff --git a/internal/handler/admin/settings.go b/internal/handler/admin/settings.go new file mode 100644 index 0000000..0d6161b --- /dev/null +++ b/internal/handler/admin/settings.go @@ -0,0 +1,29 @@ +package admin + +import ( + "net/http" + + "github.com/gin-gonic/gin" + "github.com/rose_cat707/luminary/internal/settings" +) + +func (h *Handler) GetSettings(c *gin.Context) { + c.JSON(http.StatusOK, h.settings.PublicView()) +} + +func (h *Handler) UpdateSettings(c *gin.Context) { + var in settings.UpdateInput + if err := c.ShouldBindJSON(&in); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid request"}) + return + } + if err := h.settings.Update(in); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + h.loginLimit.Configure(h.settings.LoginMaxAttempts(), h.settings.LoginLockout()) + if h.monitor != nil { + h.monitor.NotifySettingsChanged() + } + c.JSON(http.StatusOK, h.settings.PublicView()) +} diff --git a/internal/handler/admin/workflows.go b/internal/handler/admin/workflows.go new file mode 100644 index 0000000..689a20e --- /dev/null +++ b/internal/handler/admin/workflows.go @@ -0,0 +1,116 @@ +package admin + +import ( + "encoding/json" + "net/http" + + "github.com/gin-gonic/gin" + "github.com/rose_cat707/luminary/internal/model" + "github.com/rose_cat707/luminary/internal/prediction" +) + +func (h *Handler) GetWorkflowParamMeta(c *gin.Context) { + c.JSON(http.StatusOK, gin.H{ + "txt2img": prediction.Txt2ImgParams, + "img2img": prediction.Img2ImgParams, + }) +} + +func (h *Handler) AnalyzeWorkflow(c *gin.Context) { + var req struct { + WorkflowJSON string `json:"workflow_json"` + WorkflowType model.WorkflowType `json:"workflow_type"` + } + if err := c.ShouldBindJSON(&req); err != nil || req.WorkflowJSON == "" { + c.JSON(http.StatusBadRequest, gin.H{"error": "workflow_json required"}) + return + } + if req.WorkflowType == "" { + req.WorkflowType = model.WorkflowTxt2Img + } + result, err := prediction.AnalyzeWorkflow(req.WorkflowJSON, req.WorkflowType) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + c.JSON(http.StatusOK, result) +} + +func (h *Handler) GetPrediction(c *gin.Context) { + if h.predictions == nil { + c.JSON(http.StatusNotFound, gin.H{"error": "not found"}) + return + } + p, err := h.predictions.GetDBPrediction(c.Param("id")) + if err != nil { + c.JSON(http.StatusNotFound, gin.H{"error": "not found"}) + return + } + images, _ := h.images.ListByPrediction(p.ID) + c.JSON(http.StatusOK, gin.H{ + "prediction": h.predictions.APIView(p, h.publicBaseURL()), + "images": images, + }) +} + +func (h *Handler) ServeAdminImage(c *gin.Context) { + if h.images == nil { + c.JSON(http.StatusNotFound, gin.H{"error": "not found"}) + return + } + img, err := h.images.Get(c.Param("id")) + if err != nil { + c.JSON(http.StatusNotFound, gin.H{"error": "not found"}) + return + } + c.Header("Content-Type", img.Mime) + c.File(img.LocalPath) +} + +func bindingFromSuggestions(suggestions []prediction.BindingSuggestion, overrides map[string]*prediction.NodeField) prediction.InputBinding { + binding := prediction.InputBinding{} + for _, s := range suggestions { + if o, ok := overrides[s.Param]; ok && o != nil { + if o.Node != "" && o.Field != "" { + binding[s.Param] = o + } + continue + } + if s.Node != "" && s.Field != "" { + binding[s.Param] = &prediction.NodeField{Node: s.Node, Field: s.Field} + } + } + return binding +} + +func validateImageModelCreate(req struct { + ModelID string + DisplayName string + WorkflowType model.WorkflowType + WorkflowJSON string + InputBindingJSON string + InputBinding map[string]*prediction.NodeField +}) error { + if req.WorkflowJSON == "" { + return nil + } + var workflow map[string]any + if err := json.Unmarshal([]byte(req.WorkflowJSON), &workflow); err != nil { + return err + } + var binding prediction.InputBinding + if req.InputBindingJSON != "" { + b, err := prediction.ParseBinding(req.InputBindingJSON) + if err != nil { + return err + } + binding = b + } else if req.InputBinding != nil { + binding = req.InputBinding + } + wt := req.WorkflowType + if wt == "" { + wt = model.WorkflowTxt2Img + } + return binding.Validate(workflow, prediction.RequiredBindings(wt)) +} diff --git a/internal/handler/files/handler.go b/internal/handler/files/handler.go new file mode 100644 index 0000000..2d7e623 --- /dev/null +++ b/internal/handler/files/handler.go @@ -0,0 +1,43 @@ +package files + +import ( + "net/http" + "strconv" + + "github.com/gin-gonic/gin" + "github.com/rose_cat707/luminary/internal/storage/imagestore" +) + +type Handler struct { + store *imagestore.Store +} + +func New(store *imagestore.Store) *Handler { + return &Handler{store: store} +} + +func (h *Handler) Register(r *gin.Engine) { + r.GET("/files/:id", h.Serve) +} + +func (h *Handler) Serve(c *gin.Context) { + id := c.Param("id") + expStr := c.Query("exp") + sig := c.Query("sig") + exp, err := strconv.ParseInt(expStr, 10, 64) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid exp"}) + return + } + if !h.store.Verify(id, exp, sig) { + c.JSON(http.StatusForbidden, gin.H{"error": "invalid signature"}) + return + } + img, err := h.store.Get(id) + if err != nil { + c.JSON(http.StatusNotFound, gin.H{"error": "not found"}) + return + } + c.Header("Content-Type", img.Mime) + c.File(img.LocalPath) +} diff --git a/internal/handler/proxy/errors.go b/internal/handler/proxy/errors.go new file mode 100644 index 0000000..58b8e6f --- /dev/null +++ b/internal/handler/proxy/errors.go @@ -0,0 +1,30 @@ +package proxy + +import ( + "errors" + "net/http" + "strings" + + "github.com/rose_cat707/luminary/internal/gateway" +) + +func gatewayHTTPStatus(err error) int { + switch { + case errors.Is(err, gateway.ErrRateLimitExceeded): + return http.StatusTooManyRequests + case errors.Is(err, gateway.ErrModelNotAllowed), + errors.Is(err, gateway.ErrBudgetExceeded): + return http.StatusForbidden + case errors.Is(err, gateway.ErrInvalidJSON), + errors.Is(err, gateway.ErrModelRequired): + return http.StatusBadRequest + } + msg := err.Error() + if strings.Contains(msg, "not enabled") || strings.Contains(msg, "not allowed") { + return http.StatusForbidden + } + if strings.Contains(msg, "invalid api key") || strings.Contains(msg, "expired") { + return http.StatusUnauthorized + } + return http.StatusBadGateway +} diff --git a/internal/handler/proxy/handler.go b/internal/handler/proxy/handler.go new file mode 100644 index 0000000..f76f6f0 --- /dev/null +++ b/internal/handler/proxy/handler.go @@ -0,0 +1,100 @@ +package proxy + +import ( + "bufio" + "io" + "net/http" + + "github.com/gin-gonic/gin" + "github.com/rose_cat707/luminary/internal/gateway" + "github.com/rose_cat707/luminary/internal/middleware" + "github.com/rose_cat707/luminary/internal/model" + "github.com/rose_cat707/luminary/internal/provider" +) + +type Handler struct { + gateway *gateway.Service +} + +func New(gw *gateway.Service) *Handler { + return &Handler{gateway: gw} +} + +func (h *Handler) Register(r *gin.RouterGroup) { + r.GET("/models", h.ListModels) + r.POST("/chat/completions", h.forward(provider.EndpointChatCompletion)) + r.POST("/completions", h.forward(provider.EndpointTextCompletion)) + r.POST("/responses", h.forward(provider.EndpointResponses)) + r.POST("/embeddings", h.forward(provider.EndpointEmbeddings)) + r.POST("/rerank", h.forward(provider.EndpointRerank)) +} + +func (h *Handler) ingressKey(c *gin.Context) (*model.IngressKey, bool) { + v, ok := c.Get("ingress_key") + if !ok { + return nil, false + } + k, ok := v.(*model.IngressKey) + return k, ok +} + +func (h *Handler) ListModels(c *gin.Context) { + ingress, ok := h.ingressKey(c) + if !ok { + c.JSON(http.StatusUnauthorized, gin.H{"error": "unauthorized"}) + return + } + models, err := h.gateway.ListModels(c.Request.Context(), ingress) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + c.JSON(http.StatusOK, gin.H{"object": "list", "data": models}) +} + +func (h *Handler) forward(endpoint provider.EndpointType) gin.HandlerFunc { + return func(c *gin.Context) { + ingress, ok := h.ingressKey(c) + if !ok { + c.JSON(http.StatusUnauthorized, gin.H{"error": "unauthorized"}) + return + } + body, err := io.ReadAll(c.Request.Body) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid body"}) + return + } + if provider.IsStreamRequest(body) && provider.SupportsStreaming(endpoint) { + h.handleStream(c, ingress, endpoint, body) + return + } + clientIP := middleware.ClientIP(c) + resp, err := h.gateway.ForwardEndpoint(c.Request.Context(), ingress, endpoint, body, clientIP) + if err != nil { + c.JSON(gatewayHTTPStatus(err), gin.H{"error": gin.H{"message": err.Error(), "type": "gateway_error"}}) + return + } + c.Data(resp.StatusCode, "application/json", resp.Body) + } +} + +func (h *Handler) handleStream(c *gin.Context, ingress *model.IngressKey, endpoint provider.EndpointType, body []byte) { + c.Header("Content-Type", "text/event-stream") + c.Header("Cache-Control", "no-cache") + c.Header("Connection", "keep-alive") + c.Status(http.StatusOK) + + flusher, ok := c.Writer.(http.Flusher) + if !ok { + c.JSON(http.StatusInternalServerError, gin.H{"error": "streaming not supported"}) + return + } + w := bufio.NewWriter(c.Writer) + clientIP := middleware.ClientIP(c) + err := h.gateway.ForwardStream(c.Request.Context(), ingress, endpoint, body, clientIP, w, flusher) + w.Flush() + if err != nil { + _, _ = c.Writer.Write([]byte("data: {\"error\":\"" + err.Error() + "\"}\n\n")) + flusher.Flush() + } +} diff --git a/internal/handler/replicate/handler.go b/internal/handler/replicate/handler.go new file mode 100644 index 0000000..69e2a57 --- /dev/null +++ b/internal/handler/replicate/handler.go @@ -0,0 +1,159 @@ +package replicate + +import ( + "bufio" + "encoding/json" + "fmt" + "net/http" + "strings" + + "github.com/gin-gonic/gin" + "github.com/rose_cat707/luminary/internal/model" + "github.com/rose_cat707/luminary/internal/prediction" +) + +type Handler struct { + svc *prediction.Service + baseURL func() string +} + +func New(svc *prediction.Service, baseURL func() string) *Handler { + return &Handler{svc: svc, baseURL: baseURL} +} + +func (h *Handler) Register(r *gin.RouterGroup) { + r.POST("/predictions", h.Create) + r.GET("/predictions", h.List) + r.GET("/predictions/:id", h.Get) + r.POST("/predictions/:id/cancel", h.Cancel) + r.GET("/predictions/:id/stream", h.Stream) +} + +func (h *Handler) ingress(c *gin.Context) (*model.IngressKey, bool) { + v, ok := c.Get("ingress_key") + if !ok { + return nil, false + } + k, ok := v.(*model.IngressKey) + return k, ok +} + +func (h *Handler) Create(c *gin.Context) { + ingress, ok := h.ingress(c) + if !ok { + c.JSON(http.StatusUnauthorized, gin.H{"detail": "unauthorized"}) + return + } + var req prediction.CreateRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"detail": "invalid request body"}) + return + } + wait := strings.Contains(c.GetHeader("Prefer"), "wait") + p, err := h.svc.Create(c.Request.Context(), ingress, req, c.ClientIP(), wait) + if err != nil { + code := http.StatusBadRequest + if strings.Contains(err.Error(), "not found") { + code = http.StatusNotFound + } + c.JSON(code, gin.H{"detail": err.Error()}) + return + } + c.JSON(http.StatusCreated, h.svc.APIView(p, h.baseURL())) +} + +func (h *Handler) Get(c *gin.Context) { + ingress, ok := h.ingress(c) + if !ok { + c.JSON(http.StatusUnauthorized, gin.H{"detail": "unauthorized"}) + return + } + p, err := h.svc.Get(c.Request.Context(), c.Param("id"), ingress) + if err != nil { + c.JSON(http.StatusNotFound, gin.H{"detail": err.Error()}) + return + } + c.JSON(http.StatusOK, h.svc.APIView(p, h.baseURL())) +} + +func (h *Handler) List(c *gin.Context) { + ingress, ok := h.ingress(c) + if !ok { + c.JSON(http.StatusUnauthorized, gin.H{"detail": "unauthorized"}) + return + } + items, err := h.svc.List(c.Request.Context(), ingress, c.Query("cursor"), 100) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"detail": err.Error()}) + return + } + results := make([]map[string]any, 0, len(items)) + for _, p := range items { + results = append(results, h.svc.APIView(p, h.baseURL())) + } + c.JSON(http.StatusOK, gin.H{"results": results}) +} + +func (h *Handler) Cancel(c *gin.Context) { + ingress, ok := h.ingress(c) + if !ok { + c.JSON(http.StatusUnauthorized, gin.H{"detail": "unauthorized"}) + return + } + p, err := h.svc.Cancel(c.Request.Context(), c.Param("id"), ingress) + if err != nil { + c.JSON(http.StatusNotFound, gin.H{"detail": err.Error()}) + return + } + c.JSON(http.StatusOK, h.svc.APIView(p, h.baseURL())) +} + +func (h *Handler) Stream(c *gin.Context) { + ingress, ok := h.ingress(c) + if !ok { + c.JSON(http.StatusUnauthorized, gin.H{"detail": "unauthorized"}) + return + } + id := c.Param("id") + if _, err := h.svc.Get(c.Request.Context(), id, ingress); err != nil { + c.JSON(http.StatusNotFound, gin.H{"detail": err.Error()}) + return + } + + c.Header("Content-Type", "text/event-stream") + c.Header("Cache-Control", "no-cache") + c.Header("Connection", "keep-alive") + c.Status(http.StatusOK) + + flusher, ok := c.Writer.(http.Flusher) + if !ok { + return + } + w := bufio.NewWriter(c.Writer) + ch := h.svc.Hub().Subscribe(id) + defer h.svc.Hub().Unsubscribe(id, ch) + + for { + select { + case <-c.Request.Context().Done(): + return + case ev, open := <-ch: + if !open { + return + } + payload, _ := json.Marshal(map[string]any{ + "id": id, "status": ev.Status, "logs": ev.Logs, + "metrics": ev.Metrics, "output": ev.Output, "error": ev.Error, + }) + _, _ = fmt.Fprintf(w, "data: %s\n\n", payload) + w.Flush() + flusher.Flush() + if ev.Done { + _, _ = fmt.Fprintf(w, "event: done\ndata: {}\n\n") + w.Flush() + flusher.Flush() + return + } + } + } +} diff --git a/internal/handler/static/handler.go b/internal/handler/static/handler.go new file mode 100644 index 0000000..957dbe3 --- /dev/null +++ b/internal/handler/static/handler.go @@ -0,0 +1,63 @@ +package static + +import ( + "embed" + "io/fs" + "mime" + "net/http" + "path/filepath" + "strings" + + "github.com/gin-gonic/gin" +) + +type Handler struct { + content embed.FS +} + +func New(content embed.FS) *Handler { + return &Handler{content: content} +} + +func (h *Handler) Register(r *gin.Engine) { + sub, err := fs.Sub(h.content, "dist") + if err != nil { + sub = h.content + } + r.NoRoute(func(c *gin.Context) { + if strings.HasPrefix(c.Request.URL.Path, "/api/") || strings.HasPrefix(c.Request.URL.Path, "/v1/") { + c.JSON(http.StatusNotFound, gin.H{"error": "not found"}) + return + } + h.serve(c, sub) + }) + r.GET("/", func(c *gin.Context) { h.serve(c, sub) }) + r.GET("/assets/*filepath", func(c *gin.Context) { h.serve(c, sub) }) +} + +func (h *Handler) serve(c *gin.Context, sub fs.FS) { + path := strings.TrimPrefix(c.Request.URL.Path, "/") + if path == "" { + path = "index.html" + } + data, err := fs.ReadFile(sub, path) + if err != nil { + data, err = fs.ReadFile(sub, "index.html") + if err != nil { + c.String(http.StatusNotFound, "UI not built. Run: make build-web") + return + } + path = "index.html" + } + ext := filepath.Ext(path) + ct := mime.TypeByExtension(ext) + if ct == "" { + ct = "text/html" + } + if strings.HasPrefix(path, "assets/") { + c.Header("Cache-Control", "public, max-age=31536000, immutable") + } else { + c.Header("Cache-Control", "no-cache") + } + c.Data(http.StatusOK, ct, data) +} diff --git a/internal/limiter/limiter.go b/internal/limiter/limiter.go new file mode 100644 index 0000000..2275d9e --- /dev/null +++ b/internal/limiter/limiter.go @@ -0,0 +1,30 @@ +package limiter + +import ( + "github.com/rose_cat707/luminary/internal/model" + "github.com/rose_cat707/luminary/internal/store/cache" +) + +func IsProviderKeyAvailable(k model.ProviderKey) bool { + if !k.Enabled { + return false + } + if k.MaxRequestsPerDay > 0 && k.RequestsToday >= k.MaxRequestsPerDay { + return false + } + if k.MaxTokensPerDay > 0 && k.TokensToday >= k.MaxTokensPerDay { + return false + } + return true +} + +func CheckIngressBudget(k *model.IngressKey, estimatedCost float64) bool { + if k.BudgetLimit <= 0 { + return true + } + return k.BudgetUsed+estimatedCost <= k.BudgetLimit +} + +func CheckRateLimit(c *cache.Store, ingressKeyID uint, rpm, tpm, tokens int) bool { + return c.CheckRateLimit(ingressKeyID, rpm, tpm, tokens) +} diff --git a/internal/middleware/clientip.go b/internal/middleware/clientip.go new file mode 100644 index 0000000..aeb6922 --- /dev/null +++ b/internal/middleware/clientip.go @@ -0,0 +1,88 @@ +package middleware + +import ( + "net" + "strings" + "sync" + + "github.com/gin-gonic/gin" +) + +var ( + trustedMu sync.RWMutex + trustedProxies []*net.IPNet + trustedIPs []net.IP +) + +// ConfigureTrustedProxies sets CIDRs/IPs allowed to set X-Forwarded-For / X-Real-IP. +// When empty, only the direct remote address is used. +func ConfigureTrustedProxies(cidrs []string) { + trustedMu.Lock() + defer trustedMu.Unlock() + trustedProxies = nil + trustedIPs = nil + for _, cidr := range cidrs { + cidr = strings.TrimSpace(cidr) + if cidr == "" { + continue + } + if strings.Contains(cidr, "/") { + _, network, err := net.ParseCIDR(cidr) + if err == nil { + trustedProxies = append(trustedProxies, network) + } + continue + } + if ip := net.ParseIP(cidr); ip != nil { + trustedIPs = append(trustedIPs, ip) + } + } +} + +func isTrustedProxy(ip net.IP) bool { + trustedMu.RLock() + defer trustedMu.RUnlock() + if len(trustedProxies) == 0 && len(trustedIPs) == 0 { + return false + } + for _, tip := range trustedIPs { + if tip.Equal(ip) { + return true + } + } + for _, network := range trustedProxies { + if network.Contains(ip) { + return true + } + } + return false +} + +func directRemoteIP(ctx *gin.Context) string { + host, _, err := net.SplitHostPort(ctx.Request.RemoteAddr) + if err != nil { + return strings.TrimSpace(ctx.Request.RemoteAddr) + } + return host +} + +// ClientIP returns the client IP. Forwarded headers are honored only when the +// direct remote address is a configured trusted proxy. +func ClientIP(ctx *gin.Context) string { + remote := directRemoteIP(ctx) + remoteIP := net.ParseIP(remote) + if remoteIP == nil || !isTrustedProxy(remoteIP) { + if remoteIP != nil { + return remoteIP.String() + } + return ctx.ClientIP() + } + if xff := ctx.GetHeader("X-Forwarded-For"); xff != "" { + parts := strings.Split(xff, ",") + return strings.TrimSpace(parts[0]) + } + if xri := ctx.GetHeader("X-Real-IP"); xri != "" { + return strings.TrimSpace(xri) + } + return remoteIP.String() +} diff --git a/internal/middleware/clientip_test.go b/internal/middleware/clientip_test.go new file mode 100644 index 0000000..141ad88 --- /dev/null +++ b/internal/middleware/clientip_test.go @@ -0,0 +1,38 @@ +package middleware + +import ( + "net/http/httptest" + "testing" + + "github.com/gin-gonic/gin" +) + +func TestClientIP_IgnoresXFFWithoutTrustedProxy(t *testing.T) { + gin.SetMode(gin.TestMode) + ConfigureTrustedProxies(nil) + + w := httptest.NewRecorder() + c, _ := gin.CreateTestContext(w) + c.Request = httptest.NewRequest("GET", "/", nil) + c.Request.RemoteAddr = "203.0.113.10:12345" + c.Request.Header.Set("X-Forwarded-For", "1.2.3.4") + + if got := ClientIP(c); got != "203.0.113.10" { + t.Fatalf("got %s want direct remote", got) + } +} + +func TestClientIP_UsesXFFFromTrustedProxy(t *testing.T) { + gin.SetMode(gin.TestMode) + ConfigureTrustedProxies([]string{"127.0.0.1"}) + + w := httptest.NewRecorder() + c, _ := gin.CreateTestContext(w) + c.Request = httptest.NewRequest("GET", "/", nil) + c.Request.RemoteAddr = "127.0.0.1:12345" + c.Request.Header.Set("X-Forwarded-For", "1.2.3.4") + + if got := ClientIP(c); got != "1.2.3.4" { + t.Fatalf("got %s want forwarded client", got) + } +} diff --git a/internal/middleware/ipfilter_test.go b/internal/middleware/ipfilter_test.go new file mode 100644 index 0000000..363ebae --- /dev/null +++ b/internal/middleware/ipfilter_test.go @@ -0,0 +1,31 @@ +package middleware + +import ( + "net" + "testing" +) + +func TestIPMatchesRule_LoopbackEquivalence(t *testing.T) { + v4 := net.ParseIP("127.0.0.1") + v6 := net.ParseIP("::1") + + if !ipMatchesRule("127.0.0.1", v6) { + t.Fatal("::1 should match 127.0.0.1 allow rule") + } + if !ipMatchesRule("::1", v4) { + t.Fatal("127.0.0.1 should match ::1 allow rule") + } + if !ipMatchesRule("127.0.0.0/8", v6) { + t.Fatal("::1 should match 127.0.0.0/8 via loopback equivalence") + } +} + +func TestIPMatchesRule_CIDR(t *testing.T) { + ip := net.ParseIP("10.0.0.5") + if !ipMatchesRule("10.0.0.0/24", ip) { + t.Fatal("expected CIDR match") + } + if ipMatchesRule("192.168.1.0/24", ip) { + t.Fatal("expected no match") + } +} diff --git a/internal/middleware/middleware.go b/internal/middleware/middleware.go new file mode 100644 index 0000000..22c0bed --- /dev/null +++ b/internal/middleware/middleware.go @@ -0,0 +1,166 @@ +package middleware + +import ( + "net" + "net/http" + "strings" + "time" + + "github.com/gin-gonic/gin" + "github.com/rose_cat707/luminary/internal/model" + "github.com/rose_cat707/luminary/internal/store/cache" +) + +func IPFilter(c *cache.Store, scope model.IPRuleScope) gin.HandlerFunc { + return func(ctx *gin.Context) { + rules := c.ListIPRules() + if len(rules) == 0 { + ctx.Next() + return + } + clientIP := clientIP(ctx) + ip := net.ParseIP(clientIP) + if ip == nil { + ctx.AbortWithStatusJSON(http.StatusForbidden, gin.H{"error": "invalid client ip"}) + return + } + var allowRules, denyRules []model.IPRule + for _, r := range rules { + if !r.Enabled { + continue + } + if r.Scope != scope && r.Scope != model.IPScopeAll { + continue + } + if r.Type == model.IPRuleAllow { + allowRules = append(allowRules, r) + } else { + denyRules = append(denyRules, r) + } + } + for _, r := range denyRules { + if ipMatchesRule(r.CIDR, ip) { + ctx.AbortWithStatusJSON(http.StatusForbidden, gin.H{"error": "ip denied"}) + return + } + } + if len(allowRules) > 0 { + allowed := false + for _, r := range allowRules { + if ipMatchesRule(r.CIDR, ip) { + allowed = true + break + } + } + if !allowed { + ctx.AbortWithStatusJSON(http.StatusForbidden, gin.H{"error": "ip not allowed"}) + return + } + } + ctx.Next() + } +} + +func cidrContains(cidr string, ip net.IP) bool { + if !strings.Contains(cidr, "/") { + return ip.String() == cidr + } + _, network, err := net.ParseCIDR(cidr) + if err != nil { + return false + } + return network.Contains(ip) +} + +// ipMatchesRule checks CIDR/IP match, treating IPv4/IPv6 loopback as equivalent. +func ipMatchesRule(cidr string, ip net.IP) bool { + if cidrContains(cidr, ip) { + return true + } + if !ip.IsLoopback() { + return false + } + alt := loopbackAlt(ip) + return alt != nil && cidrContains(cidr, alt) +} + +func loopbackAlt(ip net.IP) net.IP { + if v4 := ip.To4(); v4 != nil && v4.IsLoopback() { + return net.ParseIP("::1") + } + if ip.Equal(net.ParseIP("::1")) { + return net.ParseIP("127.0.0.1") + } + return nil +} + +func clientIP(ctx *gin.Context) string { + return ClientIP(ctx) +} + +const sessionCookie = "luminary_session" + +type SessionValidator func(token string) (bool, time.Time) + +func AdminAuth(validate SessionValidator) gin.HandlerFunc { + return func(ctx *gin.Context) { + token := extractToken(ctx) + if token == "" { + ctx.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "unauthorized"}) + return + } + ok, expires := validate(token) + if !ok || time.Now().After(expires) { + ctx.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "session expired"}) + return + } + ctx.Set("session_token", token) + ctx.Next() + } +} + +func extractToken(ctx *gin.Context) string { + if auth := ctx.GetHeader("Authorization"); strings.HasPrefix(auth, "Bearer ") { + return strings.TrimPrefix(auth, "Bearer ") + } + if cookie, err := ctx.Cookie(sessionCookie); err == nil { + return cookie + } + return "" +} + +func SessionCookieName() string { return sessionCookie } + +func IngressAuth(gw IngressKeyResolver) gin.HandlerFunc { + return func(ctx *gin.Context) { + key := extractIngressKey(ctx) + if key == "" { + ctx.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "missing api key"}) + return + } + ingress, err := gw.ResolveIngressKey(key) + if err != nil { + ctx.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "invalid api key"}) + return + } + ctx.Set("ingress_key", ingress) + ctx.Next() + } +} + +func extractIngressKey(ctx *gin.Context) string { + if auth := ctx.GetHeader("Authorization"); strings.HasPrefix(auth, "Bearer ") { + return strings.TrimPrefix(auth, "Bearer ") + } + return ctx.GetHeader("x-api-key") +} + +type IngressKeyResolver interface { + ResolveIngressKey(key string) (*model.IngressKey, error) +} + +func Logger() gin.HandlerFunc { + return gin.LoggerWithFormatter(func(param gin.LogFormatterParams) string { + return param.TimeStamp.Format(time.RFC3339) + " " + param.Method + " " + param.Path + " " + param.ClientIP + "\n" + }) +} diff --git a/internal/model/category.go b/internal/model/category.go new file mode 100644 index 0000000..22953fc --- /dev/null +++ b/internal/model/category.go @@ -0,0 +1,26 @@ +package model + +// ProviderCategory 出口服务分类 +type ProviderCategory string + +const ( + CategoryLLM ProviderCategory = "llm" // 语言模型 + CategoryEmbedding ProviderCategory = "embedding" // 向量嵌入 + CategoryRerank ProviderCategory = "rerank" // 重排序 + CategorySpeech ProviderCategory = "speech" // 语音合成(预留) + CategoryImage ProviderCategory = "image" // 图像生成 + CategoryAudio ProviderCategory = "audio" // 音频转写(预留) +) + +func SupportedCategories() []ProviderCategory { + return []ProviderCategory{CategoryLLM, CategoryEmbedding, CategoryRerank, CategoryImage} +} + +func IsCategorySupported(c ProviderCategory) bool { + for _, s := range SupportedCategories() { + if s == c { + return true + } + } + return false +} diff --git a/internal/model/entities.go b/internal/model/entities.go new file mode 100644 index 0000000..2078732 --- /dev/null +++ b/internal/model/entities.go @@ -0,0 +1,243 @@ +package model + +import ( + "time" +) + +type ProviderType string + +const ( + ProviderOpenAI ProviderType = "openai" + ProviderAnthropic ProviderType = "anthropic" + ProviderCustom ProviderType = "custom" + ProviderAzureOpenAI ProviderType = "azure_openai" + ProviderOpenRouter ProviderType = "openrouter" + ProviderOllama ProviderType = "ollama" + ProviderComfyUI ProviderType = "comfyui" +) + +type WorkflowType string + +const ( + WorkflowTxt2Img WorkflowType = "txt2img" + WorkflowImg2Img WorkflowType = "img2img" +) + +type PredictionStatus string + +const ( + PredictionStarting PredictionStatus = "starting" + PredictionProcessing PredictionStatus = "processing" + PredictionSucceeded PredictionStatus = "succeeded" + PredictionFailed PredictionStatus = "failed" + PredictionCanceled PredictionStatus = "canceled" +) + +type ProviderStatus string + +const ( + ProviderHealthy ProviderStatus = "healthy" + ProviderUnhealthy ProviderStatus = "unhealthy" + ProviderUnknown ProviderStatus = "unknown" +) + +type Provider struct { + ID uint `gorm:"primaryKey" json:"id"` + Name string `gorm:"uniqueIndex;not null" json:"name"` + Type ProviderType `gorm:"not null" json:"type"` + Category ProviderCategory `gorm:"not null;default:llm" json:"category"` + BaseURL string `json:"base_url"` + AllowedEndpointsJSON string `gorm:"default:'[\"list_models\",\"chat_completion\"]'" json:"allowed_endpoints_json"` + EndpointPathsJSON string `gorm:"default:'{}'" json:"endpoint_paths_json"` + Status ProviderStatus `gorm:"default:unknown" json:"status"` + Enabled bool `gorm:"default:true" json:"enabled"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` + Keys []ProviderKey `gorm:"foreignKey:ProviderID" json:"keys,omitempty"` + Models []ModelEntry `gorm:"foreignKey:ProviderID" json:"models,omitempty"` +} + +type ProviderKey struct { + ID uint `gorm:"primaryKey" json:"id"` + ProviderID uint `gorm:"index;not null" json:"provider_id"` + Name string `gorm:"not null" json:"name"` + APIKeyEnc string `gorm:"not null" json:"-"` + Weight float64 `gorm:"default:1" json:"weight"` + ModelsJSON string `gorm:"default:'[\"*\"]'" json:"models_json"` + Enabled bool `gorm:"default:true" json:"enabled"` + MaxRequestsPerDay int `gorm:"default:0" json:"max_requests_per_day"` + MaxTokensPerDay int64 `gorm:"default:0" json:"max_tokens_per_day"` + RequestsToday int `gorm:"default:0" json:"requests_today"` + TokensToday int64 `gorm:"default:0" json:"tokens_today"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + +type ModelEntry struct { + ID uint `gorm:"primaryKey" json:"id"` + ProviderID uint `gorm:"index;not null" json:"provider_id"` + ModelID string `gorm:"not null" json:"model_id"` + Alias string `gorm:"index" json:"alias"` + DisplayName string `json:"display_name"` + WorkflowType WorkflowType `gorm:"default:txt2img" json:"workflow_type"` + WorkflowJSON string `gorm:"type:text" json:"workflow_json"` + InputBindingJSON string `gorm:"type:text" json:"input_binding_json"` + VersionHash string `gorm:"index" json:"version_hash"` + Enabled bool `gorm:"default:true" json:"enabled"` + CreatedAt time.Time `json:"created_at"` +} + +type IngressKey struct { + ID uint `gorm:"primaryKey" json:"id"` + Name string `gorm:"not null" json:"name"` + KeyHash string `gorm:"uniqueIndex;not null" json:"-"` + Prefix string `gorm:"not null" json:"prefix"` + Enabled bool `gorm:"default:true" json:"enabled"` + BudgetLimit float64 `gorm:"default:0" json:"budget_limit"` + BudgetUsed float64 `gorm:"default:0" json:"budget_used"` + RateLimitRPM int `gorm:"default:0" json:"rate_limit_rpm"` + RateLimitTPM int `gorm:"default:0" json:"rate_limit_tpm"` + AllowedProvidersJSON string `gorm:"default:'[\"*\"]'" json:"allowed_providers_json"` + AllowedModelsJSON string `gorm:"default:'[\"*\"]'" json:"allowed_models_json"` + RequestCount int64 `gorm:"default:0" json:"request_count"` + TokenCount int64 `gorm:"default:0" json:"token_count"` + ExpiresAt *time.Time `json:"expires_at"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + +type AdminUser struct { + ID uint `gorm:"primaryKey" json:"id"` + Username string `gorm:"uniqueIndex;not null" json:"username"` + PasswordHash string `gorm:"not null" json:"-"` + CreatedAt time.Time `json:"created_at"` +} + +type AdminSession struct { + ID uint `gorm:"primaryKey" json:"id"` + TokenHash string `gorm:"uniqueIndex;not null" json:"-"` + ExpiresAt time.Time `gorm:"index" json:"expires_at"` + CreatedAt time.Time `json:"created_at"` +} + +type IPRuleType string + +const ( + IPRuleAllow IPRuleType = "allow" + IPRuleDeny IPRuleType = "deny" +) + +type IPRuleScope string + +const ( + IPScopeAdmin IPRuleScope = "admin" + IPScopeProxy IPRuleScope = "proxy" + IPScopeAll IPRuleScope = "all" +) + +type IPRule struct { + ID uint `gorm:"primaryKey" json:"id"` + CIDR string `gorm:"column:cidr;not null" json:"cidr"` + Type IPRuleType `gorm:"not null" json:"type"` + Scope IPRuleScope `gorm:"not null;default:proxy" json:"scope"` + Enabled bool `gorm:"default:true" json:"enabled"` + CreatedAt time.Time `json:"created_at"` +} + +type UsageRecord struct { + ID uint `gorm:"primaryKey" json:"id"` + IngressKeyID uint `gorm:"index" json:"ingress_key_id"` + ProviderID uint `gorm:"index" json:"provider_id"` + ProviderKeyID uint `gorm:"index" json:"provider_key_id"` + ClientIP string `gorm:"index" json:"client_ip"` + Endpoint string `json:"endpoint"` + Model string `json:"model"` + TokensIn int `json:"tokens_in"` + TokensOut int `json:"tokens_out"` + Cost float64 `json:"cost"` + LatencyMs int64 `json:"latency_ms"` + Status string `json:"status"` + Stream bool `json:"stream"` + ErrorMsg string `json:"error_msg"` + RequestBody string `gorm:"type:text" json:"request_body,omitempty"` + ResponseBody string `gorm:"type:text" json:"response_body,omitempty"` + CreatedAt time.Time `gorm:"index" json:"created_at"` +} + +const ( + MaxUsageLogRecords = 500 + EndpointAdminLogin = "admin_login" + EndpointPrediction = "prediction" +) + +type Prediction struct { + ID string `gorm:"primaryKey" json:"id"` + IngressKeyID uint `gorm:"index;not null" json:"ingress_key_id"` + ProviderID uint `gorm:"index;not null" json:"provider_id"` + ProviderKeyID uint `gorm:"index" json:"provider_key_id"` + ModelEntryID uint `gorm:"index" json:"model_entry_id"` + Version string `json:"version"` + Model string `json:"model"` + InputJSON string `gorm:"type:text" json:"input_json"` + Status PredictionStatus `gorm:"index;not null;default:starting" json:"status"` + ComfyPromptID string `json:"comfy_prompt_id"` + ClientID string `json:"client_id"` + OutputJSON string `gorm:"type:text" json:"output_json"` + Error string `gorm:"type:text" json:"error"` + Logs string `gorm:"type:text" json:"logs"` + MetricsJSON string `gorm:"type:text" json:"metrics_json"` + CreatedAt time.Time `json:"created_at"` + StartedAt *time.Time `json:"started_at"` + CompletedAt *time.Time `json:"completed_at"` +} + +type StoredImage struct { + ID string `gorm:"primaryKey" json:"id"` + PredictionID string `gorm:"index;not null" json:"prediction_id"` + Filename string `json:"filename"` + LocalPath string `json:"-"` + Mime string `json:"mime"` + Size int64 `json:"size"` + CreatedAt time.Time `json:"created_at"` +} + +// RoutingRule Virtual Key 路由:按模型匹配绑定 Provider / ProviderKey +type RoutingRule struct { + ID uint `gorm:"primaryKey" json:"id"` + IngressKeyID uint `gorm:"index;not null" json:"ingress_key_id"` + ModelPattern string `gorm:"not null" json:"model_pattern"` + ProviderID uint `gorm:"not null" json:"provider_id"` + ProviderKeyID uint `gorm:"default:0" json:"provider_key_id"` + Priority int `gorm:"default:0" json:"priority"` + Enabled bool `gorm:"default:true" json:"enabled"` + CreatedAt time.Time `json:"created_at"` +} + +// SystemSettings singleton (id=1) for runtime configuration managed via admin UI. +type SystemSettings struct { + ID uint `gorm:"primaryKey" json:"id"` + EncryptionKey string `gorm:"not null" json:"-"` + SessionTTLSeconds int64 `gorm:"not null;default:86400" json:"session_ttl_seconds"` + LoginMaxAttempts int `gorm:"not null;default:5" json:"login_max_attempts"` + LoginLockoutSeconds int64 `gorm:"not null;default:900" json:"login_lockout_seconds"` + TrustedProxiesJSON string `gorm:"default:'[]'" json:"trusted_proxies_json"` + UsageFlushSeconds int64 `gorm:"not null;default:5" json:"usage_flush_seconds"` + HealthCheckSeconds int64 `gorm:"not null;default:30" json:"health_check_seconds"` + GatewayMaxRetries int `gorm:"not null;default:3" json:"gateway_max_retries"` + GatewayRetryBackoffMs int `gorm:"not null;default:200" json:"gateway_retry_backoff_ms"` + ImageStoragePath string `gorm:"default:'images'" json:"image_storage_path"` + PublicBaseURL string `json:"public_base_url"` + SignedURLTTLSeconds int64 `gorm:"not null;default:3600" json:"signed_url_ttl_seconds"` + PredictionWaitSeconds int64 `gorm:"not null;default:60" json:"prediction_wait_seconds"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + +type HealthCheckRecord struct { + ID uint `gorm:"primaryKey" json:"id"` + ProviderID uint `gorm:"index" json:"provider_id"` + Status ProviderStatus `json:"status"` + LatencyMs int64 `json:"latency_ms"` + ErrorMsg string `json:"error_msg"` + CheckedAt time.Time `gorm:"index" json:"checked_at"` +} diff --git a/internal/model/model_entry.go b/internal/model/model_entry.go new file mode 100644 index 0000000..fab3782 --- /dev/null +++ b/internal/model/model_entry.go @@ -0,0 +1,20 @@ +package model + +// IngressID returns the model name exposed to ingress API clients. +func (m ModelEntry) IngressID() string { + if m.Alias != "" { + return m.Alias + } + return m.ModelID +} + +// MatchesIngressName reports whether the entry matches an ingress model identifier. +func (m ModelEntry) MatchesIngressName(name string) bool { + if name == "" || !m.Enabled { + return false + } + if m.ModelID == name || m.VersionHash == name { + return true + } + return m.Alias != "" && m.Alias == name +} diff --git a/internal/monitor/service.go b/internal/monitor/service.go new file mode 100644 index 0000000..4608c43 --- /dev/null +++ b/internal/monitor/service.go @@ -0,0 +1,175 @@ +package monitor + +import ( + "context" + "log" + "time" + + "github.com/rose_cat707/luminary/internal/gateway" + "github.com/rose_cat707/luminary/internal/model" + "github.com/rose_cat707/luminary/internal/settings" + "github.com/rose_cat707/luminary/internal/store/cache" + sqlitestore "github.com/rose_cat707/luminary/internal/store/sqlite" + "gorm.io/gorm" +) + +type Service struct { + gateway *gateway.Service + cache *cache.Store + db *gorm.DB + settings *settings.Runtime + stop chan struct{} + healthRestart chan struct{} + flushRestart chan struct{} + flushFn func([]cache.UsageDelta) +} + +func New(gw *gateway.Service, c *cache.Store, store *sqlitestore.Store, rt *settings.Runtime) *Service { + return &Service{ + gateway: gw, + cache: c, + db: store.DB(), + settings: rt, + stop: make(chan struct{}), + healthRestart: make(chan struct{}, 1), + flushRestart: make(chan struct{}, 1), + } +} + +func (s *Service) Start(flushFn func([]cache.UsageDelta)) { + s.flushFn = flushFn + go s.healthLoop() + go s.usageFlushLoop() + go s.dailyResetLoop() +} + +func (s *Service) Stop() { + close(s.stop) +} + +func (s *Service) NotifySettingsChanged() { + select { + case s.healthRestart <- struct{}{}: + default: + } + select { + case s.flushRestart <- struct{}{}: + default: + } +} + +func (s *Service) healthInterval() time.Duration { + interval := s.settings.HealthCheckInterval() + if interval < time.Second { + return 30 * time.Second + } + return interval +} + +func (s *Service) flushInterval() time.Duration { + interval := s.settings.UsageFlushInterval() + if interval < time.Second { + return 5 * time.Second + } + return interval +} + +func (s *Service) healthLoop() { + var ticker *time.Ticker + resetTicker := func() { + if ticker != nil { + ticker.Stop() + } + ticker = time.NewTicker(s.healthInterval()) + } + resetTicker() + defer ticker.Stop() + s.runChecks() + for { + select { + case <-ticker.C: + s.runChecks() + case <-s.healthRestart: + resetTicker() + case <-s.stop: + return + } + } +} + +func (s *Service) usageFlushLoop() { + var ticker *time.Ticker + resetTicker := func() { + if ticker != nil { + ticker.Stop() + } + ticker = time.NewTicker(s.flushInterval()) + } + resetTicker() + defer ticker.Stop() + for { + select { + case <-ticker.C: + s.flushUsage() + case <-s.flushRestart: + resetTicker() + case <-s.stop: + s.flushUsage() + return + } + } +} + +func (s *Service) flushUsage() { + if s.flushFn == nil { + return + } + deltas := s.cache.DrainUsageDeltas() + if len(deltas) > 0 { + s.flushFn(deltas) + } +} + +func (s *Service) runChecks() { + ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second) + defer cancel() + for _, p := range s.cache.ListProviders() { + status, latency, errMsg := s.gateway.HealthCheckProvider(ctx, p.ID) + rec := model.HealthCheckRecord{ + ProviderID: p.ID, + Status: status, + LatencyMs: latency, + ErrorMsg: errMsg, + CheckedAt: time.Now(), + } + s.cache.SetHealthStatus(p.ID, rec) + if err := s.db.Create(&rec).Error; err != nil { + log.Printf("save health check: %v", err) + } + } +} + +func (s *Service) dailyResetLoop() { + for { + now := time.Now() + next := time.Date(now.Year(), now.Month(), now.Day()+1, 0, 0, 0, 0, now.Location()) + timer := time.NewTimer(time.Until(next)) + select { + case <-timer.C: + s.resetDailyCounters() + case <-s.stop: + timer.Stop() + return + } + } +} + +func (s *Service) resetDailyCounters() { + s.cache.ResetProviderKeyDailyCounts() + if err := s.db.Model(&model.ProviderKey{}).Updates(map[string]any{ + "requests_today": 0, + "tokens_today": 0, + }).Error; err != nil { + log.Printf("reset daily provider key counters: %v", err) + } +} diff --git a/internal/prediction/binding.go b/internal/prediction/binding.go new file mode 100644 index 0000000..e9642d7 --- /dev/null +++ b/internal/prediction/binding.go @@ -0,0 +1,57 @@ +package prediction + +import ( + "encoding/json" + "fmt" +) + +type NodeField struct { + Node string `json:"node"` + Field string `json:"field"` +} + +type InputBinding map[string]*NodeField + +func ParseBinding(raw string) (InputBinding, error) { + if raw == "" { + return InputBinding{}, nil + } + var b InputBinding + if err := json.Unmarshal([]byte(raw), &b); err != nil { + return nil, err + } + return b, nil +} + +func (b InputBinding) Validate(workflow map[string]any, required []string) error { + for _, name := range required { + f, ok := b[name] + if !ok || f == nil || f.Node == "" || f.Field == "" { + return fmt.Errorf("required binding missing: %s", name) + } + } + for name, f := range b { + if f == nil || f.Node == "" || f.Field == "" { + continue + } + if err := validateNodeField(workflow, f.Node, f.Field); err != nil { + return fmt.Errorf("binding %s: %w", name, err) + } + } + return nil +} + +func validateNodeField(workflow map[string]any, nodeID, field string) error { + node, ok := workflow[nodeID].(map[string]any) + if !ok { + return fmt.Errorf("node %s not found", nodeID) + } + inputs, ok := node["inputs"].(map[string]any) + if !ok { + return fmt.Errorf("node %s has no inputs", nodeID) + } + if _, ok := inputs[field]; !ok { + return fmt.Errorf("field %s not found on node %s", field, nodeID) + } + return nil +} diff --git a/internal/prediction/hub.go b/internal/prediction/hub.go new file mode 100644 index 0000000..e75d58c --- /dev/null +++ b/internal/prediction/hub.go @@ -0,0 +1,57 @@ +package prediction + +import ( + "sync" +) + +type Event struct { + Status string `json:"status"` + Logs string `json:"logs,omitempty"` + Metrics map[string]any `json:"metrics,omitempty"` + Output any `json:"output,omitempty"` + Error string `json:"error,omitempty"` + Done bool `json:"-"` +} + +type Hub struct { + mu sync.RWMutex + subs map[string]map[chan Event]struct{} +} + +func NewHub() *Hub { + return &Hub{subs: make(map[string]map[chan Event]struct{})} +} + +func (h *Hub) Subscribe(predictionID string) chan Event { + ch := make(chan Event, 16) + h.mu.Lock() + if h.subs[predictionID] == nil { + h.subs[predictionID] = make(map[chan Event]struct{}) + } + h.subs[predictionID][ch] = struct{}{} + h.mu.Unlock() + return ch +} + +func (h *Hub) Unsubscribe(predictionID string, ch chan Event) { + h.mu.Lock() + if m := h.subs[predictionID]; m != nil { + delete(m, ch) + if len(m) == 0 { + delete(h.subs, predictionID) + } + } + h.mu.Unlock() + close(ch) +} + +func (h *Hub) Publish(predictionID string, ev Event) { + h.mu.RLock() + defer h.mu.RUnlock() + for ch := range h.subs[predictionID] { + select { + case ch <- ev: + default: + } + } +} diff --git a/internal/prediction/inject.go b/internal/prediction/inject.go new file mode 100644 index 0000000..42e185a --- /dev/null +++ b/internal/prediction/inject.go @@ -0,0 +1,48 @@ +package prediction + +import ( + "crypto/rand" + "encoding/json" + "fmt" + "math/big" +) + +func BuildPrompt(workflowJSON string, binding InputBinding, input map[string]any) (map[string]any, error) { + var workflow map[string]any + if err := json.Unmarshal([]byte(workflowJSON), &workflow); err != nil { + return nil, fmt.Errorf("invalid workflow: %w", err) + } + prompt := deepCopyMap(workflow) + for name, target := range binding { + if target == nil || target.Node == "" || target.Field == "" { + continue + } + val, ok := input[name] + if !ok { + continue + } + if name == "seed" { + if iv, ok := val.(int); ok && iv < 0 { + n, _ := rand.Int(rand.Reader, big.NewInt(1<<31-1)) + val = int(n.Int64()) + } + } + node, ok := prompt[target.Node].(map[string]any) + if !ok { + return nil, fmt.Errorf("node %s not found", target.Node) + } + inputs, ok := node["inputs"].(map[string]any) + if !ok { + return nil, fmt.Errorf("node %s inputs missing", target.Node) + } + inputs[target.Field] = val + } + return prompt, nil +} + +func deepCopyMap(src map[string]any) map[string]any { + b, _ := json.Marshal(src) + var dst map[string]any + _ = json.Unmarshal(b, &dst) + return dst +} diff --git a/internal/prediction/params.go b/internal/prediction/params.go new file mode 100644 index 0000000..0b878b2 --- /dev/null +++ b/internal/prediction/params.go @@ -0,0 +1,122 @@ +package prediction + +import ( + "fmt" + + "github.com/rose_cat707/luminary/internal/model" +) + +type ParamType string + +const ( + ParamString ParamType = "string" + ParamInt ParamType = "int" + ParamFloat ParamType = "float" +) + +type ParamDef struct { + Name string `json:"name"` + Label string `json:"label"` + Type ParamType `json:"type"` + Required bool `json:"required"` + Default any `json:"default,omitempty"` +} + +var Txt2ImgParams = []ParamDef{ + {Name: "prompt", Label: "正向提示词", Type: ParamString, Required: true}, + {Name: "negative_prompt", Label: "负向提示词", Type: ParamString, Default: ""}, + {Name: "seed", Label: "随机种子", Type: ParamInt, Default: -1}, + {Name: "width", Label: "宽度", Type: ParamInt, Default: 1024}, + {Name: "height", Label: "高度", Type: ParamInt, Default: 1024}, + {Name: "steps", Label: "采样步数", Type: ParamInt, Default: 20}, + {Name: "cfg_scale", Label: "CFG", Type: ParamFloat, Default: 7.0}, +} + +var Img2ImgParams = []ParamDef{ + {Name: "prompt", Label: "正向提示词", Type: ParamString, Required: true}, + {Name: "negative_prompt", Label: "负向提示词", Type: ParamString, Default: ""}, + {Name: "image", Label: "输入图 URL", Type: ParamString, Required: true}, + {Name: "seed", Label: "随机种子", Type: ParamInt, Default: -1}, + {Name: "steps", Label: "采样步数", Type: ParamInt, Default: 20}, + {Name: "cfg_scale", Label: "CFG", Type: ParamFloat, Default: 7.0}, + {Name: "denoise", Label: "重绘幅度", Type: ParamFloat, Default: 0.75}, +} + +func ParamsForWorkflowType(wt model.WorkflowType) []ParamDef { + switch wt { + case model.WorkflowImg2Img: + return Img2ImgParams + default: + return Txt2ImgParams + } +} + +func RequiredBindings(wt model.WorkflowType) []string { + switch wt { + case model.WorkflowImg2Img: + return []string{"prompt", "image"} + default: + return []string{"prompt"} + } +} + +func ValidateInput(wt model.WorkflowType, input map[string]any) (map[string]any, error) { + defs := ParamsForWorkflowType(wt) + out := make(map[string]any, len(defs)) + for _, d := range defs { + v, ok := input[d.Name] + if !ok || v == nil { + if d.Required { + return nil, fmt.Errorf("missing required field: %s", d.Name) + } + if d.Default != nil { + out[d.Name] = d.Default + } + continue + } + coerced, err := coerceValue(d, v) + if err != nil { + return nil, fmt.Errorf("%s: %w", d.Name, err) + } + out[d.Name] = coerced + } + return out, nil +} + +func coerceValue(d ParamDef, v any) (any, error) { + switch d.Type { + case ParamString: + s, ok := v.(string) + if !ok { + return nil, fmt.Errorf("expected string") + } + if d.Required && s == "" { + return nil, fmt.Errorf("cannot be empty") + } + return s, nil + case ParamInt: + switch n := v.(type) { + case float64: + return int(n), nil + case int: + return n, nil + case int64: + return int(n), nil + default: + return nil, fmt.Errorf("expected integer") + } + case ParamFloat: + switch n := v.(type) { + case float64: + return n, nil + case int: + return float64(n), nil + case int64: + return float64(n), nil + default: + return nil, fmt.Errorf("expected number") + } + default: + return v, nil + } +} diff --git a/internal/prediction/service.go b/internal/prediction/service.go new file mode 100644 index 0000000..becb807 --- /dev/null +++ b/internal/prediction/service.go @@ -0,0 +1,475 @@ +package prediction + +import ( + "context" + "crypto/rand" + "encoding/hex" + "encoding/json" + "fmt" + "io" + "net/http" + "path" + "strings" + "sync" + "time" + + "github.com/rose_cat707/luminary/internal/model" + "github.com/rose_cat707/luminary/internal/provider/comfyui" + "github.com/rose_cat707/luminary/internal/storage/imagestore" + "github.com/rose_cat707/luminary/internal/store/cache" + "gorm.io/gorm" +) + +type Service struct { + db *gorm.DB + cache *cache.Store + images *imagestore.Store + hub *Hub + publicURL string + waitSec int64 + mu sync.Mutex + running map[string]context.CancelFunc +} + +func NewService(db *gorm.DB, c *cache.Store, images *imagestore.Store, publicURL string, waitSec int64) *Service { + if waitSec <= 0 { + waitSec = 60 + } + return &Service{ + db: db, cache: c, images: images, hub: NewHub(), + publicURL: strings.TrimRight(publicURL, "/"), waitSec: waitSec, + running: make(map[string]context.CancelFunc), + } +} + +func (s *Service) Hub() *Hub { return s.hub } + +func NewPredictionID() (string, error) { + b := make([]byte, 12) + if _, err := rand.Read(b); err != nil { + return "", err + } + return hex.EncodeToString(b), nil +} + +type CreateRequest struct { + Version string `json:"version"` + Input map[string]any `json:"input"` +} + +func (s *Service) Create(ctx context.Context, ingress *model.IngressKey, req CreateRequest, clientIP string, wait bool) (*model.Prediction, error) { + if req.Version == "" { + return nil, fmt.Errorf("version is required") + } + entry, provider, err := s.resolveModel(req.Version, ingress) + if err != nil { + return nil, err + } + if !provider.Enabled { + return nil, fmt.Errorf("provider disabled") + } + input, err := ValidateInput(entry.WorkflowType, req.Input) + if err != nil { + return nil, err + } + binding, err := ParseBinding(entry.InputBindingJSON) + if err != nil { + return nil, err + } + var workflow map[string]any + _ = json.Unmarshal([]byte(entry.WorkflowJSON), &workflow) + if err := binding.Validate(workflow, RequiredBindings(entry.WorkflowType)); err != nil { + return nil, fmt.Errorf("model binding invalid: %w", err) + } + + id, err := NewPredictionID() + if err != nil { + return nil, err + } + inputJSON, _ := json.Marshal(input) + p := model.Prediction{ + ID: id, + IngressKeyID: ingress.ID, + ProviderID: provider.ID, + ModelEntryID: entry.ID, + Version: req.Version, + Model: entry.ModelID, + InputJSON: string(inputJSON), + Status: model.PredictionStarting, + ClientID: "luminary-" + id, + CreatedAt: time.Now(), + } + if err := s.db.Create(&p).Error; err != nil { + return nil, err + } + + runCtx, cancel := context.WithCancel(context.Background()) + s.mu.Lock() + s.running[id] = cancel + s.mu.Unlock() + + go s.execute(runCtx, &p, entry, &provider, binding, input, clientIP) + + if wait { + deadline := time.After(time.Duration(s.waitSec) * time.Second) + for { + select { + case <-deadline: + return s.Get(ctx, id, ingress) + case <-time.After(200 * time.Millisecond): + cur, err := s.Get(ctx, id, ingress) + if err != nil { + return nil, err + } + if cur.Status == model.PredictionSucceeded || cur.Status == model.PredictionFailed || cur.Status == model.PredictionCanceled { + return cur, nil + } + } + } + } + return s.toAPI(&p), nil +} + +func (s *Service) resolveModel(version string, ingress *model.IngressKey) (model.ModelEntry, model.Provider, error) { + allowedProviders := cache.ParseJSONList(ingress.AllowedProvidersJSON) + allowedModels := cache.ParseJSONList(ingress.AllowedModelsJSON) + var candidates []model.ModelEntry + for _, p := range s.cache.ListProviders() { + if p.Category != model.CategoryImage || !p.Enabled { + continue + } + if !cache.MatchesList(allowedProviders, p.Name) && !cache.MatchesList(allowedProviders, string(p.Type)) && !cache.MatchesList(allowedProviders, "*") { + continue + } + for _, m := range s.cache.GetModels(p.ID) { + if !m.Enabled { + continue + } + ingressID := m.IngressID() + if !m.MatchesIngressName(version) { + continue + } + if !cache.MatchesList(allowedModels, ingressID) && !cache.MatchesList(allowedModels, m.ModelID) && !cache.MatchesList(allowedModels, "*") { + continue + } + candidates = append(candidates, m) + } + } + if len(candidates) == 0 { + return model.ModelEntry{}, model.Provider{}, fmt.Errorf("no model found for version %s", version) + } + m := candidates[0] + p, ok := s.cache.GetProvider(m.ProviderID) + if !ok { + return model.ModelEntry{}, model.Provider{}, fmt.Errorf("provider not found") + } + return m, p, nil +} + +func (s *Service) execute(ctx context.Context, p *model.Prediction, entry model.ModelEntry, provider *model.Provider, binding InputBinding, input map[string]any, clientIP string) { + defer func() { + s.mu.Lock() + delete(s.running, p.ID) + s.mu.Unlock() + }() + + start := time.Now() + s.updateStatus(p, model.PredictionProcessing, "", nil) + s.hub.Publish(p.ID, Event{Status: string(model.PredictionProcessing)}) + + client := comfyui.New(provider.BaseURL) + runInput := cloneMap(input) + if entry.WorkflowType == model.WorkflowImg2Img { + imageURL, _ := runInput["image"].(string) + data, err := downloadURL(ctx, imageURL) + if err != nil { + s.fail(p, clientIP, start, fmt.Errorf("download image: %w", err)) + return + } + name := path.Base(imageURL) + if name == "" || name == "." || name == "/" { + name = "input.png" + } + uploaded, err := client.UploadImage(ctx, data, name) + if err != nil { + s.fail(p, clientIP, start, fmt.Errorf("upload image: %w", err)) + return + } + runInput["image"] = uploaded + } + + prompt, err := BuildPrompt(entry.WorkflowJSON, binding, runInput) + if err != nil { + s.fail(p, clientIP, start, err) + return + } + + wsCtx, wsCancel := context.WithCancel(ctx) + defer wsCancel() + go client.Watch(wsCtx, p.ClientID, func(ev comfyui.ProgressEvent) { + switch ev.Type { + case "progress": + if ev.Max > 0 { + logs := fmt.Sprintf("progress %.0f/%.0f", ev.Value, ev.Max) + s.appendLogs(p, logs) + s.hub.Publish(p.ID, Event{Status: string(model.PredictionProcessing), Logs: logs}) + } + case "execution_error": + s.appendLogs(p, ev.ErrorText) + } + }) + + submit, err := client.SubmitPrompt(ctx, prompt, p.ClientID) + if err != nil { + s.fail(p, clientIP, start, err) + return + } + p.ComfyPromptID = submit.PromptID + now := time.Now() + p.StartedAt = &now + s.db.Model(p).Updates(map[string]any{"comfy_prompt_id": p.ComfyPromptID, "started_at": p.StartedAt}) + + for { + select { + case <-ctx.Done(): + _ = client.Interrupt(context.Background()) + s.updateStatus(p, model.PredictionCanceled, "canceled", nil) + s.hub.Publish(p.ID, Event{Status: string(model.PredictionCanceled), Done: true}) + s.recordUsage(ingressByID(s.cache, p.IngressKeyID), provider, clientIP, p, start, "canceled", "canceled") + return + case <-time.After(2 * time.Second): + history, err := client.GetHistory(ctx, p.ComfyPromptID) + if err != nil { + continue + } + if _, ok := history[p.ComfyPromptID]; !ok { + continue + } + images := comfyui.CollectOutputImages(history, p.ComfyPromptID) + if len(images) == 0 { + continue + } + var urls []string + for _, img := range images { + data, mime, err := client.DownloadView(ctx, img.Filename, img.Subfolder, img.Type) + if err != nil { + s.fail(p, clientIP, start, err) + return + } + stored, err := s.images.Save(p.ID, img.Filename, data, mime) + if err != nil { + s.fail(p, clientIP, start, err) + return + } + urls = append(urls, s.images.SignURL(stored.ID)) + } + outJSON, _ := json.Marshal(urls) + p.OutputJSON = string(outJSON) + completed := time.Now() + p.CompletedAt = &completed + metrics, _ := json.Marshal(map[string]any{ + "predict_time": time.Since(start).Seconds(), + "total_time": completed.Sub(p.CreatedAt).Seconds(), + }) + p.MetricsJSON = string(metrics) + s.updateStatus(p, model.PredictionSucceeded, "", &completed) + s.hub.Publish(p.ID, Event{ + Status: string(model.PredictionSucceeded), Output: urls, + Metrics: map[string]any{"predict_time": time.Since(start).Seconds()}, + Done: true, + }) + s.recordUsage(ingressByID(s.cache, p.IngressKeyID), provider, clientIP, p, start, "success", "") + return + } + } +} + +func (s *Service) fail(p *model.Prediction, clientIP string, start time.Time, err error) { + completed := time.Now() + p.CompletedAt = &completed + p.Error = err.Error() + s.updateStatus(p, model.PredictionFailed, err.Error(), &completed) + s.hub.Publish(p.ID, Event{Status: string(model.PredictionFailed), Error: err.Error(), Done: true}) + prov, _ := s.cache.GetProvider(p.ProviderID) + s.recordUsage(ingressByID(s.cache, p.IngressKeyID), &prov, clientIP, p, start, "error", err.Error()) +} + +func (s *Service) updateStatus(p *model.Prediction, status model.PredictionStatus, errMsg string, completed *time.Time) { + p.Status = status + if errMsg != "" { + p.Error = errMsg + } + updates := map[string]any{"status": status, "error": p.Error, "logs": p.Logs, "output_json": p.OutputJSON, "metrics_json": p.MetricsJSON} + if completed != nil { + updates["completed_at"] = completed + } + s.db.Model(p).Where("id = ?", p.ID).Updates(updates) +} + +func (s *Service) appendLogs(p *model.Prediction, line string) { + if p.Logs != "" { + p.Logs += "\n" + } + p.Logs += line + s.db.Model(p).Where("id = ?", p.ID).Update("logs", p.Logs) +} + +func (s *Service) Get(ctx context.Context, id string, ingress *model.IngressKey) (*model.Prediction, error) { + var p model.Prediction + if err := s.db.First(&p, "id = ?", id).Error; err != nil { + return nil, fmt.Errorf("prediction not found") + } + if ingress != nil && p.IngressKeyID != ingress.ID { + return nil, fmt.Errorf("prediction not found") + } + return s.toAPI(&p), nil +} + +func (s *Service) List(ctx context.Context, ingress *model.IngressKey, cursor string, limit int) ([]*model.Prediction, error) { + if limit <= 0 || limit > 100 { + limit = 100 + } + q := s.db.Where("ingress_key_id = ?", ingress.ID).Order("created_at desc").Limit(limit) + if cursor != "" { + q = q.Where("id < ?", cursor) + } + var rows []model.Prediction + if err := q.Find(&rows).Error; err != nil { + return nil, err + } + out := make([]*model.Prediction, 0, len(rows)) + for i := range rows { + out = append(out, s.toAPI(&rows[i])) + } + return out, nil +} + +func (s *Service) Cancel(ctx context.Context, id string, ingress *model.IngressKey) (*model.Prediction, error) { + p, err := s.Get(ctx, id, ingress) + if err != nil { + return nil, err + } + if p.Status == model.PredictionSucceeded || p.Status == model.PredictionFailed || p.Status == model.PredictionCanceled { + return p, nil + } + s.mu.Lock() + if cancel, ok := s.running[id]; ok { + cancel() + } + s.mu.Unlock() + return s.Get(ctx, id, ingress) +} + +func (s *Service) toAPI(p *model.Prediction) *model.Prediction { + return p +} + +func (s *Service) APIView(p *model.Prediction, baseURL string) map[string]any { + var input any + _ = json.Unmarshal([]byte(p.InputJSON), &input) + var output any + if p.OutputJSON != "" { + _ = json.Unmarshal([]byte(p.OutputJSON), &output) + } + var metrics any + if p.MetricsJSON != "" { + _ = json.Unmarshal([]byte(p.MetricsJSON), &metrics) + } + base := strings.TrimRight(baseURL, "/") + return map[string]any{ + "id": p.ID, + "version": p.Version, + "input": input, + "output": output, + "error": nilIfEmpty(p.Error), + "status": p.Status, + "created_at": p.CreatedAt.UTC().Format(time.RFC3339Nano), + "started_at": formatTime(p.StartedAt), + "completed_at": formatTime(p.CompletedAt), + "logs": p.Logs, + "metrics": metrics, + "urls": map[string]string{ + "get": fmt.Sprintf("%s/v1/predictions/%s", base, p.ID), + "cancel": fmt.Sprintf("%s/v1/predictions/%s/cancel", base, p.ID), + "stream": fmt.Sprintf("%s/v1/predictions/%s/stream", base, p.ID), + }, + } +} + +func nilIfEmpty(s string) any { + if s == "" { + return nil + } + return s +} + +func formatTime(t *time.Time) any { + if t == nil { + return nil + } + return t.UTC().Format(time.RFC3339Nano) +} + +func downloadURL(ctx context.Context, raw string) ([]byte, error) { + req, err := http.NewRequestWithContext(ctx, http.MethodGet, raw, nil) + if err != nil { + return nil, err + } + resp, err := http.DefaultClient.Do(req) + if err != nil { + return nil, err + } + defer resp.Body.Close() + if resp.StatusCode >= 400 { + return nil, fmt.Errorf("http %d", resp.StatusCode) + } + return io.ReadAll(resp.Body) +} + +func cloneMap(in map[string]any) map[string]any { + b, _ := json.Marshal(in) + var out map[string]any + _ = json.Unmarshal(b, &out) + return out +} + +func ingressByID(c *cache.Store, id uint) *model.IngressKey { + for _, k := range c.ListIngressKeys() { + if k.ID == id { + kCopy := k + return &kCopy + } + } + return nil +} + +func (s *Service) recordUsage(ingress *model.IngressKey, provider *model.Provider, clientIP string, p *model.Prediction, start time.Time, status, errMsg string) { + if ingress == nil { + return + } + var providerID uint + if provider != nil { + providerID = provider.ID + } + s.cache.RecordUsage(cache.UsageDelta{ + IngressKeyID: ingress.ID, + ProviderID: providerID, + ClientIP: clientIP, + Endpoint: model.EndpointPrediction, + Model: p.ID, + LatencyMs: time.Since(start).Milliseconds(), + Status: status, + ErrorMsg: errMsg, + RequestBody: p.InputJSON, + ResponseBody: p.OutputJSON, + Requests: 1, + }) +} + +func (s *Service) GetDBPrediction(id string) (*model.Prediction, error) { + var p model.Prediction + if err := s.db.First(&p, "id = ?", id).Error; err != nil { + return nil, err + } + return &p, nil +} diff --git a/internal/prediction/workflow_analyze.go b/internal/prediction/workflow_analyze.go new file mode 100644 index 0000000..a6fb5a0 --- /dev/null +++ b/internal/prediction/workflow_analyze.go @@ -0,0 +1,268 @@ +package prediction + +import ( + "crypto/sha256" + "encoding/hex" + "encoding/json" + "fmt" + "strings" + + "github.com/rose_cat707/luminary/internal/model" +) + +type WorkflowNode struct { + ID string `json:"id"` + ClassType string `json:"class_type"` + Title string `json:"title"` + InjectableFields []string `json:"injectable_fields"` +} + +type BindingCandidate struct { + Node string `json:"node"` + Field string `json:"field"` +} + +type BindingSuggestion struct { + Param string `json:"param"` + Node string `json:"node,omitempty"` + Field string `json:"field,omitempty"` + Type ParamType `json:"type"` + DefaultFromWorkflow any `json:"default_from_workflow,omitempty"` + Confidence string `json:"confidence"` + Candidates []BindingCandidate `json:"candidates"` +} + +type AnalyzeResult struct { + VersionHashPreview string `json:"version_hash_preview"` + Nodes []WorkflowNode `json:"nodes"` + Suggestions []BindingSuggestion `json:"suggestions"` +} + +func HashWorkflowJSON(raw string) string { + sum := sha256.Sum256([]byte(raw)) + return hex.EncodeToString(sum[:]) +} + +func AnalyzeWorkflow(workflowJSON string, wt model.WorkflowType) (*AnalyzeResult, error) { + var workflow map[string]any + if err := json.Unmarshal([]byte(workflowJSON), &workflow); err != nil { + return nil, fmt.Errorf("invalid workflow json: %w", err) + } + nodes := parseNodes(workflow) + suggestions := suggestBindings(workflow, nodes, wt) + return &AnalyzeResult{ + VersionHashPreview: HashWorkflowJSON(workflowJSON), + Nodes: nodes, + Suggestions: suggestions, + }, nil +} + +func parseNodes(workflow map[string]any) []WorkflowNode { + var out []WorkflowNode + for id, raw := range workflow { + node, ok := raw.(map[string]any) + if !ok { + continue + } + classType, _ := node["class_type"].(string) + title := "" + if meta, ok := node["_meta"].(map[string]any); ok { + title, _ = meta["title"].(string) + } + var fields []string + if inputs, ok := node["inputs"].(map[string]any); ok { + for k, v := range inputs { + if isInjectableValue(v) { + fields = append(fields, k) + } + } + } + out = append(out, WorkflowNode{ + ID: id, ClassType: classType, Title: title, InjectableFields: fields, + }) + } + return out +} + +func isInjectableValue(v any) bool { + switch v.(type) { + case string, float64, bool, int, int64: + return true + default: + return false + } +} + +func suggestBindings(workflow map[string]any, nodes []WorkflowNode, wt model.WorkflowType) []BindingSuggestion { + defs := ParamsForWorkflowType(wt) + var clipNodes []WorkflowNode + var samplerNodes []WorkflowNode + var latentNodes []WorkflowNode + var loadImageNodes []WorkflowNode + for _, n := range nodes { + switch n.ClassType { + case "CLIPTextEncode": + clipNodes = append(clipNodes, n) + case "KSampler", "KSamplerAdvanced", "RandomNoise": + samplerNodes = append(samplerNodes, n) + case "EmptyLatentImage": + latentNodes = append(latentNodes, n) + case "LoadImage": + loadImageNodes = append(loadImageNodes, n) + } + } + var out []BindingSuggestion + for _, d := range defs { + s := BindingSuggestion{Param: d.Name, Type: d.Type, Confidence: "low"} + switch d.Name { + case "prompt": + s = suggestCLIP(clipNodes, workflow, false) + case "negative_prompt": + s = suggestCLIP(clipNodes, workflow, true) + case "seed": + s = suggestSamplerField(samplerNodes, workflow, "seed", d.Type) + if s.Node == "" { + s = suggestSamplerField(samplerNodes, workflow, "noise_seed", d.Type) + } + case "steps": + s = suggestSamplerField(samplerNodes, workflow, "steps", d.Type) + case "cfg_scale": + s = suggestSamplerField(samplerNodes, workflow, "cfg", d.Type) + case "denoise": + s = suggestSamplerField(samplerNodes, workflow, "denoise", d.Type) + case "width": + s = suggestLatentField(latentNodes, workflow, "width", d.Type) + case "height": + s = suggestLatentField(latentNodes, workflow, "height", d.Type) + case "image": + s = suggestLoadImage(loadImageNodes, workflow, d.Type) + default: + s.Param = d.Name + s.Type = d.Type + } + if s.Param == "" { + s.Param = d.Name + s.Type = d.Type + } + if s.Node != "" { + s.Confidence = "high" + } + out = append(out, s) + } + return out +} + +func suggestCLIP(nodes []WorkflowNode, workflow map[string]any, negative bool) BindingSuggestion { + s := BindingSuggestion{Param: "prompt", Type: ParamString, Confidence: "low"} + if negative { + s.Param = "negative_prompt" + } + var candidates []BindingCandidate + var picked *WorkflowNode + for i := range nodes { + n := nodes[i] + titleLower := strings.ToLower(n.Title) + isNeg := strings.Contains(titleLower, "negative") || strings.Contains(titleLower, "neg") + if !hasField(n, "text") { + continue + } + candidates = append(candidates, BindingCandidate{Node: n.ID, Field: "text"}) + if negative && isNeg && picked == nil { + picked = &nodes[i] + } + if !negative && !isNeg && picked == nil { + picked = &nodes[i] + } + } + if picked == nil && len(candidates) > 0 { + idx := 0 + if negative && len(candidates) > 1 { + idx = 1 + } + for i := range nodes { + if nodes[i].ID == candidates[idx].Node { + picked = &nodes[i] + break + } + } + } + s.Candidates = candidates + if picked != nil { + s.Node = picked.ID + s.Field = "text" + s.DefaultFromWorkflow = nodeFieldValue(workflow, picked.ID, "text") + } + return s +} + +func suggestSamplerField(nodes []WorkflowNode, workflow map[string]any, field string, pt ParamType) BindingSuggestion { + paramName := field + if field == "cfg" { + paramName = "cfg_scale" + } + s := BindingSuggestion{Param: paramName, Type: pt, Confidence: "low"} + var candidates []BindingCandidate + for _, n := range nodes { + if hasField(n, field) { + candidates = append(candidates, BindingCandidate{Node: n.ID, Field: field}) + if s.Node == "" { + s.Node = n.ID + s.Field = field + s.DefaultFromWorkflow = nodeFieldValue(workflow, n.ID, field) + } + } + } + s.Candidates = candidates + return s +} + +func suggestLatentField(nodes []WorkflowNode, workflow map[string]any, field string, pt ParamType) BindingSuggestion { + s := BindingSuggestion{Param: field, Type: pt, Confidence: "low"} + for _, n := range nodes { + if hasField(n, field) { + s.Candidates = append(s.Candidates, BindingCandidate{Node: n.ID, Field: field}) + if s.Node == "" { + s.Node = n.ID + s.Field = field + s.DefaultFromWorkflow = nodeFieldValue(workflow, n.ID, field) + } + } + } + return s +} + +func suggestLoadImage(nodes []WorkflowNode, workflow map[string]any, pt ParamType) BindingSuggestion { + s := BindingSuggestion{Param: "image", Type: pt, Confidence: "low"} + for _, n := range nodes { + if hasField(n, "image") { + s.Candidates = append(s.Candidates, BindingCandidate{Node: n.ID, Field: "image"}) + if s.Node == "" { + s.Node = n.ID + s.Field = "image" + s.DefaultFromWorkflow = nodeFieldValue(workflow, n.ID, "image") + } + } + } + return s +} + +func hasField(n WorkflowNode, field string) bool { + for _, f := range n.InjectableFields { + if f == field { + return true + } + } + return false +} + +func nodeFieldValue(workflow map[string]any, nodeID, field string) any { + node, ok := workflow[nodeID].(map[string]any) + if !ok { + return nil + } + inputs, ok := node["inputs"].(map[string]any) + if !ok { + return nil + } + return inputs[field] +} diff --git a/internal/pricing/cost.go b/internal/pricing/cost.go new file mode 100644 index 0000000..78dc3cb --- /dev/null +++ b/internal/pricing/cost.go @@ -0,0 +1,66 @@ +package pricing + +import "strings" + +// 价格单位:美元 / 1M tokens +type ModelPrice struct { + InputPer1M float64 + OutputPer1M float64 +} + +var catalog = map[string]ModelPrice{ + "gpt-4o": {InputPer1M: 2.5, OutputPer1M: 10}, + "gpt-4o-mini": {InputPer1M: 0.15, OutputPer1M: 0.6}, + "gpt-4-turbo": {InputPer1M: 10, OutputPer1M: 30}, + "gpt-3.5-turbo": {InputPer1M: 0.5, OutputPer1M: 1.5}, + "claude-3-5-sonnet": {InputPer1M: 3, OutputPer1M: 15}, + "claude-3-5-haiku": {InputPer1M: 0.8, OutputPer1M: 4}, + "claude-3-opus": {InputPer1M: 15, OutputPer1M: 75}, + "text-embedding-3-small": {InputPer1M: 0.02, OutputPer1M: 0}, + "text-embedding-3-large": {InputPer1M: 0.13, OutputPer1M: 0}, + "text-embedding-ada-002": {InputPer1M: 0.1, OutputPer1M: 0}, + "bge-reranker-v2-m3": {InputPer1M: 0.1, OutputPer1M: 0}, + "rerank-english-v3.0": {InputPer1M: 2.0, OutputPer1M: 0}, + "rerank-multilingual-v3.0": {InputPer1M: 2.0, OutputPer1M: 0}, +} + +func Estimate(model string, tokensIn, tokensOut int, endpoint string) float64 { + if strings.Contains(endpoint, "embeddings") || strings.Contains(endpoint, "rerank") { + tokensOut = 0 + } + price, ok := lookup(model) + if !ok { + // fallback: average small model pricing + price = ModelPrice{InputPer1M: 0.5, OutputPer1M: 1.5} + } + inCost := float64(tokensIn) / 1_000_000 * price.InputPer1M + outCost := float64(tokensOut) / 1_000_000 * price.OutputPer1M + return inCost + outCost +} + +func lookup(model string) (ModelPrice, bool) { + if p, ok := catalog[model]; ok { + return p, true + } + // strip provider prefix + if i := strings.LastIndex(model, "/"); i >= 0 { + if p, ok := catalog[model[i+1:]]; ok { + return p, true + } + } + // prefix match + for k, v := range catalog { + if strings.HasPrefix(model, k) { + return v, true + } + } + return ModelPrice{}, false +} + +func ListCatalog() map[string]ModelPrice { + out := make(map[string]ModelPrice, len(catalog)) + for k, v := range catalog { + out[k] = v + } + return out +} diff --git a/internal/provider/anthropic/client.go b/internal/provider/anthropic/client.go new file mode 100644 index 0000000..6d122b5 --- /dev/null +++ b/internal/provider/anthropic/client.go @@ -0,0 +1,283 @@ +package anthropic + +import ( + "context" + "encoding/json" + "fmt" + "strings" + + "github.com/rose_cat707/luminary/internal/model" + "github.com/rose_cat707/luminary/internal/provider" +) + +type Client struct { + http *provider.HTTPClientWrapper +} + +func New() *Client { + return &Client{http: provider.NewHTTPClientWrapper()} +} + +func (c *Client) Type() string { return "anthropic" } + +func (c *Client) ListModels(ctx context.Context, apiKey, baseURL string) ([]provider.ModelInfo, error) { + return c.ListModelsWithConfig(ctx, apiKey, provider.ProviderConfig{ + Type: model.ProviderAnthropic, + BaseURL: provider.NormalizeBaseURL(baseURL, model.ProviderAnthropic), + }) +} + +func (c *Client) ListModelsWithConfig(ctx context.Context, apiKey string, cfg provider.ProviderConfig) ([]provider.ModelInfo, error) { + cfg.Type = model.ProviderAnthropic + cfg.BaseURL = provider.NormalizeBaseURL(cfg.BaseURL, model.ProviderAnthropic) + url := cfg.ResolveEndpointURL(provider.EndpointListModels) + resp, err := provider.ForwardHTTP(ctx, c.http.Client, "GET", url, apiKey, model.ProviderAnthropic, nil, nil) + if err != nil { + return nil, err + } + if resp.StatusCode >= 400 { + return nil, fmt.Errorf("anthropic list models: status %d: %s", resp.StatusCode, string(resp.Body)) + } + var out struct { + Data []struct { + ID string `json:"id"` + Name string `json:"display_name"` + } `json:"data"` + } + if err := json.Unmarshal(resp.Body, &out); err != nil { + return nil, err + } + models := make([]provider.ModelInfo, 0, len(out.Data)) + for _, m := range out.Data { + name := m.Name + if name == "" { + name = m.ID + } + models = append(models, provider.ModelInfo{ID: m.ID, DisplayName: name}) + } + return models, nil +} + +func (c *Client) ChatCompletion(ctx context.Context, apiKey, baseURL string, req *provider.ChatRequest) (*provider.ChatResponse, error) { + cfg := provider.ProviderConfig{ + Type: model.ProviderAnthropic, + BaseURL: provider.NormalizeBaseURL(baseURL, model.ProviderAnthropic), + } + anthropicReq, err := convertOpenAIToAnthropic(req) + if err != nil { + return nil, err + } + body, err := json.Marshal(anthropicReq) + if err != nil { + return nil, err + } + url := cfg.ResolveEndpointURL(provider.EndpointChatCompletion) + resp, err := provider.ForwardHTTP(ctx, c.http.Client, "POST", url, apiKey, model.ProviderAnthropic, body, nil) + if err != nil { + return nil, err + } + if resp.StatusCode >= 400 { + return &provider.ChatResponse{StatusCode: resp.StatusCode, Body: resp.Body}, nil + } + openAIResp, tokensIn, tokensOut := convertAnthropicToOpenAI(resp.Body, req.Model) + return &provider.ChatResponse{ + StatusCode: 200, + Body: openAIResp, + TokensIn: tokensIn, + TokensOut: tokensOut, + }, nil +} + +func (c *Client) ForwardWithConfig(ctx context.Context, apiKey string, cfg provider.ProviderConfig, endpoint provider.EndpointType, body []byte) (*provider.ChatResponse, error) { + cfg.Type = model.ProviderAnthropic + cfg.BaseURL = provider.NormalizeBaseURL(cfg.BaseURL, model.ProviderAnthropic) + if endpoint == provider.EndpointChatCompletion { + var payload map[string]any + if err := json.Unmarshal(body, &payload); err == nil { + modelName, _ := payload["model"].(string) + anthropicReq, err := convertOpenAIToAnthropic(&provider.ChatRequest{Model: modelName, Raw: body}) + if err == nil { + body, _ = json.Marshal(anthropicReq) + } + } + } + url := cfg.ResolveEndpointURL(endpoint) + resp, err := provider.ForwardHTTP(ctx, c.http.Client, "POST", url, apiKey, model.ProviderAnthropic, body, nil) + if err != nil { + return nil, err + } + if endpoint == provider.EndpointChatCompletion && resp.StatusCode < 400 { + var payload map[string]any + modelName := "" + _ = json.Unmarshal(body, &payload) + if m, ok := payload["model"].(string); ok { + modelName = m + } + openAIResp, tokensIn, tokensOut := convertAnthropicToOpenAI(resp.Body, modelName) + return &provider.ChatResponse{StatusCode: 200, Body: openAIResp, TokensIn: tokensIn, TokensOut: tokensOut}, nil + } + return &provider.ChatResponse{StatusCode: resp.StatusCode, Body: resp.Body, TokensIn: resp.TokensIn, TokensOut: resp.TokensOut}, nil +} + +func (c *Client) HealthCheck(ctx context.Context, apiKey, baseURL string) error { + _, err := c.ListModels(ctx, apiKey, baseURL) + return err +} + +func (c *Client) HealthCheckWithConfig(ctx context.Context, apiKey string, cfg provider.ProviderConfig) error { + _, err := c.ListModelsWithConfig(ctx, apiKey, cfg) + return err +} + +// convertOpenAIToAnthropic and convertAnthropicToOpenAI unchanged below + +func convertOpenAIToAnthropic(req *provider.ChatRequest) (map[string]any, error) { + var payload map[string]any + raw := req.Raw + if len(raw) == 0 { + b, _ := json.Marshal(req) + raw = b + } + if err := json.Unmarshal(raw, &payload); err != nil { + return nil, err + } + model, _ := payload["model"].(string) + if model == "" { + model = req.Model + } + msgs, _ := payload["messages"].([]any) + var system string + var anthropicMsgs []map[string]any + for _, m := range msgs { + msg, _ := m.(map[string]any) + role, _ := msg["role"].(string) + if role == "system" { + system = extractOpenAIContent(msg["content"]) + continue + } + if role != "assistant" { + role = "user" + } + content := convertOpenAIContentBlocks(msg["content"]) + anthropicMsgs = append(anthropicMsgs, map[string]any{"role": role, "content": content}) + } + out := map[string]any{ + "model": model, + "max_tokens": 4096, + "messages": anthropicMsgs, + } + if system != "" { + out["system"] = system + } + if maxTok, ok := payload["max_tokens"].(float64); ok { + out["max_tokens"] = int(maxTok) + } + return out, nil +} + +func extractOpenAIContent(content any) string { + switch v := content.(type) { + case string: + return v + case []any: + var parts []string + for _, p := range v { + part, ok := p.(map[string]any) + if !ok { + continue + } + if text, ok := part["text"].(string); ok { + parts = append(parts, text) + } + } + return strings.Join(parts, "\n") + default: + return "" + } +} + +func convertOpenAIContentBlocks(content any) any { + switch v := content.(type) { + case string: + if v == "" { + return "" + } + return v + case []any: + blocks := make([]map[string]any, 0, len(v)) + for _, p := range v { + part, ok := p.(map[string]any) + if !ok { + continue + } + partType, _ := part["type"].(string) + switch partType { + case "text": + if text, ok := part["text"].(string); ok { + blocks = append(blocks, map[string]any{"type": "text", "text": text}) + } + case "image_url": + if imageURL, ok := part["image_url"].(map[string]any); ok { + if url, ok := imageURL["url"].(string); ok && url != "" { + blocks = append(blocks, map[string]any{ + "type": "image", + "source": map[string]any{ + "type": "url", + "url": url, + }, + }) + } + } + case "image": + if source, ok := part["source"].(map[string]any); ok { + blocks = append(blocks, map[string]any{"type": "image", "source": source}) + } + default: + if text, ok := part["text"].(string); ok { + blocks = append(blocks, map[string]any{"type": "text", "text": text}) + } + } + } + if len(blocks) == 1 { + if text, ok := blocks[0]["text"].(string); ok && blocks[0]["type"] == "text" { + return text + } + } + if len(blocks) > 0 { + return blocks + } + return "" + default: + return "" + } +} + +func convertAnthropicToOpenAI(body []byte, model string) ([]byte, int, int) { + var resp struct { + Content []struct { + Text string `json:"text"` + } `json:"content"` + Usage struct { + InputTokens int `json:"input_tokens"` + OutputTokens int `json:"output_tokens"` + } `json:"usage"` + } + _ = json.Unmarshal(body, &resp) + text := "" + if len(resp.Content) > 0 { + text = resp.Content[0].Text + } + openAI := map[string]any{ + "id": "chatcmpl-luminary", + "object": "chat.completion", + "model": model, + "choices": []any{map[string]any{"index": 0, "message": map[string]string{"role": "assistant", "content": text}, "finish_reason": "stop"}}, + "usage": map[string]int{ + "prompt_tokens": resp.Usage.InputTokens, + "completion_tokens": resp.Usage.OutputTokens, + "total_tokens": resp.Usage.InputTokens + resp.Usage.OutputTokens, + }, + } + b, _ := json.Marshal(openAI) + return b, resp.Usage.InputTokens, resp.Usage.OutputTokens +} diff --git a/internal/provider/anthropic/content_test.go b/internal/provider/anthropic/content_test.go new file mode 100644 index 0000000..63eca42 --- /dev/null +++ b/internal/provider/anthropic/content_test.go @@ -0,0 +1,26 @@ +package anthropic + +import "testing" + +func TestConvertOpenAIContentBlocks_TextAndImage(t *testing.T) { + content := []any{ + map[string]any{"type": "text", "text": "describe this"}, + map[string]any{"type": "image_url", "image_url": map[string]any{"url": "https://example.com/a.png"}}, + } + blocks, ok := convertOpenAIContentBlocks(content).([]map[string]any) + if !ok || len(blocks) != 2 { + t.Fatalf("expected 2 blocks, got %#v", convertOpenAIContentBlocks(content)) + } + if blocks[0]["text"] != "describe this" { + t.Fatalf("unexpected text block: %#v", blocks[0]) + } + if blocks[1]["type"] != "image" { + t.Fatalf("unexpected image block: %#v", blocks[1]) + } +} + +func TestExtractOpenAIContent_String(t *testing.T) { + if got := extractOpenAIContent("hello"); got != "hello" { + t.Fatalf("got %q", got) + } +} diff --git a/internal/provider/comfyui/client.go b/internal/provider/comfyui/client.go new file mode 100644 index 0000000..e15ba6c --- /dev/null +++ b/internal/provider/comfyui/client.go @@ -0,0 +1,290 @@ +package comfyui + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "mime/multipart" + "net/http" + "net/url" + "strings" + "time" + + "github.com/gorilla/websocket" +) + +type Client struct { + BaseURL string + HTTPClient *http.Client +} + +func New(baseURL string) *Client { + return &Client{ + BaseURL: strings.TrimRight(baseURL, "/"), + HTTPClient: &http.Client{Timeout: 120 * time.Second}, + } +} + +type PromptResponse struct { + PromptID string `json:"prompt_id"` + Number int `json:"number"` +} + +func (c *Client) SubmitPrompt(ctx context.Context, prompt map[string]any, clientID string) (*PromptResponse, error) { + body, _ := json.Marshal(map[string]any{ + "prompt": prompt, + "client_id": clientID, + }) + req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.BaseURL+"/prompt", bytes.NewReader(body)) + if err != nil { + return nil, err + } + req.Header.Set("Content-Type", "application/json") + resp, err := c.HTTPClient.Do(req) + if err != nil { + return nil, err + } + defer resp.Body.Close() + data, _ := io.ReadAll(resp.Body) + if resp.StatusCode >= 400 { + return nil, fmt.Errorf("comfyui prompt %d: %s", resp.StatusCode, string(data)) + } + var out PromptResponse + if err := json.Unmarshal(data, &out); err != nil { + return nil, err + } + return &out, nil +} + +func (c *Client) Interrupt(ctx context.Context) error { + req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.BaseURL+"/interrupt", nil) + if err != nil { + return err + } + resp, err := c.HTTPClient.Do(req) + if err != nil { + return err + } + defer resp.Body.Close() + if resp.StatusCode >= 400 { + data, _ := io.ReadAll(resp.Body) + return fmt.Errorf("comfyui interrupt %d: %s", resp.StatusCode, string(data)) + } + return nil +} + +type HistoryEntry struct { + Outputs map[string]HistoryNodeOutput `json:"outputs"` + Status map[string]any `json:"status"` +} + +type HistoryNodeOutput struct { + Images []OutputImage `json:"images"` +} + +type OutputImage struct { + Filename string `json:"filename"` + Subfolder string `json:"subfolder"` + Type string `json:"type"` +} + +func (c *Client) GetHistory(ctx context.Context, promptID string) (map[string]HistoryEntry, error) { + req, err := http.NewRequestWithContext(ctx, http.MethodGet, c.BaseURL+"/history/"+promptID, nil) + if err != nil { + return nil, err + } + resp, err := c.HTTPClient.Do(req) + if err != nil { + return nil, err + } + defer resp.Body.Close() + data, _ := io.ReadAll(resp.Body) + if resp.StatusCode >= 400 { + return nil, fmt.Errorf("comfyui history %d: %s", resp.StatusCode, string(data)) + } + var out map[string]HistoryEntry + if err := json.Unmarshal(data, &out); err != nil { + return nil, err + } + return out, nil +} + +func (c *Client) DownloadView(ctx context.Context, filename, subfolder, imageType string) ([]byte, string, error) { + q := url.Values{} + q.Set("filename", filename) + if subfolder != "" { + q.Set("subfolder", subfolder) + } + if imageType == "" { + imageType = "output" + } + q.Set("type", imageType) + u := c.BaseURL + "/view?" + q.Encode() + req, err := http.NewRequestWithContext(ctx, http.MethodGet, u, nil) + if err != nil { + return nil, "", err + } + resp, err := c.HTTPClient.Do(req) + if err != nil { + return nil, "", err + } + defer resp.Body.Close() + data, err := io.ReadAll(resp.Body) + if err != nil { + return nil, "", err + } + if resp.StatusCode >= 400 { + return nil, "", fmt.Errorf("comfyui view %d", resp.StatusCode) + } + mime := resp.Header.Get("Content-Type") + if mime == "" { + mime = "image/png" + } + return data, mime, nil +} + +func (c *Client) UploadImage(ctx context.Context, data []byte, filename string) (string, error) { + var buf bytes.Buffer + w := multipart.NewWriter(&buf) + part, err := w.CreateFormFile("image", filename) + if err != nil { + return "", err + } + if _, err := part.Write(data); err != nil { + return "", err + } + _ = w.WriteField("overwrite", "true") + _ = w.Close() + req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.BaseURL+"/upload/image", &buf) + if err != nil { + return "", err + } + req.Header.Set("Content-Type", w.FormDataContentType()) + resp, err := c.HTTPClient.Do(req) + if err != nil { + return "", err + } + defer resp.Body.Close() + body, _ := io.ReadAll(resp.Body) + if resp.StatusCode >= 400 { + return "", fmt.Errorf("comfyui upload %d: %s", resp.StatusCode, string(body)) + } + var out struct { + Name string `json:"name"` + } + if err := json.Unmarshal(body, &out); err != nil { + return "", err + } + return out.Name, nil +} + +func (c *Client) HealthCheck(ctx context.Context) error { + req, err := http.NewRequestWithContext(ctx, http.MethodGet, c.BaseURL+"/system_stats", nil) + if err != nil { + return err + } + resp, err := c.HTTPClient.Do(req) + if err != nil { + return err + } + defer resp.Body.Close() + if resp.StatusCode >= 400 { + return fmt.Errorf("status %d", resp.StatusCode) + } + return nil +} + +type ProgressEvent struct { + Type string + Data map[string]any + Node string + Value float64 + Max float64 + PromptID string + ErrorText string +} + +func (c *Client) wsURL(clientID string) string { + u, _ := url.Parse(c.BaseURL) + scheme := "ws" + if u.Scheme == "https" { + scheme = "wss" + } + return fmt.Sprintf("%s://%s/ws?clientId=%s", scheme, u.Host, url.QueryEscape(clientID)) +} + +func (c *Client) Watch(ctx context.Context, clientID string, onEvent func(ProgressEvent)) error { + conn, _, err := websocket.DefaultDialer.DialContext(ctx, c.wsURL(clientID), nil) + if err != nil { + return err + } + defer conn.Close() + done := make(chan struct{}) + go func() { + defer close(done) + for { + _, msg, err := conn.ReadMessage() + if err != nil { + return + } + var envelope struct { + Type string `json:"type"` + Data json.RawMessage `json:"data"` + } + if err := json.Unmarshal(msg, &envelope); err != nil { + continue + } + ev := ProgressEvent{Type: envelope.Type} + var data map[string]any + _ = json.Unmarshal(envelope.Data, &data) + ev.Data = data + if envelope.Type == "progress" { + if n, ok := data["node"].(string); ok { + ev.Node = n + } + if v, ok := data["value"].(float64); ok { + ev.Value = v + } + if m, ok := data["max"].(float64); ok { + ev.Max = m + } + } + if envelope.Type == "executing" { + if n, ok := data["node"].(string); ok { + ev.Node = n + } + if pid, ok := data["prompt_id"].(string); ok { + ev.PromptID = pid + } + } + if envelope.Type == "execution_error" { + if msgs, ok := data["exception_message"].(string); ok { + ev.ErrorText = msgs + } + } + onEvent(ev) + } + }() + select { + case <-ctx.Done(): + _ = conn.WriteMessage(websocket.CloseMessage, websocket.FormatCloseMessage(websocket.CloseNormalClosure, "")) + <-done + return ctx.Err() + case <-done: + return nil + } +} + +func CollectOutputImages(history map[string]HistoryEntry, promptID string) []OutputImage { + entry, ok := history[promptID] + if !ok { + return nil + } + var images []OutputImage + for _, out := range entry.Outputs { + images = append(images, out.Images...) + } + return images +} diff --git a/internal/provider/custom/client.go b/internal/provider/custom/client.go new file mode 100644 index 0000000..4980c98 --- /dev/null +++ b/internal/provider/custom/client.go @@ -0,0 +1,16 @@ +package custom + +import ( + "github.com/rose_cat707/luminary/internal/provider/openai" +) + +// Custom providers use OpenAI-compatible API at a custom base URL. +type Client struct { + *openai.Client +} + +func New() *Client { + return &Client{Client: openai.New()} +} + +func (c *Client) Type() string { return "custom" } diff --git a/internal/provider/endpoints.go b/internal/provider/endpoints.go new file mode 100644 index 0000000..0b16192 --- /dev/null +++ b/internal/provider/endpoints.go @@ -0,0 +1,176 @@ +package provider + +import ( + "strings" + + "github.com/rose_cat707/luminary/internal/model" +) + +// EndpointType 接口类型,对应入口 /v1/* 与上游 path 映射 +type EndpointType string + +const ( + EndpointListModels EndpointType = "list_models" + EndpointTextCompletion EndpointType = "text_completion" + EndpointChatCompletion EndpointType = "chat_completion" + EndpointResponses EndpointType = "responses" + EndpointEmbeddings EndpointType = "embeddings" + EndpointRerank EndpointType = "rerank" + EndpointSpeech EndpointType = "speech" + EndpointTranscription EndpointType = "transcription" + EndpointImageGeneration EndpointType = "image_generation" +) + +type EndpointMeta struct { + Key EndpointType `json:"key"` + Label string `json:"label"` + Method string `json:"method"` + IngressPath string `json:"ingress_path"` + Categories []model.ProviderCategory `json:"categories"` +} + +var AllEndpoints = []EndpointMeta{ + {Key: EndpointListModels, Label: "List Models", Method: "GET", IngressPath: "/v1/models", Categories: []model.ProviderCategory{model.CategoryLLM, model.CategoryEmbedding, model.CategoryRerank}}, + {Key: EndpointTextCompletion, Label: "Text Completion", Method: "POST", IngressPath: "/v1/completions", Categories: []model.ProviderCategory{model.CategoryLLM}}, + {Key: EndpointChatCompletion, Label: "Chat Completion", Method: "POST", IngressPath: "/v1/chat/completions", Categories: []model.ProviderCategory{model.CategoryLLM}}, + {Key: EndpointResponses, Label: "Responses", Method: "POST", IngressPath: "/v1/responses", Categories: []model.ProviderCategory{model.CategoryLLM}}, + {Key: EndpointEmbeddings, Label: "Embeddings", Method: "POST", IngressPath: "/v1/embeddings", Categories: []model.ProviderCategory{model.CategoryEmbedding}}, + {Key: EndpointRerank, Label: "Rerank", Method: "POST", IngressPath: "/v1/rerank", Categories: []model.ProviderCategory{model.CategoryRerank}}, + {Key: EndpointSpeech, Label: "Speech", Method: "POST", IngressPath: "/v1/audio/speech", Categories: []model.ProviderCategory{model.CategorySpeech}}, + {Key: EndpointTranscription, Label: "Transcription", Method: "POST", IngressPath: "/v1/audio/transcriptions", Categories: []model.ProviderCategory{model.CategoryAudio}}, + {Key: EndpointImageGeneration, Label: "Image Generation", Method: "POST", IngressPath: "/v1/images/generations", Categories: []model.ProviderCategory{model.CategoryImage}}, +} + +// DefaultPaths 各 Provider 类型的默认上游 path(相对 base_url) +var DefaultPaths = map[model.ProviderType]map[EndpointType]string{ + model.ProviderOpenAI: { + EndpointListModels: "/models", + EndpointTextCompletion: "/completions", + EndpointChatCompletion: "/chat/completions", + EndpointResponses: "/responses", + EndpointEmbeddings: "/embeddings", + EndpointRerank: "/rerank", + EndpointSpeech: "/audio/speech", + EndpointTranscription: "/audio/transcriptions", + EndpointImageGeneration: "/images/generations", + }, + model.ProviderAnthropic: { + EndpointListModels: "/models", + EndpointChatCompletion: "/messages", + }, + model.ProviderCustom: { + EndpointListModels: "/models", + EndpointTextCompletion: "/completions", + EndpointChatCompletion: "/chat/completions", + EndpointResponses: "/responses", + EndpointEmbeddings: "/embeddings", + EndpointRerank: "/rerank", + EndpointSpeech: "/audio/speech", + EndpointTranscription: "/audio/transcriptions", + EndpointImageGeneration: "/images/generations", + }, + model.ProviderAzureOpenAI: { + EndpointListModels: "/models", + EndpointChatCompletion: "/chat/completions", + EndpointResponses: "/responses", + EndpointEmbeddings: "/embeddings", + EndpointRerank: "/rerank", + }, + model.ProviderOpenRouter: { + EndpointListModels: "/models", + EndpointChatCompletion: "/chat/completions", + EndpointResponses: "/responses", + EndpointEmbeddings: "/embeddings", + EndpointRerank: "/rerank", + }, + model.ProviderOllama: { + EndpointListModels: "/models", + EndpointChatCompletion: "/chat/completions", + EndpointEmbeddings: "/embeddings", + EndpointRerank: "/rerank", + }, + model.ProviderComfyUI: {}, +} + +func DefaultPath(providerType model.ProviderType, endpoint EndpointType) string { + if m, ok := DefaultPaths[providerType]; ok { + if p, ok := m[endpoint]; ok { + return p + } + } + if p, ok := DefaultPaths[model.ProviderOpenAI][endpoint]; ok { + return p + } + return "" +} + +// DefaultEndpointsForCategory 创建出口时默认启用的 endpoint keys +func DefaultEndpointsForCategory(cat model.ProviderCategory) []string { + switch cat { + case model.CategoryEmbedding: + return []string{string(EndpointListModels), string(EndpointEmbeddings)} + case model.CategoryRerank: + return []string{string(EndpointListModels), string(EndpointRerank)} + case model.CategoryImage: + return []string{} + default: + return []string{string(EndpointListModels), string(EndpointChatCompletion)} + } +} + +func EndpointsForCategory(cat model.ProviderCategory) []EndpointMeta { + var out []EndpointMeta + for _, e := range AllEndpoints { + for _, c := range e.Categories { + if c == cat { + out = append(out, e) + break + } + } + } + return out +} + +// ResolveURL 解析最终请求 URL:override 可为相对 path 或完整 URL +func ResolveURL(baseURL, override, defaultPath string) string { + if override != "" { + if strings.HasPrefix(override, "http://") || strings.HasPrefix(override, "https://") { + return override + } + return joinURL(baseURL, override) + } + return joinURL(baseURL, defaultPath) +} + +func joinURL(base, path string) string { + base = strings.TrimRight(base, "/") + if path == "" { + return base + } + if !strings.HasPrefix(path, "/") { + path = "/" + path + } + if base == "" { + return path + } + return base + path +} + +func CategoryLabel(cat model.ProviderCategory) string { + switch cat { + case model.CategoryLLM: + return "语言模型" + case model.CategoryEmbedding: + return "向量嵌入" + case model.CategoryRerank: + return "重排序" + case model.CategorySpeech: + return "语音合成" + case model.CategoryImage: + return "图像生成" + case model.CategoryAudio: + return "音频转写" + default: + return string(cat) + } +} diff --git a/internal/provider/forward.go b/internal/provider/forward.go new file mode 100644 index 0000000..55b4ca1 --- /dev/null +++ b/internal/provider/forward.go @@ -0,0 +1,198 @@ +package provider + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "time" + + "github.com/rose_cat707/luminary/internal/model" +) + +type ForwardRequest struct { + Endpoint EndpointType + Method string + Body []byte + Headers map[string]string +} + +type ForwardResponse struct { + StatusCode int + Body []byte + TokensIn int + TokensOut int +} + +type ProviderConfig struct { + Type model.ProviderType + Category model.ProviderCategory + BaseURL string + EndpointPaths map[string]string // endpoint key -> path override +} + +func ParseEndpointPaths(jsonStr string) map[string]string { + out := map[string]string{} + if jsonStr == "" || jsonStr == "{}" { + return out + } + _ = json.Unmarshal([]byte(jsonStr), &out) + return out +} + +func ParseAllowedEndpoints(jsonStr string, category model.ProviderCategory) []string { + var out []string + if jsonStr == "" { + return DefaultEndpointsForCategory(category) + } + _ = json.Unmarshal([]byte(jsonStr), &out) + if len(out) == 0 { + return DefaultEndpointsForCategory(category) + } + return out +} + +func (cfg *ProviderConfig) ResolveEndpointURL(endpoint EndpointType) string { + override := cfg.EndpointPaths[string(endpoint)] + defaultPath := DefaultPath(cfg.Type, endpoint) + return ResolveURL(cfg.BaseURL, override, defaultPath) +} + +func ForwardHTTP(ctx context.Context, client *http.Client, method, url string, apiKey string, providerType model.ProviderType, body []byte, extraHeaders map[string]string) (*ForwardResponse, error) { + var reader io.Reader + if len(body) > 0 { + reader = bytes.NewReader(body) + } + req, err := http.NewRequestWithContext(ctx, method, url, reader) + if err != nil { + return nil, err + } + setAuthHeaders(req, apiKey, providerType) + req.Header.Set("Content-Type", "application/json") + for k, v := range extraHeaders { + req.Header.Set(k, v) + } + resp, err := client.Do(req) + if err != nil { + return nil, err + } + defer resp.Body.Close() + respBody, err := io.ReadAll(resp.Body) + if err != nil { + return nil, err + } + tokensIn, tokensOut := parseUsageJSON(respBody) + return &ForwardResponse{ + StatusCode: resp.StatusCode, + Body: respBody, + TokensIn: tokensIn, + TokensOut: tokensOut, + }, nil +} + +func setAuthHeaders(req *http.Request, apiKey string, providerType model.ProviderType) { + switch providerType { + case model.ProviderAnthropic: + req.Header.Set("x-api-key", apiKey) + req.Header.Set("anthropic-version", "2023-06-01") + case model.ProviderAzureOpenAI: + if apiKey != "" { + req.Header.Set("api-key", apiKey) + } + case model.ProviderOllama: + if apiKey != "" { + req.Header.Set("Authorization", "Bearer "+apiKey) + } + default: + if apiKey != "" { + req.Header.Set("Authorization", "Bearer "+apiKey) + } + } +} + +func parseUsageJSON(body []byte) (int, int) { + var u struct { + Usage struct { + PromptTokens int `json:"prompt_tokens"` + CompletionTokens int `json:"completion_tokens"` + InputTokens int `json:"input_tokens"` + OutputTokens int `json:"output_tokens"` + } `json:"usage"` + } + _ = json.Unmarshal(body, &u) + in := u.Usage.PromptTokens + out := u.Usage.CompletionTokens + if in == 0 { + in = u.Usage.InputTokens + } + if out == 0 { + out = u.Usage.OutputTokens + } + return in, out +} + +func DefaultBaseURL(providerType model.ProviderType) string { + switch providerType { + case model.ProviderAnthropic: + return "https://api.anthropic.com/v1" + case model.ProviderAzureOpenAI: + return "" // must be configured per deployment + case model.ProviderOpenRouter: + return "https://openrouter.ai/api/v1" + case model.ProviderOllama: + return "http://127.0.0.1:11434/v1" + default: + return "https://api.openai.com/v1" + } +} + +func IsOpenAICompatible(t model.ProviderType) bool { + switch t { + case model.ProviderOpenAI, model.ProviderCustom, model.ProviderAzureOpenAI, model.ProviderOpenRouter, model.ProviderOllama: + return true + default: + return false + } +} + +func NormalizeBaseURL(baseURL string, providerType model.ProviderType) string { + if baseURL == "" { + return DefaultBaseURL(providerType) + } + return baseURL +} + +func IsEndpointAllowed(allowed []string, endpoint EndpointType) bool { + for _, a := range allowed { + if a == string(endpoint) || a == "*" { + return true + } + } + return false +} + +func NewHTTPClient() *http.Client { + return &http.Client{Timeout: 120 * time.Second} +} + +func ValidateCategoryEndpoints(category model.ProviderCategory, allowed []string) error { + if !model.IsCategorySupported(category) { + return fmt.Errorf("category %s is not supported yet", category) + } + valid := EndpointsForCategory(category) + validSet := map[string]bool{} + for _, e := range valid { + validSet[string(e.Key)] = true + } + for _, a := range allowed { + if a == "*" { + continue + } + if !validSet[a] { + return fmt.Errorf("endpoint %s is not available for category %s", a, category) + } + } + return nil +} diff --git a/internal/provider/http_client.go b/internal/provider/http_client.go new file mode 100644 index 0000000..07872c8 --- /dev/null +++ b/internal/provider/http_client.go @@ -0,0 +1,11 @@ +package provider + +import "net/http" + +type HTTPClientWrapper struct { + Client *http.Client +} + +func NewHTTPClientWrapper() *HTTPClientWrapper { + return &HTTPClientWrapper{Client: NewHTTPClient()} +} diff --git a/internal/provider/openai/client.go b/internal/provider/openai/client.go new file mode 100644 index 0000000..291f700 --- /dev/null +++ b/internal/provider/openai/client.go @@ -0,0 +1,94 @@ +package openai + +import ( + "context" + "encoding/json" + "fmt" + + "github.com/rose_cat707/luminary/internal/model" + "github.com/rose_cat707/luminary/internal/provider" +) + +type Client struct { + http *provider.HTTPClientWrapper +} + +func New() *Client { + return &Client{http: provider.NewHTTPClientWrapper()} +} + +func (c *Client) Type() string { return "openai" } + +func (c *Client) cfg(baseURL string) provider.ProviderConfig { + return provider.ProviderConfig{ + Type: model.ProviderOpenAI, + Category: model.CategoryLLM, + BaseURL: provider.NormalizeBaseURL(baseURL, model.ProviderOpenAI), + } +} + +func (c *Client) ListModels(ctx context.Context, apiKey, baseURL string) ([]provider.ModelInfo, error) { + return c.ListModelsWithConfig(ctx, apiKey, provider.ProviderConfig{ + Type: model.ProviderOpenAI, + BaseURL: provider.NormalizeBaseURL(baseURL, model.ProviderOpenAI), + }) +} + +func (c *Client) ListModelsWithConfig(ctx context.Context, apiKey string, cfg provider.ProviderConfig) ([]provider.ModelInfo, error) { + cfg.Type = model.ProviderOpenAI + cfg.BaseURL = provider.NormalizeBaseURL(cfg.BaseURL, model.ProviderOpenAI) + url := cfg.ResolveEndpointURL(provider.EndpointListModels) + resp, err := provider.ForwardHTTP(ctx, c.http.Client, "GET", url, apiKey, cfg.Type, nil, nil) + if err != nil { + return nil, err + } + if resp.StatusCode >= 400 { + return nil, fmt.Errorf("list models: %s", string(resp.Body)) + } + var out struct { + Data []struct { + ID string `json:"id"` + } `json:"data"` + } + if err := json.Unmarshal(resp.Body, &out); err != nil { + return nil, err + } + models := make([]provider.ModelInfo, 0, len(out.Data)) + for _, m := range out.Data { + models = append(models, provider.ModelInfo{ID: m.ID, DisplayName: m.ID}) + } + return models, nil +} + +func (c *Client) ChatCompletion(ctx context.Context, apiKey, baseURL string, req *provider.ChatRequest) (*provider.ChatResponse, error) { + return c.ForwardWithConfig(ctx, apiKey, provider.ProviderConfig{ + Type: model.ProviderOpenAI, + BaseURL: provider.NormalizeBaseURL(baseURL, model.ProviderOpenAI), + }, provider.EndpointChatCompletion, req.Raw) +} + +func (c *Client) ForwardWithConfig(ctx context.Context, apiKey string, cfg provider.ProviderConfig, endpoint provider.EndpointType, body []byte) (*provider.ChatResponse, error) { + cfg.Type = model.ProviderOpenAI + cfg.BaseURL = provider.NormalizeBaseURL(cfg.BaseURL, model.ProviderOpenAI) + url := cfg.ResolveEndpointURL(endpoint) + resp, err := provider.ForwardHTTP(ctx, c.http.Client, "POST", url, apiKey, cfg.Type, body, nil) + if err != nil { + return nil, err + } + return &provider.ChatResponse{ + StatusCode: resp.StatusCode, + Body: resp.Body, + TokensIn: resp.TokensIn, + TokensOut: resp.TokensOut, + }, nil +} + +func (c *Client) HealthCheck(ctx context.Context, apiKey, baseURL string) error { + _, err := c.ListModels(ctx, apiKey, baseURL) + return err +} + +func (c *Client) HealthCheckWithConfig(ctx context.Context, apiKey string, cfg provider.ProviderConfig) error { + _, err := c.ListModelsWithConfig(ctx, apiKey, cfg) + return err +} diff --git a/internal/provider/provider.go b/internal/provider/provider.go new file mode 100644 index 0000000..4d4dae9 --- /dev/null +++ b/internal/provider/provider.go @@ -0,0 +1,33 @@ +package provider + +import ( + "context" + "encoding/json" +) + +type ModelInfo struct { + ID string `json:"id"` + DisplayName string `json:"display_name,omitempty"` +} + +type ChatRequest struct { + Model string `json:"model"` + Messages json.RawMessage `json:"messages"` + Stream bool `json:"stream"` + Raw json.RawMessage `json:"-"` +} + +type ChatResponse struct { + StatusCode int + Body []byte + Headers map[string]string + TokensIn int + TokensOut int +} + +type Client interface { + Type() string + ListModels(ctx context.Context, apiKey, baseURL string) ([]ModelInfo, error) + ChatCompletion(ctx context.Context, apiKey, baseURL string, req *ChatRequest) (*ChatResponse, error) + HealthCheck(ctx context.Context, apiKey, baseURL string) error +} diff --git a/internal/provider/responses_adapter.go b/internal/provider/responses_adapter.go new file mode 100644 index 0000000..6dd3b23 --- /dev/null +++ b/internal/provider/responses_adapter.go @@ -0,0 +1,640 @@ +package provider + +import ( + "bufio" + "encoding/json" + "fmt" + "io" + "net/http" + "strings" +) + +// CanAdaptResponses reports whether responses ingress can be served via chat completions. +func CanAdaptResponses(allowsResponses, allowsChat bool) bool { + return allowsChat && !allowsResponses +} + +// ResponsesToChatCompletion converts an OpenAI Responses API request to chat completions format. +func ResponsesToChatCompletion(body []byte) ([]byte, error) { + var req map[string]json.RawMessage + if err := json.Unmarshal(body, &req); err != nil { + return nil, fmt.Errorf("invalid json: %w", err) + } + + out := make(map[string]any) + if raw, ok := req["model"]; ok { + var model string + if err := json.Unmarshal(raw, &model); err != nil || model == "" { + return nil, fmt.Errorf("model is required") + } + out["model"] = model + } + + messages := make([]map[string]any, 0, 4) + if raw, ok := req["instructions"]; ok { + var instructions string + if err := json.Unmarshal(raw, &instructions); err == nil && instructions != "" { + messages = append(messages, map[string]any{"role": "system", "content": instructions}) + } else { + var instructionParts []json.RawMessage + if err := json.Unmarshal(raw, &instructionParts); err == nil { + if text := joinContentPartTexts(instructionParts); text != "" { + messages = append(messages, map[string]any{"role": "system", "content": text}) + } + } + } + } + if raw, ok := req["input"]; ok { + inputMsgs, err := parseResponsesInput(raw) + if err != nil { + return nil, err + } + messages = append(messages, inputMsgs...) + } + if len(messages) == 0 { + return nil, fmt.Errorf("input is required") + } + out["messages"] = messages + + copyField(req, out, "stream") + copyField(req, out, "temperature") + copyField(req, out, "top_p") + copyField(req, out, "user") + copyField(req, out, "stop") + copyField(req, out, "tools") + copyField(req, out, "tool_choice") + copyField(req, out, "parallel_tool_calls") + copyField(req, out, "seed") + copyField(req, out, "n") + copyField(req, out, "presence_penalty") + copyField(req, out, "frequency_penalty") + copyField(req, out, "logit_bias") + copyField(req, out, "response_format") + + if raw, ok := req["max_output_tokens"]; ok { + var v int + if err := json.Unmarshal(raw, &v); err == nil && v > 0 { + out["max_tokens"] = v + } + } else { + copyField(req, out, "max_tokens") + } + + if raw, ok := req["text"]; ok { + if rf := responsesTextToResponseFormat(raw); rf != nil { + out["response_format"] = rf + } + } + + return json.Marshal(out) +} + +func copyField(src map[string]json.RawMessage, dst map[string]any, key string) { + if raw, ok := src[key]; ok && len(raw) > 0 && string(raw) != "null" { + var v any + if json.Unmarshal(raw, &v) == nil { + dst[key] = v + } + } +} + +func responsesTextToResponseFormat(raw json.RawMessage) any { + var text struct { + Format struct { + Type string `json:"type"` + } `json:"format"` + } + if err := json.Unmarshal(raw, &text); err != nil || text.Format.Type == "" { + return nil + } + switch text.Format.Type { + case "json_object": + return map[string]any{"type": "json_object"} + case "text": + return map[string]any{"type": "text"} + default: + return map[string]any{"type": text.Format.Type} + } +} + +func parseResponsesInput(raw json.RawMessage) ([]map[string]any, error) { + var asString string + if err := json.Unmarshal(raw, &asString); err == nil { + if asString == "" { + return nil, fmt.Errorf("input is required") + } + return []map[string]any{{"role": "user", "content": asString}}, nil + } + + var asArray []json.RawMessage + if err := json.Unmarshal(raw, &asArray); err != nil { + return nil, fmt.Errorf("invalid input") + } + out := make([]map[string]any, 0, len(asArray)) + for _, item := range asArray { + msg, err := parseResponsesInputItem(item) + if err != nil { + return nil, err + } + out = append(out, msg) + } + return out, nil +} + +func parseResponsesInputItem(raw json.RawMessage) (map[string]any, error) { + var asString string + if err := json.Unmarshal(raw, &asString); err == nil { + return map[string]any{"role": "user", "content": asString}, nil + } + + var item map[string]json.RawMessage + if err := json.Unmarshal(raw, &item); err != nil { + return nil, fmt.Errorf("invalid input item") + } + + itemType := "" + if rawType, ok := item["type"]; ok { + _ = json.Unmarshal(rawType, &itemType) + } + + role := "user" + if rawRole, ok := item["role"]; ok { + _ = json.Unmarshal(rawRole, &role) + } + if role == "" { + role = "user" + } + role = normalizeResponsesRole(role) + + switch itemType { + case "message": + if rawContent, ok := item["content"]; ok { + content, err := parseResponsesContent(rawContent) + if err != nil { + return nil, err + } + return map[string]any{"role": role, "content": content}, nil + } + case "input_text": + var text string + if rawText, ok := item["text"]; ok { + _ = json.Unmarshal(rawText, &text) + } + if text != "" { + return map[string]any{"role": role, "content": text}, nil + } + case "function_call_output": + return parseFunctionCallOutputItem(item, role) + } + + if rawContent, ok := item["content"]; ok { + content, err := parseResponsesContent(rawContent) + if err != nil { + return nil, err + } + return map[string]any{"role": role, "content": content}, nil + } + if rawText, ok := item["text"]; ok { + var text string + if err := json.Unmarshal(rawText, &text); err == nil { + return map[string]any{"role": role, "content": text}, nil + } + } + if rawInput, ok := item["input"]; ok { + var text string + if err := json.Unmarshal(rawInput, &text); err == nil { + return map[string]any{"role": role, "content": text}, nil + } + } + return nil, fmt.Errorf("unsupported input item") +} + +func normalizeResponsesRole(role string) string { + switch role { + case "developer": + return "system" + default: + return role + } +} + +func parseResponsesContent(raw json.RawMessage) (any, error) { + var asString string + if err := json.Unmarshal(raw, &asString); err == nil { + return asString, nil + } + var parts []json.RawMessage + if err := json.Unmarshal(raw, &parts); err != nil { + return nil, fmt.Errorf("invalid content") + } + converted := make([]any, 0, len(parts)) + for _, part := range parts { + if item, ok := convertResponsesContentPart(part); ok { + converted = append(converted, item) + } + } + if len(converted) == 0 { + return "", nil + } + if len(converted) == 1 { + if text, ok := converted[0].(string); ok { + return text, nil + } + if m, ok := converted[0].(map[string]any); ok { + if m["type"] == "text" { + if text, ok := m["text"].(string); ok { + return text, nil + } + } + } + } + return converted, nil +} + +func convertResponsesContentPart(raw json.RawMessage) (any, bool) { + var asString string + if err := json.Unmarshal(raw, &asString); err == nil { + return asString, true + } + var part map[string]json.RawMessage + if err := json.Unmarshal(raw, &part); err != nil { + return nil, false + } + typ := "" + if rawType, ok := part["type"]; ok { + _ = json.Unmarshal(rawType, &typ) + } + switch typ { + case "input_text", "text", "output_text": + var text string + if rawText, ok := part["text"]; ok { + _ = json.Unmarshal(rawText, &text) + } + if text == "" { + return nil, false + } + return map[string]any{"type": "text", "text": text}, true + case "input_image": + out := map[string]any{"type": "image_url", "image_url": map[string]any{}} + img := out["image_url"].(map[string]any) + if rawURL, ok := part["image_url"]; ok { + var url string + if err := json.Unmarshal(rawURL, &url); err == nil { + img["url"] = url + } else { + var nested map[string]any + if err := json.Unmarshal(rawURL, &nested); err == nil { + for k, v := range nested { + img[k] = v + } + } + } + } + if rawDetail, ok := part["detail"]; ok { + var detail string + if json.Unmarshal(rawDetail, &detail) == nil { + img["detail"] = detail + } + } + if img["url"] == nil { + return nil, false + } + return out, true + case "image_url": + var v any + _ = json.Unmarshal(raw, &v) + return v, true + case "input_file": + var fileID, filename string + if rawID, ok := part["file_id"]; ok { + _ = json.Unmarshal(rawID, &fileID) + } + if rawName, ok := part["filename"]; ok { + _ = json.Unmarshal(rawName, &filename) + } + label := filename + if label == "" { + label = fileID + } + if label == "" { + return nil, false + } + return map[string]any{"type": "text", "text": "[file:" + label + "]"}, true + default: + return nil, false + } +} + +func joinContentPartTexts(parts []json.RawMessage) string { + var texts []string + for _, part := range parts { + if item, ok := convertResponsesContentPart(part); ok { + if text, ok := item.(string); ok { + texts = append(texts, text) + } else if m, ok := item.(map[string]any); ok && m["type"] == "text" { + if text, ok := m["text"].(string); ok { + texts = append(texts, text) + } + } + } + } + return strings.Join(texts, "\n") +} + +func parseFunctionCallOutputItem(item map[string]json.RawMessage, role string) (map[string]any, error) { + var output, callID string + if rawOutput, ok := item["output"]; ok { + _ = json.Unmarshal(rawOutput, &output) + } + if rawCallID, ok := item["call_id"]; ok { + _ = json.Unmarshal(rawCallID, &callID) + } + if output == "" { + return nil, fmt.Errorf("unsupported input item") + } + msg := map[string]any{"role": "tool", "content": output} + if callID != "" { + msg["tool_call_id"] = callID + } + if role != "" && role != "user" { + msg["role"] = role + } + return msg, nil +} + +// ChatCompletionToResponses converts a chat completions response to Responses API format. +func ChatCompletionToResponses(body []byte) ([]byte, error) { + var chat map[string]any + if err := json.Unmarshal(body, &chat); err != nil { + return nil, err + } + if _, ok := chat["error"]; ok { + return body, nil + } + + content := extractChatContent(chat) + chatID, _ := chat["id"].(string) + model, _ := chat["model"].(string) + created := extractCreatedAt(chat) + respID := responsesIDFromChatID(chatID) + msgID := messageIDFromResponseID(respID) + usage := chatUsageToResponses(chat["usage"]) + + resp := buildResponseObject(respID, msgID, model, created, content, usage, "completed") + return json.Marshal(resp) +} + +func extractCreatedAt(chat map[string]any) int64 { + switch v := chat["created"].(type) { + case float64: + return int64(v) + case int64: + return v + case int: + return int64(v) + default: + return 0 + } +} + +func responsesIDFromChatID(chatID string) string { + if chatID == "" { + return "resp_adapted" + } + if strings.HasPrefix(chatID, "resp_") { + return chatID + } + if strings.HasPrefix(chatID, "chatcmpl-") { + return "resp_" + strings.TrimPrefix(chatID, "chatcmpl-") + } + return "resp_" + chatID +} + +func messageIDFromResponseID(respID string) string { + if strings.HasPrefix(respID, "resp_") { + return "msg_" + strings.TrimPrefix(respID, "resp_") + } + return "msg_" + respID +} + +func buildOutputTextContent(text string) map[string]any { + return map[string]any{ + "type": "output_text", + "text": text, + "annotations": []any{}, + } +} + +func buildOutputMessage(msgID, content, status string) map[string]any { + return map[string]any{ + "type": "message", + "id": msgID, + "role": "assistant", + "status": status, + "content": []map[string]any{buildOutputTextContent(content)}, + } +} + +func buildResponseObject(respID, msgID, model string, created int64, content string, usage map[string]any, status string) map[string]any { + msgStatus := status + if status == "in_progress" || status == "queued" { + msgStatus = "in_progress" + } + resp := map[string]any{ + "id": respID, + "object": "response", + "created_at": created, + "status": status, + "model": model, + "output_text": content, + "error": nil, + "incomplete_details": nil, + "parallel_tool_calls": true, + "output": []map[string]any{buildOutputMessage(msgID, content, msgStatus)}, + } + if usage != nil { + resp["usage"] = usage + } + return resp +} + +func extractChatContent(chat map[string]any) string { + choices, ok := chat["choices"].([]any) + if !ok || len(choices) == 0 { + return "" + } + choice, ok := choices[0].(map[string]any) + if !ok { + return "" + } + if msg, ok := choice["message"].(map[string]any); ok { + if content, ok := msg["content"].(string); ok { + return content + } + } + if delta, ok := choice["delta"].(map[string]any); ok { + if content, ok := delta["content"].(string); ok { + return content + } + } + return "" +} + +func chatUsageToResponses(usage any) map[string]any { + u, ok := usage.(map[string]any) + if !ok { + return nil + } + in, hasIn := numberAsInt(u["prompt_tokens"]) + out, hasOut := numberAsInt(u["completion_tokens"]) + total, hasTotal := numberAsInt(u["total_tokens"]) + if !hasIn && !hasOut && !hasTotal { + return nil + } + if !hasTotal && hasIn && hasOut { + total = in + out + } + return map[string]any{ + "input_tokens": in, + "output_tokens": out, + "total_tokens": total, + "input_tokens_details": map[string]any{ + "cached_tokens": 0, + }, + "output_tokens_details": map[string]any{ + "reasoning_tokens": 0, + }, + } +} + +func numberAsInt(v any) (int, bool) { + switch n := v.(type) { + case float64: + return int(n), true + case int: + return n, true + case int64: + return int(n), true + default: + return 0, false + } +} + +// AdaptResponsesStream wraps a chat-completions SSE stream as Responses API SSE events. +func AdaptResponsesStream(chatBody []byte, upstream io.Reader, w io.Writer, flusher http.Flusher) (*StreamResult, error) { + var req map[string]any + _ = json.Unmarshal(chatBody, &req) + model, _ := req["model"].(string) + + result := &StreamResult{StatusCode: http.StatusOK} + respID := "resp_adapted" + msgID := "msg_adapted" + seq := 0 + var fullText string + var usage map[string]any + started := false + + writeEvent := func(eventType string, payload map[string]any) error { + payload["type"] = eventType + payload["sequence_number"] = seq + seq++ + b, err := json.Marshal(payload) + if err != nil { + return err + } + if _, err := fmt.Fprintf(w, "event: %s\ndata: %s\n\n", eventType, b); err != nil { + return err + } + if flusher != nil { + flusher.Flush() + } + return nil + } + + emitCreated := func(status string) error { + response := buildResponseObject(respID, msgID, model, 0, "", nil, status) + response["output"] = []any{} + response["output_text"] = "" + return writeEvent("response.created", map[string]any{"response": response}) + } + + scanner := bufio.NewScanner(upstream) + scanner.Buffer(make([]byte, 64*1024), 1024*1024) + for scanner.Scan() { + line := scanner.Text() + if !strings.HasPrefix(line, "data: ") { + continue + } + data := strings.TrimPrefix(line, "data: ") + if data == "[DONE]" { + break + } + tokensIn, tokensOut := parseStreamChunk([]byte(data)) + result.TokensIn += tokensIn + result.TokensOut += tokensOut + + var chunk map[string]any + if err := json.Unmarshal([]byte(data), &chunk); err != nil { + continue + } + if id, ok := chunk["id"].(string); ok && id != "" { + respID = responsesIDFromChatID(id) + msgID = messageIDFromResponseID(respID) + } + if m, ok := chunk["model"].(string); ok && m != "" { + model = m + } + if u := chatUsageToResponses(chunk["usage"]); u != nil { + usage = u + } + + delta := extractChatContent(chunk) + if delta == "" { + continue + } + + if !started { + started = true + if err := emitCreated("in_progress"); err != nil { + return result, err + } + if err := writeEvent("response.in_progress", map[string]any{ + "response": buildResponseObject(respID, msgID, model, 0, "", nil, "in_progress"), + }); err != nil { + return result, err + } + if err := writeEvent("response.output_item.added", map[string]any{ + "output_index": 0, + "item": map[string]any{ + "type": "message", "id": msgID, "role": "assistant", "status": "in_progress", "content": []any{}, + }, + }); err != nil { + return result, err + } + } + + fullText += delta + if err := writeEvent("response.output_text.delta", map[string]any{ + "item_id": msgID, "output_index": 0, "content_index": 0, "delta": delta, "logprobs": []any{}, + }); err != nil { + return result, err + } + } + if err := scanner.Err(); err != nil { + return result, err + } + + if started { + _ = writeEvent("response.output_text.done", map[string]any{ + "item_id": msgID, "output_index": 0, "content_index": 0, "text": fullText, "logprobs": []any{}, + }) + doneItem := buildOutputMessage(msgID, fullText, "completed") + _ = writeEvent("response.output_item.done", map[string]any{ + "output_index": 0, + "item": doneItem, + }) + final := buildResponseObject(respID, msgID, model, 0, fullText, usage, "completed") + _ = writeEvent("response.completed", map[string]any{"response": final}) + } + return result, nil +} diff --git a/internal/provider/responses_adapter_test.go b/internal/provider/responses_adapter_test.go new file mode 100644 index 0000000..acf54f6 --- /dev/null +++ b/internal/provider/responses_adapter_test.go @@ -0,0 +1,179 @@ +package provider + +import ( + "encoding/json" + "strings" + "testing" +) + +func TestResponsesToChatCompletion_StringInput(t *testing.T) { + body := []byte(`{"model":"gpt-4o-mini","instructions":"Be brief","input":"Hello"}`) + out, err := ResponsesToChatCompletion(body) + if err != nil { + t.Fatal(err) + } + var req map[string]any + if err := json.Unmarshal(out, &req); err != nil { + t.Fatal(err) + } + if req["model"] != "gpt-4o-mini" { + t.Fatalf("model: %v", req["model"]) + } + msgs := req["messages"].([]any) + if len(msgs) != 2 { + t.Fatalf("messages len: %d", len(msgs)) + } + sys := msgs[0].(map[string]any) + user := msgs[1].(map[string]any) + if sys["role"] != "system" || sys["content"] != "Be brief" { + t.Fatalf("system: %#v", sys) + } + if user["role"] != "user" || user["content"] != "Hello" { + t.Fatalf("user: %#v", user) + } +} + +func TestChatCompletionToResponses(t *testing.T) { + body := []byte(`{"id":"chatcmpl-abc123","model":"gpt-4o-mini","created":123,"choices":[{"message":{"role":"assistant","content":"Hi"}}],"usage":{"prompt_tokens":3,"completion_tokens":1,"total_tokens":4}}`) + out, err := ChatCompletionToResponses(body) + if err != nil { + t.Fatal(err) + } + var resp map[string]any + if err := json.Unmarshal(out, &resp); err != nil { + t.Fatal(err) + } + if resp["object"] != "response" { + t.Fatalf("object: %v", resp["object"]) + } + if resp["id"] != "resp_abc123" { + t.Fatalf("id: %v", resp["id"]) + } + if resp["output_text"] != "Hi" { + t.Fatalf("output_text: %v", resp["output_text"]) + } + output := resp["output"].([]any) + msg := output[0].(map[string]any) + content := msg["content"].([]any) + text := content[0].(map[string]any) + if text["type"] != "output_text" || text["text"] != "Hi" { + t.Fatalf("text: %#v", text) + } + if _, ok := text["annotations"]; !ok { + t.Fatalf("missing annotations: %#v", text) + } + usage := resp["usage"].(map[string]any) + if usage["input_tokens"].(float64) != 3 { + t.Fatalf("usage: %#v", usage) + } + if _, ok := usage["input_tokens_details"]; !ok { + t.Fatalf("missing input_tokens_details") + } +} + +func TestResponsesToChatCompletion_InputTextParts(t *testing.T) { + body := []byte(`{ + "model":"gpt-4o-mini", + "input":[{"role":"user","content":[{"type":"input_text","text":"Hello"}]}] + }`) + out, err := ResponsesToChatCompletion(body) + if err != nil { + t.Fatal(err) + } + var req map[string]any + if err := json.Unmarshal(out, &req); err != nil { + t.Fatal(err) + } + msgs := req["messages"].([]any) + user := msgs[0].(map[string]any) + if user["content"] != "Hello" { + t.Fatalf("content: %#v", user["content"]) + } +} + +func TestResponsesToChatCompletion_InputTextAndImage(t *testing.T) { + body := []byte(`{ + "model":"gpt-4o-mini", + "input":[{"role":"user","content":[ + {"type":"input_text","text":"describe"}, + {"type":"input_image","image_url":"https://example.com/a.png","detail":"auto"} + ]}] + }`) + out, err := ResponsesToChatCompletion(body) + if err != nil { + t.Fatal(err) + } + var req map[string]any + if err := json.Unmarshal(out, &req); err != nil { + t.Fatal(err) + } + msgs := req["messages"].([]any) + user := msgs[0].(map[string]any) + parts := user["content"].([]any) + if len(parts) != 2 { + t.Fatalf("parts: %#v", parts) + } + textPart := parts[0].(map[string]any) + if textPart["type"] != "text" || textPart["text"] != "describe" { + t.Fatalf("text part: %#v", textPart) + } + imgPart := parts[1].(map[string]any) + if imgPart["type"] != "image_url" { + t.Fatalf("image part: %#v", imgPart) + } +} + +func TestResponsesToChatCompletion_MessageItem(t *testing.T) { + body := []byte(`{ + "model":"gpt-4o-mini", + "input":[{"type":"message","role":"user","content":[{"type":"input_text","text":"Hi"}]}] + }`) + out, err := ResponsesToChatCompletion(body) + if err != nil { + t.Fatal(err) + } + if strings.Contains(string(out), "input_text") { + t.Fatalf("should not contain input_text: %s", out) + } + var req map[string]any + if err := json.Unmarshal(out, &req); err != nil { + t.Fatal(err) + } + msgs := req["messages"].([]any) + user := msgs[0].(map[string]any) + if user["content"] != "Hi" { + t.Fatalf("content: %#v", user["content"]) + } +} + +func TestAdaptResponsesStream(t *testing.T) { + upstream := strings.NewReader("data: {\"id\":\"chatcmpl-1\",\"model\":\"gpt-4o-mini\",\"choices\":[{\"delta\":{\"content\":\"Hi\"}}]}\n\ndata: [DONE]\n\n") + var out strings.Builder + result, err := AdaptResponsesStream([]byte(`{"model":"gpt-4o-mini","stream":true}`), upstream, &out, nil) + if err != nil { + t.Fatal(err) + } + if result.StatusCode != 200 { + t.Fatalf("status: %d", result.StatusCode) + } + s := out.String() + for _, event := range []string{ + "response.created", + "response.in_progress", + "response.output_item.added", + "response.output_text.delta", + "response.output_text.done", + "response.output_item.done", + "response.completed", + } { + if !strings.Contains(s, event) { + t.Fatalf("missing event %s: %s", event, s) + } + } + if !strings.Contains(s, `"delta":"Hi"`) { + t.Fatalf("missing delta text: %s", s) + } + if !strings.Contains(s, `"output_text":"Hi"`) { + t.Fatalf("missing output_text in completed: %s", s) + } +} diff --git a/internal/provider/stream.go b/internal/provider/stream.go new file mode 100644 index 0000000..3ff8df1 --- /dev/null +++ b/internal/provider/stream.go @@ -0,0 +1,100 @@ +package provider + +import ( + "bufio" + "bytes" + "context" + "encoding/json" + "io" + "net/http" + "strings" + + "github.com/rose_cat707/luminary/internal/model" +) + +type StreamResult struct { + StatusCode int + TokensIn int + TokensOut int + Body []byte // populated for non-2xx responses +} + +func ForwardStreamHTTP(ctx context.Context, client *http.Client, method, url string, apiKey string, providerType model.ProviderType, body []byte, w io.Writer, flusher http.Flusher) (*StreamResult, error) { + var reader io.Reader + if len(body) > 0 { + reader = bytes.NewReader(body) + } + req, err := http.NewRequestWithContext(ctx, method, url, reader) + if err != nil { + return nil, err + } + setAuthHeaders(req, apiKey, providerType) + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Accept", "text/event-stream") + resp, err := client.Do(req) + if err != nil { + return nil, err + } + defer resp.Body.Close() + + if resp.StatusCode >= 400 { + b, _ := io.ReadAll(resp.Body) + if flusher != nil { + w.Write(b) + flusher.Flush() + } + return &StreamResult{StatusCode: resp.StatusCode, Body: b}, nil + } + + result := &StreamResult{StatusCode: resp.StatusCode} + scanner := bufio.NewScanner(resp.Body) + scanner.Buffer(make([]byte, 64*1024), 1024*1024) + for scanner.Scan() { + line := scanner.Text() + if strings.HasPrefix(line, "data: ") { + data := strings.TrimPrefix(line, "data: ") + if data == "[DONE]" { + continue + } + tokensIn, tokensOut := parseStreamChunk([]byte(data)) + result.TokensIn += tokensIn + result.TokensOut += tokensOut + } + if _, err := w.Write([]byte(line + "\n")); err != nil { + return result, err + } + if flusher != nil { + flusher.Flush() + } + } + return result, scanner.Err() +} + +func parseStreamChunk(data []byte) (int, int) { + var chunk struct { + Usage struct { + PromptTokens int `json:"prompt_tokens"` + CompletionTokens int `json:"completion_tokens"` + } `json:"usage"` + } + if err := json.Unmarshal(data, &chunk); err == nil && chunk.Usage.PromptTokens+chunk.Usage.CompletionTokens > 0 { + return chunk.Usage.PromptTokens, chunk.Usage.CompletionTokens + } + return 0, 0 +} + +func NewStreamHTTPClient() *http.Client { + return &http.Client{} // no timeout for streams +} + +func IsStreamRequest(body []byte) bool { + var payload struct { + Stream bool `json:"stream"` + } + _ = json.Unmarshal(body, &payload) + return payload.Stream +} + +func SupportsStreaming(endpoint EndpointType) bool { + return endpoint == EndpointChatCompletion || endpoint == EndpointResponses +} diff --git a/internal/provider/types.go b/internal/provider/types.go new file mode 100644 index 0000000..fb67d12 --- /dev/null +++ b/internal/provider/types.go @@ -0,0 +1,96 @@ +package provider + +import "github.com/rose_cat707/luminary/internal/model" + +type ProviderTypeMeta struct { + Key model.ProviderType `json:"key"` + Label string `json:"label"` +} + +var allProviderTypes = []ProviderTypeMeta{ + {Key: model.ProviderOpenAI, Label: "OpenAI"}, + {Key: model.ProviderAnthropic, Label: "Anthropic"}, + {Key: model.ProviderAzureOpenAI, Label: "Azure OpenAI"}, + {Key: model.ProviderOpenRouter, Label: "OpenRouter"}, + {Key: model.ProviderOllama, Label: "Ollama"}, + {Key: model.ProviderCustom, Label: "Custom (OpenAI compatible)"}, + {Key: model.ProviderComfyUI, Label: "ComfyUI"}, +} + +func ProviderTypeLabel(t model.ProviderType) string { + for _, m := range allProviderTypes { + if m.Key == t { + return m.Label + } + } + return string(t) +} + +// TypesForCategory 各出口分类支持的协议类型 +func TypesForCategory(cat model.ProviderCategory) []ProviderTypeMeta { + switch cat { + case model.CategoryImage: + return []ProviderTypeMeta{{Key: model.ProviderComfyUI, Label: "ComfyUI"}} + case model.CategoryEmbedding: + return filterTypes( + model.ProviderOpenAI, + model.ProviderAzureOpenAI, + model.ProviderOpenRouter, + model.ProviderOllama, + model.ProviderCustom, + ) + case model.CategoryRerank: + return filterTypes( + model.ProviderOpenAI, + model.ProviderAzureOpenAI, + model.ProviderOpenRouter, + model.ProviderOllama, + model.ProviderCustom, + ) + default: + return filterTypes( + model.ProviderOpenAI, + model.ProviderAnthropic, + model.ProviderAzureOpenAI, + model.ProviderOpenRouter, + model.ProviderOllama, + model.ProviderCustom, + ) + } +} + +func filterTypes(keys ...model.ProviderType) []ProviderTypeMeta { + set := map[model.ProviderType]bool{} + for _, k := range keys { + set[k] = true + } + out := make([]ProviderTypeMeta, 0, len(keys)) + for _, m := range allProviderTypes { + if set[m.Key] { + out = append(out, m) + } + } + return out +} + +func DefaultProviderType(cat model.ProviderCategory) model.ProviderType { + types := TypesForCategory(cat) + if len(types) == 0 { + return model.ProviderOpenAI + } + return types[0].Key +} + +func IsTypeValidForCategory(cat model.ProviderCategory, t model.ProviderType) bool { + for _, m := range TypesForCategory(cat) { + if m.Key == t { + return true + } + } + return false +} + +// AllProviderTypes 返回全部协议类型(用于管理台展示标签) +func AllProviderTypes() []ProviderTypeMeta { + return append([]ProviderTypeMeta(nil), allProviderTypes...) +} diff --git a/internal/provider/types_test.go b/internal/provider/types_test.go new file mode 100644 index 0000000..ef5e769 --- /dev/null +++ b/internal/provider/types_test.go @@ -0,0 +1,30 @@ +package provider + +import ( + "testing" + + "github.com/rose_cat707/luminary/internal/model" +) + +func TestTypesForCategory(t *testing.T) { + llm := TypesForCategory(model.CategoryLLM) + if len(llm) != 6 { + t.Fatalf("llm types: got %d want 6", len(llm)) + } + image := TypesForCategory(model.CategoryImage) + if len(image) != 1 || image[0].Key != model.ProviderComfyUI { + t.Fatalf("image types: %+v", image) + } + embedding := TypesForCategory(model.CategoryEmbedding) + for _, m := range embedding { + if m.Key == model.ProviderAnthropic { + t.Fatal("anthropic should not support embedding category") + } + } + if !IsTypeValidForCategory(model.CategoryRerank, model.ProviderCustom) { + t.Fatal("custom should be valid for rerank") + } + if IsTypeValidForCategory(model.CategoryRerank, model.ProviderAnthropic) { + t.Fatal("anthropic should not be valid for rerank") + } +} diff --git a/internal/settings/encryption.go b/internal/settings/encryption.go new file mode 100644 index 0000000..9651b92 --- /dev/null +++ b/internal/settings/encryption.go @@ -0,0 +1,66 @@ +package settings + +import ( + "log" + "os" + "strings" + + "github.com/rose_cat707/luminary/internal/auth" + "github.com/rose_cat707/luminary/internal/model" + "gorm.io/gorm" +) + +// encryptionKeyCandidates returns possible keys ordered for repair attempts. +func encryptionKeyCandidates() []string { + seen := map[string]bool{} + var out []string + add := func(key string) { + key = strings.TrimSpace(key) + if key == "" || seen[key] { + return + } + seen[key] = true + out = append(out, key) + } + + add(os.Getenv("LUMINARY_ENCRYPTION_KEY")) + add(legacyDefaultEncryptionKey) + return out +} + +// EnsureEncryptionKeyWorks verifies provider keys can be decrypted and repairs the +// stored encryption_key from legacy sources when needed. +func (r *Runtime) EnsureEncryptionKeyWorks() { + var sample model.ProviderKey + err := r.db.Where("api_key_enc <> ''").First(&sample).Error + if err == gorm.ErrRecordNotFound { + return + } + if err != nil { + log.Printf("settings: check encryption key: %v", err) + return + } + + current := r.EncryptionKey() + if _, err := auth.Decrypt(sample.APIKeyEnc, current); err == nil { + return + } + + for _, candidate := range encryptionKeyCandidates() { + if candidate == current { + continue + } + if _, err := auth.Decrypt(sample.APIKeyEnc, candidate); err != nil { + continue + } + key := candidate + if err := r.Update(UpdateInput{EncryptionKey: &key}); err != nil { + log.Printf("settings: repair encryption_key: %v", err) + return + } + log.Printf("settings: repaired encryption_key from legacy source") + return + } + + log.Printf("settings: provider keys cannot be decrypted; set the correct encryption_key in admin system settings or re-save provider keys") +} diff --git a/internal/settings/legacy.go b/internal/settings/legacy.go new file mode 100644 index 0000000..6eee5d9 --- /dev/null +++ b/internal/settings/legacy.go @@ -0,0 +1,22 @@ +package settings + +import ( + "os" + "strings" + + "github.com/rose_cat707/luminary/internal/model" +) + +const legacyDefaultEncryptionKey = "change-me-in-production-32bytes!!" + +func mergeLegacySettings(s model.SystemSettings) model.SystemSettings { + if key := strings.TrimSpace(os.Getenv("LUMINARY_ENCRYPTION_KEY")); key != "" { + s.EncryptionKey = key + } + return s +} + +// LegacyAdminCredentials returns default bootstrap credentials for first admin. +func LegacyAdminCredentials() (username, password string) { + return "admin", "admin123" +} diff --git a/internal/settings/runtime.go b/internal/settings/runtime.go new file mode 100644 index 0000000..e7656c6 --- /dev/null +++ b/internal/settings/runtime.go @@ -0,0 +1,262 @@ +package settings + +import ( + "crypto/rand" + "encoding/hex" + "encoding/json" + "sync" + "time" + + "github.com/rose_cat707/luminary/internal/middleware" + "github.com/rose_cat707/luminary/internal/model" + "gorm.io/gorm" +) + +// Runtime holds mutable system settings loaded from the database. +type Runtime struct { + mu sync.RWMutex + db *gorm.DB + data model.SystemSettings +} + +func NewRuntime(db *gorm.DB) *Runtime { + return &Runtime{db: db} +} + +func (r *Runtime) Load() error { + var s model.SystemSettings + err := r.db.First(&s, 1).Error + if err == gorm.ErrRecordNotFound { + s = defaultSettings() + s = mergeLegacySettings(s) + if err := r.db.Create(&s).Error; err != nil { + return err + } + } else if err != nil { + return err + } + r.mu.Lock() + r.data = s + r.mu.Unlock() + r.applySideEffects() + return nil +} + +// AfterLoad runs post-bootstrap checks that depend on other tables. +func (r *Runtime) AfterLoad() { + r.EnsureEncryptionKeyWorks() +} + +func defaultSettings() model.SystemSettings { + key := make([]byte, 32) + _, _ = rand.Read(key) + return model.SystemSettings{ + ID: 1, + EncryptionKey: hex.EncodeToString(key), + SessionTTLSeconds: int64((24 * time.Hour).Seconds()), + LoginMaxAttempts: 5, + LoginLockoutSeconds: int64((15 * time.Minute).Seconds()), + TrustedProxiesJSON: "[]", + UsageFlushSeconds: 5, + HealthCheckSeconds: 30, + GatewayMaxRetries: 3, + GatewayRetryBackoffMs: 200, + ImageStoragePath: "images", + SignedURLTTLSeconds: 3600, + PredictionWaitSeconds: 60, + } +} + +func (r *Runtime) Snapshot() model.SystemSettings { + r.mu.RLock() + defer r.mu.RUnlock() + return r.data +} + +func (r *Runtime) EncryptionKey() string { + r.mu.RLock() + defer r.mu.RUnlock() + return r.data.EncryptionKey +} + +func (r *Runtime) SessionTTL() time.Duration { + r.mu.RLock() + defer r.mu.RUnlock() + return time.Duration(r.data.SessionTTLSeconds) * time.Second +} + +func (r *Runtime) LoginMaxAttempts() int { + r.mu.RLock() + defer r.mu.RUnlock() + return r.data.LoginMaxAttempts +} + +func (r *Runtime) LoginLockout() time.Duration { + r.mu.RLock() + defer r.mu.RUnlock() + return time.Duration(r.data.LoginLockoutSeconds) * time.Second +} + +func (r *Runtime) TrustedProxies() []string { + r.mu.RLock() + defer r.mu.RUnlock() + var out []string + _ = json.Unmarshal([]byte(r.data.TrustedProxiesJSON), &out) + return out +} + +func (r *Runtime) UsageFlushInterval() time.Duration { + r.mu.RLock() + defer r.mu.RUnlock() + return time.Duration(r.data.UsageFlushSeconds) * time.Second +} + +func (r *Runtime) HealthCheckInterval() time.Duration { + r.mu.RLock() + defer r.mu.RUnlock() + return time.Duration(r.data.HealthCheckSeconds) * time.Second +} + +func (r *Runtime) GatewayMaxRetries() int { + r.mu.RLock() + defer r.mu.RUnlock() + return r.data.GatewayMaxRetries +} + +func (r *Runtime) GatewayRetryBackoffMs() int { + r.mu.RLock() + defer r.mu.RUnlock() + return r.data.GatewayRetryBackoffMs +} + +func (r *Runtime) ImageStoragePath() string { + r.mu.RLock() + defer r.mu.RUnlock() + if r.data.ImageStoragePath == "" { + return "images" + } + return r.data.ImageStoragePath +} + +func (r *Runtime) PublicBaseURL() string { + r.mu.RLock() + defer r.mu.RUnlock() + return r.data.PublicBaseURL +} + +func (r *Runtime) SignedURLTTL() time.Duration { + r.mu.RLock() + defer r.mu.RUnlock() + sec := r.data.SignedURLTTLSeconds + if sec <= 0 { + sec = 3600 + } + return time.Duration(sec) * time.Second +} + +func (r *Runtime) PredictionWaitSeconds() int64 { + r.mu.RLock() + defer r.mu.RUnlock() + if r.data.PredictionWaitSeconds <= 0 { + return 60 + } + return r.data.PredictionWaitSeconds +} + +type UpdateInput struct { + EncryptionKey *string `json:"encryption_key"` + SessionTTLSeconds *int64 `json:"session_ttl_seconds"` + LoginMaxAttempts *int `json:"login_max_attempts"` + LoginLockoutSeconds *int64 `json:"login_lockout_seconds"` + TrustedProxies []string `json:"trusted_proxies"` + UsageFlushSeconds *int64 `json:"usage_flush_seconds"` + HealthCheckSeconds *int64 `json:"health_check_seconds"` + GatewayMaxRetries *int `json:"gateway_max_retries"` + GatewayRetryBackoffMs *int `json:"gateway_retry_backoff_ms"` + ImageStoragePath *string `json:"image_storage_path"` + PublicBaseURL *string `json:"public_base_url"` + SignedURLTTLSeconds *int64 `json:"signed_url_ttl_seconds"` + PredictionWaitSeconds *int64 `json:"prediction_wait_seconds"` +} + +func (r *Runtime) Update(in UpdateInput) error { + r.mu.Lock() + s := r.data + if in.EncryptionKey != nil && *in.EncryptionKey != "" { + s.EncryptionKey = *in.EncryptionKey + } + if in.SessionTTLSeconds != nil && *in.SessionTTLSeconds > 0 { + s.SessionTTLSeconds = *in.SessionTTLSeconds + } + if in.LoginMaxAttempts != nil && *in.LoginMaxAttempts > 0 { + s.LoginMaxAttempts = *in.LoginMaxAttempts + } + if in.LoginLockoutSeconds != nil && *in.LoginLockoutSeconds > 0 { + s.LoginLockoutSeconds = *in.LoginLockoutSeconds + } + if in.TrustedProxies != nil { + b, _ := json.Marshal(in.TrustedProxies) + s.TrustedProxiesJSON = string(b) + } + if in.UsageFlushSeconds != nil && *in.UsageFlushSeconds > 0 { + s.UsageFlushSeconds = *in.UsageFlushSeconds + } + if in.HealthCheckSeconds != nil && *in.HealthCheckSeconds > 0 { + s.HealthCheckSeconds = *in.HealthCheckSeconds + } + if in.GatewayMaxRetries != nil && *in.GatewayMaxRetries > 0 { + s.GatewayMaxRetries = *in.GatewayMaxRetries + } + if in.GatewayRetryBackoffMs != nil && *in.GatewayRetryBackoffMs >= 0 { + s.GatewayRetryBackoffMs = *in.GatewayRetryBackoffMs + } + if in.ImageStoragePath != nil { + s.ImageStoragePath = *in.ImageStoragePath + } + if in.PublicBaseURL != nil { + s.PublicBaseURL = *in.PublicBaseURL + } + if in.SignedURLTTLSeconds != nil && *in.SignedURLTTLSeconds > 0 { + s.SignedURLTTLSeconds = *in.SignedURLTTLSeconds + } + if in.PredictionWaitSeconds != nil && *in.PredictionWaitSeconds > 0 { + s.PredictionWaitSeconds = *in.PredictionWaitSeconds + } + if err := r.db.Save(&s).Error; err != nil { + return err + } + r.data = s + r.mu.Unlock() + r.applySideEffects() + return nil +} + +func (r *Runtime) applySideEffects() { + middleware.ConfigureTrustedProxies(r.TrustedProxies()) +} + +func (r *Runtime) PublicView() map[string]any { + s := r.Snapshot() + masked := "(已设置)" + if s.EncryptionKey == "" { + masked = "(未设置)" + } + var proxies []string + _ = json.Unmarshal([]byte(s.TrustedProxiesJSON), &proxies) + return map[string]any{ + "encryption_key_set": s.EncryptionKey != "", + "encryption_key_hint": masked, + "session_ttl_seconds": s.SessionTTLSeconds, + "login_max_attempts": s.LoginMaxAttempts, + "login_lockout_seconds": s.LoginLockoutSeconds, + "trusted_proxies": proxies, + "usage_flush_seconds": s.UsageFlushSeconds, + "health_check_seconds": s.HealthCheckSeconds, + "gateway_max_retries": s.GatewayMaxRetries, + "gateway_retry_backoff_ms": s.GatewayRetryBackoffMs, + "image_storage_path": s.ImageStoragePath, + "public_base_url": s.PublicBaseURL, + "signed_url_ttl_seconds": s.SignedURLTTLSeconds, + "prediction_wait_seconds": s.PredictionWaitSeconds, + } +} diff --git a/internal/storage/imagestore/store.go b/internal/storage/imagestore/store.go new file mode 100644 index 0000000..d3bd66e --- /dev/null +++ b/internal/storage/imagestore/store.go @@ -0,0 +1,93 @@ +package imagestore + +import ( + "crypto/hmac" + "crypto/sha256" + "encoding/hex" + "fmt" + "os" + "path/filepath" + "strconv" + "strings" + "time" + + "github.com/google/uuid" + "github.com/rose_cat707/luminary/internal/model" + "gorm.io/gorm" +) + +type Store struct { + db *gorm.DB + baseDir string + signKey string + publicURL string + ttl time.Duration +} + +func New(db *gorm.DB, baseDir, publicURL, signKey string, ttl time.Duration) *Store { + return &Store{db: db, baseDir: baseDir, publicURL: strings.TrimRight(publicURL, "/"), signKey: signKey, ttl: ttl} +} + +func (s *Store) EnsureDir() error { + return os.MkdirAll(s.baseDir, 0o755) +} + +func (s *Store) Save(predictionID, filename string, data []byte, mime string) (*model.StoredImage, error) { + if err := s.EnsureDir(); err != nil { + return nil, err + } + dir := filepath.Join(s.baseDir, predictionID) + if err := os.MkdirAll(dir, 0o755); err != nil { + return nil, err + } + localPath := filepath.Join(dir, filename) + if err := os.WriteFile(localPath, data, 0o644); err != nil { + return nil, err + } + img := model.StoredImage{ + ID: uuid.NewString(), + PredictionID: predictionID, + Filename: filename, + LocalPath: localPath, + Mime: mime, + Size: int64(len(data)), + CreatedAt: time.Now(), + } + if err := s.db.Create(&img).Error; err != nil { + return nil, err + } + return &img, nil +} + +func (s *Store) Get(id string) (*model.StoredImage, error) { + var img model.StoredImage + if err := s.db.First(&img, "id = ?", id).Error; err != nil { + return nil, err + } + return &img, nil +} + +func (s *Store) ListByPrediction(predictionID string) ([]model.StoredImage, error) { + var imgs []model.StoredImage + err := s.db.Where("prediction_id = ?", predictionID).Order("created_at asc").Find(&imgs).Error + return imgs, err +} + +func (s *Store) SignURL(imageID string) string { + exp := time.Now().Add(s.ttl).Unix() + sig := s.sign(imageID, exp) + return fmt.Sprintf("%s/files/%s?exp=%d&sig=%s", s.publicURL, imageID, exp, sig) +} + +func (s *Store) Verify(imageID string, exp int64, sig string) bool { + if time.Now().Unix() > exp { + return false + } + return hmac.Equal([]byte(s.sign(imageID, exp)), []byte(sig)) +} + +func (s *Store) sign(imageID string, exp int64) string { + mac := hmac.New(sha256.New, []byte(s.signKey)) + mac.Write([]byte(imageID + "|" + strconv.FormatInt(exp, 10))) + return hex.EncodeToString(mac.Sum(nil)) +} diff --git a/internal/store/cache/cache.go b/internal/store/cache/cache.go new file mode 100644 index 0000000..3501c70 --- /dev/null +++ b/internal/store/cache/cache.go @@ -0,0 +1,455 @@ +package cache + +import ( + "encoding/json" + "sync" + "sync/atomic" + "time" + + "github.com/rose_cat707/luminary/internal/auth" + "github.com/rose_cat707/luminary/internal/model" +) + +type IngressKeySnapshot struct { + Key model.IngressKey + KeyPlain string // only set on create, not persisted in cache long-term +} + +type UsageDelta struct { + IngressKeyID uint + ProviderKeyID uint + ProviderID uint + ClientIP string + Endpoint string + Model string + TokensIn int + TokensOut int + Cost float64 + LatencyMs int64 + Status string + Stream bool + ErrorMsg string + RequestBody string + ResponseBody string + BudgetUsed float64 + Requests int64 + Tokens int64 +} + +type Store struct { + mu sync.RWMutex + + providers map[uint]*model.Provider + providerKeys map[uint]*model.ProviderKey + providerByName map[string]*model.Provider + models map[uint][]model.ModelEntry + + ingressKeys map[uint]*model.IngressKey + ingressByHash map[string]*model.IngressKey + + ipRules []model.IPRule + routingRules map[uint][]model.RoutingRule // ingressKeyID -> rules + + healthStatus map[uint]model.HealthCheckRecord + + usageDeltas []UsageDelta + usageMu sync.Mutex + + // rate limit counters: ingressKeyID -> window start + counts + rateMu sync.Mutex + rateWindows map[uint]*rateWindow +} + +type rateWindow struct { + start time.Time + requests int + tokens int +} + +func New() *Store { + return &Store{ + providers: make(map[uint]*model.Provider), + providerKeys: make(map[uint]*model.ProviderKey), + providerByName: make(map[string]*model.Provider), + models: make(map[uint][]model.ModelEntry), + ingressKeys: make(map[uint]*model.IngressKey), + ingressByHash: make(map[string]*model.IngressKey), + routingRules: make(map[uint][]model.RoutingRule), + healthStatus: make(map[uint]model.HealthCheckRecord), + rateWindows: make(map[uint]*rateWindow), + } +} + +func (c *Store) LoadProviders(providers []model.Provider) { + c.mu.Lock() + defer c.mu.Unlock() + c.providers = make(map[uint]*model.Provider) + c.providerByName = make(map[string]*model.Provider) + for i := range providers { + p := providers[i] + c.providers[p.ID] = &p + c.providerByName[p.Name] = &p + } +} + +func (c *Store) LoadProviderKeys(keys []model.ProviderKey) { + c.mu.Lock() + defer c.mu.Unlock() + c.providerKeys = make(map[uint]*model.ProviderKey) + for i := range keys { + k := keys[i] + c.providerKeys[k.ID] = &k + } +} + +func (c *Store) LoadModels(models []model.ModelEntry) { + c.mu.Lock() + defer c.mu.Unlock() + c.models = make(map[uint][]model.ModelEntry) + for _, m := range models { + c.models[m.ProviderID] = append(c.models[m.ProviderID], m) + } +} + +func (c *Store) LoadIngressKeys(keys []model.IngressKey) { + c.mu.Lock() + defer c.mu.Unlock() + c.ingressKeys = make(map[uint]*model.IngressKey) + c.ingressByHash = make(map[string]*model.IngressKey) + for i := range keys { + k := keys[i] + c.ingressKeys[k.ID] = &k + c.ingressByHash[k.KeyHash] = &k + } +} + +func (c *Store) LoadIPRules(rules []model.IPRule) { + c.mu.Lock() + defer c.mu.Unlock() + c.ipRules = rules +} + +func (c *Store) GetProvider(id uint) (model.Provider, bool) { + c.mu.RLock() + defer c.mu.RUnlock() + p, ok := c.providers[id] + if !ok { + return model.Provider{}, false + } + return *p, true +} + +func (c *Store) GetProviderByName(name string) (model.Provider, bool) { + c.mu.RLock() + defer c.mu.RUnlock() + p, ok := c.providerByName[name] + if !ok { + return model.Provider{}, false + } + return *p, true +} + +func (c *Store) ListProviders() []model.Provider { + c.mu.RLock() + defer c.mu.RUnlock() + out := make([]model.Provider, 0, len(c.providers)) + for _, p := range c.providers { + out = append(out, *p) + } + return out +} + +func (c *Store) SetProvider(p model.Provider) { + c.mu.Lock() + defer c.mu.Unlock() + c.providers[p.ID] = &p + c.providerByName[p.Name] = &p +} + +func (c *Store) DeleteProvider(id uint) { + c.mu.Lock() + defer c.mu.Unlock() + if p, ok := c.providers[id]; ok { + delete(c.providerByName, p.Name) + } + delete(c.providers, id) + delete(c.models, id) +} + +func (c *Store) GetProviderKey(id uint) (model.ProviderKey, bool) { + c.mu.RLock() + defer c.mu.RUnlock() + k, ok := c.providerKeys[id] + if !ok { + return model.ProviderKey{}, false + } + return *k, true +} + +func (c *Store) ListProviderKeys(providerID uint) []model.ProviderKey { + c.mu.RLock() + defer c.mu.RUnlock() + var out []model.ProviderKey + for _, k := range c.providerKeys { + if k.ProviderID == providerID { + out = append(out, *k) + } + } + return out +} + +func (c *Store) ListAllProviderKeys() []model.ProviderKey { + c.mu.RLock() + defer c.mu.RUnlock() + out := make([]model.ProviderKey, 0, len(c.providerKeys)) + for _, k := range c.providerKeys { + out = append(out, *k) + } + return out +} + +func (c *Store) SetProviderKey(k model.ProviderKey) { + c.mu.Lock() + defer c.mu.Unlock() + c.providerKeys[k.ID] = &k +} + +func (c *Store) DeleteProviderKey(id uint) { + c.mu.Lock() + defer c.mu.Unlock() + delete(c.providerKeys, id) +} + +func (c *Store) GetModels(providerID uint) []model.ModelEntry { + c.mu.RLock() + defer c.mu.RUnlock() + models := c.models[providerID] + if models == nil { + return []model.ModelEntry{} + } + return models +} + +func (c *Store) SetModels(providerID uint, models []model.ModelEntry) { + c.mu.Lock() + defer c.mu.Unlock() + c.models[providerID] = models +} + +func (c *Store) ListIngressKeys() []model.IngressKey { + c.mu.RLock() + defer c.mu.RUnlock() + out := make([]model.IngressKey, 0, len(c.ingressKeys)) + for _, k := range c.ingressKeys { + out = append(out, *k) + } + return out +} + +func (c *Store) GetIngressKey(id uint) (model.IngressKey, bool) { + c.mu.RLock() + defer c.mu.RUnlock() + k, ok := c.ingressKeys[id] + if !ok { + return model.IngressKey{}, false + } + return *k, true +} + +func (c *Store) SetIngressKey(k model.IngressKey) { + c.mu.Lock() + defer c.mu.Unlock() + c.ingressKeys[k.ID] = &k + c.ingressByHash[k.KeyHash] = &k +} + +func (c *Store) DeleteIngressKey(id uint) { + c.mu.Lock() + defer c.mu.Unlock() + if k, ok := c.ingressKeys[id]; ok { + delete(c.ingressByHash, k.KeyHash) + } + delete(c.ingressKeys, id) +} + +func (c *Store) LookupIngressKey(plain string) (*model.IngressKey, bool) { + c.mu.RLock() + defer c.mu.RUnlock() + hash := auth.HashIngressKey(plain) + if k, ok := c.ingressByHash[hash]; ok && k.Enabled { + cp := *k + return &cp, true + } + for _, k := range c.ingressKeys { + if !k.Enabled || !auth.IsLegacyIngressHash(k.KeyHash) { + continue + } + if auth.CheckIngressKey(k.KeyHash, plain) { + cp := *k + return &cp, true + } + } + return nil, false +} + +func (c *Store) ResetProviderKeyDailyCounts() { + c.mu.Lock() + defer c.mu.Unlock() + for _, k := range c.providerKeys { + k.RequestsToday = 0 + k.TokensToday = 0 + } +} + +func (c *Store) ListIPRules() []model.IPRule { + c.mu.RLock() + defer c.mu.RUnlock() + out := make([]model.IPRule, len(c.ipRules)) + copy(out, c.ipRules) + return out +} + +func (c *Store) SetIPRules(rules []model.IPRule) { + c.mu.Lock() + defer c.mu.Unlock() + c.ipRules = rules +} + +func (c *Store) SetHealthStatus(providerID uint, rec model.HealthCheckRecord) { + c.mu.Lock() + defer c.mu.Unlock() + c.healthStatus[providerID] = rec + if p, ok := c.providers[providerID]; ok { + p.Status = rec.Status + } +} + +func (c *Store) GetHealthStatus() map[uint]model.HealthCheckRecord { + c.mu.RLock() + defer c.mu.RUnlock() + out := make(map[uint]model.HealthCheckRecord, len(c.healthStatus)) + for k, v := range c.healthStatus { + out[k] = v + } + return out +} + +func (c *Store) RecordUsage(delta UsageDelta) { + c.mu.Lock() + if k, ok := c.ingressKeys[delta.IngressKeyID]; ok { + k.BudgetUsed += delta.BudgetUsed + k.RequestCount += delta.Requests + k.TokenCount += delta.Tokens + } + if pk, ok := c.providerKeys[delta.ProviderKeyID]; ok { + pk.RequestsToday += int(delta.Requests) + pk.TokensToday += delta.Tokens + } + c.mu.Unlock() + + c.usageMu.Lock() + c.usageDeltas = append(c.usageDeltas, delta) + c.usageMu.Unlock() +} + +func (c *Store) DrainUsageDeltas() []UsageDelta { + c.usageMu.Lock() + defer c.usageMu.Unlock() + if len(c.usageDeltas) == 0 { + return nil + } + out := c.usageDeltas + c.usageDeltas = nil + return out +} + +func (c *Store) CheckRateLimit(ingressKeyID uint, rpm, tpm int, tokens int) bool { + if rpm <= 0 && tpm <= 0 { + return true + } + c.rateMu.Lock() + defer c.rateMu.Unlock() + now := time.Now() + w, ok := c.rateWindows[ingressKeyID] + if !ok || now.Sub(w.start) >= time.Minute { + c.rateWindows[ingressKeyID] = &rateWindow{start: now, requests: 1, tokens: tokens} + return true + } + if rpm > 0 && w.requests >= rpm { + return false + } + if tpm > 0 && w.tokens+tokens > tpm { + return false + } + w.requests++ + w.tokens += tokens + return true +} + +func ParseJSONList(s string) []string { + var out []string + if s == "" { + return []string{"*"} + } + _ = json.Unmarshal([]byte(s), &out) + if len(out) == 0 { + return []string{"*"} + } + return out +} + +func MatchesList(list []string, value string) bool { + for _, item := range list { + if item == "*" || item == value { + return true + } + } + return false +} + +func ModelsToJSON(models []string) string { + b, _ := json.Marshal(models) + return string(b) +} + +func (c *Store) LoadRoutingRules(rules []model.RoutingRule) { + c.mu.Lock() + defer c.mu.Unlock() + c.routingRules = make(map[uint][]model.RoutingRule) + for _, r := range rules { + c.routingRules[r.IngressKeyID] = append(c.routingRules[r.IngressKeyID], r) + } +} + +func (c *Store) ListRoutingRules(ingressKeyID uint) []model.RoutingRule { + c.mu.RLock() + defer c.mu.RUnlock() + out := c.routingRules[ingressKeyID] + cp := make([]model.RoutingRule, len(out)) + copy(cp, out) + return cp +} + +func (c *Store) SetRoutingRules(ingressKeyID uint, rules []model.RoutingRule) { + c.mu.Lock() + defer c.mu.Unlock() + c.routingRules[ingressKeyID] = rules +} + +func (c *Store) DeleteRoutingRulesForIngress(ingressKeyID uint) { + c.mu.Lock() + defer c.mu.Unlock() + delete(c.routingRules, ingressKeyID) +} + +type Counter struct { + v atomic.Int64 +} + +func (c *Counter) Add(n int64) { + c.v.Add(n) +} + +func (c *Counter) Load() int64 { + return c.v.Load() +} diff --git a/internal/store/sqlite/repository.go b/internal/store/sqlite/repository.go new file mode 100644 index 0000000..c143744 --- /dev/null +++ b/internal/store/sqlite/repository.go @@ -0,0 +1,136 @@ +package sqlite + +import ( + "time" + + "github.com/rose_cat707/luminary/internal/auth" + "github.com/rose_cat707/luminary/internal/model" + "github.com/rose_cat707/luminary/internal/settings" + "github.com/rose_cat707/luminary/internal/store/cache" + "gorm.io/gorm" +) + +type Repository struct { + db *gorm.DB + cache *cache.Store +} + +func NewRepository(store *Store, c *cache.Store) *Repository { + return &Repository{db: store.DB(), cache: c} +} + +func (r *Repository) BootstrapFromDB() error { + var providers []model.Provider + if err := r.db.Find(&providers).Error; err != nil { + return err + } + r.cache.LoadProviders(providers) + + var keys []model.ProviderKey + if err := r.db.Find(&keys).Error; err != nil { + return err + } + r.cache.LoadProviderKeys(keys) + + var models []model.ModelEntry + if err := r.db.Find(&models).Error; err != nil { + return err + } + r.cache.LoadModels(models) + + var ingress []model.IngressKey + if err := r.db.Find(&ingress).Error; err != nil { + return err + } + r.cache.LoadIngressKeys(ingress) + + var rules []model.IPRule + if err := r.db.Find(&rules).Error; err != nil { + return err + } + r.cache.LoadIPRules(rules) + + var routing []model.RoutingRule + if err := r.db.Find(&routing).Error; err != nil { + return err + } + r.cache.LoadRoutingRules(routing) + r.PruneUsageRecords(model.MaxUsageLogRecords) + return nil +} + +func (r *Repository) EnsureAdmin() error { + var count int64 + r.db.Model(&model.AdminUser{}).Count(&count) + if count > 0 { + return nil + } + username, password := settings.LegacyAdminCredentials() + hash, err := auth.HashPassword(password) + if err != nil { + return err + } + return r.db.Create(&model.AdminUser{Username: username, PasswordHash: hash}).Error +} + +func (r *Repository) FlushUsage(deltas []cache.UsageDelta) { + for _, d := range deltas { + r.db.Create(&model.UsageRecord{ + IngressKeyID: d.IngressKeyID, + ProviderID: d.ProviderID, + ProviderKeyID: d.ProviderKeyID, + ClientIP: d.ClientIP, + Endpoint: d.Endpoint, + Model: d.Model, + TokensIn: d.TokensIn, + TokensOut: d.TokensOut, + Cost: d.Cost, + LatencyMs: d.LatencyMs, + Status: d.Status, + Stream: d.Stream, + ErrorMsg: d.ErrorMsg, + RequestBody: d.RequestBody, + ResponseBody: d.ResponseBody, + CreatedAt: time.Now(), + }) + if d.IngressKeyID > 0 { + r.db.Model(&model.IngressKey{}).Where("id = ?", d.IngressKeyID).Updates(map[string]any{ + "budget_used": gorm.Expr("budget_used + ?", d.BudgetUsed), + "request_count": gorm.Expr("request_count + ?", d.Requests), + "token_count": gorm.Expr("token_count + ?", d.Tokens), + }) + } + if d.ProviderKeyID > 0 { + r.db.Model(&model.ProviderKey{}).Where("id = ?", d.ProviderKeyID).Updates(map[string]any{ + "requests_today": gorm.Expr("requests_today + ?", d.Requests), + "tokens_today": gorm.Expr("tokens_today + ?", d.Tokens), + }) + } + } + r.PruneUsageRecords(model.MaxUsageLogRecords) +} + +func (r *Repository) RecordRequestLog(rec model.UsageRecord) { + if rec.CreatedAt.IsZero() { + rec.CreatedAt = time.Now() + } + r.db.Create(&rec) + r.PruneUsageRecords(model.MaxUsageLogRecords) +} + +func (r *Repository) PruneUsageRecords(max int) { + if max <= 0 { + return + } + var count int64 + r.db.Model(&model.UsageRecord{}).Count(&count) + if count <= int64(max) { + return + } + var keepIDs []uint + r.db.Model(&model.UsageRecord{}).Order("created_at desc").Limit(max).Pluck("id", &keepIDs) + if len(keepIDs) == 0 { + return + } + r.db.Where("id NOT IN ?", keepIDs).Delete(&model.UsageRecord{}) +} diff --git a/internal/store/sqlite/store.go b/internal/store/sqlite/store.go new file mode 100644 index 0000000..c827959 --- /dev/null +++ b/internal/store/sqlite/store.go @@ -0,0 +1,109 @@ +package sqlite + +import ( + "fmt" + "os" + "path/filepath" + + "github.com/glebarez/sqlite" + "github.com/rose_cat707/luminary/internal/model" + "gorm.io/gorm" + "gorm.io/gorm/logger" +) + +type Store struct { + db *gorm.DB +} + +func New(path string) (*Store, error) { + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + return nil, fmt.Errorf("create data dir: %w", err) + } + db, err := gorm.Open(sqlite.Open(path+"?_pragma=journal_mode(WAL)&_pragma=busy_timeout(5000)"), &gorm.Config{ + Logger: logger.Default.LogMode(logger.Warn), + }) + if err != nil { + return nil, fmt.Errorf("open sqlite: %w", err) + } + sqlDB, err := db.DB() + if err != nil { + return nil, err + } + sqlDB.SetMaxOpenConns(1) + if err := ensureIPRulesSchema(db); err != nil { + return nil, fmt.Errorf("migrate ip_rules: %w", err) + } + if err := db.AutoMigrate( + &model.Provider{}, + &model.ProviderKey{}, + &model.ModelEntry{}, + &model.IngressKey{}, + &model.AdminUser{}, + &model.AdminSession{}, + &model.UsageRecord{}, + &model.HealthCheckRecord{}, + &model.RoutingRule{}, + &model.SystemSettings{}, + &model.Prediction{}, + &model.StoredImage{}, + ); err != nil { + return nil, fmt.Errorf("migrate: %w", err) + } + return &Store{db: db}, nil +} + +// ensureIPRulesSchema manages ip_rules without GORM AutoMigrate (avoids bad table rebuilds). +func ensureIPRulesSchema(db *gorm.DB) error { + if !tableExists(db, "ip_rules") { + return db.Exec(` + CREATE TABLE ip_rules ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + cidr TEXT NOT NULL, + type TEXT NOT NULL, + scope TEXT NOT NULL DEFAULT 'proxy', + enabled NUMERIC DEFAULT 1, + created_at DATETIME + ); + `).Error + } + if !tableHasColumn(db, "ip_rules", "c_id_r") { + return nil + } + + cidrExpr := "c_id_r" + if tableHasColumn(db, "ip_rules", "cidr") { + cidrExpr = "COALESCE(NULLIF(cidr, ''), c_id_r)" + } + + return db.Exec(` + CREATE TABLE ip_rules_mig ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + cidr TEXT NOT NULL, + type TEXT NOT NULL, + scope TEXT NOT NULL DEFAULT 'proxy', + enabled NUMERIC DEFAULT 1, + created_at DATETIME + ); + INSERT INTO ip_rules_mig (id, cidr, type, scope, enabled, created_at) + SELECT id, ` + cidrExpr + `, type, scope, enabled, created_at + FROM ip_rules; + DROP TABLE ip_rules; + ALTER TABLE ip_rules_mig RENAME TO ip_rules; + `).Error +} + +func tableHasColumn(db *gorm.DB, table, column string) bool { + var n int64 + db.Raw(`SELECT COUNT(*) FROM pragma_table_info(?) WHERE name = ?`, table, column).Scan(&n) + return n > 0 +} + +func tableExists(db *gorm.DB, table string) bool { + var n int64 + db.Raw(`SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name=?`, table).Scan(&n) + return n > 0 +} + +func (s *Store) DB() *gorm.DB { + return s.db +} diff --git a/internal/store/sqlite/store_test.go b/internal/store/sqlite/store_test.go new file mode 100644 index 0000000..bab34b6 --- /dev/null +++ b/internal/store/sqlite/store_test.go @@ -0,0 +1,69 @@ +package sqlite + +import ( + "os" + "path/filepath" + "testing" + + "github.com/glebarez/sqlite" + "github.com/rose_cat707/luminary/internal/model" + "gorm.io/gorm" + "gorm.io/gorm/logger" +) + +func TestPreMigrateIPRulesTable_LegacySchema(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "legacy.db") + + legacy, err := gorm.Open(sqlite.Open(path), &gorm.Config{Logger: logger.Default.LogMode(logger.Silent)}) + if err != nil { + t.Fatal(err) + } + if err := legacy.Exec(` + CREATE TABLE ip_rules ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + c_id_r TEXT NOT NULL, + type TEXT NOT NULL, + scope TEXT NOT NULL DEFAULT 'all', + enabled NUMERIC DEFAULT 1, + created_at DATETIME + ); + INSERT INTO ip_rules (c_id_r, type, scope, enabled) VALUES ('10.0.0.1', 'allow', 'proxy', 1); + `).Error; err != nil { + t.Fatal(err) + } + sqlDB, _ := legacy.DB() + sqlDB.Close() + + store, err := New(path) + if err != nil { + t.Fatalf("New() with legacy schema: %v", err) + } + + var rules []model.IPRule + if err := store.DB().Find(&rules).Error; err != nil { + t.Fatal(err) + } + if len(rules) != 1 || rules[0].CIDR != "10.0.0.1" { + t.Fatalf("unexpected rules: %+v", rules) + } + if store.DB().Migrator().HasColumn(&model.IPRule{}, "c_id_r") { + t.Fatal("legacy c_id_r column should be removed") + } + if tableHasColumn(store.DB(), "ip_rules", "c_id_r") { + t.Fatal("legacy c_id_r column should be removed") + } +} + +func TestNew_FreshDatabase(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "fresh.db") + store, err := New(path) + if err != nil { + t.Fatal(err) + } + if err := store.DB().Create(&model.IPRule{CIDR: "127.0.0.1", Type: model.IPRuleAllow, Scope: model.IPScopeProxy, Enabled: true}).Error; err != nil { + t.Fatal(err) + } + _ = os.Remove(path) +} diff --git a/scripts/luminary-recover.sh b/scripts/luminary-recover.sh new file mode 100755 index 0000000..d7dfcfb --- /dev/null +++ b/scripts/luminary-recover.sh @@ -0,0 +1,23 @@ +#!/bin/sh +# Emergency recovery inside the container or local checkout. +set -eu + +DATA_DIR="${LUMINARY_DATA:-/data}" +REPO_DIR="${LUMINARY_HOME:-/opt/luminary}" + +if [ -x /usr/local/bin/luminary-recover ]; then + exec /usr/local/bin/luminary-recover -data-dir "$DATA_DIR" "$@" +fi + +if [ -x "${REPO_DIR}/luminary-recover" ]; then + exec "${REPO_DIR}/luminary-recover" -data-dir "$DATA_DIR" "$@" +fi + +if [ -f "${REPO_DIR}/go.mod" ]; then + cd "$REPO_DIR" + export CGO_ENABLED=0 + exec go run ./cmd/luminary-recover -data-dir "$DATA_DIR" "$@" +fi + +echo "luminary-recover binary not found" >&2 +exit 1 diff --git a/web/.npmrc b/web/.npmrc new file mode 100644 index 0000000..7549542 --- /dev/null +++ b/web/.npmrc @@ -0,0 +1 @@ +registry=https://registry.npmmirror.com diff --git a/web/dist/index.html b/web/dist/index.html new file mode 100644 index 0000000..4492bc4 --- /dev/null +++ b/web/dist/index.html @@ -0,0 +1,14 @@ + + + + + + + Luminary AI Gateway + + + + +
+ + diff --git a/web/index.html b/web/index.html new file mode 100644 index 0000000..24662d8 --- /dev/null +++ b/web/index.html @@ -0,0 +1,13 @@ + + + + + + + Luminary AI Gateway + + +
+ + + diff --git a/web/package-lock.json b/web/package-lock.json new file mode 100644 index 0000000..61cf72b --- /dev/null +++ b/web/package-lock.json @@ -0,0 +1,2188 @@ +{ + "name": "luminary-web", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "luminary-web", + "version": "0.1.0", + "dependencies": { + "@element-plus/icons-vue": "^2.3.1", + "axios": "^1.7.9", + "echarts": "^5.6.0", + "element-plus": "^2.9.4", + "pinia": "^2.3.1", + "vue": "^3.5.13", + "vue-i18n": "^9.14.4", + "vue-router": "^4.5.0" + }, + "devDependencies": { + "@vitejs/plugin-vue": "^5.2.1", + "typescript": "~5.7.3", + "vite": "^6.0.11", + "vue-tsc": "^2.2.0" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.29.7", + "resolved": "https://registry.npmmirror.com/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz", + "integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.29.7", + "resolved": "https://registry.npmmirror.com/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz", + "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.29.7", + "resolved": "https://registry.npmmirror.com/@babel/parser/-/parser-7.29.7.tgz", + "integrity": "sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg==", + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.7" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/types": { + "version": "7.29.7", + "resolved": "https://registry.npmmirror.com/@babel/types/-/types-7.29.7.tgz", + "integrity": "sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA==", + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@ctrl/tinycolor": { + "version": "4.2.0", + "resolved": "https://registry.npmmirror.com/@ctrl/tinycolor/-/tinycolor-4.2.0.tgz", + "integrity": "sha512-kzyuwOAQnXJNLS9PSyrk0CWk35nWJW/zl/6KvnTBMFK65gm7U1/Z5BqjxeapjZCIhQcM/DsrEmcbRwDyXyXK4A==", + "license": "MIT", + "engines": { + "node": ">=14" + } + }, + "node_modules/@element-plus/icons-vue": { + "version": "2.3.2", + "resolved": "https://registry.npmmirror.com/@element-plus/icons-vue/-/icons-vue-2.3.2.tgz", + "integrity": "sha512-OzIuTaIfC8QXEPmJvB4Y4kw34rSXdCJzxcD1kFStBvr8bK6X1zQAYDo0CNMjojnfTqRQCJ0I7prlErcoRiET2A==", + "license": "MIT", + "peerDependencies": { + "vue": "^3.2.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.25.12", + "resolved": "https://registry.npmmirror.com/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz", + "integrity": "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.25.12", + "resolved": "https://registry.npmmirror.com/@esbuild/android-arm/-/android-arm-0.25.12.tgz", + "integrity": "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmmirror.com/@esbuild/android-arm64/-/android-arm64-0.25.12.tgz", + "integrity": "sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmmirror.com/@esbuild/android-x64/-/android-x64-0.25.12.tgz", + "integrity": "sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmmirror.com/@esbuild/darwin-arm64/-/darwin-arm64-0.25.12.tgz", + "integrity": "sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmmirror.com/@esbuild/darwin-x64/-/darwin-x64-0.25.12.tgz", + "integrity": "sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmmirror.com/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.12.tgz", + "integrity": "sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmmirror.com/@esbuild/freebsd-x64/-/freebsd-x64-0.25.12.tgz", + "integrity": "sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.25.12", + "resolved": "https://registry.npmmirror.com/@esbuild/linux-arm/-/linux-arm-0.25.12.tgz", + "integrity": "sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmmirror.com/@esbuild/linux-arm64/-/linux-arm64-0.25.12.tgz", + "integrity": "sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.25.12", + "resolved": "https://registry.npmmirror.com/@esbuild/linux-ia32/-/linux-ia32-0.25.12.tgz", + "integrity": "sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.25.12", + "resolved": "https://registry.npmmirror.com/@esbuild/linux-loong64/-/linux-loong64-0.25.12.tgz", + "integrity": "sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.25.12", + "resolved": "https://registry.npmmirror.com/@esbuild/linux-mips64el/-/linux-mips64el-0.25.12.tgz", + "integrity": "sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.25.12", + "resolved": "https://registry.npmmirror.com/@esbuild/linux-ppc64/-/linux-ppc64-0.25.12.tgz", + "integrity": "sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.25.12", + "resolved": "https://registry.npmmirror.com/@esbuild/linux-riscv64/-/linux-riscv64-0.25.12.tgz", + "integrity": "sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.25.12", + "resolved": "https://registry.npmmirror.com/@esbuild/linux-s390x/-/linux-s390x-0.25.12.tgz", + "integrity": "sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmmirror.com/@esbuild/linux-x64/-/linux-x64-0.25.12.tgz", + "integrity": "sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmmirror.com/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.12.tgz", + "integrity": "sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmmirror.com/@esbuild/netbsd-x64/-/netbsd-x64-0.25.12.tgz", + "integrity": "sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmmirror.com/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.12.tgz", + "integrity": "sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmmirror.com/@esbuild/openbsd-x64/-/openbsd-x64-0.25.12.tgz", + "integrity": "sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmmirror.com/@esbuild/openharmony-arm64/-/openharmony-arm64-0.25.12.tgz", + "integrity": "sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmmirror.com/@esbuild/sunos-x64/-/sunos-x64-0.25.12.tgz", + "integrity": "sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmmirror.com/@esbuild/win32-arm64/-/win32-arm64-0.25.12.tgz", + "integrity": "sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.25.12", + "resolved": "https://registry.npmmirror.com/@esbuild/win32-ia32/-/win32-ia32-0.25.12.tgz", + "integrity": "sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmmirror.com/@esbuild/win32-x64/-/win32-x64-0.25.12.tgz", + "integrity": "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@floating-ui/core": { + "version": "1.7.5", + "resolved": "https://registry.npmmirror.com/@floating-ui/core/-/core-1.7.5.tgz", + "integrity": "sha512-1Ih4WTWyw0+lKyFMcBHGbb5U5FtuHJuujoyyr5zTaWS5EYMeT6Jb2AuDeftsCsEuchO+mM2ij5+q9crhydzLhQ==", + "license": "MIT", + "dependencies": { + "@floating-ui/utils": "^0.2.11" + } + }, + "node_modules/@floating-ui/dom": { + "version": "1.7.6", + "resolved": "https://registry.npmmirror.com/@floating-ui/dom/-/dom-1.7.6.tgz", + "integrity": "sha512-9gZSAI5XM36880PPMm//9dfiEngYoC6Am2izES1FF406YFsjvyBMmeJ2g4SAju3xWwtuynNRFL2s9hgxpLI5SQ==", + "license": "MIT", + "dependencies": { + "@floating-ui/core": "^1.7.5", + "@floating-ui/utils": "^0.2.11" + } + }, + "node_modules/@floating-ui/utils": { + "version": "0.2.11", + "resolved": "https://registry.npmmirror.com/@floating-ui/utils/-/utils-0.2.11.tgz", + "integrity": "sha512-RiB/yIh78pcIxl6lLMG0CgBXAZ2Y0eVHqMPYugu+9U0AeT6YBeiJpf7lbdJNIugFP5SIjwNRgo4DhR1Qxi26Gg==", + "license": "MIT" + }, + "node_modules/@intlify/core-base": { + "version": "9.14.4", + "resolved": "https://registry.npmmirror.com/@intlify/core-base/-/core-base-9.14.4.tgz", + "integrity": "sha512-vtZCt7NqWhKEtHa3SD/322DlgP5uR9MqWxnE0y8Q0tjDs9H5Lxhss+b5wv8rmuXRoHKLESNgw9d+EN9ybBbj9g==", + "license": "MIT", + "dependencies": { + "@intlify/message-compiler": "9.14.4", + "@intlify/shared": "9.14.4" + }, + "engines": { + "node": ">= 16" + }, + "funding": { + "url": "https://github.com/sponsors/kazupon" + } + }, + "node_modules/@intlify/message-compiler": { + "version": "9.14.4", + "resolved": "https://registry.npmmirror.com/@intlify/message-compiler/-/message-compiler-9.14.4.tgz", + "integrity": "sha512-vcyCLiVRN628U38c3PbahrhbbXrckrM9zpy0KZVlDk2Z0OnGwv8uQNNXP3twwGtfLsCf4gu3ci6FMIZnPaqZsw==", + "license": "MIT", + "dependencies": { + "@intlify/shared": "9.14.4", + "source-map-js": "^1.0.2" + }, + "engines": { + "node": ">= 16" + }, + "funding": { + "url": "https://github.com/sponsors/kazupon" + } + }, + "node_modules/@intlify/shared": { + "version": "9.14.4", + "resolved": "https://registry.npmmirror.com/@intlify/shared/-/shared-9.14.4.tgz", + "integrity": "sha512-P9zv6i1WvMc9qDBWvIgKkymjY2ptIiQ065PjDv7z7fDqH3J/HBRBN5IoiR46r/ujRcU7hCuSIZWvCAFCyuOYZA==", + "license": "MIT", + "engines": { + "node": ">= 16" + }, + "funding": { + "url": "https://github.com/sponsors/kazupon" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmmirror.com/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "license": "MIT" + }, + "node_modules/@popperjs/core": { + "name": "@sxzz/popperjs-es", + "version": "2.11.8", + "resolved": "https://registry.npmmirror.com/@sxzz/popperjs-es/-/popperjs-es-2.11.8.tgz", + "integrity": "sha512-wOwESXvvED3S8xBmcPWHs2dUuzrE4XiZeFu7e1hROIJkm02a49N120pmOXxY33sBb6hArItm5W5tcg1cBtV+HQ==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/popperjs" + } + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.62.0", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.62.0.tgz", + "integrity": "sha512-IPIQ55ythEHkfEd9jMEi32OQ7SxURsGA43JI22lj01OLZNt2NUbJX8YUHxkVWyQ6daHPNn0truF5nSj3DQp6YQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.62.0", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.62.0.tgz", + "integrity": "sha512-M6s9cr10MibETyo8JsOkq+Lo1+lU6hcvb1MApnUql5qte/5hMEgzlN8/ReIKNfRV8rrqX50W1BX9zoUhC192RA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.62.0", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.62.0.tgz", + "integrity": "sha512-BqCoMoIbn0keKys+dEAdBa70EtOwV1bEsQCUgU9FdiZmmMge/Zk7LlkYGqbrdHR+Frnt0E1FOanly+rlwvvQzw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.62.0", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.62.0.tgz", + "integrity": "sha512-SIMzST3VFNXDAbeIWDWiFCNM5qncUBDWaEV7NfE7oZbDt2mgfW4MvbKdbYiGOLoM32gbTv608UMd0XktEYSD7w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.62.0", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.62.0.tgz", + "integrity": "sha512-ezjfSQMP7ArdUsbBwbQIfwAlhE84I2iVnzQNCFSveqV42q+BmKlzVpf7mxv5EchLcoWU4y6/heFzVg1F+hodUQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.62.0", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.62.0.tgz", + "integrity": "sha512-9+qTWGW9AZRhnUgwtTwzNwcPlL87ngkeN0LA+q1bADvmY9aNvWaF2TFW8BZgnQPYxpDI7+rMVLivcd4V737TAQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.62.0", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.62.0.tgz", + "integrity": "sha512-T1dMEQhXA/jkJ/jyMIw9IovK8bSUq7A8kLIlvZTb/6YIVsp2zLavr4F3oyllHWo7eIVJRyE5n3tUjQJEbE1IuQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.62.0", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.62.0.tgz", + "integrity": "sha512-2as0LgT7qQpyceQq6VUJYnumUMUrgGQCWIiDIN9DE0/tglsk6o66uCB4f3djRawAltvfCNLyZZrsqbPA6inCsA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.62.0", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.62.0.tgz", + "integrity": "sha512-bVURMg+6eNN9C/yc0aVjooZcwTTtYF4YW3xta5pP0//r3o1V8gXEHXWCndj47w/HhwsFroZrFhR+6uQP5T0n0g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.62.0", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.62.0.tgz", + "integrity": "sha512-Ful8pM/2yYI83PViWdFdpZhdI8HJ5qsXANe5atypbHDf+KIBBDsZsbyy8hbXnULVvW9NsTh5DHwbcBftyLTfiw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.62.0", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.62.0.tgz", + "integrity": "sha512-9Gp/DgrkzfUBmNPVTyPTvay+4xEP7M/clXpj3efXBcm6uTIVIgDg4rqUpqKXvLEuFRVuEpSAOkhgNeecvaZ4Cg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.62.0", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.62.0.tgz", + "integrity": "sha512-m9tsJz54LUXkSYM8+8PG81B9IKK5r+2T0clMq4QrS16xFosufU7firBDAZEsDheDs7wTlP7h3++S7lMsU955HA==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.62.0", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.62.0.tgz", + "integrity": "sha512-3UvJ5PNVU16aJf6M3tFI24pWzAl2/ynfbyRN3ICyQajK1lSkrnVYNnLz3v04J32qKa0FczJc22zeToc0lr2A3w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.62.0", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.62.0.tgz", + "integrity": "sha512-vRWUAbYLGHBZS6Q8Msb2sfnf1fvJf+47t8l/TwOerM2qArzy+IeNMTHrYLHXh95h8MoatPHI5hhSZNs+mGXKPg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.62.0", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.62.0.tgz", + "integrity": "sha512-c00T5SYENHAt86cfW47URaP3Us5vLC/4QO7GYud1G5VNRffCwwCuBspwqYrriuJB+5m0WFzClCn9wed0FBjKvg==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.62.0", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.62.0.tgz", + "integrity": "sha512-krrCDilhXOwFkSkO3Wm9I/f9H0L92XHHwy2fwxjukxIbh0dem8gZqOW5Y8BsHrpJv5qwlRBV+Wl4ZFyRWhUpwg==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.62.0", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.62.0.tgz", + "integrity": "sha512-7pfYFSTc4/rUC/FtAI0Qp6QthDBCIi6/AuP1xYqFk5vanI6KnL5dWKP60OM/05LOsbwTmIcvr6eXC4CJuJ75IA==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.62.0", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.62.0.tgz", + "integrity": "sha512-7SDIalKeIpG0Ifogbbdn58HmSotYMlf23K3dCJEmiVd9Fg36Vmni82iPQec27N3wY4Bvbxftkxz6vSx9OcouTg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.62.0", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.62.0.tgz", + "integrity": "sha512-eRZevouTH2i1HeAVLqJuLnt256krQkGY0TN6WsTmsIhuzbh457HuWDMakKwmi0Cjadux983CoSr8Lim2QhUIFw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.62.0", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.62.0.tgz", + "integrity": "sha512-3oVS7FLGa4U1qcvao9ylGxrjXZyUQqR8UwxEcnUEyPX53O/C/mKDZegNXTdHCP+h3e6ta/f1EN38Yif1mmZHYg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.62.0", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.62.0.tgz", + "integrity": "sha512-yTB9TgfWj5wHe5QgktAgXTLLot1gvEjl1NiPPAUiCs4oPrIWFl5V4nC3GrkNdj9LaAU4s94nVrGbGOCqUpyWsg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.62.0", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.62.0.tgz", + "integrity": "sha512-5LOhoaesY3doG1c+ac/2JtgREpKoJr5bUHH8tKY0V8di7+uSV6BwLs2PlR0/yzefGOkR+wE7ZolZphHCsyG5Rw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.62.0", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.62.0.tgz", + "integrity": "sha512-yYkWHhmbhRTWTnWos5HC4GcPQfjlzzCNbM9e/+GXrLuaBXYA3qSDR9f0Vgufd5S8yX81U8jPKp7ZnAjZFMtRnw==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.62.0", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.62.0.tgz", + "integrity": "sha512-SoTb6lPg25xZlA2ibwQ++ahCCnH+FP0qmEuafMJ4gznZKOlXioKEAeJLgCrqjM98ACziXM9V1amFjICVL4IFoA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.62.0", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.62.0.tgz", + "integrity": "sha512-5L+T1fMX4RIEBoZzT0+sQ0PhTS36NULFmMXtl1TZo44TMAROIMHbZufSOjVWt/Y622BtxgxtaNOokbTDvfsrZA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@types/estree": { + "version": "1.0.9", + "resolved": "https://registry.npmmirror.com/@types/estree/-/estree-1.0.9.tgz", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/lodash": { + "version": "4.17.24", + "resolved": "https://registry.npmmirror.com/@types/lodash/-/lodash-4.17.24.tgz", + "integrity": "sha512-gIW7lQLZbue7lRSWEFql49QJJWThrTFFeIMJdp3eH4tKoxm1OvEPg02rm4wCCSHS0cL3/Fizimb35b7k8atwsQ==", + "license": "MIT" + }, + "node_modules/@types/lodash-es": { + "version": "4.17.12", + "resolved": "https://registry.npmmirror.com/@types/lodash-es/-/lodash-es-4.17.12.tgz", + "integrity": "sha512-0NgftHUcV4v34VhXm8QBSftKVXtbkBG3ViCjs6+eJ5a6y6Mi/jiFGPc1sC7QK+9BFhWrURE3EOggmWaSxL9OzQ==", + "license": "MIT", + "dependencies": { + "@types/lodash": "*" + } + }, + "node_modules/@types/web-bluetooth": { + "version": "0.0.21", + "resolved": "https://registry.npmmirror.com/@types/web-bluetooth/-/web-bluetooth-0.0.21.tgz", + "integrity": "sha512-oIQLCGWtcFZy2JW77j9k8nHzAOpqMHLQejDA48XXMWH6tjCQHz5RCFz1bzsmROyL6PUm+LLnUiI4BCn221inxA==", + "license": "MIT" + }, + "node_modules/@vitejs/plugin-vue": { + "version": "5.2.4", + "resolved": "https://registry.npmmirror.com/@vitejs/plugin-vue/-/plugin-vue-5.2.4.tgz", + "integrity": "sha512-7Yx/SXSOcQq5HiiV3orevHUFn+pmMB4cgbEkDYgnkUWb0WfeQ/wa2yFv6D5ICiCQOVpjA7vYDXrC7AGO8yjDHA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "peerDependencies": { + "vite": "^5.0.0 || ^6.0.0", + "vue": "^3.2.25" + } + }, + "node_modules/@volar/language-core": { + "version": "2.4.15", + "resolved": "https://registry.npmmirror.com/@volar/language-core/-/language-core-2.4.15.tgz", + "integrity": "sha512-3VHw+QZU0ZG9IuQmzT68IyN4hZNd9GchGPhbD9+pa8CVv7rnoOZwo7T8weIbrRmihqy3ATpdfXFnqRrfPVK6CA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@volar/source-map": "2.4.15" + } + }, + "node_modules/@volar/source-map": { + "version": "2.4.15", + "resolved": "https://registry.npmmirror.com/@volar/source-map/-/source-map-2.4.15.tgz", + "integrity": "sha512-CPbMWlUN6hVZJYGcU/GSoHu4EnCHiLaXI9n8c9la6RaI9W5JHX+NqG+GSQcB0JdC2FIBLdZJwGsfKyBB71VlTg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@volar/typescript": { + "version": "2.4.15", + "resolved": "https://registry.npmmirror.com/@volar/typescript/-/typescript-2.4.15.tgz", + "integrity": "sha512-2aZ8i0cqPGjXb4BhkMsPYDkkuc2ZQ6yOpqwAuNwUoncELqoy5fRgOQtLR9gB0g902iS0NAkvpIzs27geVyVdPg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@volar/language-core": "2.4.15", + "path-browserify": "^1.0.1", + "vscode-uri": "^3.0.8" + } + }, + "node_modules/@vue/compiler-core": { + "version": "3.5.38", + "resolved": "https://registry.npmmirror.com/@vue/compiler-core/-/compiler-core-3.5.38.tgz", + "integrity": "sha512-s99aGxWYig9ErHbct27KXEGhrBYlRI6c4MwAgXErOAbX9xiW37/uMa+XUDO69zLz83dng8UUZ70CTOJrLrYrEQ==", + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.7", + "@vue/shared": "3.5.38", + "entities": "^7.0.1", + "estree-walker": "^2.0.2", + "source-map-js": "^1.2.1" + } + }, + "node_modules/@vue/compiler-dom": { + "version": "3.5.38", + "resolved": "https://registry.npmmirror.com/@vue/compiler-dom/-/compiler-dom-3.5.38.tgz", + "integrity": "sha512-JTqp25l8aFfJYF7/KmsXZjAxJz7T+SjmTJLoXVjHtc2BrSgSiW2n9Aem/cWq1OPe68A8JL06B3eVdhlP0H4TVw==", + "license": "MIT", + "dependencies": { + "@vue/compiler-core": "3.5.38", + "@vue/shared": "3.5.38" + } + }, + "node_modules/@vue/compiler-sfc": { + "version": "3.5.38", + "resolved": "https://registry.npmmirror.com/@vue/compiler-sfc/-/compiler-sfc-3.5.38.tgz", + "integrity": "sha512-DuA2GiZawSEW442iw/9+Fkol8hTgb4Ke5KkhmSry65QA7YuyMbIdy8p0XZRMvNwJdgRz307W8g1CSzdvS4nuNg==", + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.7", + "@vue/compiler-core": "3.5.38", + "@vue/compiler-dom": "3.5.38", + "@vue/compiler-ssr": "3.5.38", + "@vue/shared": "3.5.38", + "estree-walker": "^2.0.2", + "magic-string": "^0.30.21", + "postcss": "^8.5.15", + "source-map-js": "^1.2.1" + } + }, + "node_modules/@vue/compiler-ssr": { + "version": "3.5.38", + "resolved": "https://registry.npmmirror.com/@vue/compiler-ssr/-/compiler-ssr-3.5.38.tgz", + "integrity": "sha512-7s+W5Gc42FGxZMcuwl8H5B29T8BJPMdBT7KHFE+BbAuZ/iTEdTtv7z2XiMjiaUUw4w3ZcCEdHs36RuYJ2VA7bA==", + "license": "MIT", + "dependencies": { + "@vue/compiler-dom": "3.5.38", + "@vue/shared": "3.5.38" + } + }, + "node_modules/@vue/compiler-vue2": { + "version": "2.7.16", + "resolved": "https://registry.npmmirror.com/@vue/compiler-vue2/-/compiler-vue2-2.7.16.tgz", + "integrity": "sha512-qYC3Psj9S/mfu9uVi5WvNZIzq+xnXMhOwbTFKKDD7b1lhpnn71jXSFdTQ+WsIEk0ONCd7VV2IMm7ONl6tbQ86A==", + "dev": true, + "license": "MIT", + "dependencies": { + "de-indent": "^1.0.2", + "he": "^1.2.0" + } + }, + "node_modules/@vue/devtools-api": { + "version": "6.6.4", + "resolved": "https://registry.npmmirror.com/@vue/devtools-api/-/devtools-api-6.6.4.tgz", + "integrity": "sha512-sGhTPMuXqZ1rVOk32RylztWkfXTRhuS7vgAKv0zjqk8gbsHkJ7xfFf+jbySxt7tWObEJwyKaHMikV/WGDiQm8g==", + "license": "MIT" + }, + "node_modules/@vue/language-core": { + "version": "2.2.12", + "resolved": "https://registry.npmmirror.com/@vue/language-core/-/language-core-2.2.12.tgz", + "integrity": "sha512-IsGljWbKGU1MZpBPN+BvPAdr55YPkj2nB/TBNGNC32Vy2qLG25DYu/NBN2vNtZqdRbTRjaoYrahLrToim2NanA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@volar/language-core": "2.4.15", + "@vue/compiler-dom": "^3.5.0", + "@vue/compiler-vue2": "^2.7.16", + "@vue/shared": "^3.5.0", + "alien-signals": "^1.0.3", + "minimatch": "^9.0.3", + "muggle-string": "^0.4.1", + "path-browserify": "^1.0.1" + }, + "peerDependencies": { + "typescript": "*" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/@vue/reactivity": { + "version": "3.5.38", + "resolved": "https://registry.npmmirror.com/@vue/reactivity/-/reactivity-3.5.38.tgz", + "integrity": "sha512-pG6LV/NDNRbKizcUjFFLAfjaL8mcv4DmR9avNcUw2gDHBzZneuS2TWCmp633ynzxz9YYKNeEPK2I8Wraqy2HUQ==", + "license": "MIT", + "dependencies": { + "@vue/shared": "3.5.38" + } + }, + "node_modules/@vue/runtime-core": { + "version": "3.5.38", + "resolved": "https://registry.npmmirror.com/@vue/runtime-core/-/runtime-core-3.5.38.tgz", + "integrity": "sha512-iyW8WVfF1CpCXxncZY5Ei6rSd6oZr5DgEom//fUjRBRl56AXPD+s9ATvukRt77ZFTuYlnVA1bxY+dJB94tWVYw==", + "license": "MIT", + "dependencies": { + "@vue/reactivity": "3.5.38", + "@vue/shared": "3.5.38" + } + }, + "node_modules/@vue/runtime-dom": { + "version": "3.5.38", + "resolved": "https://registry.npmmirror.com/@vue/runtime-dom/-/runtime-dom-3.5.38.tgz", + "integrity": "sha512-apX2wt9sdfDshS+a2xueFZLVpt0GkRJZSoPmrW/SA4yzXTznhfcMVW59gr7h4YQeY0vJhdJkk2rsIDwgfFgC5A==", + "license": "MIT", + "dependencies": { + "@vue/reactivity": "3.5.38", + "@vue/runtime-core": "3.5.38", + "@vue/shared": "3.5.38", + "csstype": "^3.2.3" + } + }, + "node_modules/@vue/server-renderer": { + "version": "3.5.38", + "resolved": "https://registry.npmmirror.com/@vue/server-renderer/-/server-renderer-3.5.38.tgz", + "integrity": "sha512-vue8vbf2QlV4quHqzwmJy6dWfmRhP1J8l4wtZg60CL6VoKqcPY2oe7may3+1d9qfpedjK5PRLFqd5k3Isj9mUw==", + "license": "MIT", + "dependencies": { + "@vue/compiler-ssr": "3.5.38", + "@vue/shared": "3.5.38" + }, + "peerDependencies": { + "vue": "3.5.38" + } + }, + "node_modules/@vue/shared": { + "version": "3.5.38", + "resolved": "https://registry.npmmirror.com/@vue/shared/-/shared-3.5.38.tgz", + "integrity": "sha512-FTW0AFZNaK5/mOqvGBwVfUlNLU38TiQn4+DQgIFUnrBBJQ1crMJ82yeGQLV5jyKFsO8yRukpbuP7x+nRbH6aug==", + "license": "MIT" + }, + "node_modules/@vueuse/core": { + "version": "14.3.0", + "resolved": "https://registry.npmmirror.com/@vueuse/core/-/core-14.3.0.tgz", + "integrity": "sha512-aHfz47g0ZhMtTVHmIzMVpJy8ePhhOy68GY5bv110+5DVtZ+W7BsOx+m61UNQqfrWyPztIHIanWa3E2tib3NFIw==", + "license": "MIT", + "dependencies": { + "@types/web-bluetooth": "^0.0.21", + "@vueuse/metadata": "14.3.0", + "@vueuse/shared": "14.3.0" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + }, + "peerDependencies": { + "vue": "^3.5.0" + } + }, + "node_modules/@vueuse/metadata": { + "version": "14.3.0", + "resolved": "https://registry.npmmirror.com/@vueuse/metadata/-/metadata-14.3.0.tgz", + "integrity": "sha512-BwxmbAzwAVF50+MW57GXOUEV61nFBGnlBvrTqj49PqWJu3uw7hdu72ztXeZ33RdZtDY6kO+bfCAE1PCn88Tktw==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, + "node_modules/@vueuse/shared": { + "version": "14.3.0", + "resolved": "https://registry.npmmirror.com/@vueuse/shared/-/shared-14.3.0.tgz", + "integrity": "sha512-bZpge9eSXwa4ToSiqJ7j6KRwhAsneMFoSz3LMWKQDkqimm3D/tbFlrklrs/IOqC8tEcYmXQZJ6N0UrjhBirVCg==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/antfu" + }, + "peerDependencies": { + "vue": "^3.5.0" + } + }, + "node_modules/agent-base": { + "version": "6.0.2", + "resolved": "https://registry.npmmirror.com/agent-base/-/agent-base-6.0.2.tgz", + "integrity": "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==", + "license": "MIT", + "dependencies": { + "debug": "4" + }, + "engines": { + "node": ">= 6.0.0" + } + }, + "node_modules/alien-signals": { + "version": "1.0.13", + "resolved": "https://registry.npmmirror.com/alien-signals/-/alien-signals-1.0.13.tgz", + "integrity": "sha512-OGj9yyTnJEttvzhTUWuscOvtqxq5vrhF7vL9oS0xJ2mK0ItPYP1/y+vCFebfxoEyAz0++1AIwJ5CMr+Fk3nDmg==", + "dev": true, + "license": "MIT" + }, + "node_modules/async-validator": { + "version": "4.2.5", + "resolved": "https://registry.npmmirror.com/async-validator/-/async-validator-4.2.5.tgz", + "integrity": "sha512-7HhHjtERjqlNbZtqNqy2rckN/SpOOlmDliet+lP7k+eKZEjPk3DgyeU9lIXLdeLz0uBbbVp+9Qdow9wJWgwwfg==", + "license": "MIT" + }, + "node_modules/asynckit": { + "version": "0.4.0", + "resolved": "https://registry.npmmirror.com/asynckit/-/asynckit-0.4.0.tgz", + "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", + "license": "MIT" + }, + "node_modules/axios": { + "version": "1.18.0", + "resolved": "https://registry.npmmirror.com/axios/-/axios-1.18.0.tgz", + "integrity": "sha512-E32NzpYKp++W7XRe52rHiXV2ehxmh3wbdgO7MHeFM+vqxLBYHzt0ElkiImtOBxtOmyp0yoC8C6uESVV84Y2/hw==", + "license": "MIT", + "dependencies": { + "follow-redirects": "^1.16.0", + "form-data": "^4.0.5", + "https-proxy-agent": "^5.0.1", + "proxy-from-env": "^2.1.0" + } + }, + "node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmmirror.com/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/brace-expansion": { + "version": "2.1.1", + "resolved": "https://registry.npmmirror.com/brace-expansion/-/brace-expansion-2.1.1.tgz", + "integrity": "sha512-WR1cURNjuvBLMZBMbqM0UoE+WAfdUcEV1ccD8PVBVOI+Z3ND4+SZbN8RsfT2bMuG1qwz5RFvPukSZm5fF2D5eA==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmmirror.com/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/combined-stream": { + "version": "1.0.8", + "resolved": "https://registry.npmmirror.com/combined-stream/-/combined-stream-1.0.8.tgz", + "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", + "license": "MIT", + "dependencies": { + "delayed-stream": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmmirror.com/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "license": "MIT" + }, + "node_modules/dayjs": { + "version": "1.11.21", + "resolved": "https://registry.npmmirror.com/dayjs/-/dayjs-1.11.21.tgz", + "integrity": "sha512-98IT+HOahAisibz/yjKbzuOBwYcjJ7BCLPzARyHiyEBmRz4fatF+KPJszEHXsGYjUG234aH/cOjW1wwTbKUZlA==", + "license": "MIT" + }, + "node_modules/de-indent": { + "version": "1.0.2", + "resolved": "https://registry.npmmirror.com/de-indent/-/de-indent-1.0.2.tgz", + "integrity": "sha512-e/1zu3xH5MQryN2zdVaF0OrdNLUbvWxzMbi+iNA6Bky7l1RoP8a2fIbRocyHclXt/arDrrR6lL3TqFD9pMQTsg==", + "dev": true, + "license": "MIT" + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmmirror.com/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/delayed-stream": { + "version": "1.0.0", + "resolved": "https://registry.npmmirror.com/delayed-stream/-/delayed-stream-1.0.0.tgz", + "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", + "license": "MIT", + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmmirror.com/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/echarts": { + "version": "5.6.0", + "resolved": "https://registry.npmmirror.com/echarts/-/echarts-5.6.0.tgz", + "integrity": "sha512-oTbVTsXfKuEhxftHqL5xprgLoc0k7uScAwtryCgWF6hPYFLRwOUHiFmHGCBKP5NPFNkDVopOieyUqYGH8Fa3kA==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "2.3.0", + "zrender": "5.6.1" + } + }, + "node_modules/element-plus": { + "version": "2.14.2", + "resolved": "https://registry.npmmirror.com/element-plus/-/element-plus-2.14.2.tgz", + "integrity": "sha512-eNH9uP3wQoNqieEIHXiNvIVv+zO5sZDU0CAZq5b0zqSN06DD0/V9xIq1R/qm3rw5k3nBTM1JvpxhCfRbaFLzDQ==", + "license": "MIT", + "dependencies": { + "@ctrl/tinycolor": "^4.2.0", + "@element-plus/icons-vue": "^2.3.2", + "@floating-ui/dom": "^1.7.6", + "@popperjs/core": "npm:@sxzz/popperjs-es@^2.11.8", + "@types/lodash": "^4.17.24", + "@types/lodash-es": "^4.17.12", + "@vueuse/core": "14.3.0", + "async-validator": "^4.2.5", + "dayjs": "^1.11.20", + "lodash": "^4.18.1", + "lodash-es": "^4.18.1", + "lodash-unified": "^1.0.3", + "memoize-one": "^6.0.0", + "normalize-wheel-es": "^1.2.0", + "vue-component-type-helpers": "^3.3.3" + }, + "peerDependencies": { + "vue": "^3.3.7" + } + }, + "node_modules/entities": { + "version": "7.0.1", + "resolved": "https://registry.npmmirror.com/entities/-/entities-7.0.1.tgz", + "integrity": "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmmirror.com/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmmirror.com/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-object-atoms": { + "version": "1.1.2", + "resolved": "https://registry.npmmirror.com/es-object-atoms/-/es-object-atoms-1.1.2.tgz", + "integrity": "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-set-tostringtag": { + "version": "2.1.0", + "resolved": "https://registry.npmmirror.com/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", + "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/esbuild": { + "version": "0.25.12", + "resolved": "https://registry.npmmirror.com/esbuild/-/esbuild-0.25.12.tgz", + "integrity": "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.25.12", + "@esbuild/android-arm": "0.25.12", + "@esbuild/android-arm64": "0.25.12", + "@esbuild/android-x64": "0.25.12", + "@esbuild/darwin-arm64": "0.25.12", + "@esbuild/darwin-x64": "0.25.12", + "@esbuild/freebsd-arm64": "0.25.12", + "@esbuild/freebsd-x64": "0.25.12", + "@esbuild/linux-arm": "0.25.12", + "@esbuild/linux-arm64": "0.25.12", + "@esbuild/linux-ia32": "0.25.12", + "@esbuild/linux-loong64": "0.25.12", + "@esbuild/linux-mips64el": "0.25.12", + "@esbuild/linux-ppc64": "0.25.12", + "@esbuild/linux-riscv64": "0.25.12", + "@esbuild/linux-s390x": "0.25.12", + "@esbuild/linux-x64": "0.25.12", + "@esbuild/netbsd-arm64": "0.25.12", + "@esbuild/netbsd-x64": "0.25.12", + "@esbuild/openbsd-arm64": "0.25.12", + "@esbuild/openbsd-x64": "0.25.12", + "@esbuild/openharmony-arm64": "0.25.12", + "@esbuild/sunos-x64": "0.25.12", + "@esbuild/win32-arm64": "0.25.12", + "@esbuild/win32-ia32": "0.25.12", + "@esbuild/win32-x64": "0.25.12" + } + }, + "node_modules/estree-walker": { + "version": "2.0.2", + "resolved": "https://registry.npmmirror.com/estree-walker/-/estree-walker-2.0.2.tgz", + "integrity": "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==", + "license": "MIT" + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmmirror.com/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/follow-redirects": { + "version": "1.16.0", + "resolved": "https://registry.npmmirror.com/follow-redirects/-/follow-redirects-1.16.0.tgz", + "integrity": "sha512-y5rN/uOsadFT/JfYwhxRS5R7Qce+g3zG97+JrtFZlC9klX/W5hD7iiLzScI4nZqUS7DNUdhPgw4xI8W2LuXlUw==", + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/RubenVerborgh" + } + ], + "license": "MIT", + "engines": { + "node": ">=4.0" + }, + "peerDependenciesMeta": { + "debug": { + "optional": true + } + } + }, + "node_modules/form-data": { + "version": "4.0.6", + "resolved": "https://registry.npmmirror.com/form-data/-/form-data-4.0.6.tgz", + "integrity": "sha512-vKatAh4SlVfgbv+YtmhiRjhEMJsYpsG1Y2rMQtR+SVSbytsSD1YGzDIcrAJmdFec88u/+VoGmxnl+80gL1tRCQ==", + "license": "MIT", + "dependencies": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.8", + "es-set-tostringtag": "^2.1.0", + "hasown": "^2.0.4", + "mime-types": "^2.1.35" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmmirror.com/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmmirror.com/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmmirror.com/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmmirror.com/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmmirror.com/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmmirror.com/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-tostringtag": { + "version": "1.0.2", + "resolved": "https://registry.npmmirror.com/has-tostringtag/-/has-tostringtag-1.0.2.tgz", + "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", + "license": "MIT", + "dependencies": { + "has-symbols": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.4", + "resolved": "https://registry.npmmirror.com/hasown/-/hasown-2.0.4.tgz", + "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==", + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/he": { + "version": "1.2.0", + "resolved": "https://registry.npmmirror.com/he/-/he-1.2.0.tgz", + "integrity": "sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw==", + "dev": true, + "license": "MIT", + "bin": { + "he": "bin/he" + } + }, + "node_modules/https-proxy-agent": { + "version": "5.0.1", + "resolved": "https://registry.npmmirror.com/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz", + "integrity": "sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==", + "license": "MIT", + "dependencies": { + "agent-base": "6", + "debug": "4" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/lodash": { + "version": "4.18.1", + "resolved": "https://registry.npmmirror.com/lodash/-/lodash-4.18.1.tgz", + "integrity": "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==", + "license": "MIT" + }, + "node_modules/lodash-es": { + "version": "4.18.1", + "resolved": "https://registry.npmmirror.com/lodash-es/-/lodash-es-4.18.1.tgz", + "integrity": "sha512-J8xewKD/Gk22OZbhpOVSwcs60zhd95ESDwezOFuA3/099925PdHJ7OFHNTGtajL3AlZkykD32HykiMo+BIBI8A==", + "license": "MIT" + }, + "node_modules/lodash-unified": { + "version": "1.0.3", + "resolved": "https://registry.npmmirror.com/lodash-unified/-/lodash-unified-1.0.3.tgz", + "integrity": "sha512-WK9qSozxXOD7ZJQlpSqOT+om2ZfcT4yO+03FuzAHD0wF6S0l0090LRPDx3vhTTLZ8cFKpBn+IOcVXK6qOcIlfQ==", + "license": "MIT", + "peerDependencies": { + "@types/lodash-es": "*", + "lodash": "*", + "lodash-es": "*" + } + }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmmirror.com/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmmirror.com/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/memoize-one": { + "version": "6.0.0", + "resolved": "https://registry.npmmirror.com/memoize-one/-/memoize-one-6.0.0.tgz", + "integrity": "sha512-rkpe71W0N0c0Xz6QD0eJETuWAJGnJ9afsl1srmwPrI+yBCkge5EycXXbYRyvL29zZVUWQCY7InPRCv3GDXuZNw==", + "license": "MIT" + }, + "node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmmirror.com/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmmirror.com/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/minimatch": { + "version": "9.0.9", + "resolved": "https://registry.npmmirror.com/minimatch/-/minimatch-9.0.9.tgz", + "integrity": "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.2" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmmirror.com/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/muggle-string": { + "version": "0.4.1", + "resolved": "https://registry.npmmirror.com/muggle-string/-/muggle-string-0.4.1.tgz", + "integrity": "sha512-VNTrAak/KhO2i8dqqnqnAHOa3cYBwXEZe9h+D5h/1ZqFSTEFHdM65lR7RoIqq3tBBYavsOXV84NoHXZ0AkPyqQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/nanoid": { + "version": "3.3.12", + "resolved": "https://registry.npmmirror.com/nanoid/-/nanoid-3.3.12.tgz", + "integrity": "sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/normalize-wheel-es": { + "version": "1.2.0", + "resolved": "https://registry.npmmirror.com/normalize-wheel-es/-/normalize-wheel-es-1.2.0.tgz", + "integrity": "sha512-Wj7+EJQ8mSuXr2iWfnujrimU35R2W4FAErEyTmJoJ7ucwTn2hOUSsRehMb5RSYkxXGTM7Y9QpvPmp++w5ftoJw==", + "license": "BSD-3-Clause" + }, + "node_modules/path-browserify": { + "version": "1.0.1", + "resolved": "https://registry.npmmirror.com/path-browserify/-/path-browserify-1.0.1.tgz", + "integrity": "sha512-b7uo2UCUOYZcnF/3ID0lulOJi/bafxa1xPe7ZPsammBSpjSWQkjNxlt635YGS2MiR9GjvuXCtz2emr3jbsz98g==", + "dev": true, + "license": "MIT" + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmmirror.com/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.4", + "resolved": "https://registry.npmmirror.com/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/pinia": { + "version": "2.3.1", + "resolved": "https://registry.npmmirror.com/pinia/-/pinia-2.3.1.tgz", + "integrity": "sha512-khUlZSwt9xXCaTbbxFYBKDc/bWAGWJjOgvxETwkTN7KRm66EeT1ZdZj6i2ceh9sP2Pzqsbc704r2yngBrxBVug==", + "license": "MIT", + "dependencies": { + "@vue/devtools-api": "^6.6.3", + "vue-demi": "^0.14.10" + }, + "funding": { + "url": "https://github.com/sponsors/posva" + }, + "peerDependencies": { + "typescript": ">=4.4.4", + "vue": "^2.7.0 || ^3.5.11" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/postcss": { + "version": "8.5.15", + "resolved": "https://registry.npmmirror.com/postcss/-/postcss-8.5.15.tgz", + "integrity": "sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.12", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/proxy-from-env": { + "version": "2.1.0", + "resolved": "https://registry.npmmirror.com/proxy-from-env/-/proxy-from-env-2.1.0.tgz", + "integrity": "sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA==", + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "node_modules/rollup": { + "version": "4.62.0", + "resolved": "https://registry.npmmirror.com/rollup/-/rollup-4.62.0.tgz", + "integrity": "sha512-nc72Wgq62I7rtDV4izT5/aaS0zxy3kttkinf9586ApknY3jZO9NYsmtc24fUckA0X7Q2v+ML4a15pdUlV5V/jA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.9" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@rollup/rollup-android-arm-eabi": "4.62.0", + "@rollup/rollup-android-arm64": "4.62.0", + "@rollup/rollup-darwin-arm64": "4.62.0", + "@rollup/rollup-darwin-x64": "4.62.0", + "@rollup/rollup-freebsd-arm64": "4.62.0", + "@rollup/rollup-freebsd-x64": "4.62.0", + "@rollup/rollup-linux-arm-gnueabihf": "4.62.0", + "@rollup/rollup-linux-arm-musleabihf": "4.62.0", + "@rollup/rollup-linux-arm64-gnu": "4.62.0", + "@rollup/rollup-linux-arm64-musl": "4.62.0", + "@rollup/rollup-linux-loong64-gnu": "4.62.0", + "@rollup/rollup-linux-loong64-musl": "4.62.0", + "@rollup/rollup-linux-ppc64-gnu": "4.62.0", + "@rollup/rollup-linux-ppc64-musl": "4.62.0", + "@rollup/rollup-linux-riscv64-gnu": "4.62.0", + "@rollup/rollup-linux-riscv64-musl": "4.62.0", + "@rollup/rollup-linux-s390x-gnu": "4.62.0", + "@rollup/rollup-linux-x64-gnu": "4.62.0", + "@rollup/rollup-linux-x64-musl": "4.62.0", + "@rollup/rollup-openbsd-x64": "4.62.0", + "@rollup/rollup-openharmony-arm64": "4.62.0", + "@rollup/rollup-win32-arm64-msvc": "4.62.0", + "@rollup/rollup-win32-ia32-msvc": "4.62.0", + "@rollup/rollup-win32-x64-gnu": "4.62.0", + "@rollup/rollup-win32-x64-msvc": "4.62.0", + "fsevents": "~2.3.2" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmmirror.com/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.17", + "resolved": "https://registry.npmmirror.com/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/tslib": { + "version": "2.3.0", + "resolved": "https://registry.npmmirror.com/tslib/-/tslib-2.3.0.tgz", + "integrity": "sha512-N82ooyxVNm6h1riLCoyS9e3fuJ3AMG2zIZs2Gd1ATcSFjSA23Q0fzjjZeh0jbJvWVDZ0cJT8yaNNaaXHzueNjg==", + "license": "0BSD" + }, + "node_modules/typescript": { + "version": "5.7.3", + "resolved": "https://registry.npmmirror.com/typescript/-/typescript-5.7.3.tgz", + "integrity": "sha512-84MVSjMEHP+FQRPy3pX9sTVV/INIex71s9TL2Gm5FG/WG1SqXeKyZ0k7/blY/4FdOzI12CBy1vGc4og/eus0fw==", + "devOptional": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/vite": { + "version": "6.4.3", + "resolved": "https://registry.npmmirror.com/vite/-/vite-6.4.3.tgz", + "integrity": "sha512-NTKlcQjlAK7MlQoyb6LgaqHc8sso/pVyUJYWMws3jg21uTJw/LddqIFPcPqP6PzpgbIcZyKI85sFE4HBrQDA8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.25.0", + "fdir": "^6.4.4", + "picomatch": "^4.0.2", + "postcss": "^8.5.3", + "rollup": "^4.34.9", + "tinyglobby": "^0.2.13" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^18.0.0 || ^20.0.0 || >=22.0.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", + "jiti": ">=1.21.0", + "less": "*", + "lightningcss": "^1.21.0", + "sass": "*", + "sass-embedded": "*", + "stylus": "*", + "sugarss": "*", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/vscode-uri": { + "version": "3.1.0", + "resolved": "https://registry.npmmirror.com/vscode-uri/-/vscode-uri-3.1.0.tgz", + "integrity": "sha512-/BpdSx+yCQGnCvecbyXdxHDkuk55/G3xwnC0GqY4gmQ3j+A+g8kzzgB4Nk/SINjqn6+waqw3EgbVF2QKExkRxQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/vue": { + "version": "3.5.38", + "resolved": "https://registry.npmmirror.com/vue/-/vue-3.5.38.tgz", + "integrity": "sha512-vAMKHfImQlYSy0C+PBue4s3ERZ2xGKfgZg5GXAsLInq1dyh2H78ILVP5sK0KPFPVW4kv+OGCIvBEondcjpZp7A==", + "license": "MIT", + "dependencies": { + "@vue/compiler-dom": "3.5.38", + "@vue/compiler-sfc": "3.5.38", + "@vue/runtime-dom": "3.5.38", + "@vue/server-renderer": "3.5.38", + "@vue/shared": "3.5.38" + }, + "peerDependencies": { + "typescript": "*" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/vue-component-type-helpers": { + "version": "3.3.5", + "resolved": "https://registry.npmmirror.com/vue-component-type-helpers/-/vue-component-type-helpers-3.3.5.tgz", + "integrity": "sha512-Fe1jyPJoUGpJOYKOri44jduR7My4yYINOMJISuMAbmrs+L5LbIDUc8NTWZYY3EJLK0yPLuCmcd5zoCsE4k2/KA==", + "license": "MIT" + }, + "node_modules/vue-demi": { + "version": "0.14.10", + "resolved": "https://registry.npmmirror.com/vue-demi/-/vue-demi-0.14.10.tgz", + "integrity": "sha512-nMZBOwuzabUO0nLgIcc6rycZEebF6eeUfaiQx9+WSk8e29IbLvPU9feI6tqW4kTo3hvoYAJkMh8n8D0fuISphg==", + "hasInstallScript": true, + "license": "MIT", + "bin": { + "vue-demi-fix": "bin/vue-demi-fix.js", + "vue-demi-switch": "bin/vue-demi-switch.js" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + }, + "peerDependencies": { + "@vue/composition-api": "^1.0.0-rc.1", + "vue": "^3.0.0-0 || ^2.6.0" + }, + "peerDependenciesMeta": { + "@vue/composition-api": { + "optional": true + } + } + }, + "node_modules/vue-i18n": { + "version": "9.14.4", + "resolved": "https://registry.npmmirror.com/vue-i18n/-/vue-i18n-9.14.4.tgz", + "integrity": "sha512-B934C8yUyWLT0EMud3DySrwSUJI7ZNiWYsEEz2gknTthqKiG4dzWE/WSa8AzCuSQzwBEv4HtG1jZDhgzPfWSKQ==", + "license": "MIT", + "dependencies": { + "@intlify/core-base": "9.14.4", + "@intlify/shared": "9.14.4", + "@vue/devtools-api": "^6.5.0" + }, + "engines": { + "node": ">= 16" + }, + "funding": { + "url": "https://github.com/sponsors/kazupon" + }, + "peerDependencies": { + "vue": "^3.0.0" + } + }, + "node_modules/vue-router": { + "version": "4.6.4", + "resolved": "https://registry.npmmirror.com/vue-router/-/vue-router-4.6.4.tgz", + "integrity": "sha512-Hz9q5sa33Yhduglwz6g9skT8OBPii+4bFn88w6J+J4MfEo4KRRpmiNG/hHHkdbRFlLBOqxN8y8gf2Fb0MTUgVg==", + "license": "MIT", + "dependencies": { + "@vue/devtools-api": "^6.6.4" + }, + "funding": { + "url": "https://github.com/sponsors/posva" + }, + "peerDependencies": { + "vue": "^3.5.0" + } + }, + "node_modules/vue-tsc": { + "version": "2.2.12", + "resolved": "https://registry.npmmirror.com/vue-tsc/-/vue-tsc-2.2.12.tgz", + "integrity": "sha512-P7OP77b2h/Pmk+lZdJ0YWs+5tJ6J2+uOQPo7tlBnY44QqQSPYvS0qVT4wqDJgwrZaLe47etJLLQRFia71GYITw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@volar/typescript": "2.4.15", + "@vue/language-core": "2.2.12" + }, + "bin": { + "vue-tsc": "bin/vue-tsc.js" + }, + "peerDependencies": { + "typescript": ">=5.0.0" + } + }, + "node_modules/zrender": { + "version": "5.6.1", + "resolved": "https://registry.npmmirror.com/zrender/-/zrender-5.6.1.tgz", + "integrity": "sha512-OFXkDJKcrlx5su2XbzJvj/34Q3m6PvyCZkVPHGYpcCJ52ek4U/ymZyfuV1nKE23AyBJ51E/6Yr0mhZ7xGTO4ag==", + "license": "BSD-3-Clause", + "dependencies": { + "tslib": "2.3.0" + } + } + } +} diff --git a/web/package.json b/web/package.json new file mode 100644 index 0000000..34b4226 --- /dev/null +++ b/web/package.json @@ -0,0 +1,27 @@ +{ + "name": "luminary-web", + "private": true, + "version": "0.1.0", + "type": "module", + "scripts": { + "dev": "vite", + "build": "vite build", + "preview": "vite preview" + }, + "dependencies": { + "@element-plus/icons-vue": "^2.3.1", + "axios": "^1.7.9", + "echarts": "^5.6.0", + "element-plus": "^2.9.4", + "pinia": "^2.3.1", + "vue": "^3.5.13", + "vue-i18n": "^9.14.4", + "vue-router": "^4.5.0" + }, + "devDependencies": { + "@vitejs/plugin-vue": "^5.2.1", + "typescript": "~5.7.3", + "vite": "^6.0.11", + "vue-tsc": "^2.2.0" + } +} diff --git a/web/public/favicon.svg b/web/public/favicon.svg new file mode 100644 index 0000000..6e67969 --- /dev/null +++ b/web/public/favicon.svg @@ -0,0 +1,16 @@ + + + + + + + + + + diff --git a/web/src/App.vue b/web/src/App.vue new file mode 100644 index 0000000..7ede937 --- /dev/null +++ b/web/src/App.vue @@ -0,0 +1,16 @@ + + + diff --git a/web/src/api/client.ts b/web/src/api/client.ts new file mode 100644 index 0000000..267dd57 --- /dev/null +++ b/web/src/api/client.ts @@ -0,0 +1,113 @@ +import axios from 'axios' + +const api = axios.create({ + baseURL: '/api/admin', + withCredentials: true, +}) + +api.interceptors.response.use( + (r) => r, + (err) => { + if (err.response?.status === 401 && !window.location.pathname.startsWith('/login')) { + window.location.href = '/login' + } + return Promise.reject(err) + }, +) + +export default api + +export interface Provider { + id: number + name: string + type: string + category: string + base_url: string + allowed_endpoints_json: string + endpoint_paths_json: string + status: string + enabled?: boolean + keys?: ProviderKey[] + models?: ModelEntry[] +} + +export interface ProviderKey { + id: number + provider_id: number + name: string + weight: number + models_json: string + enabled: boolean + max_requests_per_day: number + max_tokens_per_day: number + requests_today: number + tokens_today: number +} + +export interface ModelEntry { + id: number + provider_id: number + model_id: string + alias?: string + display_name: string + workflow_type?: string + workflow_json?: string + input_binding_json?: string + version_hash?: string + enabled: boolean + created_at?: string +} + +export interface IngressKey { + id: number + name: string + prefix: string + enabled: boolean + budget_limit: number + budget_used: number + rate_limit_rpm: number + rate_limit_tpm: number + allowed_providers_json: string + allowed_models_json: string + request_count: number + token_count: number +} + +export interface IPRule { + id: number + cidr: string + type: 'allow' | 'deny' + scope: 'admin' | 'proxy' | 'all' + enabled: boolean +} + +export interface DashboardOverview { + total_requests_24h: number + total_tokens_24h: number + error_rate: number + active_ingress_keys: number + providers: number +} + +export interface ProviderModelUsage { + provider_id: number + provider_name: string + model: string + request_count: number + token_count: number + cost: number +} + +export interface ClientIPUsage { + client_ip: string + request_count: number + token_count: number + cost: number +} + +export interface RequestTrendBucket { + hour: string + requests: number + errors: number + tokens: number +} diff --git a/web/src/components/AppCard.vue b/web/src/components/AppCard.vue new file mode 100644 index 0000000..04669e1 --- /dev/null +++ b/web/src/components/AppCard.vue @@ -0,0 +1,19 @@ + + + diff --git a/web/src/components/BrandIcon.vue b/web/src/components/BrandIcon.vue new file mode 100644 index 0000000..daf9f89 --- /dev/null +++ b/web/src/components/BrandIcon.vue @@ -0,0 +1,30 @@ + + + diff --git a/web/src/components/ChartBox.vue b/web/src/components/ChartBox.vue new file mode 100644 index 0000000..b44e4c6 --- /dev/null +++ b/web/src/components/ChartBox.vue @@ -0,0 +1,47 @@ + + + diff --git a/web/src/components/LocaleSwitcher.vue b/web/src/components/LocaleSwitcher.vue new file mode 100644 index 0000000..d1d4a2e --- /dev/null +++ b/web/src/components/LocaleSwitcher.vue @@ -0,0 +1,22 @@ + + + diff --git a/web/src/components/PageHeader.vue b/web/src/components/PageHeader.vue new file mode 100644 index 0000000..34a0b45 --- /dev/null +++ b/web/src/components/PageHeader.vue @@ -0,0 +1,18 @@ + + + diff --git a/web/src/components/StatCard.vue b/web/src/components/StatCard.vue new file mode 100644 index 0000000..c0a24a0 --- /dev/null +++ b/web/src/components/StatCard.vue @@ -0,0 +1,13 @@ + + + diff --git a/web/src/components/WorkflowModelDialog.vue b/web/src/components/WorkflowModelDialog.vue new file mode 100644 index 0000000..f59eac3 --- /dev/null +++ b/web/src/components/WorkflowModelDialog.vue @@ -0,0 +1,187 @@ + + + diff --git a/web/src/i18n/index.ts b/web/src/i18n/index.ts new file mode 100644 index 0000000..eafc0ad --- /dev/null +++ b/web/src/i18n/index.ts @@ -0,0 +1,30 @@ +import { createI18n } from 'vue-i18n' +import zhCN from './locales/zh-CN' +import en from './locales/en' + +const STORAGE_KEY = 'luminary-locale' + +function detectLocale(): string { + const saved = localStorage.getItem(STORAGE_KEY) + if (saved === 'zh-CN' || saved === 'en') return saved + const lang = navigator.language.toLowerCase() + return lang.startsWith('zh') ? 'zh-CN' : 'en' +} + +export const i18n = createI18n({ + legacy: false, + locale: detectLocale(), + fallbackLocale: 'zh-CN', + messages: { + 'zh-CN': zhCN, + en, + }, +}) + +export function setLocale(locale: 'zh-CN' | 'en') { + i18n.global.locale.value = locale + localStorage.setItem(STORAGE_KEY, locale) + document.documentElement.lang = locale === 'zh-CN' ? 'zh-CN' : 'en' +} + +document.documentElement.lang = i18n.global.locale.value === 'zh-CN' ? 'zh-CN' : 'en' diff --git a/web/src/i18n/locales/en.ts b/web/src/i18n/locales/en.ts new file mode 100644 index 0000000..9039eba --- /dev/null +++ b/web/src/i18n/locales/en.ts @@ -0,0 +1,244 @@ +export default { + common: { + brand: 'Luminary', + cancel: 'Cancel', + confirm: 'Confirm', + save: 'Save', + edit: 'Edit', + create: 'Create', + add: 'Add', + delete: 'Delete', + refresh: 'Refresh', + query: 'Search', + copy: 'Copy', + actions: 'Actions', + name: 'Name', + status: 'Status', + type: 'Type', + enabled: 'Enabled', + disabled: 'Disabled', + yes: 'Yes', + no: 'No', + all: 'All', + loading: 'Loading...', + empty: '(empty)', + dash: '—', + infinity: '∞', + confirmDelete: 'Delete this item?', + auto: 'Auto', + detail: 'Details', + model: 'Model', + requests: 'Requests', + tokens: 'Tokens', + cost: 'Cost ($)', + latencyMs: 'Latency (ms)', + latency: 'Latency', + stream: 'Stream', + error: 'Error', + time: 'Time', + username: 'Username', + password: 'Password', + weight: 'Weight', + priority: 'Priority', + category: 'Category', + budget: 'Budget', + soon: 'Coming soon', + whitelistMore: '{items} and {count} more', + language: 'Language', + zh: '中文', + en: 'English', + }, + nav: { + dashboard: 'Dashboard', + providers: 'Egress', + ingress: 'Ingress', + logs: 'Request Logs', + ipRules: 'IP Rules', + settings: 'Settings', + security: 'Security', + logout: 'Sign out', + }, + login: { + subtitle: 'AI Gateway Console', + title: 'Sign in', + username: 'Username', + passwordPlaceholder: 'Enter password', + submit: 'Sign in', + footer: 'Luminary Gateway — unified AI egress & ingress management', + success: 'Signed in successfully', + failed: 'Sign in failed. Check username and password.', + rateLimited: 'Too many attempts. Please try again later.', + rateLimitedSec: 'Too many attempts. Try again in {sec} seconds.', + ipBlocked: 'Your IP is not allowed to access the admin console. Check IP rules or contact an administrator.', + }, + dashboard: { + title: 'Dashboard', + description: 'Real-time gateway requests, error rate, and provider health', + requests24h: '24h Requests', + tokens24h: '24h Tokens', + errorRate: 'Error Rate', + activeIngressKeys: 'Active Ingress Keys', + requestTrend: '24h Request Trend', + tokenTrend: '24h Token Trend', + seriesRequests: 'Requests', + seriesErrors: 'Errors', + seriesTokens: 'Tokens', + providerHealth: 'Provider Health', + ingressTop5: 'Top 5 Ingress Keys', + providerModelUsage: 'Provider + Model Usage (24h)', + clientIpUsage: 'Client IP Usage (24h)', + clientIp: 'Client IP', + requestCount: 'Requests', + }, + providers: { + title: 'Egress Providers', + description: 'Configure upstream providers, API paths, and keys', + addProvider: 'Add Provider', + protocolType: 'Protocol', + baseUrl: 'Base URL', + baseUrlPlaceholder: 'Leave empty for default', + enabledEndpoints: 'Enabled Endpoints', + models: 'Models', + modelsEmpty: 'No models yet. Click Sync Models to pull from upstream.', + displayName: 'Display Name', + syncedAt: 'Synced At', + addDialogTitle: 'Add Provider', + endpointConfig: 'Endpoint Config', + endpointConfigTitle: 'Endpoint Config — {name}', + endpointDivider: 'Endpoints & Paths', + endpointEditDivider: 'Enabled Endpoints & Path Overrides', + pathHint: 'Path can be relative (based on Base URL) or a full http(s) URL', + keysTitle: 'Keys — {name}', + addKey: 'Add Key', + addKeyDialogTitle: 'Add Provider Key', + apiKey: 'API Key', + modelWhitelist: 'Model Whitelist', + modelWhitelistPlaceholder: '["*"] or gpt-4o-mini', + maxRequestsPerDay: 'Max Requests / Day', + maxTokensPerDay: 'Max Tokens / Day', + requestsToday: 'Requests Today', + modelsTitle: 'Models — {name}', + syncModels: 'Sync Models', + addModel: 'Add Model', + addModelDialogTitle: 'Add Model', + modelId: 'Model ID', + modelAlias: 'Ingress alias', + modelAliasPlaceholder: 'Optional; ingress API uses this name', + modelAliasHint: 'Leave empty to use upstream Model ID at ingress', + upstreamModelId: 'Upstream Model ID', + editModelDialogTitle: 'Edit model', + providerNameExists: 'Provider name already exists', + modelAliasExists: 'Model alias already exists', + modelIdRequired: 'Model ID is required', + modelAdded: 'Model added', + modelDeleted: 'Model deleted', + confirmDeleteModel: 'Delete this model?', + modelsSummary: '{total} models, {enabled} enabled', + modelsAction: 'Models', + endpointsAction: 'Endpoints', + keysAction: 'Keys', + modelNotLoaded: 'Model data not loaded. Refresh and try again.', + modelEnabled: 'Model enabled', + modelDisabled: 'Model disabled', + modelNotFound: 'Model not found. Sync models and try again.', + updateFailed: 'Update failed', + createSuccess: 'Created successfully', + saved: 'Saved', + deleted: 'Deleted', + syncedCount: 'Synced {count} models', + syncFailed: 'Sync failed', + keyAdded: 'Key added', + }, + ingress: { + title: 'Ingress Keys', + description: 'Create virtual keys with budget, rate limits, and routing', + createKey: 'Create Ingress Key', + keyPrefix: 'Key Prefix', + providerWhitelist: 'Provider Whitelist', + modelWhitelist: 'Model Whitelist', + budgetLimit: 'Budget Limit', + rpmLimit: 'RPM Limit', + tpmLimit: 'TPM Limit', + whitelistAction: 'Whitelist', + routingAction: 'Routing', + createDialogTitle: 'Create Ingress Key', + providerWhitelistPlaceholder: 'Empty means all providers', + modelWhitelistPlaceholder: 'Empty means all models', + whitelistHint: 'Empty selection allows all', + modelWhitelistHint: 'Empty allows all; sync models in Egress first', + editWhitelistTitle: 'Edit Whitelist', + plainKeyTitle: 'Save your key (shown once)', + plainKeyWarning: 'Copy now — the full key cannot be viewed again after closing', + routingTitle: 'Routing Rules — {name}', + addRule: 'Add Rule', + modelPattern: 'Model Pattern', + modelPatternPlaceholder: 'gpt-4o* or claude-3-5-sonnet', + providerId: 'Provider', + providerIdPlaceholder: 'Select provider', + keyIdOptional: 'Provider key', + keyIdPlaceholder: 'Leave empty for auto load balancing', + providerRequired: 'Please select a provider', + addRuleTitle: 'Add Routing Rule', + whitelistUpdated: 'Whitelist updated', + confirmDeleteKey: 'Delete this key?', + copied: 'Copied', + ruleAdded: 'Rule added', + }, + logs: { + title: 'Request Logs', + description: 'Latest 500 records including inference and admin login', + ingress: 'Ingress', + endpoint: 'Endpoint', + modelOrUser: 'Model / User', + modelOrUserPlaceholder: 'Model / username', + clientIpPlaceholder: 'Client IP', + statusSuccess: 'Success', + statusError: 'Error', + endpointAdminLogin: 'Admin Login', + pagerHint: '{total} total, up to {max} retained', + detailTitle: 'Request Details', + requestBody: 'Request Body', + responseBody: 'Response Body', + predictionStatus: 'Prediction status', + }, + ipRules: { + title: 'IP Rules', + description: 'Whitelist / blacklist for admin console and inference API', + addRule: 'Add IP Rule', + cidr: 'CIDR / IP', + cidrPlaceholder: '192.168.1.0/24 or 127.0.0.1', + scope: 'Scope', + typeAllow: 'Allow', + typeDeny: 'Deny', + scopeProxy: 'Inference API', + scopeAdmin: 'Admin Console', + scopeAll: 'All (Admin + API)', + allowWarning: 'Allow rules restrict admin access. Ensure your current IP is included.', + added: 'Added', + }, + settings: { + title: 'Settings', + description: 'All runtime parameters are managed here — no config file required', + securitySession: 'Security & Session', + gatewayMonitor: 'Gateway & Monitoring', + changePassword: 'Change Admin Password', + encryptionKey: 'Encryption Key', + encryptionKeyPlaceholder: 'Leave empty to keep unchanged; must match old key to decrypt existing provider keys', + encryptionKeyCurrent: 'Current: {hint}', + sessionTtl: 'Session TTL (seconds)', + loginMaxAttempts: 'Max Login Attempts', + loginLockout: 'Login Lockout (seconds)', + trustedProxies: 'Trusted Proxy IPs / CIDRs', + trustedProxiesPlaceholder: 'One per line, e.g. 127.0.0.1 or 10.0.0.0/8', + usageFlush: 'Usage Flush Interval (seconds)', + healthCheck: 'Health Check Interval (seconds)', + gatewayMaxRetries: 'Gateway Max Retries', + retryBackoff: 'Retry Backoff (ms)', + saveSettings: 'Save Settings', + oldPassword: 'Current Password', + newPassword: 'New Password', + savePassword: 'Save Password', + settingsSaved: 'Settings saved', + passwordUpdated: 'Password updated', + }, +} diff --git a/web/src/i18n/locales/zh-CN.ts b/web/src/i18n/locales/zh-CN.ts new file mode 100644 index 0000000..90aaadc --- /dev/null +++ b/web/src/i18n/locales/zh-CN.ts @@ -0,0 +1,265 @@ +export default { + common: { + brand: 'Luminary', + cancel: '取消', + confirm: '确认', + save: '保存', + edit: '编辑', + create: '创建', + add: '添加', + delete: '删除', + refresh: '刷新', + query: '查询', + copy: '复制', + actions: '操作', + name: '名称', + status: '状态', + type: '类型', + enabled: '启用', + disabled: '禁用', + yes: '是', + no: '否', + all: '全部', + loading: '加载中...', + empty: '(空)', + dash: '—', + infinity: '∞', + confirmDelete: '确认删除?', + auto: '自动', + detail: '详情', + model: '模型', + requests: '请求', + tokens: 'Token', + cost: '费用 ($)', + latencyMs: '延迟 (ms)', + latency: '延迟', + stream: '流式', + error: '错误', + time: '时间', + username: '用户名', + password: '密码', + weight: '权重', + priority: '优先级', + category: '分类', + budget: '预算', + soon: '即将支持', + whitelistMore: '{items} 等 {count} 项', + language: '语言', + zh: '中文', + en: 'English', + }, + nav: { + dashboard: '看板监控', + providers: '出口管理', + ingress: '入口管理', + logs: '请求日志', + ipRules: 'IP 规则', + settings: '系统设置', + security: '安全', + logout: '退出登录', + }, + login: { + subtitle: 'AI 网关管理台', + title: '登录', + username: '用户名', + passwordPlaceholder: '请输入密码', + submit: '登录', + footer: 'Luminary Gateway — 统一 AI 出口与入口管理', + success: '登录成功', + failed: '登录失败,请检查用户名和密码', + rateLimited: '登录尝试过多,请稍后再试', + rateLimitedSec: '登录尝试过多,请 {sec} 秒后再试', + ipBlocked: '当前 IP 不在白名单内,无法访问管理台。请检查 IP 规则或联系管理员。', + }, + dashboard: { + title: '看板监控', + description: '实时查看网关请求量、错误率与 Provider 健康状态', + requests24h: '24h 请求量', + tokens24h: '24h Token', + errorRate: '错误率', + activeIngressKeys: '活跃入口密钥', + requestTrend: '24h 请求趋势', + tokenTrend: '24h Token 趋势', + seriesRequests: '请求量', + seriesErrors: '错误数', + seriesTokens: 'Token', + providerHealth: 'Provider 健康状态', + ingressTop5: '入口密钥用量 Top 5', + providerModelUsage: 'Provider + 模型用量 (24h)', + clientIpUsage: '客户端 IP 用量 (24h)', + clientIp: '客户端 IP', + requestCount: '请求数', + }, + providers: { + title: '出口管理', + description: '配置上游 Provider、接口路径与 API 密钥', + addProvider: '添加 Provider', + protocolType: '协议类型', + baseUrl: 'Base URL', + baseUrlPlaceholder: '留空使用默认', + enabledEndpoints: '已启用接口', + models: '模型', + modelsEmpty: '暂无模型,请点击「同步模型」从上游拉取', + displayName: '显示名称', + syncedAt: '同步时间', + addDialogTitle: '添加 Provider', + endpointConfig: '接口配置', + endpointConfigTitle: '接口配置 - {name}', + endpointDivider: '接口与 Path 配置', + endpointEditDivider: '启用接口与 Path 覆盖', + pathHint: 'Path 可为相对路径(基于 Base URL)或完整 URL(http/https 开头)', + keysTitle: '密钥管理 - {name}', + addKey: '添加密钥', + addKeyDialogTitle: '添加 Provider 密钥', + apiKey: 'API Key', + modelWhitelist: '模型白名单', + modelWhitelistPlaceholder: '["*"] 或 gpt-4o-mini', + maxRequestsPerDay: '日请求上限', + maxTokensPerDay: '日 Token 上限', + requestsToday: '今日请求', + modelsTitle: '模型列表 - {name}', + syncModels: '同步模型', + modelsSummary: '共 {total} 个模型,已启用 {enabled} 个', + modelsAction: '模型', + endpointsAction: '接口配置', + keysAction: '密钥', + modelNotLoaded: '模型数据未加载,请刷新页面后重试', + modelEnabled: '模型已启用', + modelDisabled: '模型已禁用', + modelNotFound: '模型不存在,请同步模型后重试', + updateFailed: '更新失败', + createSuccess: '创建成功', + saved: '已保存', + deleted: '已删除', + syncedCount: '同步 {count} 个模型', + syncFailed: '同步失败', + keyAdded: '密钥已添加', + providerEnabled: '出口已启用', + providerDisabled: '出口已暂停', + addWorkflowModel: 'Add Workflow Model', + addModel: '添加模型', + addModelDialogTitle: '手动添加模型', + modelAdded: '模型已添加', + modelDeleted: '模型已删除', + confirmDeleteModel: '确认删除此模型?', + workflowDialogTitle: '注册 Workflow 模型', + modelId: 'Model ID', + modelAlias: '入口别名', + modelAliasPlaceholder: '可选,入口 API 使用此名称', + modelAliasHint: '留空则入口使用上游 Model ID', + upstreamModelId: '上游 Model ID', + editModelDialogTitle: '编辑模型', + providerNameExists: '出口名称已存在', + modelAliasExists: '模型别名已存在', + workflowType: '类型', + txt2img: '文生图', + img2img: '图生图', + workflowJson: 'Workflow JSON', + analyzeWorkflow: '解析 Workflow', + workflowJsonRequired: '请先粘贴 Workflow JSON', + analyzeFailed: '解析失败', + modelIdRequired: '请填写 Model ID', + workflowSaved: 'Workflow 模型已保存', + paramName: '参数', + bindEnabled: '绑定', + targetNode: '目标节点', + targetField: '目标字段', + }, + ingress: { + title: '入口管理', + description: '创建与管理 Virtual Key,配置预算、限流与路由规则', + createKey: '创建入口密钥', + keyPrefix: '密钥前缀', + providerWhitelist: 'Provider 白名单', + modelWhitelist: 'Model 白名单', + budgetLimit: '预算上限', + rpmLimit: 'RPM 限制', + tpmLimit: 'TPM 限制', + whitelistAction: '白名单', + routingAction: '路由', + createDialogTitle: '创建入口密钥', + providerWhitelistPlaceholder: '不选表示允许全部 Provider', + modelWhitelistPlaceholder: '不选表示允许全部模型', + whitelistHint: '不选或清空表示允许全部', + modelWhitelistHint: '不选或清空表示允许全部;需先在出口管理同步模型', + editWhitelistTitle: '编辑白名单', + plainKeyTitle: '请保存密钥(仅显示一次)', + plainKeyWarning: '请立即复制保存,关闭后将无法再次查看完整密钥', + routingTitle: '路由规则 - {name}', + addRule: '添加规则', + modelPattern: '模型匹配', + modelPatternPlaceholder: 'gpt-4o* 或 claude-3-5-sonnet', + providerId: '出口', + providerIdPlaceholder: '选择出口', + keyIdOptional: '出口密钥', + keyIdPlaceholder: '留空则自动负载均衡', + providerRequired: '请选择出口', + addRuleTitle: '添加路由规则', + whitelistUpdated: '白名单已更新', + confirmDeleteKey: '确认删除此密钥?', + copied: '已复制', + ruleAdded: '规则已添加', + }, + logs: { + title: '请求日志', + description: '最近保留 500 条记录,含推理请求与管理台登录日志', + ingress: '入口', + endpoint: '接口', + modelOrUser: '模型 / 用户', + modelOrUserPlaceholder: '模型 / 用户名', + clientIpPlaceholder: '客户端 IP', + statusSuccess: '成功', + statusError: '失败', + endpointAdminLogin: '管理台登录', + pagerHint: '共 {total} 条,最多保留 {max} 条', + detailTitle: '请求详情', + requestBody: '请求内容', + responseBody: '响应内容', + predictionStatus: '生图任务状态', + }, + ipRules: { + title: 'IP 规则', + description: '配置管理台与推理 API 的 IP 白名单 / 黑名单', + addRule: '添加 IP 规则', + cidr: 'CIDR / IP', + cidrPlaceholder: '192.168.1.0/24 或 127.0.0.1', + scope: '范围', + typeAllow: '允许 (allow)', + typeDeny: '拒绝 (deny)', + scopeProxy: '推理 API', + scopeAdmin: '管理台', + scopeAll: '全部(管理台 + API)', + allowWarning: '允许规则会限制管理台访问,请确保已包含你当前的 IP。', + added: '已添加', + }, + settings: { + title: '系统设置', + description: '所有运行参数在管理台维护,无需配置文件即可启动', + securitySession: '安全与会话', + gatewayMonitor: '网关与监控', + changePassword: '修改管理员密码', + encryptionKey: '加密密钥', + encryptionKeyPlaceholder: '留空则不修改;修改后需与旧密钥一致才能解密已有 Provider Key', + encryptionKeyCurrent: '当前:{hint}', + sessionTtl: '会话有效期(秒)', + loginMaxAttempts: '登录最大尝试次数', + loginLockout: '登录锁定时长(秒)', + trustedProxies: '信任代理 IP/CIDR', + trustedProxiesPlaceholder: '每行一个,例如 127.0.0.1 或 10.0.0.0/8', + usageFlush: '用量刷盘间隔(秒)', + healthCheck: '健康检查间隔(秒)', + gatewayMaxRetries: '网关最大重试次数', + retryBackoff: '重试退避(毫秒)', + imageStorage: '图像存储', + imageStoragePath: '存储目录(相对 data-dir)', + publicBaseUrl: '对外 Base URL', + signedUrlTtl: '签名 URL 有效期(秒)', + predictionWait: 'Prefer:wait 最长等待(秒)', + saveSettings: '保存系统设置', + oldPassword: '旧密码', + newPassword: '新密码', + savePassword: '保存密码', + settingsSaved: '系统设置已保存', + passwordUpdated: '密码已更新', + }, +} diff --git a/web/src/layouts/MainLayout.vue b/web/src/layouts/MainLayout.vue new file mode 100644 index 0000000..305be22 --- /dev/null +++ b/web/src/layouts/MainLayout.vue @@ -0,0 +1,97 @@ + + + diff --git a/web/src/main.ts b/web/src/main.ts new file mode 100644 index 0000000..4540a91 --- /dev/null +++ b/web/src/main.ts @@ -0,0 +1,29 @@ +import { createApp } from 'vue' +import { createPinia } from 'pinia' +import ElementPlus, { ElDialog, ElDrawer } from 'element-plus' +import 'element-plus/dist/index.css' +import '@/styles/theme.css' +import * as ElementPlusIconsVue from '@element-plus/icons-vue' +import App from './App.vue' +import router from './router' +import { i18n } from './i18n' + +function disableOverlayClose(component: typeof ElDialog) { + const props = component.props as Record + if (props.closeOnClickModal) { + props.closeOnClickModal.default = false + } +} + +disableOverlayClose(ElDialog) +disableOverlayClose(ElDrawer) + +const app = createApp(App) +for (const [key, component] of Object.entries(ElementPlusIconsVue)) { + app.component(key, component) +} +app.use(createPinia()) +app.use(i18n) +app.use(router) +app.use(ElementPlus) +app.mount('#app') diff --git a/web/src/router/index.ts b/web/src/router/index.ts new file mode 100644 index 0000000..c5ce8ad --- /dev/null +++ b/web/src/router/index.ts @@ -0,0 +1,40 @@ +import { createRouter, createWebHistory } from 'vue-router' +import Layout from '@/layouts/MainLayout.vue' + +const router = createRouter({ + history: createWebHistory(), + routes: [ + { + path: '/login', + name: 'login', + component: () => import('@/views/LoginView.vue'), + }, + { + path: '/', + component: Layout, + redirect: '/dashboard', + children: [ + { path: 'dashboard', name: 'dashboard', component: () => import('@/views/DashboardView.vue') }, + { path: 'egress/providers', name: 'providers', component: () => import('@/views/ProvidersView.vue') }, + { path: 'ingress/keys', name: 'ingress-keys', component: () => import('@/views/IngressKeysView.vue') }, + { path: 'logs', name: 'logs', component: () => import('@/views/RequestLogsView.vue') }, + { path: 'security/ip-rules', name: 'ip-rules', component: () => import('@/views/IPRulesView.vue') }, + { path: 'security/settings', name: 'settings', component: () => import('@/views/SettingsView.vue') }, + ], + }, + ], +}) + +router.beforeEach(async (to) => { + if (to.name === 'login') return true + try { + const res = await fetch('/api/admin/auth/status', { credentials: 'include' }) + const data = await res.json() + if (!data.authenticated) return '/login' + } catch { + return '/login' + } + return true +}) + +export default router diff --git a/web/src/styles/theme.css b/web/src/styles/theme.css new file mode 100644 index 0000000..8040887 --- /dev/null +++ b/web/src/styles/theme.css @@ -0,0 +1,602 @@ +:root { + --background: #f4f4f5; + --foreground: #18181b; + --card: #ffffff; + --card-foreground: #18181b; + --primary: #0f7a62; + --primary-hover: #0d6b56; + --primary-foreground: #fafafa; + --muted: #f4f4f5; + --muted-foreground: #71717a; + --border: #e4e4e7; + --input: #e4e4e7; + --destructive: #dc2626; + --success: #047857; + --warning: #b45309; + --danger: #dc2626; + --info: #a1a1aa; + --sidebar: rgba(250, 250, 250, 0.85); + --sidebar-foreground: #18181b; + --sidebar-accent: #f4f4f5; + --sidebar-border: #e4e4e7; + --sidebar-width: 15rem; + --radius: 0.5rem; + --space-1: 4px; + --space-2: 8px; + --space-3: 12px; + --space-4: 16px; + --space-5: 20px; + --space-6: 24px; + --space-8: 32px; + + --el-color-primary: #0f7a62; + --el-color-primary-light-3: #4da08c; + --el-color-primary-light-5: #76b8a8; + --el-color-primary-light-7: #a0d0c4; + --el-color-primary-light-8: #b8dcd2; + --el-color-primary-light-9: #d0e8e1; + --el-color-primary-dark-2: #0c624e; + --el-color-success: #047857; + --el-color-warning: #b45309; + --el-color-danger: #dc2626; + --el-color-info: #a1a1aa; + --el-border-radius-base: 0.5rem; + --el-border-color: #e4e4e7; + --el-bg-color-page: #f4f4f5; + --el-text-color-primary: #18181b; + --el-text-color-regular: #3f3f46; + --el-text-color-secondary: #71717a; + --el-font-family: ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto, "Helvetica Neue", Arial, "PingFang SC", "Microsoft YaHei", "Noto Sans SC", sans-serif; +} + +* { box-sizing: border-box; } + +html, body, #app { + margin: 0; + padding: 0; + height: 100%; + font-size: 14px; + font-family: var(--el-font-family); + background: var(--background); + color: var(--foreground); +} + +.layout { + display: flex; + height: 100vh; +} + +.sidebar { + width: var(--sidebar-width); + flex-shrink: 0; + background: var(--sidebar); + border-right: 1px solid var(--sidebar-border); + backdrop-filter: blur(8px); + display: flex; + flex-direction: column; + overflow-y: auto; +} + +.sidebar-brand { + display: flex; + align-items: center; + gap: var(--space-3); + padding: var(--space-5) var(--space-4); +} + +.brand-icon { + width: 32px; + height: 32px; + border-radius: var(--radius); + background: var(--primary); + color: var(--primary-foreground); + display: flex; + align-items: center; + justify-content: center; +} + +.brand-name { + font-size: 1.125rem; + font-weight: 600; +} + +.nav-group-label { + font-size: 0.75rem; + text-transform: uppercase; + letter-spacing: 0.04em; + color: var(--muted-foreground); + padding: var(--space-4) var(--space-4) var(--space-2); +} + +.nav-item { + display: flex; + align-items: center; + gap: var(--space-2); + padding: var(--space-2) var(--space-3); + margin: 0 var(--space-2); + border-radius: var(--radius); + color: var(--muted-foreground); + text-decoration: none; + font-size: 0.875rem; + font-weight: 500; +} + +.nav-item:hover { background: var(--sidebar-accent); } + +.nav-item.active { + background: var(--sidebar-accent); + color: var(--primary); + font-weight: 600; +} + +.main-area { + flex: 1; + display: flex; + flex-direction: column; + min-width: 0; +} + +.topbar { + height: 52px; + display: flex; + align-items: center; + justify-content: space-between; + padding: 0 var(--space-6); + background: var(--card); + border-bottom: 1px solid var(--border); +} + +.topbar-title { + font-size: 0.875rem; + color: var(--muted-foreground); +} + +.main-content { + flex: 1; + overflow-y: auto; + padding: var(--space-6); +} + +.page { + max-width: 1400px; +} + +.page-header { + display: flex; + align-items: flex-start; + justify-content: space-between; + margin-bottom: var(--space-6); + gap: var(--space-4); +} + +.page-actions { + display: flex; + align-items: center; + gap: var(--space-2); + flex-shrink: 0; + flex-wrap: wrap; +} + +.page-title { + font-size: 1.5rem; + font-weight: 600; + letter-spacing: -0.02em; + margin: 0 0 var(--space-1); +} + +.page-desc { + font-size: 0.875rem; + color: var(--muted-foreground); + margin: 0; +} + +.stat-grid { + display: grid; + grid-template-columns: repeat(4, 1fr); + gap: var(--space-4); + margin-bottom: var(--space-6); +} + +.stat-card { + background: var(--card); + border: 1px solid var(--border); + border-radius: var(--radius); + padding: var(--space-5); +} + +.stat-card__label { + font-size: 0.875rem; + color: var(--muted-foreground); + margin-bottom: var(--space-2); +} + +.stat-card__value { + font-size: 1.75rem; + font-weight: 600; +} + +.app-card { + background: var(--card); + border: 1px solid var(--border); + border-radius: var(--radius); + margin-bottom: var(--space-4); +} + +.page > .app-card:last-child, +.page > .content-grid:last-child { + margin-bottom: 0; +} + +.app-card__header { + padding: var(--space-4) var(--space-5); + border-bottom: 1px solid var(--border); +} + +.app-card__title { + font-size: 0.9375rem; + font-weight: 600; + margin: 0; +} + +.app-card__body--padded { padding: var(--space-5); } + +.content-grid { + display: grid; + gap: var(--space-4); + margin-bottom: var(--space-6); +} + +.content-grid--2-1 { grid-template-columns: 1.4fr 1fr; } +.content-grid--2 { grid-template-columns: 1fr 1fr; } +.content-grid--3 { grid-template-columns: repeat(3, 1fr); } + +.card-toolbar { + display: flex; + align-items: center; + gap: var(--space-2); + padding: var(--space-4) var(--space-5) 0; + margin-bottom: var(--space-4); + flex-wrap: wrap; +} + +.card-toolbar--desc { + align-items: flex-start; +} + +.card-pager { + padding: var(--space-4) var(--space-5); + display: flex; + justify-content: flex-end; +} + +.card-footer-hint { + padding: 0 var(--space-5) var(--space-3); + margin: 0; +} + +.card-footer-hint + .card-pager { + padding-top: var(--space-3); +} + +.card-inset-block { + margin: 0 var(--space-5) var(--space-4); + padding: var(--space-3); + background: var(--muted); + border-radius: var(--radius); + font-size: 0.875rem; +} + +.tabs-bar { + padding: 0 var(--space-5); + border-bottom: 1px solid var(--border); +} + +.tabs-bar .el-tabs__header { margin-bottom: 0; } + +.page-toolbar { + display: flex; + align-items: center; + gap: var(--space-2); + margin-bottom: var(--space-4); + flex-wrap: wrap; +} + +.inline-actions { + display: flex; + align-items: center; + gap: var(--space-2); + flex-wrap: wrap; +} + +.card-header-row { + display: flex; + align-items: center; + justify-content: space-between; + gap: var(--space-4); +} + +.form-hint { + margin-top: 6px; + font-size: 0.75rem; + color: var(--muted-foreground); +} + +.form-hint--flush { margin-top: 0; } + +.u-mt-3 { margin-top: var(--space-3); } +.u-mt-4 { margin-top: var(--space-4); } +.u-mb-3 { margin-bottom: var(--space-3); } +.u-mb-4 { margin-bottom: var(--space-4); } +.u-mb-6 { margin-bottom: var(--space-6); } +.text-center { text-align: center; } +.w-full { width: 100%; } + +code.inline-code { + font-size: 0.75rem; + padding: 0 var(--space-1); + background: var(--muted); + border-radius: var(--radius); +} + +.section-title { + font-size: 0.875rem; + font-weight: 600; + margin-bottom: var(--space-4); +} + +.section-title:not(:first-child) { margin-top: var(--space-6); } + +.login-page { + min-height: 100vh; + display: flex; + align-items: center; + justify-content: center; + background: var(--background); + padding: var(--space-6); +} + +.login-wrap { max-width: 400px; width: 100%; text-align: center; } + +.login-brand-icon { + width: 48px; + height: 48px; + border-radius: 12px; + background: var(--primary); + color: var(--primary-foreground); + display: inline-flex; + align-items: center; + justify-content: center; + margin-bottom: var(--space-4); +} + +.login-card { + background: var(--card); + border: 1px solid var(--border); + border-radius: var(--radius); + padding: var(--space-8); + margin-top: var(--space-6); + text-align: left; +} + +.status-good { color: #047857; } +.status-warn { color: #b45309; } +.status-bad { color: #dc2626; } +.status-muted { color: var(--muted-foreground); } + +.toolbar-select { width: 120px; } +.toolbar-select--country { width: 160px; } +.toolbar-select--wide { width: 280px; } +.toolbar-input { width: 200px; } +.toolbar-input--wide { width: 260px; } +.toolbar-input--grow { flex: 1; min-width: 200px; } +.table-cell-select { width: 100%; min-width: 200px; } + +.el-form .el-select { width: 100%; } + +.app-card .el-table { + --el-table-header-bg-color: var(--muted); + font-size: 0.875rem; +} + +.app-card .el-table--small .el-table__cell { + padding: var(--space-2) var(--space-3); +} + +.app-card .el-table--small th.el-table__cell { + font-size: 0.75rem; + font-weight: 600; + color: var(--muted-foreground); +} + +.app-card .el-table--small td.el-table__cell { + color: var(--el-text-color-regular); +} + +.app-card .el-table--small .el-table__row { + height: 40px; +} + +.app-card .el-table .el-button.is-link { + padding: 0; + height: auto; +} + +.app-card .el-table .cell { + line-height: 1.4; +} + +.table-scroll--sm { max-height: 320px; } +.table-scroll--md { max-height: 360px; } +.table-scroll--lg { max-height: 480px; } + +.card-toolbar .toolbar-input--grow { + flex: 1; + min-width: 200px; +} + +.card-pager .el-pagination { + font-size: 0.875rem; +} + +.kv-block p { + margin: 0 0 var(--space-2); +} + +.kv-block p:last-child { + margin-bottom: 0; +} + +.kv-block .label { + display: inline-block; + width: 5rem; + color: var(--muted-foreground); +} + +.kv-block code { + font-size: 0.8125rem; +} + +.cell-stack { + display: flex; + flex-direction: column; + gap: var(--space-2); + width: 100%; +} + +.meta-list { + margin: 0; + padding: 0; + list-style: none; + font-size: 0.8125rem; + color: var(--muted-foreground); +} + +.meta-list li { + display: flex; + justify-content: space-between; + gap: var(--space-3); + padding: var(--space-1) 0; +} + +.meta-list__mono { + font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace; +} + +.meta-list__ok { color: var(--el-color-success); } +.meta-list__warn { color: var(--el-color-warning); } + +.chart-box { + height: 280px; +} + +.custom-scrollbar { + scrollbar-width: thin; + scrollbar-color: #e4e4e7 transparent; +} + +.custom-scrollbar::-webkit-scrollbar { + width: 6px; + height: 6px; +} + +.custom-scrollbar::-webkit-scrollbar-thumb { + background: #e4e4e7; + border-radius: 3px; +} + +.custom-scrollbar::-webkit-scrollbar-track { + background: transparent; +} + +/* Luminary app extensions */ +.page-form { + max-width: 800px; +} + +.page-form--narrow { + max-width: 480px; +} + +.page > .page-form + .app-card, +.page > .page-form + .page-form { + margin-top: var(--space-4); +} + +.settings-form.el-form .el-form-item { + margin-bottom: var(--space-5); +} + +.settings-form.el-form .el-input-number { + width: 200px; +} + +.form-hint--destructive { + color: var(--destructive); +} + +.drawer-toolbar { + display: flex; + align-items: center; + gap: var(--space-2); + margin-bottom: var(--space-4); + flex-wrap: wrap; +} + +.el-drawer__body .card-inset-block { + margin-left: 0; + margin-right: 0; +} + +.el-drawer__body pre.card-inset-block { + font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace; + font-size: 0.8125rem; + line-height: 1.5; + max-height: 320px; + overflow: auto; + white-space: pre-wrap; + word-break: break-all; + border: 1px solid var(--border); +} + +.models-expand { + padding: var(--space-3) var(--space-6) var(--space-4) 48px; + background: var(--muted); +} + +.models-empty { + color: var(--muted-foreground); + font-size: 0.875rem; +} + +.models-table { + background: var(--card); +} + +.endpoint-row { + display: grid; + grid-template-columns: 200px 1fr; + gap: var(--space-3); + align-items: center; + margin-bottom: var(--space-3); +} + +.ep-tag { + margin: var(--space-1); +} + +.soon-tag { + margin-left: var(--space-2); +} + +@media (max-width: 1024px) { + .stat-grid { grid-template-columns: repeat(2, 1fr); } + .content-grid--2-1, + .content-grid--2, + .content-grid--3 { grid-template-columns: 1fr; } +} + +@media (max-width: 640px) { + .stat-grid { grid-template-columns: 1fr; } + .main-content { padding: var(--space-4); } + .page-header { flex-direction: column; } +} diff --git a/web/src/utils/chartTheme.ts b/web/src/utils/chartTheme.ts new file mode 100644 index 0000000..e81f745 --- /dev/null +++ b/web/src/utils/chartTheme.ts @@ -0,0 +1,64 @@ +import type { EChartsOption, LineSeriesOption } from 'echarts' + +/** Design system §6.16 chart palette */ +export const CHART_COLORS = { + primary: '#0f7a62', + secondary: '#71717a', + series: ['#0f7a62', '#71717a', '#4da08c', '#76b8a8', '#a0d0c4'] as const, + axis: '#71717a', + splitLine: '#e4e4e7', +} + +export const CHART_GRID = { left: 40, right: 16, top: 16, bottom: 28 } + +export function axisStyle() { + return { + axisLabel: { color: CHART_COLORS.axis, fontSize: 12 }, + axisLine: { lineStyle: { color: CHART_COLORS.splitLine } }, + } +} + +export function lineSeries( + name: string, + data: number[], + colorIndex = 0, +): LineSeriesOption { + return { + name, + type: 'line', + smooth: true, + data, + color: CHART_COLORS.series[colorIndex] ?? CHART_COLORS.primary, + lineStyle: { width: 2 }, + showSymbol: false, + } +} + +export function buildTrendChartOption(params: { + labels: string[] + series: Array<{ name: string; data: number[]; colorIndex?: number }> +}): EChartsOption { + return { + color: [...CHART_COLORS.series], + grid: CHART_GRID, + tooltip: { trigger: 'axis' }, + legend: { + bottom: 0, + textStyle: { fontSize: 12, color: CHART_COLORS.axis }, + }, + xAxis: { + type: 'category', + data: params.labels, + boundaryGap: false, + ...axisStyle(), + }, + yAxis: { + type: 'value', + ...axisStyle(), + splitLine: { lineStyle: { color: CHART_COLORS.splitLine } }, + }, + series: params.series.map((s, i) => + lineSeries(s.name, s.data, s.colorIndex ?? i), + ), + } +} diff --git a/web/src/utils/format.ts b/web/src/utils/format.ts new file mode 100644 index 0000000..6f3a8d0 --- /dev/null +++ b/web/src/utils/format.ts @@ -0,0 +1,23 @@ +export function formatDateTime(value?: string | null): string { + if (!value) return '—' + const d = new Date(value) + if (Number.isNaN(d.getTime())) return value + const pad = (n: number) => String(n).padStart(2, '0') + return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}` +} + +export function formatChartHour(value: string, locale: string): string { + const d = new Date(value.replace(' ', 'T')) + if (Number.isNaN(d.getTime())) return value + const pad = (n: number) => String(n).padStart(2, '0') + if (locale === 'zh-CN') { + return `${pad(d.getMonth() + 1)}/${pad(d.getDate())} ${pad(d.getHours())}:00` + } + return `${pad(d.getHours())}:00` +} + +export function formatNumberCompact(n: number): string { + if (n >= 1_000_000) return (n / 1_000_000).toFixed(1) + 'M' + if (n >= 1_000) return (n / 1_000).toFixed(1) + 'K' + return String(n) +} diff --git a/web/src/views/DashboardView.vue b/web/src/views/DashboardView.vue new file mode 100644 index 0000000..cd1d041 --- /dev/null +++ b/web/src/views/DashboardView.vue @@ -0,0 +1,166 @@ + + + diff --git a/web/src/views/IPRulesView.vue b/web/src/views/IPRulesView.vue new file mode 100644 index 0000000..4c15321 --- /dev/null +++ b/web/src/views/IPRulesView.vue @@ -0,0 +1,92 @@ + + + diff --git a/web/src/views/IngressKeysView.vue b/web/src/views/IngressKeysView.vue new file mode 100644 index 0000000..ff19563 --- /dev/null +++ b/web/src/views/IngressKeysView.vue @@ -0,0 +1,462 @@ + + + diff --git a/web/src/views/LoginView.vue b/web/src/views/LoginView.vue new file mode 100644 index 0000000..5967732 --- /dev/null +++ b/web/src/views/LoginView.vue @@ -0,0 +1,65 @@ + + + diff --git a/web/src/views/ProvidersView.vue b/web/src/views/ProvidersView.vue new file mode 100644 index 0000000..b48948f --- /dev/null +++ b/web/src/views/ProvidersView.vue @@ -0,0 +1,716 @@ + + + diff --git a/web/src/views/RequestLogsView.vue b/web/src/views/RequestLogsView.vue new file mode 100644 index 0000000..7ad2e70 --- /dev/null +++ b/web/src/views/RequestLogsView.vue @@ -0,0 +1,299 @@ + + + diff --git a/web/src/views/SettingsView.vue b/web/src/views/SettingsView.vue new file mode 100644 index 0000000..6ee996e --- /dev/null +++ b/web/src/views/SettingsView.vue @@ -0,0 +1,221 @@ + + + diff --git a/web/src/vite-env.d.ts b/web/src/vite-env.d.ts new file mode 100644 index 0000000..323c78a --- /dev/null +++ b/web/src/vite-env.d.ts @@ -0,0 +1,7 @@ +/// + +declare module '*.vue' { + import type { DefineComponent } from 'vue' + const component: DefineComponent<{}, {}, any> + export default component +} diff --git a/web/static_embed.go b/web/static_embed.go new file mode 100644 index 0000000..d7dd86e --- /dev/null +++ b/web/static_embed.go @@ -0,0 +1,8 @@ +package webembed + +import "embed" + +// Placeholder dist/index.html is committed for CI and go test; production UI comes from npm run build. +// +//go:embed all:dist +var WebFS embed.FS diff --git a/web/tsconfig.json b/web/tsconfig.json new file mode 100644 index 0000000..4b2626e --- /dev/null +++ b/web/tsconfig.json @@ -0,0 +1,22 @@ +{ + "compilerOptions": { + "target": "ES2022", + "useDefineForClassFields": true, + "module": "ESNext", + "lib": ["ES2022", "DOM", "DOM.Iterable"], + "skipLibCheck": true, + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "resolveJsonModule": true, + "isolatedModules": true, + "noEmit": true, + "jsx": "preserve", + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "noFallthroughCasesInSwitch": true, + "paths": { "@/*": ["./src/*"] }, + "baseUrl": "." + }, + "include": ["src/**/*.ts", "src/**/*.tsx", "src/**/*.vue"] +} diff --git a/web/vite.config.ts b/web/vite.config.ts new file mode 100644 index 0000000..92efddf --- /dev/null +++ b/web/vite.config.ts @@ -0,0 +1,23 @@ +import { defineConfig } from 'vite' +import vue from '@vitejs/plugin-vue' +import { fileURLToPath, URL } from 'node:url' + +export default defineConfig({ + plugins: [vue()], + resolve: { + alias: { + '@': fileURLToPath(new URL('./src', import.meta.url)), + }, + }, + build: { + outDir: 'dist', + emptyOutDir: true, + }, + server: { + port: 5173, + proxy: { + '/api': { target: 'http://localhost:8293', changeOrigin: true }, + '/v1': { target: 'http://localhost:8293', changeOrigin: true }, + }, + }, +})