实现 Atlas AI 平台一期能力并完成品牌化改造。
CI / docker (push) Has been cancelled

引入用户端对话/生图与管理端 Provider 配置,统一 invoke 编排、Gateway、请求日志与模型管理;前端按设计规范拆分布局,Go module 更名为 github.com/rose_cat707/Atlas;生图长任务 SSE 推送;管理端登录防爆破;协议与服务类型联动;Replicate 模型同步修复;用户端创作台 UI 重构;管理端创作历史替代请求日志。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
renjue
2026-06-29 20:14:26 +08:00
parent 6073cbd885
commit d440fb1ae7
95 changed files with 9937 additions and 331 deletions
+2 -2
View File
@@ -21,7 +21,7 @@
| 位置 | 默认仓库根目录 `Dockerfile`;其他路径用 Variable `DOCKERFILE` | | 位置 | 默认仓库根目录 `Dockerfile`;其他路径用 Variable `DOCKERFILE` |
| 构建上下文 | 仓库根目录 `.`(整个仓库会被 `COPY` 进镜像) | | 构建上下文 | 仓库根目录 `.`(整个仓库会被 `COPY` 进镜像) |
| Go 版本 | `go.mod``go` 行与 `golang` 基础镜像大版本一致 | | Go 版本 | `go.mod``go` 行与 `golang` 基础镜像大版本一致 |
| 多架构 | `go build` 须使用 `GOARCH="$TARGETARCH"`buildx 注入);推荐 `CGO_ENABLED=0` | | 多架构 | `go build` 须使用 `GOARCH="$TARGETARCH"`buildx 注入);使用 `mattn/go-sqlite3` `CGO_ENABLED=1``gcc` |
| Go 模块代理 | **必须**在镜像内显式设置 `GOPROXY` / `GOSUMDB`(见下);容器内不会自动读取 `go.env` | | Go 模块代理 | **必须**在镜像内显式设置 `GOPROXY` / `GOSUMDB`(见下);容器内不会自动读取 `go.env` |
| 基础镜像加速 | 支持构建参数 `IMAGE_PREFIX`CI 默认 `docker.1panel.live/library/` | | 基础镜像加速 | 支持构建参数 `IMAGE_PREFIX`CI 默认 `docker.1panel.live/library/` |
| 前端(可选) | 存在 `web/` 时在 Dockerfile 内完成 `npm install` + `npm run build` | | 前端(可选) | 存在 `web/` 时在 Dockerfile 内完成 `npm install` + `npm run build` |
@@ -55,7 +55,7 @@ FROM --platform=$BUILDPLATFORM ${IMAGE_PREFIX}golang:1.25-alpine AS go-builder
ARG TARGETARCH ARG TARGETARCH
ARG GOPROXY=https://goproxy.cn,direct ARG GOPROXY=https://goproxy.cn,direct
ARG GOSUMDB=sum.golang.google.cn ARG GOSUMDB=sum.golang.google.cn
ENV CGO_ENABLED=0 GOPROXY=${GOPROXY} GOSUMDB=${GOSUMDB} ENV CGO_ENABLED=1 GOPROXY=${GOPROXY} GOSUMDB=${GOSUMDB}
WORKDIR /src WORKDIR /src
COPY go.mod go.sum ./ COPY go.mod go.sum ./
+1 -1
View File
@@ -1,3 +1,3 @@
GITEA_INSTANCE_URL=https://git.example.com GITEA_INSTANCE_URL=https://git.example.com
GITEA_RUNNER_REGISTRATION_TOKEN=从仓库_Settings_Actions_Runners_复制 GITEA_RUNNER_REGISTRATION_TOKEN=从仓库_Settings_Actions_Runners_复制
GITEA_RUNNER_NAME=go-vue-template-ci-runner GITEA_RUNNER_NAME=atlas-ci-runner
+2 -2
View File
@@ -5,13 +5,13 @@
services: services:
act-runner: act-runner:
image: docker.io/gitea/act_runner:0.2.12 image: docker.io/gitea/act_runner:0.2.12
container_name: go-vue-template-act-runner container_name: atlas-act-runner
restart: unless-stopped restart: unless-stopped
environment: environment:
CONFIG_FILE: /config.yaml CONFIG_FILE: /config.yaml
GITEA_INSTANCE_URL: ${GITEA_INSTANCE_URL:-https://git.example.com} GITEA_INSTANCE_URL: ${GITEA_INSTANCE_URL:-https://git.example.com}
GITEA_RUNNER_REGISTRATION_TOKEN: ${GITEA_RUNNER_REGISTRATION_TOKEN} GITEA_RUNNER_REGISTRATION_TOKEN: ${GITEA_RUNNER_REGISTRATION_TOKEN}
GITEA_RUNNER_NAME: ${GITEA_RUNNER_NAME:-go-vue-template-ci-runner} GITEA_RUNNER_NAME: ${GITEA_RUNNER_NAME:-atlas-ci-runner}
volumes: volumes:
- ./config.yaml:/config.yaml:ro - ./config.yaml:/config.yaml:ro
- act-runner-data:/data - act-runner-data:/data
+5
View File
@@ -66,6 +66,11 @@ jobs:
export PATH="/usr/local/go/bin:${PATH}" export PATH="/usr/local/go/bin:${PATH}"
fi fi
go version go version
if ! command -v gcc >/dev/null 2>&1; then
apt-get update
DEBIAN_FRONTEND=noninteractive apt-get install -y gcc
fi
export CGO_ENABLED=1
go test "${GO_TEST_SCOPE:-./...}" go test "${GO_TEST_SCOPE:-./...}"
- name: Setup Docker - name: Setup Docker
+8 -5
View File
@@ -1,5 +1,3 @@
# syntax=docker/dockerfile:1
ARG IMAGE_PREFIX= ARG IMAGE_PREFIX=
FROM ${IMAGE_PREFIX}node:20-alpine AS web-builder FROM ${IMAGE_PREFIX}node:20-alpine AS web-builder
@@ -12,20 +10,25 @@ RUN npm run build
FROM ${IMAGE_PREFIX}golang:1.25-alpine AS go-builder FROM ${IMAGE_PREFIX}golang:1.25-alpine AS go-builder
RUN apk add --no-cache gcc musl-dev
ARG TARGETARCH=amd64 ARG TARGETARCH=amd64
ARG GOPROXY=https://goproxy.cn,direct ARG GOPROXY=https://goproxy.cn,direct
ARG GOSUMDB=sum.golang.google.cn ARG GOSUMDB=sum.golang.google.cn
ENV CGO_ENABLED=0 GOPROXY=${GOPROXY} GOSUMDB=${GOSUMDB} ENV CGO_ENABLED=1 GOPROXY=${GOPROXY} GOSUMDB=${GOSUMDB}
WORKDIR /src WORKDIR /src
COPY go.mod go.sum ./ COPY go.mod go.sum ./
RUN go mod download RUN --mount=type=cache,target=/go/pkg/mod \
go mod download
COPY cmd/ ./cmd/ COPY cmd/ ./cmd/
COPY internal/ ./internal/ COPY internal/ ./internal/
COPY --from=web-builder /src/web/dist ./internal/api/static/ COPY --from=web-builder /src/web/dist ./internal/api/static/
RUN GOOS=linux GOARCH="$TARGETARCH" \ 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 /app ./cmd/app go build -trimpath -ldflags="-w -s" -o /app ./cmd/app
FROM ${IMAGE_PREFIX}alpine:3.20 FROM ${IMAGE_PREFIX}alpine:3.20
+7 -3
View File
@@ -1,9 +1,10 @@
.PHONY: dev dev-web build build-web run test tidy clean docker-build .PHONY: dev dev-web build build-go build-web run test tidy clean docker-build
export GOPROXY ?= https://goproxy.cn,direct export GOPROXY ?= https://goproxy.cn,direct
export GOSUMDB ?= sum.golang.google.cn export GOSUMDB ?= sum.golang.google.cn
export CGO_ENABLED ?= 1
export IMAGE_PREFIX ?= docker.1panel.live/library/ export IMAGE_PREFIX ?= docker.1panel.live/library/
export APP_IMAGE ?= go-vue-template:latest export APP_IMAGE ?= atlas:latest
dev: dev:
@echo "Run backend and frontend in separate terminals:" @echo "Run backend and frontend in separate terminals:"
@@ -21,6 +22,9 @@ build-web:
build: build-web build: build-web
go build -o dist/app ./cmd/app go build -o dist/app ./cmd/app
build-go:
go build -o dist/app ./cmd/app
run: run:
go run ./cmd/app go run ./cmd/app
@@ -32,7 +36,7 @@ test:
clean: clean:
rm -rf dist web/dist internal/api/static/* rm -rf dist web/dist internal/api/static/*
@echo '<!DOCTYPE html><html><body>App</body></html>' > internal/api/static/index.html @echo '<!DOCTYPE html><html><head><title>Atlas</title></head><body>Atlas</body></html>' > internal/api/static/index.html
docker-build: docker-build:
docker build \ docker build \
+170 -171
View File
@@ -1,14 +1,16 @@
# go-vue-template # Atlas
Go + Vue 单体应用脚手架。一个二进制同时提供 REST API 与管理台 UI,前端通过 `go:embed` 嵌入,适合内部工具、管理后台、网关控制台等场景 Atlas 是面向 AI 能力的统一接入与使用平台。Go + Vue 单体部署:用户端提供对话、文生图、图生图与历史记录;管理端配置上游 Provider 与模型、查看请求日志;内部 Gateway 兼容 OpenAI / Replicate 协议,统一编排、日志与路由
## 特性 ## 特性
- **单体部署**:API 与 SPA 同进程、同端口,生产环境无需 Node.js - **单体部署**:API 与 SPA 同进程、同端口,生产环境无需 Node.js
- **分层清晰**`cmd``api``service``store`,职责边界明确 - **多协议 Gateway**`/v1/*` 透传 OpenAIChat、Embeddings 等)与 ReplicatePredictions
- **开箱即用**:登录鉴权、设置页、审计日志、i18n、Docker 多阶段构建 - **统一 invoke 编排**:同步/异步调用、任务队列、上游轮询 + 前端 SSE 进度推送
- **纯 Go 编译**SQLite 使用 `modernc.org/sqlite``CGO_ENABLED=0` 可交叉编译 - **Provider 管理**:管理端配置上游地址与密钥;模型优先从上游 `/v1/models` 同步,也可手动维护
- **可演进**goose SQL 迁移、settings KV 配置、Pinia 状态管理 - **请求日志**Gateway 与用户 invoke 全链路记录,管理端可检索
- **用户会话隔离**:Cookie 会话区分用户数据;管理端独立 Bearer 鉴权
- **SQLite**:使用 `mattn/go-sqlite3`(CGO),编译远快于纯 Go 的 modernc 驱动;Docker/CI 镜像内已安装 `gcc`
## 快速开始 ## 快速开始
@@ -25,63 +27,79 @@ make build
docker compose up -d docker compose up -d
``` ```
默认管理员密码:`admin`(可在设置页修改)。 启动后访问:
--- | 入口 | 地址 | 说明 |
|------|------|------|
| 用户端 | `http://localhost:8080/chat` | 对话、生图、历史(无需登录) |
| 管理端 | `http://localhost:8080/admin` | Provider、日志、设置(需登录) |
| Gateway | `http://localhost:8080/v1/*` | OpenAI / Replicate 兼容 API |
| 健康检查 | `GET /health` | `{ "status": "ok" }` |
## 使用本模板 默认管理员密码:`admin`(可在管理端设置页修改)。
1. **Fork 或 clone** 本仓库作为新项目起点
2. **修改 Go module 名**:全局替换 `github.com/rose_cat707/go-vue-template` 为你的模块路径
3. **修改应用标识**:更新 `settings` 默认值、`web/` 中的品牌文案、镜像名等
4. **按需扩展**:在 `internal/` 增加业务包,在 `web/src/views/` 增加页面
```bash
# 示例:批量替换 module 名
find . -type f \( -name '*.go' -o -name 'go.mod' \) \
-exec sed -i '' 's|github.com/rose_cat707/go-vue-template|github.com/you/your-app|g' {} +
```
--- ---
## 架构 ## 架构
``` ```
┌─────────────────────────────────────────────────────────┐ ┌─────────────────────────────────────────────────────────────────────
│ 单一 Go 进程 │ 单一 Go 进程
│ ┌──────────────┐ ┌──────────────┐ ┌───────────────┐ │ ┌─────────────┐ ┌─────────────┐ ┌──────────────┐ ┌──────────
│ │ /api/v1/* │ │ /health │ │ /* (SPA) │ │ /api/v1/* │ │ /v1/* │ │ /health │ │ /* SPA
│ │ REST API │ 健康检查 │ │ embed 静态资源 │ │ 用户 + 管理 Gateway │ │ │ │ embed │ │
│ └──────┬───────┘ └──────────────┘ └───────────────┘ │ └──────┬──────┘ └──────┬──────┘ └──────────────┘ └──────────
│ │ │ │ │
│ ┌──────▼──────────────────────────────────────────┐ │ ┌──────▼────────────────▼──────────────────────────────────────┐ │
│ │ api (HTTP) → service (编排) → store (持久化) │ │ api → service (invoke / chat / reqlog / taskevents) → store │ │
│ └─────────────────────────────────────────────────┘ │ └──────────────────────────┬───────────────────────────────────┘ │
│ ┌──────▼──────┐ │ ┌──────────────────────────▼───────────────────────────────────┐
│ │ SQLite WAL │ {data}/app.db │ │ provider (Router / ModelsService / ProxyStream) → 上游 API
│ └───────────── │ └────────────────────────────────────────────────────────────────┘
└─────────────────────────────────────────────────────────┘ │ │ │
▲ ▲ │ ┌──────────────────────────▼───────────────────────────────────┐ │
│ 生产:同一端口 │ 开发:Vite :5173 代理 /api │ │ SQLite WAL {data}/app.db │ │
────────────────────────── │ └────────────────────────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────────────────────┘
```
### 调用链路
**对话(Chat**
```
浏览器 → POST /api/v1/user/conversations/:id/messages
→ chat.Service → provider.ProxyStream → 上游 SSE 真流式回传
```
**生图(异步任务)**
```
浏览器 → POST /api/v1/user/generate
→ invoke.Service 创建 task → async_worker 轮询上游
→ GET /api/v1/user/tasks/:id/events (SSE) 推送进度
```
**Gateway 直连**
```
客户端 → /v1/chat/completions | /v1/predictions ...
→ provider.Router 按模型路由 → 上游 + request_logs
``` ```
### 分层约定 ### 分层约定
| 层 | 路径 | 职责 | | 层 | 路径 | 职责 |
|----|------|------| |----|------|------|
| 入口 | `cmd/app/` | 解析参数、初始化依赖、启动 HTTP、优雅关闭 | | 入口 | `cmd/app/` | 参数解析、依赖初始化、HTTP 服务、优雅关闭 |
| HTTP | `internal/api/` | 路由注册、中间件、请求/响应、SPA 静态服务 | | HTTP | `internal/api/` | 路由、中间件、用户/管理/Gateway Handler、SPA |
| 业务 | `internal/service/` | 跨模块编排,注入 Store 与领域服务 | | 编排 | `internal/service/` | invoke、chat、reqlog、taskevents、asset |
| 持久化 | `internal/store/` | 数据库连接、迁移、仓储方法 | | 上游 | `internal/provider/` | HTTP 代理、模型列表、流式转发、Provider 路由 |
| 配置 | `internal/settings/` | 配置键常量、默认值、展示脱敏、提交校验 | | 领域 | `internal/domain/` | 服务类型、invoke 请求/响应模型 |
| 前端 | `web/` | Vue SPA 源码,构建产物复制到 `internal/api/static/` | | 持久化 | `internal/store/` | SQLite、goose 迁移、仓储与 HotCache |
| 配置 | `internal/settings/` | KV 配置键、默认值、脱敏 |
**原则:** | 前端 | `web/` | Vue SPA,构建产物嵌入 `internal/api/static/` |
- Handler 只做 HTTP 适配,不写复杂业务逻辑
- Store 封装 SQL,不暴露 `*sql.DB` 给 Handler
- 前端构建产物不提交 dist,仅保留 `static/index.html` 占位以保证 `go test` / CI 可通过
--- ---
@@ -89,96 +107,122 @@ find . -type f \( -name '*.go' -o -name 'go.mod' \) \
``` ```
. .
├── cmd/app/ # 进程入口 ├── cmd/app/ # 进程入口
├── internal/ ├── internal/
│ ├── api/ │ ├── api/
│ │ ├── handlers.go # 路由与 Handler │ │ ├── handlers.go # 基础路由、鉴权、设置
│ │ ├── auth_token.go # Token 签发与校验 │ │ ├── user_handlers.go # 用户端 API
│ │ ├── static.go # go:embed SPA │ │ ├── admin_handlers.go # 管理端 API
│ │ ── static/ # 前端构建产物(占位 index.html 已提交) │ │ ── gateway*.go # /v1 Gateway
│ ├── service/ # 业务编排 │ ├── task_sse.go # 任务 SSE
├── store/ │ └── static/ # 前端 embed 产物
│ ├── store.go │ ├── service/
│ │ ── migrations/ # goose SQL 迁移 │ │ ── invoke/ # 同步/异步 invoke + worker
└── settings/ # 配置键与规范化 │ ├── chat/ # 对话编排
├── web/ │ │ ├── reqlog/ # 请求日志
├── src/ │ └── taskevents/ # 任务事件广播
│ ├── api/ # axios 客户端与类型 │ ├── provider/ # 上游 HTTP 代理与路由
│ ├── components/ # 通用 UI 组件 │ ├── domain/
│ ├── i18n/ # 多语言 │ ├── store/migrations/ # goose SQL002_atlas, 003_provider_models
│ ├── layouts/ # 布局 └── settings/
│ │ ├── router/ # 路由与鉴权守卫 ├── web/src/
│ ├── stores/ # Pinia │ ├── views/ # Chat、生图、History、Admin 页面
│ ├── styles/ # 主题变量 │ ├── layouts/ # UserLayout / AdminLayout
│ └── views/ # 页面 ├── composables/ # useInvoke (SSE)、useUserMeta 等
── vite.config.ts ── styles/global.css # 设计规范落地样式
│ └── package.json ├── docs/UI-DESIGN-SYSTEM.md # UI 设计规范
├── Makefile ├── Makefile
├── Dockerfile ├── Dockerfile
── docker-compose.yml ── docker-compose.yml
├── go.mod
└── .gitea/ # Gitea Actions CI
├── README.md
├── workflows/ci.yml
└── act-runner/ # runner 部署参考(可选)
``` ```
--- ---
## 技术栈 ## 技术栈
| 层级 | 选型 | 说明 | | 层级 | 选型 |
|------|------|------| |------|------|
| 后端 | Go 1.25 + Gin | HTTP 路由与中间件 | | 后端 | Go 1.25 + Gin |
| 数据库 | SQLite (modernc.org/sqlite) | 纯 Go 驱动,无 CGO | | 数据库 | SQLite (mattn/go-sqlite3) |
| 迁移 | goose v3 (embed SQL) | 版本化、可回滚 | | 迁移 | goose v3 (embed SQL) |
| 前端 | Vue 3 + Vite + TypeScript | Composition API + `<script setup>` | | 前端 | Vue 3 + Vite + TypeScript |
| UI | Element Plus | 组件库与图标 | | UI | Element Plus |
| 状态 | Pinia | 全局状态(认证、语言等) | | 状态 | Pinia |
| 路由 | vue-router (History) | 路由守卫 + `/auth/status` | | HTTP | axios(管理端)/ fetch + EventSource(用户端 SSE |
| HTTP | axios | Bearer Token401 全局跳转 |
| i18n | vue-i18n | 与 Element Plus locale 联动 |
--- ---
## API 规范 ## 功能模块
### 用户端(`/chat`、`/image/*`、`/history`
- 多轮对话,SSE 流式输出
- 文生图、图生图(异步任务 + SSE 进度)
- 生成历史、参数预设
- 基于 `Atlas-Session` Cookie 的会话隔离
### 管理端(`/admin/*`
- Provider CRUDOpenAI / Replicate 协议、base_url、api_key
- 模型列表:上游 sync 或手动添加
- 请求日志检索、仪表盘统计
- 系统设置(应用名、鉴权、语言等)
### Gateway`/v1/*`
| 协议 | 路由示例 |
|------|----------|
| OpenAI | `GET /v1/models``POST /v1/chat/completions``POST /v1/embeddings` … |
| Replicate | `POST /v1/predictions``GET /v1/predictions/:id``GET /v1/predictions/:id/stream` … |
Gateway 使用与用户端相同的 Cookie 会话,按模型名路由到已配置的 Provider。
---
## API 概览
**前缀:** `/api/v1` **前缀:** `/api/v1`
### 鉴权 ### 鉴权(管理端)
| 接口 | 说明 | | 接口 | 说明 |
|------|------| |------|------|
| `POST /api/v1/auth/login` | 登录,返回 `{ "token": "..." }` | | `POST /api/v1/auth/login` | 登录,返回 `{ "token": "..." }` |
| `GET /api/v1/auth/status` | 查询 `{ "enabled": true \| false }` | | `GET /api/v1/auth/status` | `{ "enabled": true \| false }` |
| 受保护路由 | Header: `Authorization: Bearer <token>` | | 受保护路由 | Header: `Authorization: Bearer <token>` |
`auth_enabled` 不为 `"true"` 时,受保护路由跳过鉴权(便于本地调试)。 `auth_enabled` 不为 `"true"` 时,管理端路由跳过鉴权(便于本地调试)。用户端路由始终公开,通过 Cookie 区分会话。
### 用户端(节选)
| 方法 | 路径 | 说明 |
|------|------|------|
| POST | `/user/session` | 初始化会话 |
| GET | `/user/meta` | 可用模型与服务元信息 |
| * | `/user/conversations/*` | 对话 CRUD 与消息 |
| POST | `/user/generate` | 文生图 / 图生图 |
| POST | `/user/upload` | 上传参考图 |
| GET | `/user/tasks/:id/events` | 任务进度 SSE |
| * | `/user/generations/*`、`/user/presets/*` | 历史与预设 |
### 管理端(节选)
| 方法 | 路径 | 说明 |
|------|------|------|
| * | `/admin/providers/*` | Provider 与模型管理 |
| POST | `/admin/providers/:id/models/sync` | 从上游同步模型 |
| GET | `/admin/logs` | 请求日志 |
| GET | `/admin/dashboard/stats` | 统计 |
### 响应格式 ### 响应格式
```json ```json
// 成功 — 按场景选用
{ "ok": true } { "ok": true }
{ "items": [...], "total": 42 } { "items": [...], "total": 42 }
{ "app_name": "App", "data_dir": "./data" }
// 错误 — 统一字段
{ "error": "human readable message" } { "error": "human readable message" }
``` ```
### HTTP 状态码 列表接口支持 `?limit=`(默认 50,最大 500)与 `?offset=`。
| 码 | 含义 |
|----|------|
| 400 | 请求参数错误 |
| 401 | 未授权 |
| 404 | 资源不存在 |
| 500 | 服务器内部错误 |
### 分页
列表接口支持 `?limit=`(默认 50,最大 500)与 `?offset=`,响应含 `items``total`
--- ---
@@ -188,58 +232,43 @@ find . -type f \( -name '*.go' -o -name 'go.mod' \) \
| 来源 | 名称 | 默认 | 说明 | | 来源 | 名称 | 默认 | 说明 |
|------|------|------|------| |------|------|------|------|
| CLI | `-data` | `./data` | 数据目录(SQLite 等) | | CLI | `-data` | `./data` | 数据目录(SQLite、上传文件等) |
| CLI | `-addr` | `:8080` | HTTP 监听地址 | | CLI | `-addr` | `:8080` | HTTP 监听地址 |
| Env | `APP_DATA` | — | 覆盖 `-data` | | Env | `APP_DATA` | — | 覆盖 `-data` |
| Env | `APP_ADDR` | — | 覆盖 `-addr` | | Env | `APP_ADDR` | — | 覆盖 `-addr` |
### 运行时配置(SQLite KV ### 运行时配置(SQLite KV
业务配置存储在 `settings` 表,可通过 Web 设置页修改,无需重启:
| 键 | 说明 | | 键 | 说明 |
|----|------| |----|------|
| `app_name` | 应用名称 | | `app_name` | 应用名称(默认 `Atlas` |
| `admin_password` | 管理员密码GET 时脱敏) | | `admin_password` | 管理员密码 |
| `auth_enabled` | 是否启用认证`true` / `false` | | `auth_enabled` | 是否启用管理端认证 |
| `ui_locale` | 界面语言(`zh-CN` / `en` | | `ui_locale` | 界面语言(`zh-CN` / `en` |
| `file_sign_secret` | 文件访问签名密钥 |
新增配置项:在 `internal/settings/keys.go` 定义键与默认值,并编写 goose 迁移插入初始值 可通过管理端设置页修改,无需重启
--- ---
## 开发工作流 ## 开发工作流
### 双进程开发(推荐)
```bash ```bash
# 终端 1 — 后端 # 终端 1 — 后端
make run make run
# 终端 2 — 前端热更新 # 终端 2 — 前端热更新
make dev-web make dev-web
# 浏览器访问 http://localhost:5173 # 浏览器 http://localhost:5173
``` ```
Vite 将 `/api` 代理到 `http://127.0.0.1:8080`
### 单体构建
```bash
make build
# 1. web: npm run build → web/dist
# 2. cp web/dist/* → internal/api/static/
# 3. go build → dist/appembed 前端)
```
### 常用命令
| 命令 | 作用 | | 命令 | 作用 |
|------|------| |------|------|
| `make run` | 运行后端(不 embed 最新前端) | | `make run` | 运行后端 |
| `make dev-web` | 启动 Vite 开发服务器 | | `make dev-web` | Vite 开发服务器 |
| `make build` | 构建含 embed 前端的完整二进制 | | `make build` | 构建含 embed 前端的完整二进制 |
| `make test` | 运行 Go 测试 | | `make build-go` | 仅编译后端(跳过前端) |
| `make test` | Go 测试 |
| `make tidy` | `go mod tidy` | | `make tidy` | `go mod tidy` |
| `make clean` | 清理构建产物 | | `make clean` | 清理构建产物 |
@@ -249,58 +278,28 @@ make build
```bash ```bash
make docker-build make docker-build
docker run -d -p 8080:8080 -v app-data:/data go-vue-template:latest docker run -d -p 8080:8080 -v atlas-data:/data atlas:latest
``` ```
使用 或:
```bash ```bash
docker compose up -d docker compose up -d
``` ```
镜像采用多阶段构建(Node 编译前端 → Go 编译 → Alpine 运行),非 root 用户运行,数据持久化至 `/data` 镜像多阶段构建(Node 编译前端 → Go 编译 → Alpine 运行),非 root 用户,数据持久化至 `/data`。
--- ---
## Gitea Actions CI ## CI
仓库包含 `.gitea/workflows/ci.yml`push 到 `main`/`master` 或打 `v*` tag 时自动: 仓库包含 `.gitea/workflows/ci.yml`push 到 `main` 或打 `v*` tag 时运行测试并构建 Docker 镜像。详见 [.gitea/README.md](.gitea/README.md)。
1. 运行 `go test ./...`
2. 多架构 Docker 构建并推送到 Gitea Container Registry
**一次性配置:**
| 项 | 说明 |
|----|------|
| Runner | 标签 `ubuntu-latest`,挂载 `/var/run/docker.sock` |
| Secret | `REGISTRY_TOKEN`PAT`write:package` |
| Variable | `REGISTRY=你的公网 Gitea 域名`(内网部署时必填) |
PR 仅验证 Dockerfile 构建,不推送镜像。详细说明见 [.gitea/README.md](.gitea/README.md)。
--- ---
## 扩展指南 ## UI 设计
### 新增 REST 接口 前端样式与布局规范见 [docs/UI-DESIGN-SYSTEM.md](docs/UI-DESIGN-SYSTEM.md)。页面统一使用 `PageHeader`、`AppCard`、`StatCard` 等壳层组件,品牌色与间距 Token 定义在 `web/src/styles/global.css`。
1. `internal/store/migrations/00N_*.sql` — 建表或改表
2. `internal/store/store.go` — 仓储方法
3. `internal/api/handlers.go` — 注册路由与 Handler
4. `web/src/api/client.ts` — 类型定义与 API 调用
5. `web/src/views/` + `web/src/router/index.ts` — 页面与路由
### 新增业务模块
`internal/` 下新建包(如 `internal/worker/`),在 `service.App` 中注入,由 `api` 层调用。保持 Handler 薄、领域逻辑内聚。
### 前端规范
- 页面使用 `PageHeader` + `AppCard` 布局
- 样式变量定义在 `web/src/styles/theme.css`
- 文案走 `vue-i18n`,避免硬编码
- API 调用统一经 `web/src/api/client.ts`
--- ---
+11 -4
View File
@@ -10,9 +10,9 @@ import (
"syscall" "syscall"
"time" "time"
"github.com/rose_cat707/go-vue-template/internal/api" "github.com/rose_cat707/Atlas/internal/api"
"github.com/rose_cat707/go-vue-template/internal/service" "github.com/rose_cat707/Atlas/internal/service"
"github.com/rose_cat707/go-vue-template/internal/store" "github.com/rose_cat707/Atlas/internal/store"
) )
func main() { func main() {
@@ -30,7 +30,14 @@ func main() {
} }
defer st.Close() defer st.Close()
app := &service.App{Store: st, DataDir: *dataDir} ctx := context.Background()
cfg, _ := st.GetSettings(ctx)
signSecret := cfg["file_sign_secret"]
if signSecret == "" {
signSecret = "change-me-atlas-file-sign"
}
app := service.NewApp(st, *dataDir, signSecret)
srv := api.NewServer(app) srv := api.NewServer(app)
httpServer := &http.Server{ httpServer := &http.Server{
+1 -1
View File
@@ -1,7 +1,7 @@
services: services:
app: app:
build: . build: .
image: go-vue-template:latest image: atlas:latest
ports: ports:
- "8080:8080" - "8080:8080"
volumes: volumes:
File diff suppressed because it is too large Load Diff
+4 -9
View File
@@ -1,12 +1,13 @@
module github.com/rose_cat707/go-vue-template module github.com/rose_cat707/Atlas
go 1.25.7 go 1.25.7
require ( require (
github.com/gin-gonic/gin v1.12.0 github.com/gin-gonic/gin v1.12.0
github.com/google/uuid v1.6.0
github.com/mattn/go-sqlite3 v1.14.47
github.com/pressly/goose/v3 v3.27.1 github.com/pressly/goose/v3 v3.27.1
golang.org/x/crypto v0.53.0 golang.org/x/crypto v0.53.0
modernc.org/sqlite v1.52.0
) )
require ( require (
@@ -14,7 +15,6 @@ require (
github.com/bytedance/sonic v1.15.0 // indirect github.com/bytedance/sonic v1.15.0 // indirect
github.com/bytedance/sonic/loader v0.5.0 // indirect github.com/bytedance/sonic/loader v0.5.0 // indirect
github.com/cloudwego/base64x v0.1.6 // 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/gabriel-vasile/mimetype v1.4.12 // indirect
github.com/gin-contrib/sse v1.1.0 // indirect github.com/gin-contrib/sse v1.1.0 // indirect
github.com/go-playground/locales v0.14.1 // indirect github.com/go-playground/locales v0.14.1 // indirect
@@ -22,7 +22,6 @@ require (
github.com/go-playground/validator/v10 v10.30.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-json v0.10.5 // indirect
github.com/goccy/go-yaml v1.19.2 // indirect github.com/goccy/go-yaml v1.19.2 // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/json-iterator/go v1.1.12 // indirect github.com/json-iterator/go v1.1.12 // indirect
github.com/klauspost/cpuid/v2 v2.3.0 // indirect github.com/klauspost/cpuid/v2 v2.3.0 // indirect
github.com/leodido/go-urn v1.4.0 // indirect github.com/leodido/go-urn v1.4.0 // indirect
@@ -30,11 +29,9 @@ require (
github.com/mfridman/interpolate v0.0.2 // indirect github.com/mfridman/interpolate v0.0.2 // indirect
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
github.com/modern-go/reflect2 v1.0.2 // indirect github.com/modern-go/reflect2 v1.0.2 // indirect
github.com/ncruces/go-strftime v1.0.0 // indirect
github.com/pelletier/go-toml/v2 v2.2.4 // indirect github.com/pelletier/go-toml/v2 v2.2.4 // indirect
github.com/quic-go/qpack v0.6.0 // indirect github.com/quic-go/qpack v0.6.0 // indirect
github.com/quic-go/quic-go v0.59.0 // indirect github.com/quic-go/quic-go v0.59.0 // indirect
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
github.com/sethvargo/go-retry v0.3.0 // indirect github.com/sethvargo/go-retry v0.3.0 // indirect
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
github.com/ugorji/go/codec v1.3.1 // indirect github.com/ugorji/go/codec v1.3.1 // indirect
@@ -46,7 +43,5 @@ require (
golang.org/x/sys v0.46.0 // indirect golang.org/x/sys v0.46.0 // indirect
golang.org/x/text v0.38.0 // indirect golang.org/x/text v0.38.0 // indirect
google.golang.org/protobuf v1.36.11 // indirect google.golang.org/protobuf v1.36.11 // indirect
modernc.org/libc v1.72.3 // indirect modernc.org/sqlite v1.52.0 // indirect
modernc.org/mathutil v1.7.1 // indirect
modernc.org/memory v1.11.0 // indirect
) )
+2 -28
View File
@@ -32,12 +32,8 @@ github.com/goccy/go-yaml v1.19.2/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7Lk
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= 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/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/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs=
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k=
github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM=
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= 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/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 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y=
@@ -46,6 +42,8 @@ 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/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI=
github.com/mattn/go-isatty v0.0.21 h1:xYae+lCNBP7QuW4PUnNG61ffM4hVIfm+zUzDuSzYLGs= github.com/mattn/go-isatty v0.0.21 h1:xYae+lCNBP7QuW4PUnNG61ffM4hVIfm+zUzDuSzYLGs=
github.com/mattn/go-isatty v0.0.21/go.mod h1:ZXfXG4SQHsB/w3ZeOYbR0PrPwLy+n6xiMrJlRFqopa4= github.com/mattn/go-isatty v0.0.21/go.mod h1:ZXfXG4SQHsB/w3ZeOYbR0PrPwLy+n6xiMrJlRFqopa4=
github.com/mattn/go-sqlite3 v1.14.47 h1:jOBI62gS7nKeZv+as1oGEy0+1qISgXwH/QBlR6KbfIo=
github.com/mattn/go-sqlite3 v1.14.47/go.mod h1:6JTjA44L93a0QCyJef5YvlPoKXntQPjzWv5gtm9sB6w=
github.com/mfridman/interpolate v0.0.2 h1:pnuTK7MQIxxFz1Gr+rjSIx9u7qVjf5VOoM/u6BbAxPY= github.com/mfridman/interpolate v0.0.2 h1:pnuTK7MQIxxFz1Gr+rjSIx9u7qVjf5VOoM/u6BbAxPY=
github.com/mfridman/interpolate v0.0.2/go.mod h1:p+7uk6oE07mpE/Ik1b8EckO0O4ZXiGAfshKBWLUM9Xg= github.com/mfridman/interpolate v0.0.2/go.mod h1:p+7uk6oE07mpE/Ik1b8EckO0O4ZXiGAfshKBWLUM9Xg=
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
@@ -94,8 +92,6 @@ 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/arch v0.22.0/go.mod h1:dNHoOeKiyja7GTvF9NJS1l3Z2yntpQNzgrjh1cU103A=
golang.org/x/crypto v0.53.0 h1:QZ4Muo8THX6CizN2vPPd5fBGHyogrdK9fG4wLPFUsto= golang.org/x/crypto v0.53.0 h1:QZ4Muo8THX6CizN2vPPd5fBGHyogrdK9fG4wLPFUsto=
golang.org/x/crypto v0.53.0/go.mod h1:DNLU434OwVakk9PzuwV8w62mAJpRJL3vsgcfp4Qnsio= golang.org/x/crypto v0.53.0/go.mod h1:DNLU434OwVakk9PzuwV8w62mAJpRJL3vsgcfp4Qnsio=
golang.org/x/mod v0.36.0 h1:JJjpVx6myfUsUdAzZuOSTTmRE0PfZeNWzzvKrP7amb4=
golang.org/x/mod v0.36.0/go.mod h1:moc6ELqsWcOw5Ef3xVprK5ul/MvtVvkIXLziUOICjUQ=
golang.org/x/net v0.55.0 h1:bcvxaJn3e1U6InsFWt1JUq1aSjnRxLzT2rtD2KfkDF8= 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/net v0.55.0/go.mod h1:L5U2KuzuOe1lY7Z+aWVIKK6qEeJXnXV9yzGA+WCHJww=
golang.org/x/sync v0.21.0 h1:HLII4xRRTtCRkxYp4HNFF0Js/Og6q2i++KXbg0gHCwM= golang.org/x/sync v0.21.0 h1:HLII4xRRTtCRkxYp4HNFF0Js/Og6q2i++KXbg0gHCwM=
@@ -104,39 +100,17 @@ 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/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 h1:sXmwo9DwP3OK9EZ7PqAdaooSGozfl/3a6/xJcbzPRhE=
golang.org/x/text v0.38.0/go.mod h1:YXZt3QhHUKYT53r2lLKFIVi6Ao1jdzrTR/KQ09qyxF4= golang.org/x/text v0.38.0/go.mod h1:YXZt3QhHUKYT53r2lLKFIVi6Ao1jdzrTR/KQ09qyxF4=
golang.org/x/tools v0.45.0 h1:18qN3FAooORvApf5XjCXgsuayZOEtXf6JK18I3+ONa8=
golang.org/x/tools v0.45.0/go.mod h1:LuUGqqaXcXMEFEruIVJVm5mgDD8vww/z/SR1gQ4uE/0=
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 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.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 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
modernc.org/cc/v4 v4.28.2 h1:3tQ0lf2ADtoby2EtSP+J7IE2SHwEJdP8ioR59wx7XpY=
modernc.org/cc/v4 v4.28.2/go.mod h1:OnovgIhbbMXMu1aISnJ0wvVD1KnW+cAUJkIrAWh+kVI=
modernc.org/ccgo/v4 v4.34.0 h1:yRLPFZieg532OT4rp4JFNIVcquwalMX26G95WQDqwCQ=
modernc.org/ccgo/v4 v4.34.0/go.mod h1:AS5WYMyBakQ+fhsHhtP8mWB82KTGPkNNJDGfGQCe0/A=
modernc.org/fileutil v1.4.0 h1:j6ZzNTftVS054gi281TyLjHPp6CPHr2KCxEXjEbD6SM=
modernc.org/fileutil v1.4.0/go.mod h1:EqdKFDxiByqxLk8ozOxObDSfcVOv/54xDs/DUHdvCUU=
modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI=
modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito=
modernc.org/gc/v3 v3.1.2 h1:ZtDCnhonXSZexk/AYsegNRV1lJGgaNZJuKjJSWKyEqo=
modernc.org/gc/v3 v3.1.2/go.mod h1:HFK/6AGESC7Ex+EZJhJ2Gni6cTaYpSMmU/cT9RmlfYY=
modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks=
modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI=
modernc.org/libc v1.72.3 h1:ZnDF4tXn4NBXFutMMQC4vtbTFSXhhKzR73fv0beZEAU= modernc.org/libc v1.72.3 h1:ZnDF4tXn4NBXFutMMQC4vtbTFSXhhKzR73fv0beZEAU=
modernc.org/libc v1.72.3/go.mod h1:dn0dZNnnn1clLyvRxLxYExxiKRZIRENOfqQ8XEeg4Qs= modernc.org/libc v1.72.3/go.mod h1:dn0dZNnnn1clLyvRxLxYExxiKRZIRENOfqQ8XEeg4Qs=
modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU= modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=
modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg= modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg=
modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI= modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI=
modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw= modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw=
modernc.org/opt v0.2.0 h1:tGyef5ApycA7FSEOMraay9SaTk5zmbx7Tu+cJs4QKZg=
modernc.org/opt v0.2.0/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns=
modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w=
modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE=
modernc.org/sqlite v1.52.0 h1:p4dhYh2tXZCiyaqHwRVJDjIGKWyXayiQpThxgDzJaxo= modernc.org/sqlite v1.52.0 h1:p4dhYh2tXZCiyaqHwRVJDjIGKWyXayiQpThxgDzJaxo=
modernc.org/sqlite v1.52.0/go.mod h1:tcNzv5p84E0skkmJn038y+hWJbLQXQqEnQfeh5r2JLM= modernc.org/sqlite v1.52.0/go.mod h1:tcNzv5p84E0skkmJn038y+hWJbLQXQqEnQfeh5r2JLM=
modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0=
modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A=
modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=
modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM=
+199
View File
@@ -0,0 +1,199 @@
package api
import (
"net/http"
"strconv"
"github.com/gin-gonic/gin"
"github.com/rose_cat707/Atlas/internal/store"
)
func (s *Server) registerAdminRoutes(r gin.IRoutes) {
r.GET("/admin/providers", s.adminListProviders)
r.POST("/admin/providers", s.adminCreateProvider)
r.GET("/admin/providers/:id", s.adminGetProvider)
r.PUT("/admin/providers/:id", s.adminUpdateProvider)
r.DELETE("/admin/providers/:id", s.adminDeleteProvider)
r.GET("/admin/providers/:id/models", s.adminListProviderModels)
r.POST("/admin/providers/:id/models", s.adminAddProviderModel)
r.DELETE("/admin/providers/:id/models/:modelId", s.adminDeleteProviderModel)
r.POST("/admin/providers/:id/models/sync", s.adminSyncProviderModels)
r.GET("/admin/generations", s.adminListGenerations)
r.GET("/admin/generations/:id", s.adminGetGeneration)
r.DELETE("/admin/generations/:id", s.adminDeleteGeneration)
r.GET("/admin/dashboard/stats", s.adminDashboardStats)
}
func (s *Server) adminListProviders(c *gin.Context) {
items, err := s.app.Store.ListProviders(c.Request.Context(), c.Query("service_type"), false)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
masked := make([]store.Provider, len(items))
for i, p := range items {
masked[i] = store.MaskProvider(p)
}
c.JSON(http.StatusOK, gin.H{"items": masked})
}
func (s *Server) adminCreateProvider(c *gin.Context) {
var in store.Provider
if err := c.ShouldBindJSON(&in); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
if err := store.ValidateProvider(&in); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
if in.ExtraJSON == "" {
in.ExtraJSON = "{}"
}
id, err := s.app.Store.CreateProvider(c.Request.Context(), &in)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
in.ID = id
_ = s.app.Store.InsertAuditLog(c.Request.Context(), "create", "provider", in.Name)
c.JSON(http.StatusOK, store.MaskProvider(in))
}
func (s *Server) adminGetProvider(c *gin.Context) {
id, _ := strconv.ParseInt(c.Param("id"), 10, 64)
p, err := s.app.Store.GetProvider(c.Request.Context(), id)
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "not found"})
return
}
c.JSON(http.StatusOK, store.MaskProvider(*p))
}
func (s *Server) adminUpdateProvider(c *gin.Context) {
id, _ := strconv.ParseInt(c.Param("id"), 10, 64)
var in store.Provider
if err := c.ShouldBindJSON(&in); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
existing, err := s.app.Store.GetProvider(c.Request.Context(), id)
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "not found"})
return
}
in.ID = id
if in.APIKey == "" || in.APIKey == "******" {
in.APIKey = existing.APIKey
}
if err := store.ValidateProvider(&in); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
if err := s.app.Store.UpdateProvider(c.Request.Context(), &in); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
_ = s.app.Store.InsertAuditLog(c.Request.Context(), "update", "provider", in.Name)
c.JSON(http.StatusOK, store.MaskProvider(in))
}
func (s *Server) adminDeleteProvider(c *gin.Context) {
id, _ := strconv.ParseInt(c.Param("id"), 10, 64)
if err := s.app.Store.DeleteProvider(c.Request.Context(), id); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
_ = s.app.Store.InsertAuditLog(c.Request.Context(), "delete", "provider", strconv.FormatInt(id, 10))
c.JSON(http.StatusOK, gin.H{"ok": true})
}
func (s *Server) adminListProviderModels(c *gin.Context) {
id, _ := strconv.ParseInt(c.Param("id"), 10, 64)
breakdown, err := s.app.Models.Breakdown(c.Request.Context(), id)
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, breakdown)
}
func (s *Server) adminAddProviderModel(c *gin.Context) {
id, _ := strconv.ParseInt(c.Param("id"), 10, 64)
var in struct {
ModelID string `json:"model_id"`
Name string `json:"name"`
}
if err := c.ShouldBindJSON(&in); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
if err := s.app.Models.AddManual(c.Request.Context(), id, in.ModelID, in.Name); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
breakdown, _ := s.app.Models.Breakdown(c.Request.Context(), id)
c.JSON(http.StatusOK, breakdown)
}
func (s *Server) adminDeleteProviderModel(c *gin.Context) {
id, _ := strconv.ParseInt(c.Param("id"), 10, 64)
modelID := c.Param("modelId")
if err := s.app.Models.RemoveManual(c.Request.Context(), id, modelID); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
breakdown, _ := s.app.Models.Breakdown(c.Request.Context(), id)
c.JSON(http.StatusOK, breakdown)
}
func (s *Server) adminSyncProviderModels(c *gin.Context) {
id, _ := strconv.ParseInt(c.Param("id"), 10, 64)
upstream, err := s.app.Models.RefreshUpstream(c.Request.Context(), id)
if err != nil {
c.JSON(http.StatusBadGateway, gin.H{"error": err.Error()})
return
}
breakdown, _ := s.app.Models.Breakdown(c.Request.Context(), id)
c.JSON(http.StatusOK, gin.H{"upstream": upstream, "breakdown": breakdown})
}
func (s *Server) adminListGenerations(c *gin.Context) {
limit, offset := parseLimitOffset(c, 50, 500)
items, total, err := s.app.Store.ListAllGenerations(c.Request.Context(), c.Query("service_type"), limit, offset)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"items": items, "total": total})
}
func (s *Server) adminGetGeneration(c *gin.Context) {
id, _ := strconv.ParseInt(c.Param("id"), 10, 64)
item, err := s.app.Store.AdminGetGeneration(c.Request.Context(), id)
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "not found"})
return
}
c.JSON(http.StatusOK, item)
}
func (s *Server) adminDeleteGeneration(c *gin.Context) {
id, _ := strconv.ParseInt(c.Param("id"), 10, 64)
if err := s.app.Store.AdminDeleteGeneration(c.Request.Context(), id); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
_ = s.app.Store.InsertAuditLog(c.Request.Context(), "delete", "generation", strconv.FormatInt(id, 10))
c.JSON(http.StatusOK, gin.H{"ok": true})
}
func (s *Server) adminDashboardStats(c *gin.Context) {
stats, err := s.app.Store.DashboardStats(c.Request.Context())
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, stats)
}
+25
View File
@@ -0,0 +1,25 @@
package api
import (
"github.com/gin-gonic/gin"
)
func (s *Server) registerGatewayRoutes(r *gin.Engine) {
v1 := r.Group("/v1")
v1.Use(s.sessionMiddleware())
{
v1.GET("/models", s.gatewayModels)
v1.POST("/chat/completions", s.gatewayChatCompletions)
v1.POST("/responses", s.gatewayResponses)
v1.POST("/completions", s.gatewayProxyOpenAI("/v1/completions"))
v1.POST("/embeddings", s.gatewayProxyOpenAI("/v1/embeddings"))
v1.POST("/rerank", s.gatewayProxyOpenAI("/v1/rerank"))
v1.POST("/predictions", s.gatewayCreatePrediction)
v1.GET("/predictions", s.gatewayListPredictions)
v1.GET("/predictions/:id", s.gatewayGetPrediction)
v1.POST("/predictions/:id/cancel", s.gatewayCancelPrediction)
v1.GET("/predictions/:id/stream", s.gatewayStreamPrediction)
}
}
+221
View File
@@ -0,0 +1,221 @@
package api
import (
"encoding/json"
"fmt"
"io"
"net/http"
"strconv"
"strings"
"github.com/gin-gonic/gin"
"github.com/rose_cat707/Atlas/internal/domain"
"github.com/rose_cat707/Atlas/internal/provider"
)
func (s *Server) gatewayProviderID(c *gin.Context) int64 {
if v := c.GetHeader("X-Provider-Id"); v != "" {
id, _ := strconv.ParseInt(v, 10, 64)
return id
}
return 0
}
func (s *Server) gatewayModels(c *gin.Context) {
ctx := c.Request.Context()
serviceType := c.DefaultQuery("service_type", string(domain.ServiceChat))
protocol := c.DefaultQuery("protocol", domain.DefaultProtocol(domain.ServiceType(serviceType)))
cfg, err := s.app.Router.ResolveByProtocol(ctx, serviceType, protocol, s.gatewayProviderID(c))
if err != nil {
c.JSON(http.StatusBadGateway, gin.H{"error": err.Error()})
return
}
models, err := s.app.Models.ListEffective(ctx, cfg)
if err != nil || len(models) == 0 {
upstream, fetchErr := s.app.Models.FetchUpstream(ctx, cfg)
if fetchErr == nil && len(upstream) > 0 {
data := map[string]any{
"object": "list",
"data": upstream,
}
out, _ := json.Marshal(data)
c.Data(http.StatusOK, "application/json", out)
return
}
path := provider.ModelsPath(cfg)
res, err := s.app.Router.ProxyWithMeta(ctx, cfg, "GET", path, nil, nil, provider.CallMeta{
SessionID: sessionID(c),
Method: "GET",
Path: path,
})
if err != nil {
c.JSON(http.StatusBadGateway, gin.H{"error": err.Error()})
return
}
c.Data(res.StatusCode, "application/json", res.Body)
return
}
data := map[string]any{
"object": "list",
"data": models,
}
out, _ := json.Marshal(data)
c.Data(http.StatusOK, "application/json", out)
}
func (s *Server) gatewayChatCompletions(c *gin.Context) {
body, err := io.ReadAll(c.Request.Body)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
var req map[string]any
_ = json.Unmarshal(body, &req)
model, _ := req["model"].(string)
stream, _ := req["stream"].(bool)
ctx := c.Request.Context()
cfg, err := s.app.Router.ResolveByProtocol(ctx, string(domain.ServiceChat), "openai", s.gatewayProviderID(c))
if err != nil {
c.JSON(http.StatusBadGateway, gin.H{"error": err.Error()})
return
}
meta := provider.CallMeta{
SessionID: sessionID(c),
Method: "POST",
Path: "/v1/chat/completions",
Model: model,
}
if stream {
c.Header("Content-Type", "text/event-stream")
c.Header("Cache-Control", "no-cache")
c.Header("Connection", "keep-alive")
err = s.app.Router.ProxyStream(ctx, cfg, "POST", "/v1/chat/completions", body, nil, meta, c.Writer, func() { c.Writer.Flush() })
if err != nil {
c.JSON(http.StatusBadGateway, gin.H{"error": err.Error()})
}
return
}
res, err := s.app.Router.ProxyWithMeta(ctx, cfg, "POST", "/v1/chat/completions", body, nil, meta)
if err != nil {
c.JSON(http.StatusBadGateway, gin.H{"error": err.Error()})
return
}
c.Data(res.StatusCode, "application/json", res.Body)
}
func (s *Server) gatewayResponses(c *gin.Context) {
body, err := io.ReadAll(c.Request.Body)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
chatBody, model, err := responsesToChat(body)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
ctx := c.Request.Context()
cfg, err := s.app.Router.ResolveByProtocol(ctx, string(domain.ServiceChat), "openai", s.gatewayProviderID(c))
if err != nil {
c.JSON(http.StatusBadGateway, gin.H{"error": err.Error()})
return
}
meta := provider.CallMeta{
SessionID: sessionID(c),
Method: "POST",
Path: "/v1/responses",
Model: model,
}
c.Header("Content-Type", "text/event-stream")
c.Header("Cache-Control", "no-cache")
c.Header("Connection", "keep-alive")
if err := s.app.Router.ProxyStream(ctx, cfg, "POST", "/v1/chat/completions", chatBody, nil, meta, c.Writer, func() { c.Writer.Flush() }); err != nil {
c.JSON(http.StatusBadGateway, gin.H{"error": err.Error()})
}
}
func (s *Server) gatewayProxyOpenAI(path string) gin.HandlerFunc {
return func(c *gin.Context) {
body, err := io.ReadAll(c.Request.Body)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
var req map[string]any
_ = json.Unmarshal(body, &req)
model, _ := req["model"].(string)
ctx := c.Request.Context()
cfg, err := s.app.Router.ResolveByProtocol(ctx, string(domain.ServiceChat), "openai", s.gatewayProviderID(c))
if err != nil {
c.JSON(http.StatusBadGateway, gin.H{"error": err.Error()})
return
}
meta := provider.CallMeta{
SessionID: sessionID(c),
Method: c.Request.Method,
Path: path,
Model: model,
}
res, err := s.app.Router.ProxyWithMeta(ctx, cfg, c.Request.Method, path, body, nil, meta)
if err != nil {
c.JSON(http.StatusBadGateway, gin.H{"error": err.Error()})
return
}
c.Data(res.StatusCode, "application/json", res.Body)
}
}
func responsesToChat(body []byte) ([]byte, string, error) {
var req map[string]any
if err := json.Unmarshal(body, &req); err != nil {
return nil, "", err
}
model, _ := req["model"].(string)
messages := []map[string]string{}
if input, ok := req["input"].(string); ok && input != "" {
messages = append(messages, map[string]string{"role": "user", "content": input})
}
if items, ok := req["input"].([]any); ok {
for _, item := range items {
m, ok := item.(map[string]any)
if !ok {
continue
}
role, _ := m["role"].(string)
if role == "" {
role = "user"
}
content, _ := m["content"].(string)
if content == "" {
if text, ok := m["text"].(string); ok {
content = text
}
}
if content != "" {
messages = append(messages, map[string]string{"role": role, "content": content})
}
}
}
if len(messages) == 0 {
return nil, "", fmt.Errorf("responses input required")
}
out, err := json.Marshal(map[string]any{
"model": model,
"messages": messages,
"stream": true,
})
return out, model, err
}
func inferImageServiceType(input map[string]any) domain.ServiceType {
if _, ok := input["image"]; ok {
return domain.ServiceImageToImage
}
return domain.ServiceTextToImage
}
func gatewayPreferWait(c *gin.Context) bool {
return strings.Contains(strings.ToLower(c.GetHeader("Prefer")), "wait")
}
+157
View File
@@ -0,0 +1,157 @@
package api
import (
"encoding/json"
"io"
"net/http"
"github.com/gin-gonic/gin"
"github.com/rose_cat707/Atlas/internal/domain"
"github.com/rose_cat707/Atlas/internal/provider"
)
func (s *Server) gatewayCreatePrediction(c *gin.Context) {
body, err := io.ReadAll(c.Request.Body)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
var req map[string]any
if err := json.Unmarshal(body, &req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
model, _ := req["model"].(string)
version, _ := req["version"].(string)
if version == "" {
version = model
}
input, _ := req["input"].(map[string]any)
if input == nil {
input = map[string]any{}
}
params, _ := json.Marshal(input)
serviceType := inferImageServiceType(input)
mode := domain.ModeAsync
if gatewayPreferWait(c) {
mode = domain.ModeSync
}
ctx := c.Request.Context()
result, err := s.app.Invoke.Run(ctx, domain.InvokeRequest{
SessionID: sessionID(c),
ServiceType: serviceType,
ProviderID: s.gatewayProviderID(c),
Model: version,
Params: params,
Mode: mode,
})
if err != nil {
c.JSON(http.StatusBadGateway, gin.H{"error": err.Error()})
return
}
if mode == domain.ModeSync {
var output any
_ = json.Unmarshal(result.Output, &output)
c.JSON(http.StatusOK, gin.H{
"id": "sync",
"status": "succeeded",
"output": output,
})
return
}
task, err := s.app.Store.GetTask(ctx, sessionID(c), *result.TaskID)
if err != nil {
c.JSON(http.StatusOK, gin.H{"id": result.TaskID, "status": "starting"})
return
}
c.JSON(http.StatusOK, gin.H{
"id": task.ExternalID,
"status": task.Status,
})
}
func (s *Server) gatewayListPredictions(c *gin.Context) {
ctx := c.Request.Context()
cfg, err := s.app.Router.ResolveByProtocol(ctx, string(domain.ServiceImageGeneration), "replicate", s.gatewayProviderID(c))
if err != nil {
c.JSON(http.StatusBadGateway, gin.H{"error": err.Error()})
return
}
meta := provider.CallMeta{
SessionID: sessionID(c),
Method: "GET",
Path: "/v1/predictions",
}
res, err := s.app.Router.ProxyWithMeta(ctx, cfg, "GET", "/v1/predictions", nil, nil, meta)
if err != nil {
c.JSON(http.StatusBadGateway, gin.H{"error": err.Error()})
return
}
c.Data(res.StatusCode, "application/json", res.Body)
}
func (s *Server) gatewayGetPrediction(c *gin.Context) {
ctx := c.Request.Context()
cfg, err := s.app.Router.ResolveByProtocol(ctx, string(domain.ServiceImageGeneration), "replicate", s.gatewayProviderID(c))
if err != nil {
c.JSON(http.StatusBadGateway, gin.H{"error": err.Error()})
return
}
path := "/v1/predictions/" + c.Param("id")
meta := provider.CallMeta{
SessionID: sessionID(c),
Method: "GET",
Path: "/v1/predictions/:id",
}
res, err := s.app.Router.ProxyWithMeta(ctx, cfg, "GET", path, nil, nil, meta)
if err != nil {
c.JSON(http.StatusBadGateway, gin.H{"error": err.Error()})
return
}
c.Data(res.StatusCode, "application/json", res.Body)
}
func (s *Server) gatewayCancelPrediction(c *gin.Context) {
ctx := c.Request.Context()
id := c.Param("id")
cfg, err := s.app.Router.ResolveByProtocol(ctx, string(domain.ServiceImageGeneration), "replicate", s.gatewayProviderID(c))
if err != nil {
c.JSON(http.StatusBadGateway, gin.H{"error": err.Error()})
return
}
path := "/v1/predictions/" + id + "/cancel"
meta := provider.CallMeta{
SessionID: sessionID(c),
Method: "POST",
Path: "/v1/predictions/:id/cancel",
}
res, err := s.app.Router.ProxyWithMeta(ctx, cfg, "POST", path, nil, nil, meta)
if err != nil {
c.JSON(http.StatusBadGateway, gin.H{"error": err.Error()})
return
}
c.Data(res.StatusCode, "application/json", res.Body)
}
func (s *Server) gatewayStreamPrediction(c *gin.Context) {
ctx := c.Request.Context()
id := c.Param("id")
cfg, err := s.app.Router.ResolveByProtocol(ctx, string(domain.ServiceImageGeneration), "replicate", s.gatewayProviderID(c))
if err != nil {
c.JSON(http.StatusBadGateway, gin.H{"error": err.Error()})
return
}
path := "/v1/predictions/" + id + "/stream"
meta := provider.CallMeta{
SessionID: sessionID(c),
Method: "GET",
Path: "/v1/predictions/:id/stream",
}
c.Header("Content-Type", "text/event-stream")
c.Header("Cache-Control", "no-cache")
c.Header("Connection", "keep-alive")
if err := s.app.Router.ProxyStream(ctx, cfg, "GET", path, nil, nil, meta, c.Writer, func() { c.Writer.Flush() }); err != nil {
c.JSON(http.StatusBadGateway, gin.H{"error": err.Error()})
}
}
+32 -4
View File
@@ -9,16 +9,20 @@ import (
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
"golang.org/x/crypto/bcrypt" "golang.org/x/crypto/bcrypt"
"github.com/rose_cat707/go-vue-template/internal/service" "github.com/rose_cat707/Atlas/internal/service"
"github.com/rose_cat707/go-vue-template/internal/settings" "github.com/rose_cat707/Atlas/internal/settings"
) )
type Server struct { type Server struct {
app *service.App app *service.App
loginGuard *loginGuard
} }
func NewServer(app *service.App) *Server { func NewServer(app *service.App) *Server {
return &Server{app: app} return &Server{
app: app,
loginGuard: newLoginGuard(),
}
} }
func (s *Server) Router() *gin.Engine { func (s *Server) Router() *gin.Engine {
@@ -41,8 +45,14 @@ func (s *Server) Router() *gin.Engine {
protected.GET("/settings", s.getSettings) protected.GET("/settings", s.getSettings)
protected.PUT("/settings", s.putSettings) protected.PUT("/settings", s.putSettings)
protected.GET("/audit-logs", s.listAuditLogs) protected.GET("/audit-logs", s.listAuditLogs)
s.registerAdminRoutes(protected)
} }
user := api.Group("")
s.registerUserRoutes(user)
s.registerGatewayRoutes(r)
r.NoRoute(s.serveSPA) r.NoRoute(s.serveSPA)
return r return r
} }
@@ -96,6 +106,15 @@ func (s *Server) authStatus(c *gin.Context) {
} }
func (s *Server) login(c *gin.Context) { func (s *Server) login(c *gin.Context) {
ip := c.ClientIP()
if locked, retryAfter := s.loginGuard.check(ip); locked {
c.JSON(http.StatusTooManyRequests, gin.H{
"error": "too many login attempts",
"retry_after": retryAfter,
})
return
}
var req loginReq var req loginReq
if err := c.ShouldBindJSON(&req); err != nil { if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
@@ -109,9 +128,18 @@ func (s *Server) login(c *gin.Context) {
} }
hash := cfg[settings.KeyAdminPassword] hash := cfg[settings.KeyAdminPassword]
if !checkPassword(req.Password, hash) { if !checkPassword(req.Password, hash) {
time.Sleep(loginMinDelay)
if locked, retryAfter := s.loginGuard.recordFailure(ip); locked {
c.JSON(http.StatusTooManyRequests, gin.H{
"error": "too many login attempts",
"retry_after": retryAfter,
})
return
}
c.JSON(http.StatusUnauthorized, gin.H{"error": "invalid password"}) c.JSON(http.StatusUnauthorized, gin.H{"error": "invalid password"})
return return
} }
s.loginGuard.reset(ip)
token := issueToken(hash, 24*time.Hour) token := issueToken(hash, 24*time.Hour)
c.JSON(http.StatusOK, gin.H{"token": token}) c.JSON(http.StatusOK, gin.H{"token": token})
} }
+68
View File
@@ -0,0 +1,68 @@
package api
import (
"sync"
"time"
)
const (
loginMaxFailures = 5
loginLockDuration = 15 * time.Minute
loginMinDelay = time.Second
)
type loginGuard struct {
mu sync.Mutex
byIP map[string]*loginGuardEntry
}
type loginGuardEntry struct {
failures int
lockedUntil time.Time
}
func newLoginGuard() *loginGuard {
return &loginGuard{byIP: make(map[string]*loginGuardEntry)}
}
func (g *loginGuard) check(ip string) (locked bool, retryAfter int) {
g.mu.Lock()
defer g.mu.Unlock()
e := g.byIP[ip]
if e == nil {
return false, 0
}
now := time.Now()
if now.Before(e.lockedUntil) {
return true, int(e.lockedUntil.Sub(now).Seconds()) + 1
}
if e.failures >= loginMaxFailures {
e.failures = 0
e.lockedUntil = time.Time{}
}
return false, 0
}
func (g *loginGuard) recordFailure(ip string) (locked bool, retryAfter int) {
g.mu.Lock()
defer g.mu.Unlock()
e := g.byIP[ip]
if e == nil {
e = &loginGuardEntry{}
g.byIP[ip] = e
}
e.failures++
if e.failures >= loginMaxFailures {
e.lockedUntil = time.Now().Add(loginLockDuration)
return true, int(loginLockDuration.Seconds())
}
return false, 0
}
func (g *loginGuard) reset(ip string) {
g.mu.Lock()
delete(g.byIP, ip)
g.mu.Unlock()
}
+50
View File
@@ -0,0 +1,50 @@
package api
import (
"testing"
"time"
)
func TestLoginGuardLockout(t *testing.T) {
g := newLoginGuard()
ip := "203.0.113.1"
for i := 0; i < loginMaxFailures-1; i++ {
locked, _ := g.recordFailure(ip)
if locked {
t.Fatalf("unexpected lock on attempt %d", i+1)
}
}
if locked, _ := g.check(ip); locked {
t.Fatal("should not be locked before threshold")
}
locked, retryAfter := g.recordFailure(ip)
if !locked || retryAfter <= 0 {
t.Fatalf("expected lockout, locked=%v retryAfter=%d", locked, retryAfter)
}
if locked, _ := g.check(ip); !locked {
t.Fatal("expected locked after threshold")
}
g.reset(ip)
if locked, _ := g.check(ip); locked {
t.Fatal("expected unlocked after reset")
}
}
func TestLoginGuardLockExpires(t *testing.T) {
g := newLoginGuard()
ip := "203.0.113.2"
g.mu.Lock()
g.byIP[ip] = &loginGuardEntry{
failures: loginMaxFailures,
lockedUntil: time.Now().Add(-time.Second),
}
g.mu.Unlock()
if locked, _ := g.check(ip); locked {
t.Fatal("expected expired lock to clear")
}
}
+40
View File
@@ -0,0 +1,40 @@
package api
import (
"net/http"
"time"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
)
const sessionCookie = "Atlas-Session"
func (s *Server) sessionMiddleware() gin.HandlerFunc {
return func(c *gin.Context) {
sid, err := c.Cookie(sessionCookie)
if err != nil || sid == "" {
sid = uuid.New().String()
http.SetCookie(c.Writer, &http.Cookie{
Name: sessionCookie,
Value: sid,
Path: "/",
MaxAge: int((30 * 24 * time.Hour).Seconds()),
HttpOnly: true,
SameSite: http.SameSiteLaxMode,
})
}
_ = s.app.Store.UpsertSession(c.Request.Context(), sid)
c.Set("session_id", sid)
c.Next()
}
}
func sessionID(c *gin.Context) string {
if v, ok := c.Get("session_id"); ok {
if s, ok := v.(string); ok {
return s
}
}
return ""
}
+84
View File
@@ -0,0 +1,84 @@
package api
import (
"context"
"encoding/json"
"fmt"
"net/http"
"strconv"
"time"
"github.com/gin-gonic/gin"
"github.com/rose_cat707/Atlas/internal/service/taskevents"
"github.com/rose_cat707/Atlas/internal/store"
)
func (s *Server) streamTaskEvents(c *gin.Context) {
id, err := strconv.ParseInt(c.Param("id"), 10, 64)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid task id"})
return
}
ctx := c.Request.Context()
sid := sessionID(c)
task, err := s.resolveTask(ctx, sid, id)
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "not found"})
return
}
c.Header("Content-Type", "text/event-stream")
c.Header("Cache-Control", "no-cache")
c.Header("Connection", "keep-alive")
c.Status(http.StatusOK)
writeTaskSSE(c, task)
if taskevents.Terminal(task.Status) {
writeSSEDone(c)
return
}
ch := s.app.TaskEvents.Subscribe(id)
defer s.app.TaskEvents.Unsubscribe(id, ch)
heartbeat := time.NewTicker(15 * time.Second)
defer heartbeat.Stop()
for {
select {
case <-ctx.Done():
return
case t := <-ch:
writeTaskSSE(c, &t)
if taskevents.Terminal(t.Status) {
writeSSEDone(c)
return
}
case <-heartbeat.C:
fmt.Fprintf(c.Writer, ": heartbeat\n\n")
c.Writer.Flush()
}
}
}
func (s *Server) resolveTask(ctx context.Context, sessionID string, id int64) (*store.Task, error) {
key := "task:" + strconv.FormatInt(id, 10)
if v, ok := s.app.Store.Cache.Get(key); ok {
if t, ok := v.(*store.Task); ok {
return t, nil
}
}
return s.app.Store.GetTask(ctx, sessionID, id)
}
func writeTaskSSE(c *gin.Context, task *store.Task) {
data, _ := json.Marshal(task)
fmt.Fprintf(c.Writer, "event: task\ndata: %s\n\n", data)
c.Writer.Flush()
}
func writeSSEDone(c *gin.Context) {
fmt.Fprintf(c.Writer, "event: done\ndata: [DONE]\n\n")
c.Writer.Flush()
}
+429
View File
@@ -0,0 +1,429 @@
package api
import (
"encoding/json"
"net/http"
"os"
"strconv"
"time"
"github.com/gin-gonic/gin"
"github.com/rose_cat707/Atlas/internal/domain"
"github.com/rose_cat707/Atlas/internal/provider"
"github.com/rose_cat707/Atlas/internal/service/asset"
"github.com/rose_cat707/Atlas/internal/service/chat"
"github.com/rose_cat707/Atlas/internal/store"
)
func (s *Server) registerUserRoutes(r gin.IRoutes) {
r.Use(s.sessionMiddleware())
r.POST("/user/session", s.userSession)
r.GET("/user/meta", s.userMeta)
r.GET("/user/conversations", s.listConversations)
r.POST("/user/conversations", s.createConversation)
r.POST("/user/conversations/messages", s.postNewConversationMessage)
r.GET("/user/conversations/:id", s.getConversation)
r.PUT("/user/conversations/:id", s.updateConversation)
r.DELETE("/user/conversations/:id", s.deleteConversation)
r.GET("/user/conversations/:id/messages", s.listMessages)
r.POST("/user/conversations/:id/messages", s.postMessage)
r.POST("/user/generate", s.userGenerate)
r.POST("/user/upload", s.userUpload)
r.GET("/user/files/:id", s.serveFile)
r.GET("/user/generations", s.listGenerations)
r.GET("/user/generations/:id", s.getGeneration)
r.DELETE("/user/generations/:id", s.deleteGeneration)
r.GET("/user/presets", s.listPresets)
r.POST("/user/presets", s.createPreset)
r.PUT("/user/presets/:id", s.updatePreset)
r.DELETE("/user/presets/:id", s.deletePreset)
r.GET("/user/tasks/active", s.listActiveTasks)
r.GET("/user/tasks/:id/events", s.streamTaskEvents)
r.GET("/user/tasks/:id", s.getTask)
r.POST("/user/tasks/:id/cancel", s.cancelTask)
}
func (s *Server) userSession(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"session_id": sessionID(c)})
}
func (s *Server) userMeta(c *gin.Context) {
ctx := c.Request.Context()
specs := s.app.Invoke.Specs()
imageModels, _ := s.app.Models.ListImageModelsForMeta(ctx)
out := gin.H{}
for st, spec := range specs {
providers, err := s.app.Store.ListProviders(ctx, string(st), true)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
var provList []gin.H
var allModels any
switch st {
case domain.ServiceImageGeneration:
allModels = imageModels
default:
var entries []provider.ModelEntry
for _, p := range providers {
cfg, err := s.app.Router.Resolve(c.Request.Context(), string(st), p.ID)
if err != nil {
continue
}
models, err := s.app.Models.ListEffective(ctx, cfg)
if err != nil {
continue
}
entries = append(entries, models...)
}
allModels = entries
}
for _, p := range providers {
provList = append(provList, gin.H{"id": p.ID, "name": p.Name, "is_default": p.IsDefault})
}
caps := gin.H{}
if spec.Capabilities.Stream != nil {
caps["stream"] = true
}
if st == domain.ServiceImageGeneration {
if spec.Capabilities.Async != nil {
caps["async"] = true
}
} else {
if spec.Capabilities.Sync != nil {
caps["sync"] = true
}
if spec.Capabilities.Async != nil {
caps["async"] = true
}
}
defaultModel := ""
switch models := allModels.(type) {
case []provider.ModelDetail:
defaultModel = provider.DefaultModelDetail(models)
case []provider.ModelEntry:
defaultModel = provider.DefaultModel(models)
}
out[string(st)] = gin.H{
"capabilities": caps,
"providers": provList,
"models": allModels,
"default_model": defaultModel,
}
}
c.JSON(http.StatusOK, gin.H{"service_types": out})
}
func (s *Server) listConversations(c *gin.Context) {
items, err := s.app.Store.ListConversations(c.Request.Context(), sessionID(c))
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"items": items})
}
func (s *Server) createConversation(c *gin.Context) {
c.JSON(http.StatusBadRequest, gin.H{"error": "conversation is created when sending the first message"})
}
func (s *Server) postNewConversationMessage(c *gin.Context) {
s.streamConversationMessage(c, 0)
}
func (s *Server) getConversation(c *gin.Context) {
id, _ := strconv.ParseInt(c.Param("id"), 10, 64)
conv, err := s.app.Store.GetConversation(c.Request.Context(), sessionID(c), id)
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "not found"})
return
}
c.JSON(http.StatusOK, conv)
}
func (s *Server) updateConversation(c *gin.Context) {
id, _ := strconv.ParseInt(c.Param("id"), 10, 64)
var in store.Conversation
if err := c.ShouldBindJSON(&in); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
in.ID = id
in.SessionID = sessionID(c)
if err := s.app.Store.UpdateConversation(c.Request.Context(), &in); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, in)
}
func (s *Server) deleteConversation(c *gin.Context) {
id, _ := strconv.ParseInt(c.Param("id"), 10, 64)
if err := s.app.Store.DeleteConversation(c.Request.Context(), sessionID(c), id); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"ok": true})
}
func (s *Server) listMessages(c *gin.Context) {
id, _ := strconv.ParseInt(c.Param("id"), 10, 64)
items, err := s.app.Store.ListMessages(c.Request.Context(), id, 80)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"items": items})
}
func (s *Server) postMessage(c *gin.Context) {
id, _ := strconv.ParseInt(c.Param("id"), 10, 64)
s.streamConversationMessage(c, id)
}
func (s *Server) streamConversationMessage(c *gin.Context, convID int64) {
var in struct {
Content string `json:"content"`
Model string `json:"model"`
ProviderID int64 `json:"provider_id"`
}
if err := c.ShouldBindJSON(&in); err != nil || in.Content == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "content required"})
return
}
ctx := c.Request.Context()
sid := sessionID(c)
if convID == 0 {
conv := &store.Conversation{
SessionID: sid,
Title: "新对话",
Model: in.Model,
ParamsJSON: "{}",
}
id, err := s.app.Store.CreateConversation(ctx, conv)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
convID = id
} else if _, err := s.app.Store.GetConversation(ctx, sid, convID); err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "not found"})
return
}
c.Header("X-Conversation-Id", strconv.FormatInt(convID, 10))
c.Header("Content-Type", "text/event-stream")
c.Header("Cache-Control", "no-cache")
c.Header("Connection", "keep-alive")
c.Status(http.StatusOK)
err := s.app.Chat.StreamMessage(ctx, chat.StreamRequest{
SessionID: sid,
ConversationID: convID,
Content: in.Content,
Model: in.Model,
ProviderID: in.ProviderID,
}, c.Writer, func() { c.Writer.Flush() })
if err != nil {
_, _ = c.Writer.Write([]byte("data: " + `{"error":"` + err.Error() + `"}` + "\n\n"))
c.Writer.Flush()
}
}
func (s *Server) userGenerate(c *gin.Context) {
var in struct {
ServiceType string `json:"service_type"`
Params json.RawMessage `json:"params"`
Model string `json:"model"`
ProviderID int64 `json:"provider_id"`
}
if err := c.ShouldBindJSON(&in); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
st := domain.ServiceType(in.ServiceType)
if !domain.IsImageInvokeService(st) {
c.JSON(http.StatusBadRequest, gin.H{"error": "unsupported service_type"})
return
}
result, err := s.app.Invoke.Run(c.Request.Context(), domain.InvokeRequest{
SessionID: sessionID(c),
ServiceType: st,
ProviderID: in.ProviderID,
Model: in.Model,
Params: in.Params,
Mode: domain.ModeAsync,
})
if err != nil {
c.JSON(http.StatusBadGateway, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, result)
}
func (s *Server) userUpload(c *gin.Context) {
file, err := c.FormFile("file")
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "file required"})
return
}
f, err := file.Open()
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
defer f.Close()
path, size, mimeType, err := asset.SaveUpload(s.app.DataDir, sessionID(c), file.Filename, f, 10<<20)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
exp := time.Now().Add(7 * 24 * time.Hour).UTC().Format(time.RFC3339)
rec := &store.FileRecord{
SessionID: sessionID(c),
Kind: "upload",
Mime: mimeType,
StoragePath: path,
SizeBytes: size,
ExpiresAt: &exp,
}
id, err := s.app.Store.InsertFile(c.Request.Context(), rec)
if err != nil {
os.Remove(path)
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
signed := s.app.Assets.SignedURL("/api/v1", id, 7*24*time.Hour)
c.JSON(http.StatusOK, gin.H{"file_id": id, "signed_url": signed, "mime": mimeType})
}
func (s *Server) serveFile(c *gin.Context) {
id, _ := strconv.ParseInt(c.Param("id"), 10, 64)
expStr := c.Query("exp")
sig := c.Query("sig")
exp, sig, err := asset.ParseSignedQuery(expStr, sig)
if err != nil || !s.app.Assets.Verify(id, exp, sig) {
c.JSON(http.StatusForbidden, gin.H{"error": "invalid signature"})
return
}
rec, err := s.app.Store.GetFile(c.Request.Context(), sessionID(c), id)
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "not found"})
return
}
c.Header("Content-Type", rec.Mime)
c.File(rec.StoragePath)
}
func (s *Server) listGenerations(c *gin.Context) {
limit, offset := parseLimitOffset(c, 20, 100)
st := c.Query("service_type")
items, total, err := s.app.Store.ListGenerations(c.Request.Context(), sessionID(c), st, limit, offset)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"items": items, "total": total})
}
func (s *Server) getGeneration(c *gin.Context) {
id, _ := strconv.ParseInt(c.Param("id"), 10, 64)
g, err := s.app.Store.GetGeneration(c.Request.Context(), sessionID(c), id)
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "not found"})
return
}
c.JSON(http.StatusOK, g)
}
func (s *Server) deleteGeneration(c *gin.Context) {
id, _ := strconv.ParseInt(c.Param("id"), 10, 64)
if err := s.app.Store.DeleteGeneration(c.Request.Context(), sessionID(c), id); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"ok": true})
}
func (s *Server) listPresets(c *gin.Context) {
items, err := s.app.Store.ListPresets(c.Request.Context(), sessionID(c), c.Query("service_type"))
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"items": items})
}
func (s *Server) createPreset(c *gin.Context) {
var in store.Preset
if err := c.ShouldBindJSON(&in); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
in.SessionID = sessionID(c)
id, err := s.app.Store.CreatePreset(c.Request.Context(), &in)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
in.ID = id
c.JSON(http.StatusOK, in)
}
func (s *Server) updatePreset(c *gin.Context) {
id, _ := strconv.ParseInt(c.Param("id"), 10, 64)
var in store.Preset
if err := c.ShouldBindJSON(&in); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
in.ID = id
in.SessionID = sessionID(c)
if err := s.app.Store.UpdatePreset(c.Request.Context(), &in); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, in)
}
func (s *Server) deletePreset(c *gin.Context) {
id, _ := strconv.ParseInt(c.Param("id"), 10, 64)
if err := s.app.Store.DeletePreset(c.Request.Context(), sessionID(c), id); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"ok": true})
}
func (s *Server) listActiveTasks(c *gin.Context) {
items, err := s.app.Store.ListActiveTasks(c.Request.Context(), sessionID(c), c.Query("service_type"))
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"items": items})
}
func (s *Server) getTask(c *gin.Context) {
id, _ := strconv.ParseInt(c.Param("id"), 10, 64)
if v, ok := s.app.Store.Cache.Get("task:" + strconv.FormatInt(id, 10)); ok {
c.JSON(http.StatusOK, v)
return
}
t, err := s.app.Store.GetTask(c.Request.Context(), sessionID(c), id)
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "not found"})
return
}
c.JSON(http.StatusOK, t)
}
func (s *Server) cancelTask(c *gin.Context) {
id, _ := strconv.ParseInt(c.Param("id"), 10, 64)
if err := s.app.Invoke.CancelTask(c.Request.Context(), sessionID(c), id); err != nil {
c.JSON(http.StatusBadGateway, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"ok": true})
}
+57
View File
@@ -0,0 +1,57 @@
package domain
import "encoding/json"
type ServiceType string
const (
ServiceChat ServiceType = "chat"
ServiceImageGeneration ServiceType = "image_generation"
ServiceTextToImage ServiceType = "text_to_image" // legacy invoke/history kind
ServiceImageToImage ServiceType = "image_to_image" // legacy invoke/history kind
)
func IsImageInvokeService(st ServiceType) bool {
return st == ServiceImageGeneration || st == ServiceTextToImage || st == ServiceImageToImage
}
type InvokeMode string
const (
ModeStream InvokeMode = "stream"
ModeSync InvokeMode = "sync"
ModeAsync InvokeMode = "async"
)
type InvokeContext struct {
ConversationID int64 `json:"conversation_id,omitempty"`
Messages []ChatMessage `json:"messages,omitempty"`
}
type ChatMessage struct {
Role string `json:"role"`
Content string `json:"content"`
}
type InvokeRequest struct {
SessionID string `json:"session_id"`
ServiceType ServiceType `json:"service_type"`
ProviderID int64 `json:"provider_id,omitempty"`
Model string `json:"model"`
Params json.RawMessage `json:"params"`
Mode InvokeMode `json:"mode"`
Context *InvokeContext `json:"context,omitempty"`
}
type StreamChunk struct {
Delta string `json:"delta"`
Done bool `json:"done"`
Error string `json:"error,omitempty"`
}
type InvokeResult struct {
GenerationID int64 `json:"generation_id,omitempty"`
TaskID *int64 `json:"task_id,omitempty"`
Output json.RawMessage `json:"output,omitempty"`
Stream <-chan StreamChunk `json:"-"`
}
+71
View File
@@ -0,0 +1,71 @@
package domain
import "fmt"
var protocolServiceTypes = map[string][]ServiceType{
"openai": {ServiceChat},
"replicate": {ServiceImageGeneration},
}
var serviceTypeProtocol = map[ServiceType]string{
ServiceChat: "openai",
ServiceImageGeneration: "replicate",
}
func DefaultProtocol(serviceType ServiceType) string {
if p, ok := serviceTypeProtocol[serviceType]; ok {
return p
}
return "openai"
}
func DefaultServiceType(protocol string) ServiceType {
types := ServiceTypesForProtocol(protocol)
if len(types) == 0 {
return ServiceChat
}
return types[0]
}
func ServiceTypesForProtocol(protocol string) []ServiceType {
return append([]ServiceType(nil), protocolServiceTypes[protocol]...)
}
func ProtocolForServiceType(serviceType ServiceType) string {
return DefaultProtocol(serviceType)
}
func ValidateProviderBinding(protocol, serviceType string) error {
types := ServiceTypesForProtocol(protocol)
if len(types) == 0 {
return fmt.Errorf("unsupported protocol: %s", protocol)
}
st := ServiceType(serviceType)
for _, allowed := range types {
if allowed == st {
return nil
}
}
// Allow legacy provider rows until migrated.
if protocol == "replicate" && (st == ServiceTextToImage || st == ServiceImageToImage) {
return nil
}
return fmt.Errorf("protocol %s does not support service_type %s", protocol, serviceType)
}
func ImageProviderServiceTypes() []string {
return []string{
string(ServiceImageGeneration),
string(ServiceTextToImage),
string(ServiceImageToImage),
}
}
func IsImageProviderServiceType(serviceType string) bool {
switch ServiceType(serviceType) {
case ServiceImageGeneration, ServiceTextToImage, ServiceImageToImage:
return true
default:
return false
}
}
+40
View File
@@ -0,0 +1,40 @@
package domain
import "testing"
func TestValidateProviderBinding(t *testing.T) {
cases := []struct {
protocol string
serviceType string
ok bool
}{
{"openai", "chat", true},
{"replicate", "image_generation", true},
{"replicate", "text_to_image", true},
{"replicate", "image_to_image", true},
{"openai", "text_to_image", false},
{"replicate", "chat", false},
{"unknown", "chat", false},
}
for _, tc := range cases {
err := ValidateProviderBinding(tc.protocol, tc.serviceType)
if tc.ok && err != nil {
t.Fatalf("%s/%s expected ok, got %v", tc.protocol, tc.serviceType, err)
}
if !tc.ok && err == nil {
t.Fatalf("%s/%s expected error", tc.protocol, tc.serviceType)
}
}
}
func TestDefaultBindings(t *testing.T) {
if got := DefaultProtocol(ServiceImageGeneration); got != "replicate" {
t.Fatalf("expected replicate, got %s", got)
}
if got := DefaultServiceType("openai"); got != ServiceChat {
t.Fatalf("expected chat, got %s", got)
}
if got := DefaultServiceType("replicate"); got != ServiceImageGeneration {
t.Fatalf("expected image_generation, got %s", got)
}
}
+30
View File
@@ -0,0 +1,30 @@
package domain
import "time"
type StreamCapability struct{}
type SyncCapability struct{}
type AsyncCapability struct {
Driver string
PollInterval time.Duration
}
type InvokeCapabilities struct {
Stream *StreamCapability
Sync *SyncCapability
Async *AsyncCapability
}
type ModelInfo struct {
ID string `json:"id"`
Name string `json:"name"`
}
type ServiceTypeSpec struct {
Type ServiceType
Capabilities InvokeCapabilities
DefaultParams func() map[string]any
ValidateParams func(params map[string]any) error
}
+265
View File
@@ -0,0 +1,265 @@
package provider
import (
"encoding/json"
"sort"
"strings"
)
const (
ModelKindTextToImage = "text_to_image"
ModelKindImageToImage = "image_to_image"
)
type InputField struct {
Name string `json:"name"`
Title string `json:"title"`
Description string `json:"description"`
Type string `json:"type"`
Default any `json:"default,omitempty"`
Required bool `json:"required"`
Order int `json:"order"`
Widget string `json:"widget"` // text | textarea | number | integer | image | switch
}
type ModelDetail struct {
ID string `json:"id"`
ModelName string `json:"model_name,omitempty"`
Version string `json:"version,omitempty"`
Name string `json:"name"`
Description string `json:"description,omitempty"`
Kind string `json:"kind"`
InputSchema []InputField `json:"input_schema"`
}
// PredictionVersion returns the upstream prediction version identifier (owner/name).
func (m ModelDetail) PredictionVersion() string {
return m.ID
}
type rawProperty struct {
Type string `json:"type"`
Title string `json:"title"`
Description string `json:"description"`
Default any `json:"default"`
Order int `json:"x-order"`
}
func parseReplicateModelDetails(body []byte) []ModelDetail {
var resp struct {
Results []struct {
Owner string `json:"owner"`
Name string `json:"name"`
Description string `json:"description"`
LatestVersion struct {
ID string `json:"id"`
OpenAPISchema struct {
Components struct {
Schemas struct {
Input struct {
Properties map[string]json.RawMessage `json:"properties"`
Required []string `json:"required"`
} `json:"Input"`
} `json:"schemas"`
} `json:"components"`
} `json:"openapi_schema"`
} `json:"latest_version"`
} `json:"results"`
}
if err := json.Unmarshal(body, &resp); err != nil {
return nil
}
out := make([]ModelDetail, 0, len(resp.Results))
for _, item := range resp.Results {
if item.Owner == "" || item.Name == "" {
continue
}
id := item.Owner + "/" + item.Name
name := item.Name
if item.Description != "" {
name = item.Description
}
schema := parseInputSchema(item.LatestVersion.OpenAPISchema.Components.Schemas.Input.Properties, item.LatestVersion.OpenAPISchema.Components.Schemas.Input.Required)
version := item.LatestVersion.ID
if version == "" {
version = item.Name
}
out = append(out, ModelDetail{
ID: id,
ModelName: item.Name,
Version: version,
Name: name,
Description: item.Description,
Kind: inferModelKind(schema),
InputSchema: schema,
})
}
return out
}
func parseInputSchema(properties map[string]json.RawMessage, required []string) []InputField {
if len(properties) == 0 {
return defaultPromptSchema()
}
reqSet := map[string]struct{}{}
for _, name := range required {
reqSet[name] = struct{}{}
}
fields := make([]InputField, 0, len(properties))
for name, raw := range properties {
var prop rawProperty
if err := json.Unmarshal(raw, &prop); err != nil {
continue
}
title := prop.Title
if title == "" {
title = name
}
fieldType := prop.Type
if fieldType == "" {
fieldType = "string"
}
fields = append(fields, InputField{
Name: name,
Title: title,
Description: prop.Description,
Type: fieldType,
Default: prop.Default,
Required: containsRequired(reqSet, name),
Order: prop.Order,
Widget: inferWidget(name, fieldType),
})
}
sort.Slice(fields, func(i, j int) bool {
if fields[i].Order == fields[j].Order {
return fields[i].Name < fields[j].Name
}
return fields[i].Order < fields[j].Order
})
return fields
}
func inferModelKind(schema []InputField) string {
for _, f := range schema {
if f.Name == "image" && f.Required {
return ModelKindImageToImage
}
}
for _, f := range schema {
if f.Name == "image" && f.Widget == "image" {
return ModelKindImageToImage
}
}
return ModelKindTextToImage
}
func inferWidget(name, fieldType string) string {
switch fieldType {
case "integer", "number":
return fieldType
case "boolean":
return "switch"
}
if name == "image" || strings.HasSuffix(name, "_image") || strings.Contains(name, "image_url") {
return "image"
}
if name == "prompt" || name == "negative_prompt" || strings.Contains(name, "prompt") {
return "textarea"
}
return "text"
}
func defaultPromptSchema() []InputField {
return []InputField{{
Name: "prompt",
Title: "正向提示词",
Type: "string",
Required: true,
Order: 0,
Widget: "textarea",
}}
}
func manualModelDetail(modelID, name string) ModelDetail {
if name == "" {
name = modelID
}
modelName := modelID
if idx := strings.LastIndex(modelID, "/"); idx >= 0 && idx < len(modelID)-1 {
modelName = modelID[idx+1:]
}
schema := defaultPromptSchema()
kind := ModelKindTextToImage
if strings.Contains(strings.ToLower(modelID), "edit") || strings.Contains(strings.ToLower(name), "edit") {
schema = append(schema, InputField{
Name: "image",
Title: "输入图 URL",
Type: "string",
Required: true,
Order: 2,
Widget: "image",
})
kind = ModelKindImageToImage
}
return ModelDetail{
ID: modelID,
ModelName: modelName,
Version: modelID,
Name: name,
Kind: kind,
InputSchema: schema,
}
}
func containsRequired(set map[string]struct{}, name string) bool {
_, ok := set[name]
return ok
}
func ValidateModelInput(schema []InputField, params map[string]any) error {
if len(schema) == 0 {
if p, _ := params["prompt"].(string); strings.TrimSpace(p) == "" {
return errRequired("prompt")
}
return nil
}
for _, field := range schema {
if !field.Required {
continue
}
val, ok := params[field.Name]
if !ok || isEmptyValue(val) {
return errRequired(field.Name)
}
}
return nil
}
func isEmptyValue(v any) bool {
switch t := v.(type) {
case string:
return strings.TrimSpace(t) == ""
case nil:
return true
default:
return false
}
}
type validationError string
func (e validationError) Error() string { return string(e) }
func errRequired(name string) error {
return validationError(name + " required")
}
func DefaultParamValues(schema []InputField) map[string]any {
out := map[string]any{}
for _, field := range schema {
if field.Default != nil {
out[field.Name] = field.Default
}
}
return out
}
+46
View File
@@ -0,0 +1,46 @@
package provider
import (
"os"
"testing"
)
func TestParseReplicateModelDetails(t *testing.T) {
body, err := os.ReadFile("testdata/models_sample.json")
if err != nil {
t.Skip("sample file missing")
}
models := parseReplicateModelDetails(body)
if len(models) != 2 {
t.Fatalf("expected 2 models, got %d", len(models))
}
if models[0].Kind != ModelKindImageToImage {
t.Fatalf("edit model kind: %s", models[0].Kind)
}
if models[1].Kind != ModelKindTextToImage {
t.Fatalf("text model kind: %s", models[1].Kind)
}
if models[0].ModelName != "qwen-image-edit" {
t.Fatalf("edit model name: %s", models[0].ModelName)
}
if models[0].PredictionVersion() != "comfyui/qwen-image-edit" {
t.Fatalf("edit prediction version: %s", models[0].PredictionVersion())
}
}
func TestInferModelKind(t *testing.T) {
schema := []InputField{{Name: "prompt", Required: true}, {Name: "image", Required: true, Widget: "image"}}
if inferModelKind(schema) != ModelKindImageToImage {
t.Fatal("expected image_to_image")
}
}
func TestValidateModelInput(t *testing.T) {
schema := []InputField{{Name: "prompt", Required: true}}
if err := ValidateModelInput(schema, map[string]any{"prompt": "hi"}); err != nil {
t.Fatal(err)
}
if err := ValidateModelInput(schema, map[string]any{}); err == nil {
t.Fatal("expected error")
}
}
+433
View File
@@ -0,0 +1,433 @@
package provider
import (
"context"
"encoding/json"
"fmt"
"net/url"
"strings"
"github.com/rose_cat707/Atlas/internal/domain"
"github.com/rose_cat707/Atlas/internal/store"
)
type ModelEntry struct {
ID string `json:"id"`
Name string `json:"name"`
}
type ModelsService struct {
store *store.Store
router *Router
}
func NewModelsService(st *store.Store, router *Router) *ModelsService {
return &ModelsService{store: st, router: router}
}
func DefaultModel(models []ModelEntry) string {
if len(models) > 0 {
return models[0].ID
}
return ""
}
// ListEffective returns upstream models first, then manual models not already present.
func (m *ModelsService) ListEffective(ctx context.Context, cfg *Config) ([]ModelEntry, error) {
upstream, _ := m.fetchUpstream(ctx, cfg)
manual, err := m.listManual(ctx, cfg.ID)
if err != nil {
return nil, err
}
return mergeModels(upstream, manual), nil
}
func (m *ModelsService) ListForServiceType(ctx context.Context, serviceType string) ([]ModelEntry, error) {
providers, err := m.store.ListProviders(ctx, serviceType, true)
if err != nil {
return nil, err
}
seen := map[string]struct{}{}
var out []ModelEntry
for _, p := range providers {
cfg, err := m.router.providerToConfig(&p)
if err != nil {
continue
}
models, err := m.ListEffective(ctx, cfg)
if err != nil {
continue
}
for _, model := range models {
if _, ok := seen[model.ID]; ok {
continue
}
seen[model.ID] = struct{}{}
out = append(out, model)
}
}
return out, nil
}
type ModelsBreakdown struct {
Upstream []ModelEntry `json:"upstream"`
Manual []ModelEntry `json:"manual"`
Effective []ModelEntry `json:"effective"`
}
func (m *ModelsService) Breakdown(ctx context.Context, providerID int64) (*ModelsBreakdown, error) {
p, err := m.store.GetProvider(ctx, providerID)
if err != nil {
return nil, err
}
cfg, err := m.router.providerToConfig(p)
if err != nil {
return nil, err
}
upstream, _ := m.fetchUpstream(ctx, cfg)
manual, err := m.listManual(ctx, providerID)
if err != nil {
return nil, err
}
return &ModelsBreakdown{
Upstream: upstream,
Manual: manual,
Effective: mergeModels(upstream, manual),
}, nil
}
func (m *ModelsService) AddManual(ctx context.Context, providerID int64, modelID, name string) error {
modelID = strings.TrimSpace(modelID)
name = strings.TrimSpace(name)
if modelID == "" || name == "" {
return fmt.Errorf("model_id and name required")
}
_, err := m.store.CreateProviderModel(ctx, &store.ProviderModel{
ProviderID: providerID,
ModelID: modelID,
Name: name,
})
if err != nil {
return fmt.Errorf("model already exists or invalid")
}
m.invalidateCache(providerID)
return nil
}
func (m *ModelsService) RemoveManual(ctx context.Context, providerID int64, modelID string) error {
if err := m.store.DeleteProviderModel(ctx, providerID, modelID); err != nil {
return err
}
m.invalidateCache(providerID)
return nil
}
func (m *ModelsService) RefreshUpstream(ctx context.Context, providerID int64) ([]ModelEntry, error) {
p, err := m.store.GetProvider(ctx, providerID)
if err != nil {
return nil, err
}
cfg, err := m.router.providerToConfig(p)
if err != nil {
return nil, err
}
m.invalidateCache(providerID)
return m.fetchUpstream(ctx, cfg)
}
// FetchUpstream loads models from upstream using the provider protocol.
func (m *ModelsService) FetchUpstream(ctx context.Context, cfg *Config) ([]ModelEntry, error) {
return m.fetchUpstream(ctx, cfg)
}
// ModelsPath returns the upstream models API path for a provider configuration.
func ModelsPath(cfg *Config) string {
q := url.Values{}
if cfg.Protocol != "" {
q.Set("protocol", cfg.Protocol)
}
if cfg.ServiceType != "" {
q.Set("service_type", cfg.ServiceType)
}
if len(q) == 0 {
return "/v1/models"
}
return "/v1/models?" + q.Encode()
}
func (m *ModelsService) listManual(ctx context.Context, providerID int64) ([]ModelEntry, error) {
rows, err := m.store.ListProviderModels(ctx, providerID)
if err != nil {
return nil, err
}
out := make([]ModelEntry, 0, len(rows))
for _, r := range rows {
out = append(out, ModelEntry{ID: r.ModelID, Name: r.Name})
}
return out, nil
}
// ListEffectiveDetails returns upstream + manual models with input schema.
func (m *ModelsService) ListEffectiveDetails(ctx context.Context, cfg *Config) ([]ModelDetail, error) {
upstream, _ := m.fetchUpstreamDetails(ctx, cfg)
manual, err := m.listManual(ctx, cfg.ID)
if err != nil {
return nil, err
}
return mergeModelDetails(upstream, manual), nil
}
// FindModelDetail looks up a model by id for a provider configuration.
func (m *ModelsService) FindModelDetail(ctx context.Context, cfg *Config, modelID string) (*ModelDetail, error) {
models, err := m.ListEffectiveDetails(ctx, cfg)
if err != nil {
return nil, err
}
for _, md := range models {
if md.ID == modelID {
copy := md
return &copy, nil
}
}
return nil, fmt.Errorf("model not found: %s", modelID)
}
// ListImageModelsForMeta aggregates image models from image generation providers.
func (m *ModelsService) ListImageModelsForMeta(ctx context.Context) ([]ModelDetail, error) {
seen := map[string]struct{}{}
var out []ModelDetail
for _, serviceType := range domain.ImageProviderServiceTypes() {
providers, err := m.store.ListProviders(ctx, serviceType, true)
if err != nil {
return nil, err
}
for _, p := range providers {
cfg, err := m.router.providerToConfig(&p)
if err != nil {
continue
}
models, err := m.ListEffectiveDetails(ctx, cfg)
if err != nil {
continue
}
for _, model := range models {
if _, ok := seen[model.ID]; ok {
continue
}
seen[model.ID] = struct{}{}
out = append(out, model)
}
}
}
return out, nil
}
func (m *ModelsService) fetchUpstream(ctx context.Context, cfg *Config) ([]ModelEntry, error) {
details, err := m.fetchUpstreamDetails(ctx, cfg)
if err != nil || len(details) == 0 {
return nil, err
}
out := make([]ModelEntry, len(details))
for i, d := range details {
out[i] = ModelEntry{ID: d.ID, Name: d.Name}
}
return out, nil
}
func (m *ModelsService) fetchUpstreamDetails(ctx context.Context, cfg *Config) ([]ModelDetail, error) {
key := detailsCacheKey(cfg.ID)
if v, ok := m.store.Cache.Get(key); ok {
if models, ok := v.([]ModelDetail); ok {
return models, nil
}
}
path := ModelsPath(cfg)
meta := CallMeta{Method: "GET", Path: path}
res, err := m.router.ProxyWithMeta(ctx, cfg, "GET", path, nil, nil, meta)
if err != nil || res.StatusCode >= 400 {
return nil, err
}
models := parseDetailsForProtocol(cfg.Protocol, res.Body)
if len(models) > 0 {
m.store.Cache.Set(key, models)
}
return models, nil
}
func parseModelsForProtocol(protocol string, body []byte) []ModelEntry {
if protocol == "replicate" {
if models := parseReplicateModels(body); len(models) > 0 {
return models
}
}
if models := parseOpenAIModels(body); len(models) > 0 {
return models
}
if protocol == "replicate" {
return parseReplicateModels(body)
}
return nil
}
func parseOpenAIModels(body []byte) []ModelEntry {
var openai struct {
Data []struct {
ID string `json:"id"`
Name string `json:"name"`
} `json:"data"`
}
if err := json.Unmarshal(body, &openai); err == nil && len(openai.Data) > 0 {
out := make([]ModelEntry, 0, len(openai.Data))
for _, item := range openai.Data {
name := item.Name
if name == "" {
name = item.ID
}
if item.ID != "" {
out = append(out, ModelEntry{ID: item.ID, Name: name})
}
}
return out
}
var arr []struct {
ID string `json:"id"`
Name string `json:"name"`
}
if err := json.Unmarshal(body, &arr); err == nil && len(arr) > 0 {
out := make([]ModelEntry, 0, len(arr))
for _, item := range arr {
name := item.Name
if name == "" {
name = item.ID
}
if item.ID != "" {
out = append(out, ModelEntry{ID: item.ID, Name: name})
}
}
return out
}
return nil
}
func parseReplicateModels(body []byte) []ModelEntry {
var resp struct {
Results []struct {
Owner string `json:"owner"`
Name string `json:"name"`
Description string `json:"description"`
} `json:"results"`
}
if err := json.Unmarshal(body, &resp); err != nil || len(resp.Results) == 0 {
return nil
}
out := make([]ModelEntry, 0, len(resp.Results))
for _, item := range resp.Results {
if item.Owner == "" || item.Name == "" {
continue
}
id := item.Owner + "/" + item.Name
name := item.Name
if item.Description != "" {
name = item.Description
} else {
name = id
}
out = append(out, ModelEntry{ID: id, Name: name})
}
return out
}
func parseDetailsForProtocol(protocol string, body []byte) []ModelDetail {
if protocol == "replicate" {
if models := parseReplicateModelDetails(body); len(models) > 0 {
return models
}
}
if entries := parseOpenAIModels(body); len(entries) > 0 {
out := make([]ModelDetail, len(entries))
for i, e := range entries {
out[i] = manualModelDetail(e.ID, e.Name)
}
return out
}
if protocol == "replicate" {
if entries := parseReplicateModels(body); len(entries) > 0 {
out := make([]ModelDetail, len(entries))
for i, e := range entries {
out[i] = manualModelDetail(e.ID, e.Name)
}
return out
}
}
return nil
}
func mergeModelDetails(upstream []ModelDetail, manual []ModelEntry) []ModelDetail {
seen := map[string]struct{}{}
out := make([]ModelDetail, 0, len(upstream)+len(manual))
for _, m := range upstream {
if _, ok := seen[m.ID]; ok {
continue
}
seen[m.ID] = struct{}{}
out = append(out, m)
}
for _, m := range manual {
if _, ok := seen[m.ID]; ok {
continue
}
seen[m.ID] = struct{}{}
out = append(out, manualModelDetail(m.ID, m.Name))
}
return out
}
func mergeModels(upstream, manual []ModelEntry) []ModelEntry {
seen := map[string]struct{}{}
out := make([]ModelEntry, 0, len(upstream)+len(manual))
for _, m := range upstream {
if _, ok := seen[m.ID]; ok {
continue
}
seen[m.ID] = struct{}{}
out = append(out, m)
}
for _, m := range manual {
if _, ok := seen[m.ID]; ok {
continue
}
seen[m.ID] = struct{}{}
out = append(out, m)
}
return out
}
func cacheKey(providerID int64) string {
return fmt.Sprintf("upstream_models:%d", providerID)
}
func detailsCacheKey(providerID int64) string {
return fmt.Sprintf("upstream_model_details:%d", providerID)
}
func (m *ModelsService) invalidateCache(providerID int64) {
m.store.Cache.Delete(cacheKey(providerID))
m.store.Cache.Delete(detailsCacheKey(providerID))
}
func FilterModelsByKind(models []ModelDetail, kind string) []ModelDetail {
out := make([]ModelDetail, 0, len(models))
for _, m := range models {
if m.Kind == kind {
out = append(out, m)
}
}
return out
}
func DefaultModelDetail(models []ModelDetail) string {
if len(models) > 0 {
return models[0].ID
}
return ""
}
+44
View File
@@ -0,0 +1,44 @@
package provider
import "testing"
func TestModelsPath(t *testing.T) {
openai := ModelsPath(&Config{Protocol: "openai", ServiceType: "chat"})
if openai != "/v1/models?protocol=openai&service_type=chat" {
t.Fatalf("openai path: %s", openai)
}
replicate := ModelsPath(&Config{Protocol: "replicate", ServiceType: "image_generation"})
if replicate != "/v1/models?protocol=replicate&service_type=image_generation" {
t.Fatalf("replicate path: %s", replicate)
}
}
func TestParseReplicateModels(t *testing.T) {
body := []byte(`{
"results": [
{"owner":"stability-ai","name":"sdxl","description":"SDXL"},
{"owner":"black-forest-labs","name":"flux-schnell"}
]
}`)
models := parseReplicateModels(body)
if len(models) != 2 {
t.Fatalf("expected 2 models, got %d", len(models))
}
if models[0].ID != "stability-ai/sdxl" || models[0].Name != "SDXL" {
t.Fatalf("unexpected first model: %+v", models[0])
}
if models[1].ID != "black-forest-labs/flux-schnell" {
t.Fatalf("unexpected second model id: %s", models[1].ID)
}
}
func TestParseModelsForProtocol(t *testing.T) {
openaiBody := []byte(`{"data":[{"id":"gpt-4o","name":"GPT-4o"}]}`)
if models := parseModelsForProtocol("openai", openaiBody); len(models) != 1 || models[0].ID != "gpt-4o" {
t.Fatalf("openai parse failed: %+v", models)
}
replicateBody := []byte(`{"results":[{"owner":"a","name":"b"}]}`)
if models := parseModelsForProtocol("replicate", replicateBody); len(models) != 1 || models[0].ID != "a/b" {
t.Fatalf("replicate parse failed: %+v", models)
}
}
+126
View File
@@ -0,0 +1,126 @@
package provider
import (
"context"
"fmt"
"net/http"
"strings"
"time"
"github.com/rose_cat707/Atlas/internal/domain"
"github.com/rose_cat707/Atlas/internal/service/reqlog"
"github.com/rose_cat707/Atlas/internal/store"
)
type Config struct {
ID int64
Name string
Protocol string
BaseURL string
APIKey string
ServiceType string
ExtraJSON string
}
type Router struct {
store *store.Store
client *http.Client
streamClient *http.Client
logger *reqlog.Logger
}
func NewRouter(st *store.Store, logger *reqlog.Logger) *Router {
return &Router{
store: st,
client: &http.Client{
Timeout: 120 * time.Second,
},
streamClient: &http.Client{},
logger: logger,
}
}
func (r *Router) Resolve(ctx context.Context, serviceType string, providerID int64) (*Config, error) {
if providerID > 0 {
return r.resolveDirect(ctx, serviceType, providerID)
}
for _, st := range resolveServiceTypeCandidates(serviceType) {
if cfg, err := r.resolveDirect(ctx, st, 0); err == nil {
return cfg, nil
}
}
return nil, fmt.Errorf("no provider for %s", serviceType)
}
func resolveServiceTypeCandidates(serviceType string) []string {
if !domain.IsImageProviderServiceType(serviceType) {
return []string{serviceType}
}
seen := map[string]struct{}{}
var out []string
for _, st := range domain.ImageProviderServiceTypes() {
if _, ok := seen[st]; ok {
continue
}
seen[st] = struct{}{}
out = append(out, st)
}
return out
}
func (r *Router) resolveDirect(ctx context.Context, serviceType string, providerID int64) (*Config, error) {
var p *store.Provider
var err error
if providerID > 0 {
p, err = r.store.GetProvider(ctx, providerID)
} else {
p, err = r.store.DefaultProvider(ctx, serviceType)
}
if err != nil {
return nil, fmt.Errorf("no provider for %s: %w", serviceType, err)
}
return r.providerToConfig(p)
}
func (r *Router) ResolveByProtocol(ctx context.Context, serviceType, protocol string, providerID int64) (*Config, error) {
if providerID > 0 {
p, err := r.store.GetProvider(ctx, providerID)
if err != nil {
return nil, err
}
return r.providerToConfig(p)
}
for _, st := range resolveServiceTypeCandidates(serviceType) {
providers, err := r.store.ListProviders(ctx, st, true)
if err != nil {
continue
}
for _, p := range providers {
if protocol == "" || p.Protocol == protocol {
return r.providerToConfig(&p)
}
}
}
return nil, fmt.Errorf("no provider for %s/%s", serviceType, protocol)
}
func (r *Router) providerToConfig(p *store.Provider) (*Config, error) {
if !p.Enabled {
return nil, fmt.Errorf("provider %d disabled", p.ID)
}
return &Config{
ID: p.ID,
Name: p.Name,
Protocol: p.Protocol,
BaseURL: strings.TrimRight(p.BaseURL, "/"),
APIKey: p.APIKey,
ServiceType: p.ServiceType,
ExtraJSON: p.ExtraJSON,
}, nil
}
type ProxyResult struct {
StatusCode int
Body []byte
Headers http.Header
}
+143
View File
@@ -0,0 +1,143 @@
package provider
import (
"bufio"
"bytes"
"context"
"fmt"
"io"
"net/http"
"strings"
"time"
"github.com/rose_cat707/Atlas/internal/service/reqlog"
)
type CallMeta struct {
SessionID string
Method string
Path string
Model string
}
func (r *Router) ProxyWithMeta(ctx context.Context, cfg *Config, method, path string, body []byte, headers map[string]string, meta CallMeta) (*ProxyResult, error) {
start := time.Now()
res, err := r.proxy(ctx, cfg, method, path, body, headers)
r.record(meta, cfg, start, res, err, body, "")
return res, err
}
func (r *Router) ProxyStream(ctx context.Context, cfg *Config, method, path string, body []byte, headers map[string]string, meta CallMeta, w io.Writer, flush func()) error {
start := time.Now()
url := cfg.BaseURL + path
req, err := http.NewRequestWithContext(ctx, method, url, bytes.NewReader(body))
if err != nil {
r.record(meta, cfg, start, nil, err, body, "")
return err
}
req.Header.Set("Authorization", "Bearer "+cfg.APIKey)
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Accept", "text/event-stream")
for k, v := range headers {
req.Header.Set(k, v)
}
resp, err := r.streamClient.Do(req)
if err != nil {
r.record(meta, cfg, start, nil, err, body, "")
return err
}
defer resp.Body.Close()
if resp.StatusCode >= 400 {
data, _ := io.ReadAll(resp.Body)
res := &ProxyResult{StatusCode: resp.StatusCode, Body: data, Headers: resp.Header}
r.record(meta, cfg, start, res, fmt.Errorf("upstream %d", resp.StatusCode), body, "")
return fmt.Errorf("upstream %d: %s", resp.StatusCode, string(data))
}
var capture strings.Builder
reader := bufio.NewReader(resp.Body)
for {
line, readErr := reader.ReadString('\n')
if len(line) > 0 {
capture.WriteString(line)
if _, err := io.WriteString(w, line); err != nil {
return err
}
if flush != nil && (line == "\n" || strings.HasPrefix(line, "data:")) {
flush()
}
}
if readErr == io.EOF {
break
}
if readErr != nil {
r.record(meta, cfg, start, &ProxyResult{StatusCode: resp.StatusCode}, readErr, body, capture.String())
return readErr
}
}
r.record(meta, cfg, start, &ProxyResult{StatusCode: resp.StatusCode}, nil, body, capture.String())
return nil
}
func (r *Router) record(meta CallMeta, cfg *Config, start time.Time, res *ProxyResult, callErr error, reqBody []byte, streamCapture string) {
if r.logger == nil {
return
}
entry := reqlog.Entry{
SessionID: meta.SessionID,
Method: meta.Method,
Path: meta.Path,
Protocol: cfg.Protocol,
ProviderID: reqlog.ProviderIDPtr(cfg.ID),
Model: meta.Model,
LatencyMS: reqlog.Since(start),
RequestSummary: reqlog.Summarize(reqBody, 512),
}
if res != nil {
entry.StatusCode = res.StatusCode
if streamCapture != "" {
entry.ResponseSummary = reqlog.SummarizeSSE(streamCapture, 512)
} else {
entry.ResponseSummary = reqlog.Summarize(res.Body, 512)
}
}
if callErr != nil {
entry.Error = callErr.Error()
if entry.StatusCode == 0 {
entry.StatusCode = 502
}
}
r.logger.Log(context.Background(), entry)
}
func (r *Router) proxy(ctx context.Context, cfg *Config, method, path string, body []byte, headers map[string]string) (*ProxyResult, error) {
url := cfg.BaseURL + path
req, err := http.NewRequestWithContext(ctx, method, url, bytes.NewReader(body))
if err != nil {
return nil, err
}
req.Header.Set("Authorization", "Bearer "+cfg.APIKey)
if len(body) > 0 {
req.Header.Set("Content-Type", "application/json")
}
for k, v := range headers {
req.Header.Set(k, v)
}
resp, err := r.client.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
data, err := io.ReadAll(resp.Body)
if err != nil {
return nil, err
}
return &ProxyResult{StatusCode: resp.StatusCode, Body: data, Headers: resp.Header}, nil
}
// Proxy keeps backward compatibility without logging.
func (r *Router) Proxy(ctx context.Context, cfg *Config, method, path string, body []byte, headers map[string]string) (*ProxyResult, error) {
return r.proxy(ctx, cfg, method, path, body, headers)
}
+75
View File
@@ -0,0 +1,75 @@
{
"results": [
{
"description": "Qwen Image Edit",
"latest_version": {
"id": "qwen-image-edit",
"openapi_schema": {
"components": {
"schemas": {
"Input": {
"properties": {
"prompt": {
"description": "正向提示词",
"title": "正向提示词",
"type": "string",
"x-order": 0
},
"image": {
"description": "输入图 URL",
"title": "输入图 URL",
"type": "string",
"x-order": 2
},
"denoise": {
"default": 0.75,
"description": "重绘幅度",
"title": "重绘幅度",
"type": "number",
"x-order": 6
}
},
"required": ["prompt", "image"],
"type": "object"
}
}
}
}
},
"name": "qwen-image-edit",
"owner": "comfyui"
},
{
"description": "Z Image",
"latest_version": {
"id": "z-image",
"openapi_schema": {
"components": {
"schemas": {
"Input": {
"properties": {
"prompt": {
"description": "正向提示词",
"title": "正向提示词",
"type": "string",
"x-order": 0
},
"width": {
"default": 1024,
"title": "宽度",
"type": "integer",
"x-order": 3
}
},
"required": ["prompt"],
"type": "object"
}
}
}
}
},
"name": "z-image",
"owner": "comfyui"
}
]
}
+36 -3
View File
@@ -1,8 +1,41 @@
package service package service
import "github.com/rose_cat707/go-vue-template/internal/store" import (
"github.com/rose_cat707/Atlas/internal/provider"
"github.com/rose_cat707/Atlas/internal/service/asset"
"github.com/rose_cat707/Atlas/internal/service/chat"
"github.com/rose_cat707/Atlas/internal/service/invoke"
"github.com/rose_cat707/Atlas/internal/service/reqlog"
"github.com/rose_cat707/Atlas/internal/service/taskevents"
"github.com/rose_cat707/Atlas/internal/store"
)
type App struct { type App struct {
Store *store.Store Store *store.Store
DataDir string DataDir string
Router *provider.Router
Models *provider.ModelsService
TaskEvents *taskevents.Hub
Invoke *invoke.Service
Chat *chat.Service
Assets *asset.Signer
ReqLog *reqlog.Logger
}
func NewApp(st *store.Store, dataDir string, signSecret string) *App {
logger := reqlog.New(st)
router := provider.NewRouter(st, logger)
models := provider.NewModelsService(st, router)
taskEvents := taskevents.NewHub()
return &App{
Store: st,
DataDir: dataDir,
Router: router,
Models: models,
TaskEvents: taskEvents,
Invoke: invoke.NewService(st, router, models, taskEvents),
Chat: chat.NewService(st, router, models),
Assets: asset.NewSigner(signSecret),
ReqLog: logger,
}
} }
+85
View File
@@ -0,0 +1,85 @@
package asset
import (
"crypto/hmac"
"crypto/sha256"
"encoding/hex"
"fmt"
"io"
"mime"
"os"
"path/filepath"
"strconv"
"strings"
"time"
"github.com/google/uuid"
)
type Signer struct {
secret string
}
func NewSigner(secret string) *Signer {
return &Signer{secret: secret}
}
func (s *Signer) Sign(fileID int64, exp int64) string {
mac := hmac.New(sha256.New, []byte(s.secret))
mac.Write([]byte(fmt.Sprintf("%d:%d", fileID, exp)))
return hex.EncodeToString(mac.Sum(nil))
}
func (s *Signer) Verify(fileID int64, exp int64, sig string) bool {
if time.Now().Unix() > exp {
return false
}
expected := s.Sign(fileID, exp)
return hmac.Equal([]byte(expected), []byte(sig))
}
func (s *Signer) SignedURL(basePath string, fileID int64, ttl time.Duration) string {
exp := time.Now().Add(ttl).Unix()
sig := s.Sign(fileID, exp)
return fmt.Sprintf("%s/user/files/%d?exp=%d&sig=%s", basePath, fileID, exp, sig)
}
func SaveUpload(dataDir, sessionID string, filename string, r io.Reader, maxBytes int64) (string, int64, string, error) {
ext := filepath.Ext(filename)
if ext == "" {
ext = ".bin"
}
id := uuid.New().String()
dir := filepath.Join(dataDir, "files", sessionID, "uploads")
if err := os.MkdirAll(dir, 0o755); err != nil {
return "", 0, "", err
}
path := filepath.Join(dir, id+ext)
f, err := os.Create(path)
if err != nil {
return "", 0, "", err
}
defer f.Close()
n, err := io.Copy(f, io.LimitReader(r, maxBytes+1))
if err != nil {
os.Remove(path)
return "", 0, "", err
}
if n > maxBytes {
os.Remove(path)
return "", 0, "", fmt.Errorf("file too large")
}
mimeType := mime.TypeByExtension(ext)
if mimeType == "" {
mimeType = "application/octet-stream"
}
return path, n, mimeType, nil
}
func ParseSignedQuery(expStr, sig string) (int64, string, error) {
exp, err := strconv.ParseInt(expStr, 10, 64)
if err != nil {
return 0, "", err
}
return exp, strings.TrimSpace(sig), nil
}
+210
View File
@@ -0,0 +1,210 @@
package chat
import (
"context"
"encoding/json"
"fmt"
"io"
"strings"
"github.com/rose_cat707/Atlas/internal/domain"
"github.com/rose_cat707/Atlas/internal/provider"
"github.com/rose_cat707/Atlas/internal/store"
)
type Service struct {
store *store.Store
router *provider.Router
models *provider.ModelsService
}
func NewService(st *store.Store, router *provider.Router, models *provider.ModelsService) *Service {
return &Service{store: st, router: router, models: models}
}
type StreamRequest struct {
SessionID string
ConversationID int64
Content string
Model string
ProviderID int64
}
type streamCapture struct {
io.Writer
buf []byte
}
func (s *streamCapture) Write(p []byte) (int, error) {
s.buf = append(s.buf, p...)
return s.Writer.Write(p)
}
func (s *Service) StreamMessage(ctx context.Context, req StreamRequest, w io.Writer, flusher func()) error {
conv, err := s.store.GetConversation(ctx, req.SessionID, req.ConversationID)
if err != nil {
return err
}
userMsg := &store.ChatMessageRow{ConversationID: req.ConversationID, Role: "user", Content: req.Content}
if _, err := s.store.InsertMessage(ctx, userMsg); err != nil {
return err
}
msgs, err := s.store.ListMessages(ctx, req.ConversationID, 40)
if err != nil {
return err
}
model := req.Model
if model == "" {
model = conv.Model
}
cfg, err := s.router.Resolve(ctx, string(domain.ServiceChat), req.ProviderID)
if err != nil {
return err
}
if model == "" {
models, _ := s.models.ListEffective(ctx, cfg)
model = provider.DefaultModel(models)
}
body, err := json.Marshal(map[string]any{
"model": model,
"messages": toChatMessages(msgs),
"stream": true,
})
if err != nil {
return err
}
capture := &streamCapture{Writer: w}
meta := provider.CallMeta{
SessionID: req.SessionID,
Method: "POST",
Path: "/v1/chat/completions",
Model: model,
}
if err := s.router.ProxyStream(ctx, cfg, "POST", "/v1/chat/completions", body, nil, meta, capture, flusher); err != nil {
return err
}
assistant := extractAssistantFromSSE(string(capture.buf))
return s.persistAssistant(ctx, req, model, cfg, assistant)
}
func (s *Service) persistAssistant(ctx context.Context, req StreamRequest, model string, cfg *provider.Config, assistant string) error {
asst := &store.ChatMessageRow{ConversationID: req.ConversationID, Role: "assistant", Content: assistant}
if _, err := s.store.InsertMessage(ctx, asst); err != nil {
return err
}
_ = s.maybeSummarizeTitle(ctx, req, model, cfg)
pid := cfg.ID
out, _ := json.Marshal(map[string]any{"content": assistant})
gen := &store.Generation{
SessionID: req.SessionID,
ServiceType: string(domain.ServiceChat),
ProviderID: &pid,
Model: model,
InputJSON: fmt.Sprintf(`{"content":%q}`, req.Content),
OutputJSON: string(out),
}
s.store.WriteQueue.Enqueue(func(ctx context.Context, st *store.Store) error {
_, err := st.InsertGeneration(ctx, gen)
return err
})
return nil
}
func (s *Service) maybeSummarizeTitle(ctx context.Context, req StreamRequest, model string, cfg *provider.Config) error {
conv, err := s.store.GetConversation(ctx, req.SessionID, req.ConversationID)
if err != nil {
return err
}
params := conversationParams(conv.ParamsJSON)
if titleAlreadySummarized(params) {
return nil
}
userCount, err := s.store.CountUserMessages(ctx, req.ConversationID)
if err != nil {
return err
}
if userCount <= titleSummaryRoundThreshold {
return nil
}
msgs, err := s.store.ListMessages(ctx, req.ConversationID, 24)
if err != nil {
return err
}
title, err := s.generateTitle(ctx, req, model, cfg, msgs)
if err != nil || title == "" {
return err
}
conv.Title = title
if model != "" {
conv.Model = model
}
params["title_summarized"] = true
conv.ParamsJSON = conversationParamsJSON(params)
return s.store.UpdateConversation(ctx, conv)
}
func (s *Service) generateTitle(ctx context.Context, req StreamRequest, model string, cfg *provider.Config, msgs []store.ChatMessageRow) (string, error) {
chatMsgs := []map[string]string{{"role": "system", "content": titleSummarySystemPrompt}}
chatMsgs = append(chatMsgs, toChatMessages(msgs)...)
body, err := json.Marshal(map[string]any{
"model": model,
"messages": chatMsgs,
"max_tokens": 40,
"temperature": 0.3,
"stream": false,
})
if err != nil {
return "", err
}
meta := provider.CallMeta{
SessionID: req.SessionID,
Method: "POST",
Path: "/v1/chat/completions",
Model: model,
}
res, err := s.router.ProxyWithMeta(ctx, cfg, "POST", "/v1/chat/completions", body, nil, meta)
if err != nil {
return "", err
}
if res.StatusCode >= 400 {
return "", fmt.Errorf("title summarize upstream %d", res.StatusCode)
}
return sanitizeTitle(parseChatCompletionContent(res.Body)), nil
}
func extractAssistantFromSSE(raw string) string {
var assistant strings.Builder
for _, line := range strings.Split(raw, "\n") {
if !strings.HasPrefix(line, "data: ") {
continue
}
data := strings.TrimPrefix(line, "data: ")
if data == "[DONE]" {
break
}
var chunk struct {
Choices []struct {
Delta struct {
Content string `json:"content"`
} `json:"delta"`
} `json:"choices"`
}
if err := json.Unmarshal([]byte(data), &chunk); err != nil {
continue
}
if len(chunk.Choices) > 0 {
assistant.WriteString(chunk.Choices[0].Delta.Content)
}
}
return assistant.String()
}
func toChatMessages(rows []store.ChatMessageRow) []map[string]string {
out := make([]map[string]string, 0, len(rows))
for _, m := range rows {
out = append(out, map[string]string{"role": m.Role, "content": m.Content})
}
return out
}
+68
View File
@@ -0,0 +1,68 @@
package chat
import (
"encoding/json"
"strings"
"unicode/utf8"
)
const (
titleSummaryRoundThreshold = 3
titleSummarySystemPrompt = "你是会话标题生成器。根据对话内容输出一条简洁的中文标题,不超过20字,不要引号、书名号或句号,只输出标题本身。"
)
func conversationParams(raw string) map[string]any {
out := map[string]any{}
if raw == "" {
return out
}
_ = json.Unmarshal([]byte(raw), &out)
return out
}
func conversationParamsJSON(params map[string]any) string {
if len(params) == 0 {
return "{}"
}
data, err := json.Marshal(params)
if err != nil {
return "{}"
}
return string(data)
}
func titleAlreadySummarized(params map[string]any) bool {
v, ok := params["title_summarized"].(bool)
return ok && v
}
func sanitizeTitle(title string) string {
title = strings.TrimSpace(title)
title = strings.Trim(title, `"'""''《》`)
title = strings.ReplaceAll(title, "\n", " ")
if title == "" {
return ""
}
if utf8.RuneCountInString(title) > 30 {
runes := []rune(title)
title = string(runes[:30])
}
return title
}
func parseChatCompletionContent(body []byte) string {
var resp struct {
Choices []struct {
Message struct {
Content string `json:"content"`
} `json:"message"`
} `json:"choices"`
}
if err := json.Unmarshal(body, &resp); err != nil {
return ""
}
if len(resp.Choices) == 0 {
return ""
}
return strings.TrimSpace(resp.Choices[0].Message.Content)
}
+38
View File
@@ -0,0 +1,38 @@
package chat
import "testing"
func TestSanitizeTitle(t *testing.T) {
if got := sanitizeTitle(` "测试标题" `); got != "测试标题" {
t.Fatalf("unexpected: %q", got)
}
long := stringsRepeat("标题", 20)
got := sanitizeTitle(long)
if len([]rune(got)) > 30 {
t.Fatalf("title too long: %d", len([]rune(got)))
}
}
func TestTitleAlreadySummarized(t *testing.T) {
if titleAlreadySummarized(map[string]any{}) {
t.Fatal("expected false")
}
if !titleAlreadySummarized(map[string]any{"title_summarized": true}) {
t.Fatal("expected true")
}
}
func stringsRepeat(s string, n int) string {
out := ""
for i := 0; i < n; i++ {
out += s
}
return out
}
func TestParseChatCompletionContent(t *testing.T) {
body := []byte(`{"choices":[{"message":{"content":" Atlas 部署方案 "}}]}`)
if got := parseChatCompletionContent(body); got != "Atlas 部署方案" {
t.Fatalf("unexpected: %q", got)
}
}
+140
View File
@@ -0,0 +1,140 @@
package invoke
import (
"context"
"encoding/json"
"fmt"
"time"
"github.com/rose_cat707/Atlas/internal/provider"
"github.com/rose_cat707/Atlas/internal/store"
)
type asyncWorker struct {
svc *Service
}
func newAsyncWorker(svc *Service) *asyncWorker {
return &asyncWorker{svc: svc}
}
func (w *asyncWorker) run() {
ticker := time.NewTicker(2 * time.Second)
defer ticker.Stop()
for range ticker.C {
w.pollOnce()
}
}
func (w *asyncWorker) pollOnce() {
ctx := context.Background()
tasks, err := w.svc.store.ListPendingTasks(ctx)
if err != nil {
return
}
for _, task := range tasks {
w.pollTask(ctx, task)
}
}
func (w *asyncWorker) pollTask(ctx context.Context, task store.Task) {
cfg, err := w.svc.store.GetProvider(ctx, task.ProviderID)
if err != nil {
return
}
pcfg := &provider.Config{
ID: cfg.ID,
Protocol: cfg.Protocol,
BaseURL: cfg.BaseURL,
APIKey: cfg.APIKey,
}
path := fmt.Sprintf("/v1/predictions/%s", task.ExternalID)
res, err := w.svc.router.Proxy(ctx, pcfg, "GET", path, nil, nil)
if err != nil {
return
}
if res.StatusCode >= 400 {
task.Status = "failed"
task.Error = string(res.Body)
w.svc.store.WriteQueue.Enqueue(func(ctx context.Context, st *store.Store) error {
return st.UpdateTask(ctx, &task)
})
w.svc.notifyTask(task)
return
}
var pred struct {
Status string `json:"status"`
Output any `json:"output"`
Error string `json:"error"`
Progress int `json:"progress"`
}
_ = json.Unmarshal(res.Body, &pred)
task.Progress = pred.Progress
switch pred.Status {
case "succeeded":
task.Status = "succeeded"
now := time.Now().UTC().Format(time.RFC3339)
task.CompletedAt = &now
out, _ := json.Marshal(map[string]any{"output": pred.Output})
task.ResultJSON = string(out)
pid := task.ProviderID
gen := &store.Generation{
SessionID: task.SessionID,
ServiceType: task.ServiceType,
ProviderID: &pid,
InputJSON: task.InputJSON,
OutputJSON: task.ResultJSON,
TaskID: &task.ID,
}
w.svc.store.WriteQueue.Enqueue(func(ctx context.Context, st *store.Store) error {
_, err := st.InsertGeneration(ctx, gen)
return err
})
case "failed", "canceled":
task.Status = pred.Status
task.Error = pred.Error
now := time.Now().UTC().Format(time.RFC3339)
task.CompletedAt = &now
case "processing", "starting":
task.Status = "running"
default:
task.Status = "running"
}
w.svc.store.WriteQueue.Enqueue(func(ctx context.Context, st *store.Store) error {
return st.UpdateTask(ctx, &task)
})
w.svc.notifyTask(task)
}
func (s *Service) CancelTask(ctx context.Context, sessionID string, taskID int64) error {
task, err := s.store.GetTask(ctx, sessionID, taskID)
if err != nil {
return err
}
cfg, err := s.store.GetProvider(ctx, task.ProviderID)
if err != nil {
return err
}
pcfg := &provider.Config{BaseURL: cfg.BaseURL, APIKey: cfg.APIKey}
path := fmt.Sprintf("/v1/predictions/%s/cancel", task.ExternalID)
meta := provider.CallMeta{
SessionID: sessionID,
Method: "POST",
Path: "/v1/predictions/:id/cancel",
}
res, err := s.router.ProxyWithMeta(ctx, pcfg, "POST", path, nil, nil, meta)
if err != nil {
return err
}
if res.StatusCode >= 400 {
return fmt.Errorf("cancel failed: %s", string(res.Body))
}
task.Status = "canceled"
now := time.Now().UTC().Format(time.RFC3339)
task.CompletedAt = &now
if err := s.store.UpdateTask(ctx, task); err != nil {
return err
}
s.notifyTask(*task)
return nil
}
+259
View File
@@ -0,0 +1,259 @@
package invoke
import (
"context"
"encoding/json"
"fmt"
"time"
"github.com/rose_cat707/Atlas/internal/domain"
"github.com/rose_cat707/Atlas/internal/provider"
"github.com/rose_cat707/Atlas/internal/service/taskevents"
"github.com/rose_cat707/Atlas/internal/store"
)
type Service struct {
store *store.Store
router *provider.Router
models *provider.ModelsService
taskEvents *taskevents.Hub
specs map[domain.ServiceType]*domain.ServiceTypeSpec
worker *asyncWorker
}
func NewService(st *store.Store, router *provider.Router, models *provider.ModelsService, taskEvents *taskevents.Hub) *Service {
s := &Service{
store: st,
router: router,
models: models,
taskEvents: taskEvents,
specs: defaultSpecs(),
}
s.worker = newAsyncWorker(s)
go s.worker.run()
return s
}
func (s *Service) Spec(t domain.ServiceType) (*domain.ServiceTypeSpec, bool) {
spec, ok := s.specs[t]
return spec, ok
}
func (s *Service) Specs() map[domain.ServiceType]*domain.ServiceTypeSpec {
return s.specs
}
func (s *Service) Run(ctx context.Context, req domain.InvokeRequest) (*domain.InvokeResult, error) {
invokeType := req.ServiceType
if domain.IsImageInvokeService(req.ServiceType) {
invokeType = domain.ServiceImageGeneration
}
spec, ok := s.specs[invokeType]
if !ok {
return nil, fmt.Errorf("unknown service_type: %s", req.ServiceType)
}
switch req.Mode {
case domain.ModeStream:
if spec.Capabilities.Stream == nil {
return nil, fmt.Errorf("stream not supported for %s", req.ServiceType)
}
case domain.ModeSync:
if spec.Capabilities.Sync == nil {
return nil, fmt.Errorf("sync not supported for %s", req.ServiceType)
}
case domain.ModeAsync:
if spec.Capabilities.Async == nil {
return nil, fmt.Errorf("async not supported for %s", req.ServiceType)
}
default:
return nil, fmt.Errorf("invalid mode: %s", req.Mode)
}
var params map[string]any
if len(req.Params) > 0 {
if err := json.Unmarshal(req.Params, &params); err != nil {
return nil, fmt.Errorf("invalid params: %w", err)
}
}
if spec.ValidateParams != nil {
if err := spec.ValidateParams(params); err != nil {
return nil, err
}
}
cfg, err := s.router.Resolve(ctx, string(domain.ServiceImageGeneration), req.ProviderID)
if err != nil {
return nil, err
}
if req.Model == "" {
models, _ := s.models.ListEffective(ctx, cfg)
req.Model = provider.DefaultModel(models)
}
var modelDetail *provider.ModelDetail
if domain.IsImageInvokeService(req.ServiceType) {
detail, err := s.models.FindModelDetail(ctx, cfg, req.Model)
if err != nil {
return nil, err
}
modelDetail = detail
if err := provider.ValidateModelInput(detail.InputSchema, params); err != nil {
return nil, err
}
}
switch req.Mode {
case domain.ModeAsync:
return s.runAsync(ctx, req, cfg, params, modelDetail)
case domain.ModeSync:
return s.runSync(ctx, req, cfg, params, modelDetail)
default:
return nil, fmt.Errorf("stream mode handled by chat service")
}
}
func recordServiceType(req domain.InvokeRequest, detail *provider.ModelDetail) string {
if detail != nil && detail.Kind != "" {
return detail.Kind
}
return string(req.ServiceType)
}
func (s *Service) runSync(ctx context.Context, req domain.InvokeRequest, cfg *provider.Config, params map[string]any, detail *provider.ModelDetail) (*domain.InvokeResult, error) {
body, err := buildPredictionBody(predictionVersion(req.Model, detail), params)
if err != nil {
return nil, err
}
meta := provider.CallMeta{
SessionID: req.SessionID,
Method: "POST",
Path: "/v1/predictions",
Model: req.Model,
}
res, err := s.router.ProxyWithMeta(ctx, cfg, "POST", "/v1/predictions", body, map[string]string{"Prefer": "wait"}, meta)
if err != nil {
return nil, err
}
if res.StatusCode >= 400 {
return nil, fmt.Errorf("upstream error %d: %s", res.StatusCode, string(res.Body))
}
output := parsePredictionOutput(res.Body)
pid := cfg.ID
gen := &store.Generation{
SessionID: req.SessionID,
ServiceType: recordServiceType(req, detail),
ProviderID: &pid,
Model: req.Model,
InputJSON: string(req.Params),
OutputJSON: string(output),
}
id, err := s.store.InsertGeneration(ctx, gen)
if err != nil {
return nil, err
}
return &domain.InvokeResult{GenerationID: id, Output: output}, nil
}
func (s *Service) runAsync(ctx context.Context, req domain.InvokeRequest, cfg *provider.Config, params map[string]any, detail *provider.ModelDetail) (*domain.InvokeResult, error) {
body, err := buildPredictionBody(predictionVersion(req.Model, detail), params)
if err != nil {
return nil, err
}
meta := provider.CallMeta{
SessionID: req.SessionID,
Method: "POST",
Path: "/v1/predictions",
Model: req.Model,
}
res, err := s.router.ProxyWithMeta(ctx, cfg, "POST", "/v1/predictions", body, nil, meta)
if err != nil {
return nil, err
}
if res.StatusCode >= 400 {
return nil, fmt.Errorf("upstream error %d: %s", res.StatusCode, string(res.Body))
}
var pred struct {
ID string `json:"id"`
}
_ = json.Unmarshal(res.Body, &pred)
task := &store.Task{
SessionID: req.SessionID,
ServiceType: recordServiceType(req, detail),
Protocol: cfg.Protocol,
ProviderID: cfg.ID,
Status: "running",
Progress: 0,
InputJSON: string(req.Params),
ExternalID: pred.ID,
}
taskID, err := s.store.InsertTask(ctx, task)
if err != nil {
return nil, err
}
task.ID = taskID
s.notifyTask(*task)
return &domain.InvokeResult{TaskID: &taskID}, nil
}
func (s *Service) notifyTask(task store.Task) {
s.store.Cache.Set(fmt.Sprintf("task:%d", task.ID), &task)
if s.taskEvents != nil {
s.taskEvents.Publish(task)
}
}
func predictionVersion(model string, detail *provider.ModelDetail) string {
if detail != nil {
return detail.PredictionVersion()
}
return model
}
func buildPredictionBody(version string, params map[string]any) ([]byte, error) {
input := map[string]any{}
for k, v := range params {
input[k] = v
}
payload := map[string]any{
"version": version,
"input": input,
}
return json.Marshal(payload)
}
func parsePredictionOutput(body []byte) json.RawMessage {
var pred map[string]any
if err := json.Unmarshal(body, &pred); err != nil {
return json.RawMessage(`{}`)
}
out := map[string]any{}
if output, ok := pred["output"]; ok {
out["output"] = output
}
if urls, ok := pred["urls"]; ok {
out["urls"] = urls
}
data, _ := json.Marshal(out)
return data
}
func defaultSpecs() map[domain.ServiceType]*domain.ServiceTypeSpec {
return map[domain.ServiceType]*domain.ServiceTypeSpec{
domain.ServiceChat: {
Type: domain.ServiceChat,
Capabilities: domain.InvokeCapabilities{
Stream: &domain.StreamCapability{},
},
DefaultParams: func() map[string]any { return map[string]any{"temperature": 0.7} },
ValidateParams: func(params map[string]any) error { return nil },
},
domain.ServiceImageGeneration: {
Type: domain.ServiceImageGeneration,
Capabilities: domain.InvokeCapabilities{
Sync: &domain.SyncCapability{},
Async: &domain.AsyncCapability{Driver: "replicate", PollInterval: 2 * time.Second},
},
DefaultParams: func() map[string]any { return map[string]any{} },
},
}
}
+27
View File
@@ -0,0 +1,27 @@
package invoke
import (
"encoding/json"
"testing"
)
func TestBuildPredictionBody(t *testing.T) {
body, err := buildPredictionBody("comfyui/z-image", map[string]any{"prompt": "cat"})
if err != nil {
t.Fatal(err)
}
var payload map[string]any
if err := json.Unmarshal(body, &payload); err != nil {
t.Fatal(err)
}
if payload["version"] != "comfyui/z-image" {
t.Fatalf("version: %v", payload["version"])
}
if _, ok := payload["model"]; ok {
t.Fatal("should not send model field")
}
input, _ := payload["input"].(map[string]any)
if input["prompt"] != "cat" {
t.Fatalf("input: %v", input)
}
}
+84
View File
@@ -0,0 +1,84 @@
package reqlog
import (
"context"
"strings"
"time"
"github.com/rose_cat707/Atlas/internal/store"
)
type Logger struct {
store *store.Store
}
func New(st *store.Store) *Logger {
return &Logger{store: st}
}
type Entry struct {
SessionID string
Method string
Path string
Protocol string
ProviderID *int64
Model string
StatusCode int
LatencyMS int
Error string
RequestSummary string
ResponseSummary string
}
func (l *Logger) Log(ctx context.Context, e Entry) {
if l == nil || l.store == nil {
return
}
entry := e
l.store.WriteQueue.Enqueue(func(ctx context.Context, st *store.Store) error {
_, err := st.InsertRequestLog(ctx, &store.RequestLog{
SessionID: entry.SessionID,
Method: entry.Method,
Path: entry.Path,
Protocol: entry.Protocol,
ProviderID: entry.ProviderID,
Model: entry.Model,
StatusCode: entry.StatusCode,
LatencyMS: entry.LatencyMS,
Error: entry.Error,
RequestSummary: entry.RequestSummary,
ResponseSummary: entry.ResponseSummary,
})
return err
})
}
func Summarize(body []byte, max int) string {
s := strings.TrimSpace(string(body))
if len(s) <= max {
return s
}
return s[:max] + "..."
}
func SummarizeSSE(raw string, max int) string {
var b strings.Builder
for _, line := range strings.Split(raw, "\n") {
if strings.HasPrefix(line, "data: ") && !strings.Contains(line, "[DONE]") {
b.WriteString(strings.TrimPrefix(line, "data: "))
b.WriteByte(' ')
}
}
return Summarize([]byte(strings.TrimSpace(b.String())), max)
}
func Since(start time.Time) int {
return int(time.Since(start).Milliseconds())
}
func ProviderIDPtr(id int64) *int64 {
if id <= 0 {
return nil
}
return &id
}
+58
View File
@@ -0,0 +1,58 @@
package taskevents
import (
"sync"
"github.com/rose_cat707/Atlas/internal/store"
)
type Hub struct {
mu sync.RWMutex
subs map[int64]map[chan store.Task]struct{}
}
func NewHub() *Hub {
return &Hub{subs: make(map[int64]map[chan store.Task]struct{})}
}
func (h *Hub) Subscribe(taskID int64) chan store.Task {
ch := make(chan store.Task, 4)
h.mu.Lock()
if h.subs[taskID] == nil {
h.subs[taskID] = make(map[chan store.Task]struct{})
}
h.subs[taskID][ch] = struct{}{}
h.mu.Unlock()
return ch
}
func (h *Hub) Unsubscribe(taskID int64, ch chan store.Task) {
h.mu.Lock()
if m, ok := h.subs[taskID]; ok {
delete(m, ch)
if len(m) == 0 {
delete(h.subs, taskID)
}
}
h.mu.Unlock()
}
func (h *Hub) Publish(task store.Task) {
h.mu.RLock()
defer h.mu.RUnlock()
for ch := range h.subs[task.ID] {
select {
case ch <- task:
default:
}
}
}
func Terminal(status string) bool {
switch status {
case "succeeded", "failed", "canceled":
return true
default:
return false
}
}
+7 -5
View File
@@ -6,14 +6,16 @@ const (
KeyAdminPassword = "admin_password" KeyAdminPassword = "admin_password"
KeyAuthEnabled = "auth_enabled" KeyAuthEnabled = "auth_enabled"
KeyUILocale = "ui_locale" KeyUILocale = "ui_locale"
KeyFileSignSecret = "file_sign_secret"
) )
var Defaults = map[string]string{ var Defaults = map[string]string{
KeyAppName: "App", KeyAppName: "Atlas",
KeyAPIPort: "8080", KeyAPIPort: "8080",
KeyAdminPassword: "admin", KeyAdminPassword: "admin",
KeyAuthEnabled: "true", KeyAuthEnabled: "true",
KeyUILocale: "zh-CN", KeyUILocale: "zh-CN",
KeyFileSignSecret: "change-me-atlas-file-sign",
} }
// Display returns settings safe for GET responses (masks secrets). // Display returns settings safe for GET responses (masks secrets).
+46
View File
@@ -0,0 +1,46 @@
package store
import (
"sync"
"time"
)
type HotCache struct {
mu sync.RWMutex
entries map[string]cacheEntry
ttl time.Duration
}
type cacheEntry struct {
value any
expiresAt time.Time
}
func NewHotCache(ttl time.Duration) *HotCache {
return &HotCache{
entries: make(map[string]cacheEntry),
ttl: ttl,
}
}
func (c *HotCache) Get(key string) (any, bool) {
c.mu.RLock()
defer c.mu.RUnlock()
e, ok := c.entries[key]
if !ok || time.Now().After(e.expiresAt) {
return nil, false
}
return e.value, true
}
func (c *HotCache) Set(key string, value any) {
c.mu.Lock()
defer c.mu.Unlock()
c.entries[key] = cacheEntry{value: value, expiresAt: time.Now().Add(c.ttl)}
}
func (c *HotCache) Delete(key string) {
c.mu.Lock()
defer c.mu.Unlock()
delete(c.entries, key)
}
+140
View File
@@ -0,0 +1,140 @@
package store
import "context"
type Conversation struct {
ID int64 `json:"id"`
SessionID string `json:"session_id"`
Title string `json:"title"`
Model string `json:"model"`
ParamsJSON string `json:"params_json"`
CreatedAt string `json:"created_at"`
UpdatedAt string `json:"updated_at"`
}
type ChatMessageRow struct {
ID int64 `json:"id"`
ConversationID int64 `json:"conversation_id"`
Role string `json:"role"`
Content string `json:"content"`
CreatedAt string `json:"created_at"`
}
func (s *Store) ListConversations(ctx context.Context, sessionID string) ([]Conversation, error) {
_ = s.PurgeEmptyConversations(ctx, sessionID)
rows, err := s.db.QueryContext(ctx,
`SELECT c.id, c.session_id, c.title, c.model, c.params_json, c.created_at, c.updated_at
FROM chat_conversations c
WHERE c.session_id = ?
AND EXISTS (SELECT 1 FROM chat_messages m WHERE m.conversation_id = c.id)
ORDER BY c.updated_at DESC`, sessionID,
)
if err != nil {
return nil, err
}
defer rows.Close()
var items []Conversation
for rows.Next() {
var c Conversation
if err := rows.Scan(&c.ID, &c.SessionID, &c.Title, &c.Model, &c.ParamsJSON, &c.CreatedAt, &c.UpdatedAt); err != nil {
return nil, err
}
items = append(items, c)
}
return items, rows.Err()
}
func (s *Store) GetConversation(ctx context.Context, sessionID string, id int64) (*Conversation, error) {
var c Conversation
err := s.db.QueryRowContext(ctx,
`SELECT id, session_id, title, model, params_json, created_at, updated_at FROM chat_conversations WHERE id = ? AND session_id = ?`, id, sessionID,
).Scan(&c.ID, &c.SessionID, &c.Title, &c.Model, &c.ParamsJSON, &c.CreatedAt, &c.UpdatedAt)
if err != nil {
return nil, err
}
return &c, nil
}
func (s *Store) CreateConversation(ctx context.Context, c *Conversation) (int64, error) {
res, err := s.db.ExecContext(ctx,
`INSERT INTO chat_conversations (session_id, title, model, params_json) VALUES (?, ?, ?, ?)`,
c.SessionID, c.Title, c.Model, c.ParamsJSON,
)
if err != nil {
return 0, err
}
return res.LastInsertId()
}
func (s *Store) UpdateConversation(ctx context.Context, c *Conversation) error {
_, err := s.db.ExecContext(ctx,
`UPDATE chat_conversations SET title=?, model=?, params_json=?, updated_at=datetime('now') WHERE id=? AND session_id=?`,
c.Title, c.Model, c.ParamsJSON, c.ID, c.SessionID,
)
return err
}
func (s *Store) DeleteConversation(ctx context.Context, sessionID string, id int64) error {
_, err := s.db.ExecContext(ctx, `DELETE FROM chat_conversations WHERE id = ? AND session_id = ?`, id, sessionID)
return err
}
func (s *Store) PurgeEmptyConversations(ctx context.Context, sessionID string) error {
_, err := s.db.ExecContext(ctx,
`DELETE FROM chat_conversations
WHERE session_id = ?
AND NOT EXISTS (SELECT 1 FROM chat_messages m WHERE m.conversation_id = chat_conversations.id)`,
sessionID,
)
return err
}
func (s *Store) ListMessages(ctx context.Context, conversationID int64, limit int) ([]ChatMessageRow, error) {
if limit <= 0 {
limit = 80
}
rows, err := s.db.QueryContext(ctx,
`SELECT id, conversation_id, role, content, created_at FROM chat_messages WHERE conversation_id = ? ORDER BY id DESC LIMIT ?`, conversationID, limit,
)
if err != nil {
return nil, err
}
defer rows.Close()
var items []ChatMessageRow
for rows.Next() {
var m ChatMessageRow
if err := rows.Scan(&m.ID, &m.ConversationID, &m.Role, &m.Content, &m.CreatedAt); err != nil {
return nil, err
}
items = append(items, m)
}
for i, j := 0, len(items)-1; i < j; i, j = i+1, j-1 {
items[i], items[j] = items[j], items[i]
}
return items, rows.Err()
}
func (s *Store) InsertMessage(ctx context.Context, m *ChatMessageRow) (int64, error) {
res, err := s.db.ExecContext(ctx,
`INSERT INTO chat_messages (conversation_id, role, content) VALUES (?, ?, ?)`,
m.ConversationID, m.Role, m.Content,
)
if err != nil {
return 0, err
}
_, _ = s.db.ExecContext(ctx, `UPDATE chat_conversations SET updated_at=datetime('now') WHERE id=?`, m.ConversationID)
return res.LastInsertId()
}
func (s *Store) CountUserMessages(ctx context.Context, conversationID int64) (int, error) {
var count int
err := s.db.QueryRowContext(ctx,
`SELECT COUNT(*) FROM chat_messages WHERE conversation_id = ? AND role = 'user'`, conversationID,
).Scan(&count)
return count, err
}
func (s *Store) ClearMessages(ctx context.Context, conversationID int64) error {
_, err := s.db.ExecContext(ctx, `DELETE FROM chat_messages WHERE conversation_id = ?`, conversationID)
return err
}
+44
View File
@@ -0,0 +1,44 @@
package store
import "context"
type FileRecord struct {
ID int64 `json:"id"`
SessionID string `json:"session_id"`
Kind string `json:"kind"`
Mime string `json:"mime"`
StoragePath string `json:"storage_path"`
SizeBytes int64 `json:"size_bytes"`
SHA256 string `json:"sha256"`
ExpiresAt *string `json:"expires_at"`
CreatedAt string `json:"created_at"`
}
func (s *Store) InsertFile(ctx context.Context, f *FileRecord) (int64, error) {
res, err := s.db.ExecContext(ctx,
`INSERT INTO files (session_id, kind, mime, storage_path, size_bytes, sha256, expires_at) VALUES (?, ?, ?, ?, ?, ?, ?)`,
f.SessionID, f.Kind, f.Mime, f.StoragePath, f.SizeBytes, f.SHA256, f.ExpiresAt,
)
if err != nil {
return 0, err
}
return res.LastInsertId()
}
func (s *Store) GetFile(ctx context.Context, sessionID string, id int64) (*FileRecord, error) {
var f FileRecord
err := s.db.QueryRowContext(ctx,
`SELECT id, session_id, kind, mime, storage_path, size_bytes, sha256, expires_at, created_at FROM files WHERE id = ? AND session_id = ?`, id, sessionID,
).Scan(&f.ID, &f.SessionID, &f.Kind, &f.Mime, &f.StoragePath, &f.SizeBytes, &f.SHA256, &f.ExpiresAt, &f.CreatedAt)
if err != nil {
return nil, err
}
return &f, nil
}
func (s *Store) CountFiles(ctx context.Context) (int, int64, error) {
var count int
var total int64
err := s.db.QueryRowContext(ctx, `SELECT COUNT(*), COALESCE(SUM(size_bytes),0) FROM files`).Scan(&count, &total)
return count, total, err
}
+128
View File
@@ -0,0 +1,128 @@
package store
import "context"
type Generation struct {
ID int64 `json:"id"`
SessionID string `json:"session_id"`
ServiceType string `json:"service_type"`
ProviderID *int64 `json:"provider_id"`
Model string `json:"model"`
InputJSON string `json:"input_json"`
OutputJSON string `json:"output_json"`
TaskID *int64 `json:"task_id"`
CreatedAt string `json:"created_at"`
}
func (s *Store) ListGenerations(ctx context.Context, sessionID, serviceType string, limit, offset int) ([]Generation, int, error) {
countQ := `SELECT COUNT(*) FROM generations WHERE session_id = ?`
args := []any{sessionID}
if serviceType != "" {
countQ += ` AND service_type = ?`
args = append(args, serviceType)
}
var total int
if err := s.db.QueryRowContext(ctx, countQ, args...).Scan(&total); err != nil {
return nil, 0, err
}
q := `SELECT id, session_id, service_type, provider_id, model, input_json, output_json, task_id, created_at FROM generations WHERE session_id = ?`
qArgs := []any{sessionID}
if serviceType != "" {
q += ` AND service_type = ?`
qArgs = append(qArgs, serviceType)
}
q += ` ORDER BY id DESC LIMIT ? OFFSET ?`
qArgs = append(qArgs, limit, offset)
rows, err := s.db.QueryContext(ctx, q, qArgs...)
if err != nil {
return nil, 0, err
}
defer rows.Close()
var items []Generation
for rows.Next() {
var g Generation
if err := rows.Scan(&g.ID, &g.SessionID, &g.ServiceType, &g.ProviderID, &g.Model, &g.InputJSON, &g.OutputJSON, &g.TaskID, &g.CreatedAt); err != nil {
return nil, 0, err
}
items = append(items, g)
}
return items, total, rows.Err()
}
func (s *Store) GetGeneration(ctx context.Context, sessionID string, id int64) (*Generation, error) {
var g Generation
err := s.db.QueryRowContext(ctx,
`SELECT id, session_id, service_type, provider_id, model, input_json, output_json, task_id, created_at FROM generations WHERE id = ? AND session_id = ?`, id, sessionID,
).Scan(&g.ID, &g.SessionID, &g.ServiceType, &g.ProviderID, &g.Model, &g.InputJSON, &g.OutputJSON, &g.TaskID, &g.CreatedAt)
if err != nil {
return nil, err
}
return &g, nil
}
func (s *Store) InsertGeneration(ctx context.Context, g *Generation) (int64, error) {
res, err := s.db.ExecContext(ctx,
`INSERT INTO generations (session_id, service_type, provider_id, model, input_json, output_json, task_id) VALUES (?, ?, ?, ?, ?, ?, ?)`,
g.SessionID, g.ServiceType, g.ProviderID, g.Model, g.InputJSON, g.OutputJSON, g.TaskID,
)
if err != nil {
return 0, err
}
return res.LastInsertId()
}
func (s *Store) DeleteGeneration(ctx context.Context, sessionID string, id int64) error {
_, err := s.db.ExecContext(ctx, `DELETE FROM generations WHERE id = ? AND session_id = ?`, id, sessionID)
return err
}
func (s *Store) ListAllGenerations(ctx context.Context, serviceType string, limit, offset int) ([]Generation, int, error) {
countQ := `SELECT COUNT(*) FROM generations WHERE 1=1`
args := []any{}
if serviceType != "" {
countQ += ` AND service_type = ?`
args = append(args, serviceType)
}
var total int
if err := s.db.QueryRowContext(ctx, countQ, args...).Scan(&total); err != nil {
return nil, 0, err
}
q := `SELECT id, session_id, service_type, provider_id, model, input_json, output_json, task_id, created_at FROM generations WHERE 1=1`
qArgs := []any{}
if serviceType != "" {
q += ` AND service_type = ?`
qArgs = append(qArgs, serviceType)
}
q += ` ORDER BY id DESC LIMIT ? OFFSET ?`
qArgs = append(qArgs, limit, offset)
rows, err := s.db.QueryContext(ctx, q, qArgs...)
if err != nil {
return nil, 0, err
}
defer rows.Close()
var items []Generation
for rows.Next() {
var g Generation
if err := rows.Scan(&g.ID, &g.SessionID, &g.ServiceType, &g.ProviderID, &g.Model, &g.InputJSON, &g.OutputJSON, &g.TaskID, &g.CreatedAt); err != nil {
return nil, 0, err
}
items = append(items, g)
}
return items, total, rows.Err()
}
func (s *Store) AdminGetGeneration(ctx context.Context, id int64) (*Generation, error) {
var g Generation
err := s.db.QueryRowContext(ctx,
`SELECT id, session_id, service_type, provider_id, model, input_json, output_json, task_id, created_at FROM generations WHERE id = ?`, id,
).Scan(&g.ID, &g.SessionID, &g.ServiceType, &g.ProviderID, &g.Model, &g.InputJSON, &g.OutputJSON, &g.TaskID, &g.CreatedAt)
if err != nil {
return nil, err
}
return &g, nil
}
func (s *Store) AdminDeleteGeneration(ctx context.Context, id int64) error {
_, err := s.db.ExecContext(ctx, `DELETE FROM generations WHERE id = ?`, id)
return err
}
+131
View File
@@ -0,0 +1,131 @@
-- +goose Up
CREATE TABLE IF NOT EXISTS providers (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
protocol TEXT NOT NULL,
base_url TEXT NOT NULL,
api_key TEXT NOT NULL DEFAULT '',
service_type TEXT NOT NULL,
is_default INTEGER NOT NULL DEFAULT 0,
enabled INTEGER NOT NULL DEFAULT 1,
extra_json TEXT NOT NULL DEFAULT '{}',
created_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE TABLE IF NOT EXISTS user_sessions (
id TEXT PRIMARY KEY,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
last_seen_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE TABLE IF NOT EXISTS chat_conversations (
id INTEGER PRIMARY KEY AUTOINCREMENT,
session_id TEXT NOT NULL,
title TEXT NOT NULL DEFAULT '新对话',
model TEXT NOT NULL DEFAULT '',
params_json TEXT NOT NULL DEFAULT '{}',
created_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE INDEX IF NOT EXISTS idx_chat_conversations_session ON chat_conversations(session_id);
CREATE TABLE IF NOT EXISTS chat_messages (
id INTEGER PRIMARY KEY AUTOINCREMENT,
conversation_id INTEGER NOT NULL,
role TEXT NOT NULL,
content TEXT NOT NULL,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
FOREIGN KEY (conversation_id) REFERENCES chat_conversations(id) ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS idx_chat_messages_conv ON chat_messages(conversation_id);
CREATE TABLE IF NOT EXISTS generations (
id INTEGER PRIMARY KEY AUTOINCREMENT,
session_id TEXT NOT NULL,
service_type TEXT NOT NULL,
provider_id INTEGER,
model TEXT NOT NULL DEFAULT '',
input_json TEXT NOT NULL DEFAULT '{}',
output_json TEXT NOT NULL DEFAULT '{}',
task_id INTEGER,
created_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE INDEX IF NOT EXISTS idx_generations_session ON generations(session_id, created_at DESC);
CREATE TABLE IF NOT EXISTS presets (
id INTEGER PRIMARY KEY AUTOINCREMENT,
session_id TEXT NOT NULL,
name TEXT NOT NULL,
service_type TEXT NOT NULL,
params_json TEXT NOT NULL DEFAULT '{}',
created_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE INDEX IF NOT EXISTS idx_presets_session ON presets(session_id, service_type);
CREATE TABLE IF NOT EXISTS tasks (
id INTEGER PRIMARY KEY AUTOINCREMENT,
session_id TEXT NOT NULL,
service_type TEXT NOT NULL,
protocol TEXT NOT NULL,
provider_id INTEGER NOT NULL,
status TEXT NOT NULL DEFAULT 'pending',
progress INTEGER NOT NULL DEFAULT 0,
input_json TEXT NOT NULL DEFAULT '{}',
result_json TEXT NOT NULL DEFAULT '{}',
error TEXT NOT NULL DEFAULT '',
external_id TEXT NOT NULL DEFAULT '',
created_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
completed_at TEXT
);
CREATE INDEX IF NOT EXISTS idx_tasks_session_status ON tasks(session_id, status);
CREATE TABLE IF NOT EXISTS files (
id INTEGER PRIMARY KEY AUTOINCREMENT,
session_id TEXT NOT NULL,
kind TEXT NOT NULL,
mime TEXT NOT NULL DEFAULT '',
storage_path TEXT NOT NULL,
size_bytes INTEGER NOT NULL DEFAULT 0,
sha256 TEXT NOT NULL DEFAULT '',
expires_at TEXT,
created_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE INDEX IF NOT EXISTS idx_files_session ON files(session_id);
CREATE TABLE IF NOT EXISTS request_logs (
id INTEGER PRIMARY KEY AUTOINCREMENT,
session_id TEXT NOT NULL DEFAULT '',
method TEXT NOT NULL,
path TEXT NOT NULL,
protocol TEXT NOT NULL DEFAULT '',
provider_id INTEGER,
model TEXT NOT NULL DEFAULT '',
status_code INTEGER NOT NULL DEFAULT 0,
latency_ms INTEGER NOT NULL DEFAULT 0,
error TEXT NOT NULL DEFAULT '',
request_summary TEXT NOT NULL DEFAULT '',
response_summary TEXT NOT NULL DEFAULT '',
created_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE INDEX IF NOT EXISTS idx_request_logs_created ON request_logs(created_at DESC);
INSERT OR IGNORE INTO settings (key, value) VALUES
('file_sign_secret', 'change-me-atlas-file-sign'),
('write_flush_interval_ms', '200'),
('task_progress_flush_interval_ms', '1000'),
('cache_ttl_seconds', '30');
UPDATE settings SET value = 'Atlas' WHERE key = 'app_name';
-- +goose Down
DROP TABLE IF EXISTS request_logs;
DROP TABLE IF EXISTS files;
DROP TABLE IF EXISTS tasks;
DROP TABLE IF EXISTS presets;
DROP TABLE IF EXISTS generations;
DROP TABLE IF EXISTS chat_messages;
DROP TABLE IF EXISTS chat_conversations;
DROP TABLE IF EXISTS user_sessions;
DROP TABLE IF EXISTS providers;
@@ -0,0 +1,14 @@
-- +goose Up
CREATE TABLE IF NOT EXISTS provider_models (
id INTEGER PRIMARY KEY AUTOINCREMENT,
provider_id INTEGER NOT NULL,
model_id TEXT NOT NULL,
name TEXT NOT NULL,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
UNIQUE(provider_id, model_id),
FOREIGN KEY (provider_id) REFERENCES providers(id) ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS idx_provider_models_provider ON provider_models(provider_id);
-- +goose Down
DROP TABLE IF EXISTS provider_models;
@@ -0,0 +1,7 @@
-- +goose Up
UPDATE providers
SET service_type = 'image_generation'
WHERE service_type IN ('text_to_image', 'image_to_image');
-- +goose Down
-- Cannot reliably restore previous split.
+60
View File
@@ -0,0 +1,60 @@
package store
import "context"
type Preset struct {
ID int64 `json:"id"`
SessionID string `json:"session_id"`
Name string `json:"name"`
ServiceType string `json:"service_type"`
ParamsJSON string `json:"params_json"`
CreatedAt string `json:"created_at"`
}
func (s *Store) ListPresets(ctx context.Context, sessionID, serviceType string) ([]Preset, error) {
q := `SELECT id, session_id, name, service_type, params_json, created_at FROM presets WHERE session_id = ?`
args := []any{sessionID}
if serviceType != "" {
q += ` AND service_type = ?`
args = append(args, serviceType)
}
q += ` ORDER BY id DESC`
rows, err := s.db.QueryContext(ctx, q, args...)
if err != nil {
return nil, err
}
defer rows.Close()
var items []Preset
for rows.Next() {
var p Preset
if err := rows.Scan(&p.ID, &p.SessionID, &p.Name, &p.ServiceType, &p.ParamsJSON, &p.CreatedAt); err != nil {
return nil, err
}
items = append(items, p)
}
return items, rows.Err()
}
func (s *Store) CreatePreset(ctx context.Context, p *Preset) (int64, error) {
res, err := s.db.ExecContext(ctx,
`INSERT INTO presets (session_id, name, service_type, params_json) VALUES (?, ?, ?, ?)`,
p.SessionID, p.Name, p.ServiceType, p.ParamsJSON,
)
if err != nil {
return 0, err
}
return res.LastInsertId()
}
func (s *Store) UpdatePreset(ctx context.Context, p *Preset) error {
_, err := s.db.ExecContext(ctx,
`UPDATE presets SET name=?, params_json=? WHERE id=? AND session_id=?`,
p.Name, p.ParamsJSON, p.ID, p.SessionID,
)
return err
}
func (s *Store) DeletePreset(ctx context.Context, sessionID string, id int64) error {
_, err := s.db.ExecContext(ctx, `DELETE FROM presets WHERE id = ? AND session_id = ?`, id, sessionID)
return err
}
+53
View File
@@ -0,0 +1,53 @@
package store
import "context"
type ProviderModel struct {
ID int64 `json:"id"`
ProviderID int64 `json:"provider_id"`
ModelID string `json:"model_id"`
Name string `json:"name"`
CreatedAt string `json:"created_at"`
}
func (s *Store) ListProviderModels(ctx context.Context, providerID int64) ([]ProviderModel, error) {
rows, err := s.db.QueryContext(ctx,
`SELECT id, provider_id, model_id, name, created_at FROM provider_models WHERE provider_id = ? ORDER BY id ASC`, providerID,
)
if err != nil {
return nil, err
}
defer rows.Close()
var items []ProviderModel
for rows.Next() {
var m ProviderModel
if err := rows.Scan(&m.ID, &m.ProviderID, &m.ModelID, &m.Name, &m.CreatedAt); err != nil {
return nil, err
}
items = append(items, m)
}
return items, rows.Err()
}
func (s *Store) CreateProviderModel(ctx context.Context, m *ProviderModel) (int64, error) {
res, err := s.db.ExecContext(ctx,
`INSERT INTO provider_models (provider_id, model_id, name) VALUES (?, ?, ?)`,
m.ProviderID, m.ModelID, m.Name,
)
if err != nil {
return 0, err
}
return res.LastInsertId()
}
func (s *Store) DeleteProviderModel(ctx context.Context, providerID int64, modelID string) error {
_, err := s.db.ExecContext(ctx,
`DELETE FROM provider_models WHERE provider_id = ? AND model_id = ?`, providerID, modelID,
)
return err
}
func (s *Store) DeleteProviderModelsByProvider(ctx context.Context, providerID int64) error {
_, err := s.db.ExecContext(ctx, `DELETE FROM provider_models WHERE provider_id = ?`, providerID)
return err
}
+136
View File
@@ -0,0 +1,136 @@
package store
import (
"context"
"database/sql"
"fmt"
"github.com/rose_cat707/Atlas/internal/domain"
)
type Provider struct {
ID int64 `json:"id"`
Name string `json:"name"`
Protocol string `json:"protocol"`
BaseURL string `json:"base_url"`
APIKey string `json:"api_key,omitempty"`
ServiceType string `json:"service_type"`
IsDefault bool `json:"is_default"`
Enabled bool `json:"enabled"`
ExtraJSON string `json:"extra_json"`
CreatedAt string `json:"created_at"`
UpdatedAt string `json:"updated_at"`
}
func (s *Store) ListProviders(ctx context.Context, serviceType string, enabledOnly bool) ([]Provider, error) {
q := `SELECT id, name, protocol, base_url, api_key, service_type, is_default, enabled, extra_json, created_at, updated_at FROM providers WHERE 1=1`
args := []any{}
if serviceType != "" {
q += ` AND service_type = ?`
args = append(args, serviceType)
}
if enabledOnly {
q += ` AND enabled = 1`
}
q += ` ORDER BY is_default DESC, id ASC`
rows, err := s.db.QueryContext(ctx, q, args...)
if err != nil {
return nil, err
}
defer rows.Close()
var items []Provider
for rows.Next() {
var p Provider
var isDef, en int
if err := rows.Scan(&p.ID, &p.Name, &p.Protocol, &p.BaseURL, &p.APIKey, &p.ServiceType, &isDef, &en, &p.ExtraJSON, &p.CreatedAt, &p.UpdatedAt); err != nil {
return nil, err
}
p.IsDefault = isDef == 1
p.Enabled = en == 1
items = append(items, p)
}
return items, rows.Err()
}
func (s *Store) GetProvider(ctx context.Context, id int64) (*Provider, error) {
var p Provider
var isDef, en int
err := s.db.QueryRowContext(ctx,
`SELECT id, name, protocol, base_url, api_key, service_type, is_default, enabled, extra_json, created_at, updated_at FROM providers WHERE id = ?`, id,
).Scan(&p.ID, &p.Name, &p.Protocol, &p.BaseURL, &p.APIKey, &p.ServiceType, &isDef, &en, &p.ExtraJSON, &p.CreatedAt, &p.UpdatedAt)
if err != nil {
return nil, err
}
p.IsDefault = isDef == 1
p.Enabled = en == 1
return &p, nil
}
func (s *Store) CreateProvider(ctx context.Context, p *Provider) (int64, error) {
if p.IsDefault {
if _, err := s.db.ExecContext(ctx, `UPDATE providers SET is_default = 0 WHERE service_type = ?`, p.ServiceType); err != nil {
return 0, err
}
}
res, err := s.db.ExecContext(ctx,
`INSERT INTO providers (name, protocol, base_url, api_key, service_type, is_default, enabled, extra_json) VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
p.Name, p.Protocol, p.BaseURL, p.APIKey, p.ServiceType, boolInt(p.IsDefault), boolInt(p.Enabled), p.ExtraJSON,
)
if err != nil {
return 0, err
}
return res.LastInsertId()
}
func (s *Store) UpdateProvider(ctx context.Context, p *Provider) error {
if p.IsDefault {
if _, err := s.db.ExecContext(ctx, `UPDATE providers SET is_default = 0 WHERE service_type = ? AND id != ?`, p.ServiceType, p.ID); err != nil {
return err
}
}
_, err := s.db.ExecContext(ctx,
`UPDATE providers SET name=?, protocol=?, base_url=?, api_key=?, service_type=?, is_default=?, enabled=?, extra_json=?, updated_at=datetime('now') WHERE id=?`,
p.Name, p.Protocol, p.BaseURL, p.APIKey, p.ServiceType, boolInt(p.IsDefault), boolInt(p.Enabled), p.ExtraJSON, p.ID,
)
return err
}
func (s *Store) DeleteProvider(ctx context.Context, id int64) error {
_, err := s.db.ExecContext(ctx, `DELETE FROM providers WHERE id = ?`, id)
return err
}
func boolInt(v bool) int {
if v {
return 1
}
return 0
}
func (s *Store) DefaultProvider(ctx context.Context, serviceType string) (*Provider, error) {
providers, err := s.ListProviders(ctx, serviceType, true)
if err != nil {
return nil, err
}
for _, p := range providers {
if p.IsDefault {
return &p, nil
}
}
if len(providers) > 0 {
return &providers[0], nil
}
return nil, sql.ErrNoRows
}
func MaskProvider(p Provider) Provider {
p.APIKey = MaskSecret(p.APIKey)
return p
}
func ValidateProvider(p *Provider) error {
if p.Name == "" || p.Protocol == "" || p.BaseURL == "" || p.ServiceType == "" {
return fmt.Errorf("name, protocol, base_url, service_type required")
}
return domain.ValidateProviderBinding(p.Protocol, p.ServiceType)
}
+101
View File
@@ -0,0 +1,101 @@
package store
import "context"
type RequestLog struct {
ID int64 `json:"id"`
SessionID string `json:"session_id"`
Method string `json:"method"`
Path string `json:"path"`
Protocol string `json:"protocol"`
ProviderID *int64 `json:"provider_id"`
Model string `json:"model"`
StatusCode int `json:"status_code"`
LatencyMS int `json:"latency_ms"`
Error string `json:"error"`
RequestSummary string `json:"request_summary"`
ResponseSummary string `json:"response_summary"`
CreatedAt string `json:"created_at"`
}
func (s *Store) InsertRequestLog(ctx context.Context, l *RequestLog) (int64, error) {
res, err := s.db.ExecContext(ctx,
`INSERT INTO request_logs (session_id, method, path, protocol, provider_id, model, status_code, latency_ms, error, request_summary, response_summary) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
l.SessionID, l.Method, l.Path, l.Protocol, l.ProviderID, l.Model, l.StatusCode, l.LatencyMS, l.Error, l.RequestSummary, l.ResponseSummary,
)
if err != nil {
return 0, err
}
return res.LastInsertId()
}
func (s *Store) ListRequestLogs(ctx context.Context, filter RequestLogFilter, limit, offset int) ([]RequestLog, int, error) {
where := `WHERE 1=1`
args := []any{}
if filter.Path != "" {
where += ` AND path LIKE ?`
args = append(args, "%"+filter.Path+"%")
}
if filter.StatusCode > 0 {
where += ` AND status_code = ?`
args = append(args, filter.StatusCode)
}
if filter.ProviderID > 0 {
where += ` AND provider_id = ?`
args = append(args, filter.ProviderID)
}
var total int
if err := s.db.QueryRowContext(ctx, `SELECT COUNT(*) FROM request_logs `+where, args...).Scan(&total); err != nil {
return nil, 0, err
}
q := `SELECT id, session_id, method, path, protocol, provider_id, model, status_code, latency_ms, error, request_summary, response_summary, created_at FROM request_logs ` + where + ` ORDER BY id DESC LIMIT ? OFFSET ?`
qArgs := append(args, limit, offset)
rows, err := s.db.QueryContext(ctx, q, qArgs...)
if err != nil {
return nil, 0, err
}
defer rows.Close()
var items []RequestLog
for rows.Next() {
var l RequestLog
if err := rows.Scan(&l.ID, &l.SessionID, &l.Method, &l.Path, &l.Protocol, &l.ProviderID, &l.Model, &l.StatusCode, &l.LatencyMS, &l.Error, &l.RequestSummary, &l.ResponseSummary, &l.CreatedAt); err != nil {
return nil, 0, err
}
items = append(items, l)
}
return items, total, rows.Err()
}
type RequestLogFilter struct {
Path string
StatusCode int
ProviderID int64
}
type DashboardStats struct {
TotalRequests int `json:"total_requests"`
SuccessRate float64 `json:"success_rate"`
AvgLatencyMS float64 `json:"avg_latency_ms"`
P50LatencyMS float64 `json:"p50_latency_ms"`
P95LatencyMS float64 `json:"p95_latency_ms"`
ByServiceType map[string]int `json:"by_service_type"`
ByProvider map[string]int `json:"by_provider"`
FileCount int `json:"file_count"`
FileBytes int64 `json:"file_bytes"`
}
func (s *Store) DashboardStats(ctx context.Context) (*DashboardStats, error) {
stats := &DashboardStats{ByServiceType: map[string]int{}, ByProvider: map[string]int{}}
var success int
err := s.db.QueryRowContext(ctx,
`SELECT COUNT(*), COALESCE(AVG(latency_ms),0), SUM(CASE WHEN status_code >= 200 AND status_code < 400 THEN 1 ELSE 0 END) FROM request_logs WHERE created_at >= datetime('now','-7 days')`,
).Scan(&stats.TotalRequests, &stats.AvgLatencyMS, &success)
if err != nil {
return nil, err
}
if stats.TotalRequests > 0 {
stats.SuccessRate = float64(success) / float64(stats.TotalRequests)
}
stats.FileCount, stats.FileBytes, _ = s.CountFiles(ctx)
return stats, nil
}
+21
View File
@@ -0,0 +1,21 @@
package store
import "context"
type UserSession struct {
ID string `json:"id"`
CreatedAt string `json:"created_at"`
LastSeenAt string `json:"last_seen_at"`
}
func (s *Store) UpsertSession(ctx context.Context, id string) error {
_, err := s.db.ExecContext(ctx,
`INSERT INTO user_sessions (id, last_seen_at) VALUES (?, datetime('now'))
ON CONFLICT(id) DO UPDATE SET last_seen_at = datetime('now')`, id,
)
return err
}
func (s *Store) TouchSession(ctx context.Context, id string) error {
return s.UpsertSession(ctx, id)
}
+16 -5
View File
@@ -7,16 +7,19 @@ import (
"fmt" "fmt"
"os" "os"
"path/filepath" "path/filepath"
"time"
"github.com/pressly/goose/v3" "github.com/pressly/goose/v3"
_ "modernc.org/sqlite" _ "github.com/mattn/go-sqlite3"
) )
//go:embed migrations/*.sql //go:embed migrations/*.sql
var migrationsFS embed.FS var migrationsFS embed.FS
type Store struct { type Store struct {
db *sql.DB db *sql.DB
Cache *HotCache
WriteQueue *WriteQueue
} }
func Open(dataDir string) (*Store, error) { func Open(dataDir string) (*Store, error) {
@@ -24,7 +27,7 @@ func Open(dataDir string) (*Store, error) {
return nil, err return nil, err
} }
dbPath := filepath.Join(dataDir, "app.db") dbPath := filepath.Join(dataDir, "app.db")
db, err := sql.Open("sqlite", dbPath+"?_pragma=foreign_keys(1)&_pragma=journal_mode(WAL)&_pragma=busy_timeout(5000)") db, err := sql.Open("sqlite3", dbPath+"?_foreign_keys=1&_journal_mode=WAL&_busy_timeout=5000")
if err != nil { if err != nil {
return nil, err return nil, err
} }
@@ -34,12 +37,19 @@ func Open(dataDir string) (*Store, error) {
db.Close() db.Close()
return nil, err return nil, err
} }
s.Cache = NewHotCache(30 * time.Second)
s.WriteQueue = NewWriteQueue(s, 200*time.Millisecond)
return s, nil return s, nil
} }
func (s *Store) DB() *sql.DB { return s.db } func (s *Store) DB() *sql.DB { return s.db }
func (s *Store) Close() error { return s.db.Close() } func (s *Store) Close() error {
if s.WriteQueue != nil {
s.WriteQueue.Close()
}
return s.db.Close()
}
func (s *Store) migrate() error { func (s *Store) migrate() error {
goose.SetBaseFS(migrationsFS) goose.SetBaseFS(migrationsFS)
@@ -143,7 +153,8 @@ func MaskSecret(value string) string {
func ValidateSettingKey(key string) error { func ValidateSettingKey(key string) error {
switch key { switch key {
case "app_name", "api_port", "admin_password", "auth_enabled", "ui_locale": case "app_name", "api_port", "admin_password", "auth_enabled", "ui_locale",
"file_sign_secret", "write_flush_interval_ms", "task_progress_flush_interval_ms", "cache_ttl_seconds":
return nil return nil
default: default:
return fmt.Errorf("unknown setting: %s", key) return fmt.Errorf("unknown setting: %s", key)
+93
View File
@@ -0,0 +1,93 @@
package store
import "context"
type Task struct {
ID int64 `json:"id"`
SessionID string `json:"session_id"`
ServiceType string `json:"service_type"`
Protocol string `json:"protocol"`
ProviderID int64 `json:"provider_id"`
Status string `json:"status"`
Progress int `json:"progress"`
InputJSON string `json:"input_json"`
ResultJSON string `json:"result_json"`
Error string `json:"error"`
ExternalID string `json:"external_id"`
CreatedAt string `json:"created_at"`
UpdatedAt string `json:"updated_at"`
CompletedAt *string `json:"completed_at"`
}
func (s *Store) InsertTask(ctx context.Context, t *Task) (int64, error) {
res, err := s.db.ExecContext(ctx,
`INSERT INTO tasks (session_id, service_type, protocol, provider_id, status, progress, input_json, result_json, error, external_id) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
t.SessionID, t.ServiceType, t.Protocol, t.ProviderID, t.Status, t.Progress, t.InputJSON, t.ResultJSON, t.Error, t.ExternalID,
)
if err != nil {
return 0, err
}
return res.LastInsertId()
}
func (s *Store) UpdateTask(ctx context.Context, t *Task) error {
_, err := s.db.ExecContext(ctx,
`UPDATE tasks SET status=?, progress=?, result_json=?, error=?, external_id=?, updated_at=datetime('now'), completed_at=? WHERE id=? AND session_id=?`,
t.Status, t.Progress, t.ResultJSON, t.Error, t.ExternalID, t.CompletedAt, t.ID, t.SessionID,
)
return err
}
func (s *Store) GetTask(ctx context.Context, sessionID string, id int64) (*Task, error) {
var t Task
err := s.db.QueryRowContext(ctx,
`SELECT id, session_id, service_type, protocol, provider_id, status, progress, input_json, result_json, error, external_id, created_at, updated_at, completed_at FROM tasks WHERE id = ? AND session_id = ?`, id, sessionID,
).Scan(&t.ID, &t.SessionID, &t.ServiceType, &t.Protocol, &t.ProviderID, &t.Status, &t.Progress, &t.InputJSON, &t.ResultJSON, &t.Error, &t.ExternalID, &t.CreatedAt, &t.UpdatedAt, &t.CompletedAt)
if err != nil {
return nil, err
}
return &t, nil
}
func (s *Store) ListActiveTasks(ctx context.Context, sessionID, serviceType string) ([]Task, error) {
q := `SELECT id, session_id, service_type, protocol, provider_id, status, progress, input_json, result_json, error, external_id, created_at, updated_at, completed_at FROM tasks WHERE session_id = ? AND status IN ('pending','running')`
args := []any{sessionID}
if serviceType != "" {
q += ` AND service_type = ?`
args = append(args, serviceType)
}
q += ` ORDER BY id DESC`
rows, err := s.db.QueryContext(ctx, q, args...)
if err != nil {
return nil, err
}
defer rows.Close()
var items []Task
for rows.Next() {
var t Task
if err := rows.Scan(&t.ID, &t.SessionID, &t.ServiceType, &t.Protocol, &t.ProviderID, &t.Status, &t.Progress, &t.InputJSON, &t.ResultJSON, &t.Error, &t.ExternalID, &t.CreatedAt, &t.UpdatedAt, &t.CompletedAt); err != nil {
return nil, err
}
items = append(items, t)
}
return items, rows.Err()
}
func (s *Store) ListPendingTasks(ctx context.Context) ([]Task, error) {
rows, err := s.db.QueryContext(ctx,
`SELECT id, session_id, service_type, protocol, provider_id, status, progress, input_json, result_json, error, external_id, created_at, updated_at, completed_at FROM tasks WHERE status IN ('pending','running') ORDER BY id ASC`,
)
if err != nil {
return nil, err
}
defer rows.Close()
var items []Task
for rows.Next() {
var t Task
if err := rows.Scan(&t.ID, &t.SessionID, &t.ServiceType, &t.Protocol, &t.ProviderID, &t.Status, &t.Progress, &t.InputJSON, &t.ResultJSON, &t.Error, &t.ExternalID, &t.CreatedAt, &t.UpdatedAt, &t.CompletedAt); err != nil {
return nil, err
}
items = append(items, t)
}
return items, rows.Err()
}
+90
View File
@@ -0,0 +1,90 @@
package store
import (
"context"
"log"
"sync"
"time"
)
type writeJob func(ctx context.Context, db *Store) error
type WriteQueue struct {
store *Store
ch chan writeJob
wg sync.WaitGroup
interval time.Duration
stopCh chan struct{}
}
func NewWriteQueue(s *Store, flushInterval time.Duration) *WriteQueue {
wq := &WriteQueue{
store: s,
ch: make(chan writeJob, 256),
interval: flushInterval,
stopCh: make(chan struct{}),
}
wq.wg.Add(1)
go wq.run()
return wq
}
func (wq *WriteQueue) Enqueue(job writeJob) {
select {
case wq.ch <- job:
default:
go func() { wq.ch <- job }()
}
}
func (wq *WriteQueue) ForceFlush(ctx context.Context, job writeJob) error {
return job(ctx, wq.store)
}
func (wq *WriteQueue) run() {
defer wq.wg.Done()
ticker := time.NewTicker(wq.interval)
defer ticker.Stop()
pending := make([]writeJob, 0, 32)
flush := func() {
if len(pending) == 0 {
return
}
jobs := pending
pending = pending[:0]
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
for _, job := range jobs {
if err := job(ctx, wq.store); err != nil {
log.Printf("write queue: %v", err)
}
}
}
for {
select {
case <-wq.stopCh:
flush()
for {
select {
case job := <-wq.ch:
pending = append(pending, job)
default:
flush()
return
}
}
case job := <-wq.ch:
pending = append(pending, job)
if len(pending) >= 32 {
flush()
}
case <-ticker.C:
flush()
}
}
}
func (wq *WriteQueue) Close() {
close(wq.stopCh)
wq.wg.Wait()
}
+3 -1
View File
@@ -2,8 +2,10 @@
<html lang="zh-CN"> <html lang="zh-CN">
<head> <head>
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>App</title> <meta name="description" content="Atlas — AI 创作与管理平台" />
<title>Atlas</title>
</head> </head>
<body> <body>
<div id="app"></div> <div id="app"></div>
+7
View File
@@ -0,0 +1,7 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32" fill="none">
<rect width="32" height="32" rx="8" fill="#0f7a62"/>
<circle cx="16" cy="16" r="9" stroke="#fafafa" stroke-width="1.5" opacity="0.95"/>
<path d="M7 16c3.2-5 14.8-5 18 0M7 16c3.2 5 14.8 5 18 0M16 7v18" stroke="#fafafa" stroke-width="1.2" stroke-linecap="round" opacity="0.85"/>
<path d="M12.5 21.5 16 10l3.5 11.5" stroke="#fafafa" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M13.8 18.2h4.4" stroke="#0f7a62" stroke-width="1.6" stroke-linecap="round"/>
</svg>

After

Width:  |  Height:  |  Size: 574 B

+2 -2
View File
@@ -19,8 +19,8 @@ api.interceptors.response.use(
(err) => { (err) => {
if (err.response?.status === 401 && err.config?.url !== '/auth/login') { if (err.response?.status === 401 && err.config?.url !== '/auth/login') {
localStorage.removeItem('app_token') localStorage.removeItem('app_token')
if (router.currentRoute.value.name !== 'login') { if (!router.currentRoute.value.path.startsWith('/admin/login')) {
router.push('/login') router.push('/admin/login')
} }
} }
return Promise.reject(err) return Promise.reject(err)
+12 -1
View File
@@ -1,5 +1,16 @@
<script setup lang="ts">
defineProps<{ title?: string; padded?: boolean }>()
</script>
<template> <template>
<div class="app-card"> <div class="app-card">
<slot /> <div v-if="title || $slots.header" class="app-card__header">
<slot name="header">
<h3 class="app-card__title">{{ title }}</h3>
</slot>
</div>
<div :class="padded ? 'app-card__body--padded' : ''">
<slot />
</div>
</div> </div>
</template> </template>
+43
View File
@@ -0,0 +1,43 @@
<script setup lang="ts">
withDefaults(defineProps<{ size?: number; variant?: 'mark' | 'full' }>(), {
size: 32,
variant: 'mark',
})
</script>
<template>
<svg
class="atlas-logo"
:width="size"
:height="size"
viewBox="0 0 32 32"
fill="none"
xmlns="http://www.w3.org/2000/svg"
aria-hidden="true"
>
<rect width="32" height="32" rx="8" fill="#0f7a62" />
<circle cx="16" cy="16" r="9" stroke="#fafafa" stroke-width="1.5" opacity="0.95" />
<path
d="M7 16c3.2-5 14.8-5 18 0M7 16c3.2 5 14.8 5 18 0M16 7v18"
stroke="#fafafa"
stroke-width="1.2"
stroke-linecap="round"
opacity="0.85"
/>
<path
d="M12.5 21.5 16 10l3.5 11.5"
stroke="#fafafa"
stroke-width="1.8"
stroke-linecap="round"
stroke-linejoin="round"
/>
<path d="M13.8 18.2h4.4" stroke="#0f7a62" stroke-width="1.6" stroke-linecap="round" />
</svg>
</template>
<style scoped>
.atlas-logo {
display: block;
flex-shrink: 0;
}
</style>
+4 -4
View File
@@ -1,14 +1,14 @@
<script setup lang="ts"> <script setup lang="ts">
defineProps<{ title: string; subtitle?: string }>() defineProps<{ title: string; description?: string }>()
</script> </script>
<template> <template>
<div class="page-header"> <div class="page-header">
<div> <div>
<h2 class="page-header__title">{{ title }}</h2> <h1 class="page-title">{{ title }}</h1>
<p v-if="subtitle" class="page-header__subtitle">{{ subtitle }}</p> <p v-if="description" class="page-desc">{{ description }}</p>
</div> </div>
<div class="page-header__actions"> <div v-if="$slots.actions" class="page-actions">
<slot name="actions" /> <slot name="actions" />
</div> </div>
</div> </div>
+10
View File
@@ -0,0 +1,10 @@
<script setup lang="ts">
defineProps<{ label: string; value: string | number }>()
</script>
<template>
<div class="stat-card">
<div class="stat-card__label">{{ label }}</div>
<div class="stat-card__value">{{ value }}</div>
</div>
</template>
+46
View File
@@ -0,0 +1,46 @@
<script setup lang="ts">
import { modelKindLabel } from '@/composables/useImageStudio'
import type { ModelInfo } from '@/composables/useUserMeta'
defineProps<{
modelValue: string
models: ModelInfo[]
label?: string
placeholder?: string
showKind?: boolean
}>()
defineEmits<{
'update:modelValue': [value: string]
}>()
function optionLabel(model: ModelInfo, showKind?: boolean) {
if (!showKind || !model.kind) return model.name
return `${model.name} · ${modelKindLabel(model.kind)}`
}
</script>
<template>
<el-form-item :label="label ?? '模型'">
<el-select
:model-value="modelValue"
:placeholder="placeholder ?? '选择模型'"
:disabled="!models.length"
filterable
style="width: 100%"
@update:model-value="$emit('update:modelValue', $event)"
>
<el-option
v-for="m in models"
:key="m.id"
:label="optionLabel(m, showKind)"
:value="m.id"
/>
</el-select>
<p v-if="!models.length" class="form-hint">
尚未配置可用模型请前往
<router-link to="/admin/providers">管理端 · Provider</router-link>
添加并同步模型
</p>
</el-form-item>
</template>
@@ -0,0 +1,153 @@
<script setup lang="ts">
import { ref } from 'vue'
import { UploadFilled } from '@element-plus/icons-vue'
import { ElMessage } from 'element-plus'
import api from '@/api/client'
import type { InputField } from '@/composables/useUserMeta'
const props = defineProps<{
schema: InputField[]
modelValue: Record<string, unknown>
}>()
const emit = defineEmits<{
'update:modelValue': [value: Record<string, unknown>]
}>()
const uploadingFields = ref<Set<string>>(new Set())
function updateField(name: string, value: unknown) {
emit('update:modelValue', { ...props.modelValue, [name]: value })
}
async function onUpload(field: InputField, file: File) {
uploadingFields.value = new Set(uploadingFields.value).add(field.name)
try {
const form = new FormData()
form.append('file', file)
const res = await api.post<{ signed_url: string }>('/user/upload', form, {
headers: { 'Content-Type': 'multipart/form-data' },
})
updateField(field.name, res.data.signed_url)
ElMessage.success(`${field.title}已上传`)
} catch {
ElMessage.error('上传失败')
} finally {
const next = new Set(uploadingFields.value)
next.delete(field.name)
uploadingFields.value = next
}
return false
}
function isUploading(name: string) {
return uploadingFields.value.has(name)
}
function imagePreview(name: string) {
const value = props.modelValue[name]
return typeof value === 'string' ? value : ''
}
function numberValue(name: string, fallback = 0) {
const value = props.modelValue[name]
if (typeof value === 'number') return value
if (typeof value === 'string' && value !== '') return Number(value)
return fallback
}
</script>
<template>
<template v-for="field in schema" :key="field.name">
<el-form-item :label="field.title" :required="field.required">
<el-input
v-if="field.widget === 'textarea'"
:model-value="String(modelValue[field.name] ?? '')"
type="textarea"
:rows="field.name.includes('negative') ? 3 : 5"
maxlength="4000"
show-word-limit
:placeholder="field.description || field.title"
@update:model-value="updateField(field.name, $event)"
/>
<el-input
v-else-if="field.widget === 'text'"
:model-value="String(modelValue[field.name] ?? '')"
:placeholder="field.description || field.title"
@update:model-value="updateField(field.name, $event)"
/>
<el-input-number
v-else-if="field.widget === 'integer'"
:model-value="numberValue(field.name, Number(field.default ?? 0))"
:step="1"
controls-position="right"
style="width: 100%"
@update:model-value="updateField(field.name, $event)"
/>
<div v-else-if="field.widget === 'number'" class="number-field">
<el-slider
v-if="field.name === 'denoise' || field.name === 'strength' || field.name.includes('scale')"
:model-value="numberValue(field.name, Number(field.default ?? 0))"
:min="0"
:max="field.name === 'denoise' || field.name === 'strength' ? 1 : 20"
:step="field.name === 'denoise' || field.name === 'strength' ? 0.05 : 0.5"
show-input
@update:model-value="updateField(field.name, $event)"
/>
<el-input-number
v-else
:model-value="numberValue(field.name, Number(field.default ?? 0))"
:step="0.1"
controls-position="right"
style="width: 100%"
@update:model-value="updateField(field.name, $event)"
/>
</div>
<el-switch
v-else-if="field.widget === 'switch'"
:model-value="Boolean(modelValue[field.name] ?? field.default ?? false)"
@update:model-value="updateField(field.name, $event)"
/>
<div v-else-if="field.widget === 'image'" class="image-field">
<el-upload
class="upload-dropzone"
drag
:auto-upload="true"
:show-file-list="false"
accept="image/*"
:disabled="isUploading(field.name)"
:before-upload="(file: File) => onUpload(field, file)"
>
<el-icon :size="32"><UploadFilled /></el-icon>
<div class="el-upload__text">拖拽图片到此处,或<em>点击上传</em></div>
</el-upload>
<div v-if="imagePreview(field.name)" class="upload-preview">
<img :src="imagePreview(field.name)" :alt="field.title" />
</div>
<p v-if="field.description" class="form-hint">{{ field.description }}</p>
</div>
<p v-if="field.description && field.widget !== 'image'" class="form-hint">{{ field.description }}</p>
</el-form-item>
</template>
</template>
<style scoped>
.number-field {
width: 100%;
}
.image-field {
width: 100%;
}
.upload-preview {
margin-top: 12px;
}
.upload-preview img {
max-width: 100%;
max-height: 220px;
border-radius: 8px;
object-fit: contain;
}
</style>
+19
View File
@@ -0,0 +1,19 @@
<script setup lang="ts">
defineProps<{
title: string
description?: string
}>()
</script>
<template>
<div class="studio-empty">
<div class="studio-empty__icon">
<slot name="icon" />
</div>
<h3 class="studio-empty__title">{{ title }}</h3>
<p v-if="description" class="studio-empty__desc">{{ description }}</p>
<div v-if="$slots.action" class="studio-empty__action">
<slot name="action" />
</div>
</div>
</template>
@@ -0,0 +1,40 @@
<script setup lang="ts">
import type { TaskItem } from '@/composables/useInvoke'
defineProps<{
tasks: TaskItem[]
}>()
function progressOf(task: TaskItem) {
if (task.progress > 0) return task.progress
if (task.status === 'running') return 35
if (task.status === 'pending') return 10
return 0
}
function statusLabel(status: string) {
const map: Record<string, string> = {
pending: '排队中',
running: '生成中',
succeeded: '已完成',
failed: '失败',
canceled: '已取消',
}
return map[status] ?? status
}
</script>
<template>
<div v-if="tasks.length" class="task-progress-list">
<div v-for="task in tasks" :key="task.id" class="task-progress-item">
<div class="task-progress-item__head">
<span class="task-progress-item__id">任务 #{{ task.id }}</span>
<span class="task-progress-item__status" :class="`is-${task.status}`">
{{ statusLabel(task.status) }}
</span>
</div>
<el-progress :percentage="progressOf(task)" :stroke-width="8" :show-text="false" />
<p v-if="task.error" class="task-progress-item__error">{{ task.error }}</p>
</div>
</div>
</template>
+37
View File
@@ -0,0 +1,37 @@
import { ref } from 'vue'
import api from '@/api/client'
export interface Generation {
id: number
service_type: string
model: string
input_json: string
output_json: string
created_at: string
}
export function useGenerations(serviceType?: string) {
const items = ref<Generation[]>([])
const total = ref(0)
const loading = ref(false)
async function load(limit = 20, offset = 0, typeOverride?: string) {
loading.value = true
try {
const res = await api.get<{ items: Generation[]; total: number }>('/user/generations', {
params: { service_type: typeOverride ?? serviceType ?? undefined, limit, offset },
})
items.value = res.data.items
total.value = res.data.total
} finally {
loading.value = false
}
}
async function remove(id: number) {
await api.delete(`/user/generations/${id}`)
await load()
}
return { items, total, loading, load, remove }
}
+100
View File
@@ -0,0 +1,100 @@
import { computed, onMounted, onUnmounted, ref } from 'vue'
import api from '@/api/client'
import { useUserMeta, type ModelInfo } from '@/composables/useUserMeta'
import type { TaskItem } from '@/composables/useInvoke'
const serviceType = 'image_generation'
const legacyImageServiceTypes = ['text_to_image', 'image_to_image'] as const
const terminalStatuses = new Set(['succeeded', 'failed', 'canceled'])
export function useImageStudio() {
const { load, getService } = useUserMeta()
const activeTasks = ref<TaskItem[]>([])
const streams = new Map<number, EventSource>()
const models = computed(() => (getService(serviceType)?.models ?? []) as ModelInfo[])
const defaultModel = computed(() => {
const id = getService(serviceType)?.default_model
if (id && models.value.some((m) => m.id === id)) return id
return models.value[0]?.id ?? ''
})
function upsertTask(task: TaskItem) {
const idx = activeTasks.value.findIndex((t) => t.id === task.id)
if (idx >= 0) activeTasks.value[idx] = task
else activeTasks.value.push(task)
}
function closeStream(taskId: number) {
const es = streams.get(taskId)
if (es) {
es.close()
streams.delete(taskId)
}
}
function watchTask(taskId: number) {
if (streams.has(taskId)) return
const es = new EventSource(`/api/v1/user/tasks/${taskId}/events`, { withCredentials: true })
streams.set(taskId, es)
es.addEventListener('task', (ev) => {
const task = JSON.parse(ev.data) as TaskItem
upsertTask(task)
if (terminalStatuses.has(task.status)) closeStream(taskId)
})
es.addEventListener('done', () => closeStream(taskId))
es.onerror = () => closeStream(taskId)
}
async function loadActiveTasks() {
const types = [serviceType, ...legacyImageServiceTypes]
const responses = await Promise.all(
types.map((st) =>
api.get<{ items: TaskItem[] }>('/user/tasks/active', { params: { service_type: st } }),
),
)
const items = responses.flatMap((res) => res.data.items)
activeTasks.value = items
for (const task of items) watchTask(task.id)
}
async function generate(params: Record<string, unknown>, model: string) {
try {
const res = await api.post('/user/generate', {
service_type: serviceType,
params,
model,
})
if (res.data.task_id) {
watchTask(res.data.task_id)
await loadActiveTasks()
}
return res.data
} catch (err: unknown) {
const msg =
(err as { response?: { data?: { error?: string } } })?.response?.data?.error ?? '生成请求失败'
throw new Error(msg)
}
}
onMounted(loadActiveTasks)
onUnmounted(() => {
streams.forEach((es) => es.close())
streams.clear()
})
return {
load,
models,
defaultModel,
activeTasks,
generate,
loadActiveTasks,
}
}
export function modelKindLabel(kind?: ModelInfo['kind']) {
return kind === 'image_to_image' ? '图生图' : '文生图'
}
+13
View File
@@ -0,0 +1,13 @@
export interface TaskItem {
id: number
status: string
progress: number
result_json: string
error: string
}
const terminalStatuses = new Set(['succeeded', 'failed', 'canceled'])
export function isTerminalTaskStatus(status: string) {
return terminalStatuses.has(status)
}
+36
View File
@@ -0,0 +1,36 @@
import { ref } from 'vue'
import api from '@/api/client'
export interface Preset {
id: number
name: string
service_type: string
params_json: string
}
export function usePresets(serviceType: string) {
const items = ref<Preset[]>([])
async function load() {
const res = await api.get<{ items: Preset[] }>('/user/presets', {
params: { service_type: serviceType },
})
items.value = res.data.items
}
async function save(name: string, params: Record<string, unknown>) {
await api.post('/user/presets', {
name,
service_type: serviceType,
params_json: JSON.stringify(params),
})
await load()
}
async function remove(id: number) {
await api.delete(`/user/presets/${id}`)
await load()
}
return { items, load, save, remove }
}
+56
View File
@@ -0,0 +1,56 @@
import { ref } from 'vue'
import api from '@/api/client'
export interface InputField {
name: string
title: string
description?: string
type: string
default?: unknown
required: boolean
order: number
widget: string
}
export interface ModelInfo {
id: string
name: string
description?: string
kind?: 'text_to_image' | 'image_to_image'
input_schema?: InputField[]
}
export interface ServiceTypeMeta {
capabilities: { stream?: boolean; sync?: boolean; async?: boolean }
providers: { id: number; name: string; is_default: boolean }[]
models: ModelInfo[]
default_model: string
}
const meta = ref<Record<string, ServiceTypeMeta> | null>(null)
const loading = ref(false)
export function useUserMeta() {
async function ensureSession() {
await api.post('/user/session')
}
async function load() {
if (meta.value) return meta.value
loading.value = true
try {
await ensureSession()
const res = await api.get<{ service_types: Record<string, ServiceTypeMeta> }>('/user/meta')
meta.value = res.data.service_types
return meta.value
} finally {
loading.value = false
}
}
function getService(type: string) {
return meta.value?.[type]
}
return { meta, loading, load, ensureSession, getService }
}
+64
View File
@@ -0,0 +1,64 @@
export const protocolOptions = [
{ value: 'openai', label: 'OpenAI' },
{ value: 'replicate', label: 'Replicate' },
] as const
export const serviceTypeOptions = [
{ value: 'chat', label: '对话' },
{ value: 'image_generation', label: '生图' },
{ value: 'text_to_image', label: '文生图' },
{ value: 'image_to_image', label: '图生图' },
] as const
const protocolToServiceTypes: Record<string, string[]> = {
openai: ['chat'],
replicate: ['image_generation'],
}
const serviceTypeToProtocol: Record<string, string> = {
chat: 'openai',
image_generation: 'replicate',
text_to_image: 'replicate',
image_to_image: 'replicate',
}
export function defaultServiceType(protocol: string): string {
return protocolToServiceTypes[protocol]?.[0] ?? 'chat'
}
export function protocolForServiceType(serviceType: string): string {
return serviceTypeToProtocol[serviceType] ?? 'openai'
}
export function serviceTypesForProtocol(protocol: string): string[] {
return protocolToServiceTypes[protocol] ?? []
}
export function serviceTypeOptionsForProtocol(protocol: string) {
const allowed = new Set(serviceTypesForProtocol(protocol))
return serviceTypeOptions.filter((o) => allowed.has(o.value))
}
export function protocolOptionsForServiceType(serviceType: string) {
const protocol = protocolForServiceType(serviceType)
return protocolOptions.filter((o) => o.value === protocol)
}
export function adaptProviderForm(form: { protocol: string; service_type: string }) {
const allowed = serviceTypesForProtocol(form.protocol)
if (!allowed.includes(form.service_type)) {
form.service_type = defaultServiceType(form.protocol)
}
const expectedProtocol = protocolForServiceType(form.service_type)
if (form.protocol !== expectedProtocol) {
form.protocol = expectedProtocol
}
}
export function serviceTypeLabel(value: string): string {
return serviceTypeOptions.find((o) => o.value === value)?.label ?? value
}
export function protocolLabel(value: string): string {
return protocolOptions.find((o) => o.value === value)?.label ?? value
}
+34 -1
View File
@@ -7,17 +7,20 @@ export default {
}, },
nav: { nav: {
dashboard: 'Dashboard', dashboard: 'Dashboard',
providers: 'Providers',
history: 'Creations',
settings: 'Settings', settings: 'Settings',
}, },
login: { login: {
title: 'Sign in', title: 'Sign in',
subtitle: 'Admin Console', subtitle: 'Atlas Admin Console',
password: 'Password', password: 'Password',
passwordPlaceholder: 'Enter admin password', passwordPlaceholder: 'Enter admin password',
submit: 'Sign in', submit: 'Sign in',
hint: 'Default password: admin', hint: 'Default password: admin',
failed: 'Login failed', failed: 'Login failed',
wrongPassword: 'Invalid password', wrongPassword: 'Invalid password',
locked: 'Too many login attempts. Try again in {minutes} minute(s).',
}, },
dashboard: { dashboard: {
title: 'Dashboard', title: 'Dashboard',
@@ -26,10 +29,40 @@ export default {
}, },
settings: { settings: {
title: 'Settings', title: 'Settings',
description: 'Application configuration and admin account',
basic: 'General',
appName: 'App name', appName: 'App name',
authEnabled: 'Enable auth', authEnabled: 'Enable auth',
adminPassword: 'Admin password', adminPassword: 'Admin password',
uiLocale: 'UI locale', uiLocale: 'UI locale',
saved: 'Settings saved', saved: 'Settings saved',
}, },
admin: {
stats: {
requests: 'Requests (7d)',
successRate: 'Success rate',
latency: 'Avg latency',
files: 'File storage',
fileCount: 'File count',
},
history: {
title: 'Creation history',
description: 'All chat and image generations across sessions',
allTypes: 'All types',
type: 'Type',
typeChat: 'Chat',
typeTextImage: 'Text to image',
typeImageImage: 'Image to image',
session: 'Session',
summary: 'Summary',
time: 'Time',
actions: 'Actions',
detail: 'Detail',
delete: 'Delete',
detailTitle: 'Creation detail',
input: 'Input',
output: 'Output',
total: '{n} total',
},
},
} }
+34 -1
View File
@@ -7,17 +7,20 @@ export default {
}, },
nav: { nav: {
dashboard: '概览', dashboard: '概览',
providers: 'Provider',
history: '创作历史',
settings: '设置', settings: '设置',
}, },
login: { login: {
title: '登录', title: '登录',
subtitle: '管理控制台', subtitle: 'Atlas 管理控制台',
password: '密码', password: '密码',
passwordPlaceholder: '请输入管理员密码', passwordPlaceholder: '请输入管理员密码',
submit: '登录', submit: '登录',
hint: '默认密码:admin', hint: '默认密码:admin',
failed: '登录失败', failed: '登录失败',
wrongPassword: '密码错误', wrongPassword: '密码错误',
locked: '登录尝试次数过多,请 {minutes} 分钟后再试',
}, },
dashboard: { dashboard: {
title: '概览', title: '概览',
@@ -26,10 +29,40 @@ export default {
}, },
settings: { settings: {
title: '设置', title: '设置',
description: '应用基础配置与管理员账号',
basic: '基础设置',
appName: '应用名称', appName: '应用名称',
authEnabled: '启用认证', authEnabled: '启用认证',
adminPassword: '管理员密码', adminPassword: '管理员密码',
uiLocale: '界面语言', uiLocale: '界面语言',
saved: '设置已保存', saved: '设置已保存',
}, },
admin: {
stats: {
requests: '近 7 日请求',
successRate: '成功率',
latency: '平均延迟',
files: '文件占用',
fileCount: '文件数量',
},
history: {
title: '创作历史',
description: '查看所有用户的对话、文生图、图生图记录',
allTypes: '全部类型',
type: '类型',
typeChat: '对话',
typeTextImage: '文生图',
typeImageImage: '图生图',
session: '会话',
summary: '摘要',
time: '时间',
actions: '操作',
detail: '详情',
delete: '删除',
detailTitle: '创作详情',
input: '输入',
output: '输出',
total: '共 {n} 条',
},
},
} }
+89
View File
@@ -0,0 +1,89 @@
<script setup lang="ts">
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import { useRoute, useRouter } from 'vue-router'
import { Odometer, Setting, Connection, Clock } from '@element-plus/icons-vue'
import { useAuthStore } from '@/stores/auth'
import AtlasLogo from '@/components/AtlasLogo.vue'
const { t } = useI18n()
const route = useRoute()
const router = useRouter()
const auth = useAuthStore()
const navItems = [
{ path: '/admin', key: 'nav.dashboard', icon: Odometer, exact: true as const },
{ path: '/admin/providers', key: 'nav.providers', icon: Connection, exact: false as const },
{ path: '/admin/history', key: 'nav.history', icon: Clock, exact: false as const },
{ path: '/admin/settings', key: 'nav.settings', icon: Setting, exact: false as const },
] as const
const pageTitle = computed(() => {
const hit = navItems.find((item) =>
item.exact ? route.path === item.path : route.path.startsWith(item.path),
)
return hit ? t(hit.key) : 'Atlas'
})
function isActive(item: (typeof navItems)[number]) {
return item.exact ? route.path === item.path : route.path.startsWith(item.path)
}
function logout() {
auth.logout()
router.push('/admin/login')
}
</script>
<template>
<div class="layout">
<aside class="sidebar">
<div class="sidebar-brand">
<AtlasLogo :size="32" />
<span class="brand-name">Atlas</span>
</div>
<div class="nav-group-label">{{ t('common.management') }}</div>
<router-link
v-for="item in navItems"
:key="item.path"
:to="item.path"
class="nav-item"
:class="{ active: isActive(item) }"
>
<el-icon :size="16"><component :is="item.icon" /></el-icon>
{{ t(item.key) }}
</router-link>
</aside>
<div class="main-area">
<header class="topbar">
<span class="topbar-title">{{ pageTitle }}</span>
<div class="topbar-actions">
<router-link to="/chat" class="text-muted">用户端</router-link>
<el-button text @click="logout">{{ t('common.logout') }}</el-button>
</div>
</header>
<main class="main-content">
<div class="page">
<router-view />
</div>
</main>
</div>
</div>
</template>
<style scoped>
.topbar-actions {
display: flex;
align-items: center;
gap: var(--space-2);
}
.text-muted {
color: var(--color-text-secondary);
font-size: var(--text-sm);
text-decoration: none;
margin-right: var(--space-2);
}
.text-muted:hover {
color: var(--color-primary);
}
</style>
-58
View File
@@ -1,58 +0,0 @@
<script setup lang="ts">
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import { useRoute, useRouter } from 'vue-router'
import { Odometer, Setting } from '@element-plus/icons-vue'
import { useAuthStore } from '@/stores/auth'
const { t } = useI18n()
const route = useRoute()
const router = useRouter()
const auth = useAuthStore()
const navItems = [
{ path: '/', key: 'nav.dashboard', icon: Odometer },
{ path: '/settings', key: 'nav.settings', icon: Setting },
] as const
const pageTitle = computed(() => {
const hit = navItems.find((item) => item.path === route.path)
return hit ? t(hit.key) : 'App'
})
function logout() {
auth.logout()
router.push('/login')
}
</script>
<template>
<div class="layout">
<aside class="sidebar">
<div class="sidebar-brand">
<div class="brand-icon">A</div>
<span class="brand-name">App</span>
</div>
<div class="nav-group-label">{{ t('common.management') }}</div>
<router-link
v-for="item in navItems"
:key="item.path"
:to="item.path"
class="nav-item"
:class="{ active: route.path === item.path }"
>
<el-icon :size="16"><component :is="item.icon" /></el-icon>
{{ t(item.key) }}
</router-link>
</aside>
<div class="main-area">
<header class="topbar">
<span class="topbar-title">{{ pageTitle }}</span>
<el-button text @click="logout">{{ t('common.logout') }}</el-button>
</header>
<main class="main-content">
<router-view />
</main>
</div>
</div>
</template>
+81
View File
@@ -0,0 +1,81 @@
<script setup lang="ts">
import { computed } from 'vue'
import { useRoute } from 'vue-router'
import { ChatDotRound, Picture, Clock, Setting } from '@element-plus/icons-vue'
import AtlasLogo from '@/components/AtlasLogo.vue'
const route = useRoute()
const navItems = [
{ path: '/chat', label: '对话', icon: ChatDotRound },
{ path: '/image', label: '生图', icon: Picture },
{ path: '/history', label: '历史', icon: Clock },
] as const
const pageTitle = computed(
() => navItems.find((i) => route.path === i.path || route.path.startsWith(`${i.path}/`))?.label ?? 'Atlas',
)
function isActive(path: string) {
return route.path === path || route.path.startsWith(`${path}/`)
}
</script>
<template>
<div class="layout">
<aside class="sidebar">
<div class="sidebar-brand">
<AtlasLogo :size="32" />
<div>
<span class="brand-name">Atlas</span>
<div class="brand-tagline">AI 创作台</div>
</div>
</div>
<div class="nav-group-label">创作</div>
<router-link
v-for="item in navItems"
:key="item.path"
:to="item.path"
class="nav-item"
:class="{ active: isActive(item.path) }"
>
<el-icon :size="16"><component :is="item.icon" /></el-icon>
{{ item.label }}
</router-link>
<div class="sidebar-footer">
<router-link to="/admin/providers" class="nav-item nav-item--muted">
<el-icon :size="16"><Setting /></el-icon>
管理端
</router-link>
</div>
</aside>
<div class="main-area">
<header class="topbar">
<span class="topbar-title">{{ pageTitle }}</span>
</header>
<main class="main-content">
<div class="page">
<router-view />
</div>
</main>
</div>
</div>
</template>
<style scoped>
.brand-tagline {
font-size: 0.75rem;
color: var(--muted-foreground);
margin-top: 2px;
}
.sidebar-footer {
margin-top: auto;
padding: var(--space-3) 0 var(--space-4);
border-top: 1px solid var(--sidebar-border);
}
.nav-item--muted {
color: var(--muted-foreground);
}
</style>
+1 -1
View File
@@ -2,7 +2,7 @@ import { createApp } from 'vue'
import { createPinia } from 'pinia' import { createPinia } from 'pinia'
import ElementPlus from 'element-plus' import ElementPlus from 'element-plus'
import 'element-plus/dist/index.css' import 'element-plus/dist/index.css'
import '@/styles/theme.css' import '@/styles/global.css'
import App from './App.vue' import App from './App.vue'
import router from './router' import router from './router'
+29 -9
View File
@@ -1,30 +1,50 @@
import { createRouter, createWebHistory } from 'vue-router' import { createRouter, createWebHistory } from 'vue-router'
import { useAuthStore } from '@/stores/auth' import { useAuthStore } from '@/stores/auth'
import MainLayout from '@/layouts/MainLayout.vue' import UserLayout from '@/layouts/UserLayout.vue'
import AdminLayout from '@/layouts/AdminLayout.vue'
const router = createRouter({ const router = createRouter({
history: createWebHistory(), history: createWebHistory(),
routes: [ routes: [
{ {
path: '/login', path: '/',
name: 'login', component: UserLayout,
meta: { public: true },
children: [
{ path: '', redirect: '/chat' },
{ path: 'chat', name: 'chat', component: () => import('@/views/ChatView.vue') },
{ path: 'image', name: 'image', component: () => import('@/views/ImageStudioView.vue') },
{ path: 'image/text', redirect: '/image' },
{ path: 'image/edit', redirect: '/image' },
{ path: 'history', name: 'history', component: () => import('@/views/HistoryView.vue') },
],
},
{
path: '/admin/login',
name: 'admin-login',
component: () => import('@/views/LoginView.vue'), component: () => import('@/views/LoginView.vue'),
meta: { public: true }, meta: { public: true },
}, },
{ {
path: '/', path: '/admin',
component: MainLayout, component: AdminLayout,
children: [ children: [
{ path: '', name: 'dashboard', component: () => import('@/views/DashboardView.vue') }, { path: '', name: 'admin-dashboard', component: () => import('@/views/AdminDashboardView.vue') },
{ path: 'settings', name: 'settings', component: () => import('@/views/SettingsView.vue') }, { path: 'providers', name: 'admin-providers', component: () => import('@/views/AdminProvidersView.vue') },
{ path: 'history', name: 'admin-history', component: () => import('@/views/AdminHistoryView.vue') },
{ path: 'logs', redirect: '/admin/history' },
{ path: 'settings', name: 'admin-settings', component: () => import('@/views/SettingsView.vue') },
], ],
}, },
{ path: '/login', redirect: '/admin/login' },
], ],
}) })
router.beforeEach(async (to) => { router.beforeEach(async (to) => {
const auth = useAuthStore()
if (to.meta.public) return true if (to.meta.public) return true
if (!to.path.startsWith('/admin')) return true
const auth = useAuthStore()
if (auth.isLoggedIn()) return true if (auth.isLoggedIn()) return true
try { try {
const res = await fetch('/api/v1/auth/status') const res = await fetch('/api/v1/auth/status')
@@ -34,7 +54,7 @@ router.beforeEach(async (to) => {
return true return true
} }
} catch { /* ignore */ } } catch { /* ignore */ }
return '/login' return '/admin/login'
}) })
export default router export default router
+885
View File
@@ -0,0 +1,885 @@
: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;
/* 页面局部 token 别名(与设计规范一致) */
--color-border: var(--border);
--color-primary: var(--primary);
--color-text-primary: var(--foreground);
--color-text-secondary: var(--muted-foreground);
--color-bg-subtle: var(--muted);
--radius-md: var(--radius);
--text-xs: 0.75rem;
--text-sm: 0.875rem;
--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--2-1,
.content-grid--2,
.content-grid--3 {
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 {
display: inline-flex;
align-items: center;
justify-content: center;
margin-bottom: var(--space-4);
background: transparent;
}
.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;
}
/* ── 用户端 AI 创作台 ── */
.studio-page {
min-height: calc(100vh - 52px - var(--space-6) * 2);
}
.studio-chat-layout {
min-height: calc(100vh - 200px);
}
.studio-panel {
background: var(--card);
border: 1px solid var(--border);
border-radius: var(--radius);
margin-bottom: var(--space-4);
display: flex;
flex-direction: column;
min-height: 0;
}
.studio-panel__header {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--space-3);
padding: var(--space-4) var(--space-5);
border-bottom: 1px solid var(--border);
flex-shrink: 0;
}
.studio-panel__title {
margin: 0;
font-size: 0.9375rem;
font-weight: 600;
}
.studio-panel__body {
flex: 1;
min-height: 0;
display: flex;
flex-direction: column;
}
.studio-panel__body--padded {
padding: var(--space-5);
}
.studio-empty {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
text-align: center;
padding: var(--space-8) var(--space-5);
min-height: 280px;
}
.studio-empty__icon {
width: 56px;
height: 56px;
border-radius: var(--radius);
background: var(--muted);
color: var(--primary);
display: flex;
align-items: center;
justify-content: center;
margin-bottom: var(--space-4);
}
.studio-empty__title {
margin: 0 0 var(--space-2);
font-size: 1rem;
font-weight: 600;
}
.studio-empty__desc {
margin: 0;
max-width: 360px;
color: var(--muted-foreground);
font-size: 0.875rem;
line-height: 1.6;
}
.studio-empty__action {
margin-top: var(--space-4);
}
.chat-thread {
flex: 1;
overflow-y: auto;
padding: var(--space-5);
display: flex;
flex-direction: column;
gap: var(--space-4);
min-height: 420px;
max-height: calc(100vh - 340px);
}
.chat-message {
display: flex;
gap: var(--space-3);
max-width: 92%;
}
.chat-message.is-user {
align-self: flex-end;
flex-direction: row-reverse;
}
.chat-message__avatar {
width: 32px;
height: 32px;
border-radius: var(--radius);
background: var(--muted);
color: var(--muted-foreground);
display: flex;
align-items: center;
justify-content: center;
font-size: 0.75rem;
font-weight: 600;
flex-shrink: 0;
}
.chat-message.is-user .chat-message__avatar {
background: rgba(15, 122, 98, 0.12);
color: var(--primary);
}
.chat-message.is-assistant .chat-message__avatar {
background: var(--primary);
color: var(--primary-foreground);
}
.chat-message__bubble {
padding: var(--space-3) var(--space-4);
border-radius: var(--radius);
border: 1px solid var(--border);
background: var(--card);
line-height: 1.65;
white-space: pre-wrap;
word-break: break-word;
}
.chat-message.is-user .chat-message__bubble {
background: rgba(15, 122, 98, 0.08);
border-color: rgba(15, 122, 98, 0.2);
}
.chat-message.is-streaming .chat-message__bubble::after {
content: '▍';
margin-left: 2px;
animation: chat-cursor 1s step-end infinite;
color: var(--primary);
}
@keyframes chat-cursor {
50% { opacity: 0; }
}
.chat-composer {
border-top: 1px solid var(--border);
padding: var(--space-4) var(--space-5);
background: var(--card);
flex-shrink: 0;
}
.chat-composer__toolbar {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--space-3);
margin-bottom: var(--space-3);
flex-wrap: wrap;
}
.chat-composer__hint {
font-size: 0.75rem;
color: var(--muted-foreground);
}
.chat-composer__actions {
display: flex;
align-items: flex-end;
gap: var(--space-3);
}
.chat-composer__actions .el-textarea {
flex: 1;
}
.conv-sidebar {
min-height: 520px;
}
.conv-sidebar__actions {
padding: var(--space-4) var(--space-5);
border-bottom: 1px solid var(--border);
}
.conv-list {
padding: var(--space-3);
display: flex;
flex-direction: column;
gap: var(--space-2);
overflow-y: auto;
max-height: calc(100vh - 280px);
}
.conv-item {
width: 100%;
display: flex;
align-items: center;
gap: var(--space-2);
padding: var(--space-2);
border: 1px solid var(--border);
border-radius: var(--radius);
background: var(--card);
transition: border-color 0.15s, background 0.15s;
}
.conv-item__main {
flex: 1;
min-width: 0;
text-align: left;
padding: var(--space-1) var(--space-2);
border: none;
background: transparent;
cursor: pointer;
}
.conv-item:hover {
border-color: rgba(15, 122, 98, 0.35);
background: rgba(15, 122, 98, 0.04);
}
.conv-item.active {
border-color: var(--primary);
background: rgba(15, 122, 98, 0.08);
}
.conv-item__delete {
flex-shrink: 0;
opacity: 0;
transition: opacity 0.15s;
}
.conv-item:hover .conv-item__delete,
.conv-item.active .conv-item__delete {
opacity: 1;
}
.conv-item__title {
font-size: 0.875rem;
font-weight: 600;
color: var(--foreground);
margin-bottom: var(--space-1);
}
.conv-item__meta {
font-size: 0.75rem;
color: var(--muted-foreground);
}
.image-preview-box {
min-height: 420px;
display: flex;
align-items: center;
justify-content: center;
border: 1px dashed var(--border);
border-radius: var(--radius);
background: var(--muted);
padding: var(--space-4);
}
.image-preview-box img {
max-width: 100%;
max-height: 520px;
border-radius: var(--radius);
box-shadow: 0 8px 24px rgba(24, 24, 27, 0.08);
}
.upload-dropzone {
width: 100%;
}
.upload-dropzone .el-upload-dragger {
border-radius: var(--radius);
border-color: var(--border);
background: var(--muted);
padding: var(--space-6);
}
.upload-dropzone .el-upload-dragger:hover {
border-color: var(--primary);
}
.upload-preview {
margin-top: var(--space-3);
border: 1px solid var(--border);
border-radius: var(--radius);
overflow: hidden;
background: var(--card);
}
.upload-preview img {
display: block;
width: 100%;
max-height: 220px;
object-fit: contain;
}
.task-progress-list {
margin-top: var(--space-4);
display: flex;
flex-direction: column;
gap: var(--space-3);
}
.task-progress-item {
padding: var(--space-3);
border: 1px solid var(--border);
border-radius: var(--radius);
background: var(--muted);
}
.task-progress-item__head {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: var(--space-2);
font-size: 0.8125rem;
}
.task-progress-item__status.is-succeeded { color: var(--success); }
.task-progress-item__status.is-failed { color: var(--destructive); }
.task-progress-item__status.is-running,
.task-progress-item__status.is-pending { color: var(--primary); }
.task-progress-item__error {
margin: var(--space-2) 0 0;
font-size: 0.75rem;
color: var(--destructive);
}
.form-hint a {
color: var(--primary);
text-decoration: none;
}
.form-hint a:hover {
text-decoration: underline;
}
.settings-form {
max-width: 560px;
}
@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; }
+42
View File
@@ -0,0 +1,42 @@
import { serviceTypeLabel } from '@/constants/providerCompat'
import { parseImageUrl } from '@/utils/output'
export interface GenerationRow {
service_type: string
input_json: string
output_json: string
}
export function summarizeGeneration(g: GenerationRow): string {
if (g.service_type === 'chat') {
try {
return JSON.parse(g.input_json).content ?? g.input_json
} catch {
return g.input_json
}
}
try {
const input = JSON.parse(g.input_json)
if (input.prompt) return input.prompt
} catch { /* ignore */ }
try {
const out = JSON.parse(g.output_json)
const url = parseImageUrl(out)
if (url) return url
return out.content ?? g.output_json.slice(0, 80)
} catch {
return g.output_json.slice(0, 80)
}
}
export function formatGenerationType(type: string): string {
return serviceTypeLabel(type)
}
export function previewImageFromGeneration(g: GenerationRow): string {
try {
return parseImageUrl(JSON.parse(g.output_json))
} catch {
return ''
}
}
+43
View File
@@ -0,0 +1,43 @@
import type { InputField } from '@/composables/useUserMeta'
export function defaultParamsFromSchema(schema: InputField[] = []): Record<string, unknown> {
const out: Record<string, unknown> = {}
for (const field of schema) {
if (field.default !== undefined && field.default !== null) {
out[field.name] = field.default
} else if (field.widget === 'textarea' || field.widget === 'text') {
out[field.name] = ''
}
}
return out
}
export function validateParams(
schema: InputField[],
params: Record<string, unknown>,
): string | null {
for (const field of schema) {
if (!field.required) continue
const value = params[field.name]
if (value === undefined || value === null) {
return `请填写${field.title}`
}
if (typeof value === 'string' && !value.trim()) {
return `请填写${field.title}`
}
}
return null
}
export function mergeParams(
schema: InputField[],
params: Record<string, unknown>,
): Record<string, unknown> {
const out = defaultParamsFromSchema(schema)
for (const [key, value] of Object.entries(params)) {
if (value !== undefined && value !== null && value !== '') {
out[key] = value
}
}
return out
}
+17
View File
@@ -0,0 +1,17 @@
export function parseImageUrl(payload: unknown): string {
if (!payload) return ''
let data: unknown = payload
if (typeof payload === 'string') {
try {
data = JSON.parse(payload)
} catch {
return payload.startsWith('http') ? payload : ''
}
}
if (typeof data !== 'object' || !data) return ''
const obj = data as Record<string, unknown>
const val = obj.output ?? obj.url
if (Array.isArray(val)) return String(val[0] ?? '')
if (typeof val === 'string') return val
return ''
}
+57
View File
@@ -0,0 +1,57 @@
<script setup lang="ts">
import { onMounted, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import api from '@/api/client'
import PageHeader from '@/components/PageHeader.vue'
import StatCard from '@/components/StatCard.vue'
import AppCard from '@/components/AppCard.vue'
const { t } = useI18n()
const loading = ref(true)
const stats = ref({
total_requests: 0,
success_rate: 0,
avg_latency_ms: 0,
file_count: 0,
file_bytes: 0,
})
const appInfo = ref({ app_name: 'Atlas', data_dir: '' })
onMounted(async () => {
try {
const [dash, st] = await Promise.all([
api.get('/dashboard'),
api.get('/admin/dashboard/stats'),
])
appInfo.value = dash.data
stats.value = st.data
} finally {
loading.value = false
}
})
function formatBytes(n: number) {
if (n < 1024) return `${n} B`
if (n < 1024 * 1024) return `${(n / 1024).toFixed(1)} KB`
return `${(n / 1024 / 1024).toFixed(1)} MB`
}
</script>
<template>
<div>
<PageHeader :title="t('dashboard.title')" />
<div v-loading="loading" class="stat-grid">
<StatCard :label="t('admin.stats.requests')" :value="stats.total_requests" />
<StatCard :label="t('admin.stats.successRate')" :value="`${Math.round(stats.success_rate * 100)}%`" />
<StatCard :label="t('admin.stats.latency')" :value="`${Math.round(stats.avg_latency_ms)} ms`" />
<StatCard :label="t('admin.stats.files')" :value="formatBytes(stats.file_bytes)" />
</div>
<AppCard padded :title="t('dashboard.title')" style="margin-top: 16px">
<el-descriptions :column="1" border>
<el-descriptions-item :label="t('dashboard.appName')">{{ appInfo.app_name }}</el-descriptions-item>
<el-descriptions-item :label="t('dashboard.dataDir')">{{ appInfo.data_dir }}</el-descriptions-item>
<el-descriptions-item :label="t('admin.stats.fileCount')">{{ stats.file_count }}</el-descriptions-item>
</el-descriptions>
</AppCard>
</div>
</template>
+194
View File
@@ -0,0 +1,194 @@
<script setup lang="ts">
import { onMounted, ref, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import { ElMessage, ElMessageBox } from 'element-plus'
import api from '@/api/client'
import PageHeader from '@/components/PageHeader.vue'
import AppCard from '@/components/AppCard.vue'
import {
formatGenerationType,
previewImageFromGeneration,
summarizeGeneration,
} from '@/utils/generationSummary'
interface Generation {
id: number
session_id: string
service_type: string
model: string
input_json: string
output_json: string
created_at: string
}
const { t } = useI18n()
const loading = ref(true)
const items = ref<Generation[]>([])
const total = ref(0)
const filter = ref('')
const page = ref(1)
const pageSize = ref(20)
const detailVisible = ref(false)
const detail = ref<Generation | null>(null)
onMounted(load)
watch(filter, () => {
page.value = 1
})
watch([filter, page], load)
async function load() {
loading.value = true
try {
const res = await api.get<{ items: Generation[]; total: number }>('/admin/generations', {
params: {
service_type: filter.value || undefined,
limit: pageSize.value,
offset: (page.value - 1) * pageSize.value,
},
})
items.value = res.data.items
total.value = res.data.total
} finally {
loading.value = false
}
}
function shortSession(id: string) {
if (id.length <= 12) return id
return `${id.slice(0, 8)}`
}
async function openDetail(row: Generation) {
const res = await api.get<Generation>(`/admin/generations/${row.id}`)
detail.value = res.data
detailVisible.value = true
}
async function remove(id: number) {
await ElMessageBox.confirm('确定删除这条创作记录?', '确认', { type: 'warning' })
await api.delete(`/admin/generations/${id}`)
ElMessage.success('已删除')
await load()
}
function formatJson(raw: string) {
try {
return JSON.stringify(JSON.parse(raw), null, 2)
} catch {
return raw
}
}
</script>
<template>
<div>
<PageHeader
:title="t('admin.history.title')"
:description="t('admin.history.description')"
/>
<AppCard>
<div class="card-toolbar">
<el-select
v-model="filter"
clearable
:placeholder="t('admin.history.allTypes')"
class="toolbar-select--wide"
@clear="page = 1"
>
<el-option :label="t('admin.history.typeChat')" value="chat" />
<el-option :label="t('admin.history.typeTextImage')" value="text_to_image" />
<el-option :label="t('admin.history.typeImageImage')" value="image_to_image" />
</el-select>
<span class="form-hint form-hint--flush">{{ t('admin.history.total', { n: total }) }}</span>
</div>
<el-table v-loading="loading" :data="items" size="small">
<el-table-column prop="id" label="ID" width="72" />
<el-table-column :label="t('admin.history.type')" width="100">
<template #default="{ row }">
<el-tag size="small" effect="plain">{{ formatGenerationType(row.service_type) }}</el-tag>
</template>
</el-table-column>
<el-table-column prop="model" label="模型" width="160" show-overflow-tooltip />
<el-table-column :label="t('admin.history.session')" width="120">
<template #default="{ row }">
<code class="inline-code">{{ shortSession(row.session_id) }}</code>
</template>
</el-table-column>
<el-table-column :label="t('admin.history.summary')" min-width="220" show-overflow-tooltip>
<template #default="{ row }">{{ summarizeGeneration(row) }}</template>
</el-table-column>
<el-table-column prop="created_at" :label="t('admin.history.time')" width="176" />
<el-table-column :label="t('admin.history.actions')" width="140" fixed="right">
<template #default="{ row }">
<el-button link type="primary" @click="openDetail(row)">{{ t('admin.history.detail') }}</el-button>
<el-button link type="danger" @click="remove(row.id)">{{ t('admin.history.delete') }}</el-button>
</template>
</el-table-column>
</el-table>
<div class="card-pager">
<el-pagination
v-model:current-page="page"
:page-size="pageSize"
:total="total"
layout="total, prev, pager, next"
background
small
/>
</div>
</AppCard>
<el-dialog v-model="detailVisible" :title="t('admin.history.detailTitle')" width="720px" destroy-on-close>
<template v-if="detail">
<el-descriptions :column="2" border size="small" class="u-mb-4">
<el-descriptions-item label="ID">{{ detail.id }}</el-descriptions-item>
<el-descriptions-item :label="t('admin.history.type')">
{{ formatGenerationType(detail.service_type) }}
</el-descriptions-item>
<el-descriptions-item label="模型">{{ detail.model || '—' }}</el-descriptions-item>
<el-descriptions-item :label="t('admin.history.time')">{{ detail.created_at }}</el-descriptions-item>
<el-descriptions-item :label="t('admin.history.session')" :span="2">
<code class="inline-code">{{ detail.session_id }}</code>
</el-descriptions-item>
</el-descriptions>
<div v-if="previewImageFromGeneration(detail)" class="detail-preview u-mb-4">
<img :src="previewImageFromGeneration(detail)" alt="preview" />
</div>
<h4 class="section-title">{{ t('admin.history.input') }}</h4>
<pre class="json-block">{{ formatJson(detail.input_json) }}</pre>
<h4 class="section-title">{{ t('admin.history.output') }}</h4>
<pre class="json-block">{{ formatJson(detail.output_json) }}</pre>
</template>
</el-dialog>
</div>
</template>
<style scoped>
.detail-preview {
border: 1px solid var(--border);
border-radius: var(--radius);
padding: var(--space-3);
background: var(--muted);
text-align: center;
}
.detail-preview img {
max-width: 100%;
max-height: 320px;
border-radius: var(--radius);
}
.json-block {
margin: 0 0 var(--space-4);
padding: var(--space-3);
background: var(--muted);
border-radius: var(--radius);
font-size: 0.75rem;
line-height: 1.5;
overflow: auto;
max-height: 240px;
}
</style>
+320
View File
@@ -0,0 +1,320 @@
<script setup lang="ts">
import { computed, onMounted, reactive, ref, watch } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import api from '@/api/client'
import PageHeader from '@/components/PageHeader.vue'
import AppCard from '@/components/AppCard.vue'
import {
adaptProviderForm,
defaultServiceType,
protocolForServiceType,
protocolLabel,
protocolOptions,
serviceTypeLabel,
serviceTypeOptionsForProtocol,
} from '@/constants/providerCompat'
interface Provider {
id?: number
name: string
protocol: string
base_url: string
api_key: string
service_type: string
is_default: boolean
enabled: boolean
}
interface ModelEntry {
id: string
name: string
}
interface ModelsBreakdown {
upstream: ModelEntry[]
manual: ModelEntry[]
effective: ModelEntry[]
}
const loading = ref(true)
const saving = ref(false)
const syncing = ref(false)
const items = ref<Provider[]>([])
const dialogVisible = ref(false)
const modelsDialogVisible = ref(false)
const activeProviderId = ref<number | null>(null)
const models = ref<ModelsBreakdown>({ upstream: [], manual: [], effective: [] })
const newModel = reactive({ model_id: '', name: '' })
const form = reactive<Provider>({
name: '',
protocol: 'openai',
base_url: '',
api_key: '',
service_type: 'chat',
is_default: false,
enabled: true,
})
const filteredProtocols = computed(() => protocolOptions)
const filteredServiceTypes = computed(() => serviceTypeOptionsForProtocol(form.protocol))
const bindingHint = computed(() => {
if (form.protocol === 'openai') return 'OpenAI 协议用于对话(Chat Completions'
return 'Replicate 协议用于生图(Predictions),文生图/图生图由模型 schema 自动识别'
})
let syncingForm = false
watch(
() => form.protocol,
(protocol) => {
if (syncingForm) return
syncingForm = true
const allowed = serviceTypeOptionsForProtocol(protocol).map((o) => o.value)
if (!(allowed as readonly string[]).includes(form.service_type)) {
form.service_type = defaultServiceType(protocol)
}
syncingForm = false
},
)
watch(
() => form.service_type,
(serviceType) => {
if (syncingForm) return
syncingForm = true
const expected = protocolForServiceType(serviceType)
if (form.protocol !== expected) {
form.protocol = expected
}
syncingForm = false
},
)
onMounted(load)
async function load() {
loading.value = true
try {
const res = await api.get<{ items: Provider[] }>('/admin/providers')
items.value = res.data.items
} finally {
loading.value = false
}
}
function openCreate() {
Object.assign(form, {
id: undefined,
name: '',
protocol: 'openai',
base_url: '',
api_key: '',
service_type: 'chat',
is_default: false,
enabled: true,
})
adaptProviderForm(form)
dialogVisible.value = true
}
function openEdit(row: Provider) {
Object.assign(form, row)
adaptProviderForm(form)
dialogVisible.value = true
}
async function save() {
saving.value = true
try {
if (form.id) await api.put(`/admin/providers/${form.id}`, form)
else await api.post('/admin/providers', form)
dialogVisible.value = false
ElMessage.success('已保存')
await load()
} finally {
saving.value = false
}
}
async function remove(row: Provider) {
await ElMessageBox.confirm(`确定删除 Provider「${row.name}」?`, '确认', { type: 'warning' })
await api.delete(`/admin/providers/${row.id}`)
ElMessage.success('已删除')
await load()
}
async function openModels(row: Provider) {
if (!row.id) return
activeProviderId.value = row.id
await loadModels(row.id)
modelsDialogVisible.value = true
}
async function loadModels(providerId: number) {
const res = await api.get<ModelsBreakdown>(`/admin/providers/${providerId}/models`)
models.value = res.data
}
async function syncUpstream() {
if (!activeProviderId.value) return
syncing.value = true
try {
await api.post(`/admin/providers/${activeProviderId.value}/models/sync`)
await loadModels(activeProviderId.value)
ElMessage.success('已从上游刷新')
} catch {
ElMessage.warning('上游 models 接口不可用,请手动添加模型')
} finally {
syncing.value = false
}
}
async function addManualModel() {
if (!activeProviderId.value || !newModel.model_id.trim()) return
await api.post(`/admin/providers/${activeProviderId.value}/models`, {
model_id: newModel.model_id.trim(),
name: newModel.name.trim() || newModel.model_id.trim(),
})
newModel.model_id = ''
newModel.name = ''
await loadModels(activeProviderId.value)
ElMessage.success('已添加')
}
async function removeManual(modelId: string) {
if (!activeProviderId.value) return
await api.delete(`/admin/providers/${activeProviderId.value}/models/${encodeURIComponent(modelId)}`)
await loadModels(activeProviderId.value)
}
</script>
<template>
<div>
<PageHeader title="Provider 配置" description="配置外部 Gateway;模型优先从上游同步,亦可手动补充" />
<AppCard>
<div class="card-toolbar">
<span class="form-hint form-hint--flush"> {{ items.length }} Provider</span>
<div class="inline-actions" style="margin-left: auto">
<el-button type="primary" @click="openCreate">新建 Provider</el-button>
</div>
</div>
<el-table v-loading="loading" :data="items" size="small" empty-text="暂无 Provider,请点击右上角新建">
<el-table-column prop="name" label="名称" min-width="120" show-overflow-tooltip />
<el-table-column label="协议" width="108">
<template #default="{ row }">
<el-tag size="small" effect="plain">{{ protocolLabel(row.protocol) }}</el-tag>
</template>
</el-table-column>
<el-table-column label="服务类型" width="108">
<template #default="{ row }">
<el-tag size="small" type="info" effect="plain">{{ serviceTypeLabel(row.service_type) }}</el-tag>
</template>
</el-table-column>
<el-table-column label="Gateway URL" min-width="200">
<template #default="{ row }">
<span class="cell-mono" :title="row.base_url">{{ row.base_url }}</span>
</template>
</el-table-column>
<el-table-column label="状态" width="120">
<template #default="{ row }">
<div class="cell-stack">
<el-tag v-if="row.is_default" size="small" type="success" effect="light">默认</el-tag>
<el-tag v-if="row.enabled" size="small" type="success" effect="plain">启用</el-tag>
<el-tag v-else size="small" type="info" effect="plain">停用</el-tag>
</div>
</template>
</el-table-column>
<el-table-column label="操作" width="168" fixed="right">
<template #default="{ row }">
<el-button link type="primary" @click="openEdit(row)">编辑</el-button>
<el-button link type="primary" @click="openModels(row)">模型</el-button>
<el-button link type="danger" @click="remove(row)">删除</el-button>
</template>
</el-table-column>
</el-table>
</AppCard>
<el-dialog v-model="dialogVisible" :title="form.id ? '编辑 Provider' : '新建 Provider'" width="560px" destroy-on-close>
<el-form label-width="112px" label-position="right">
<el-form-item label="名称" required><el-input v-model="form.name" placeholder="例如:OpenAI 主线路" /></el-form-item>
<el-form-item label="协议" required>
<el-select v-model="form.protocol" style="width: 100%">
<el-option v-for="opt in filteredProtocols" :key="opt.value" :label="opt.label" :value="opt.value" />
</el-select>
</el-form-item>
<el-form-item label="服务类型" required>
<el-select v-model="form.service_type" style="width: 100%">
<el-option v-for="opt in filteredServiceTypes" :key="opt.value" :label="opt.label" :value="opt.value" />
</el-select>
<p class="form-hint">{{ bindingHint }}</p>
</el-form-item>
<el-form-item label="Gateway URL" required>
<el-input v-model="form.base_url" placeholder="https://api.example.com" />
</el-form-item>
<el-form-item label="API Key">
<el-input v-model="form.api_key" type="password" show-password placeholder="保存后脱敏展示" />
</el-form-item>
<el-form-item label="设为默认"><el-switch v-model="form.is_default" /></el-form-item>
<el-form-item label="启用"><el-switch v-model="form.enabled" /></el-form-item>
</el-form>
<template #footer>
<el-button @click="dialogVisible = false">取消</el-button>
<el-button type="primary" :loading="saving" @click="save">保存</el-button>
</template>
</el-dialog>
<el-dialog v-model="modelsDialogVisible" title="模型管理" width="680px" destroy-on-close>
<div class="card-toolbar card-toolbar--desc models-toolbar">
<el-button size="small" :loading="syncing" @click="syncUpstream">从上游同步</el-button>
<span class="form-hint form-hint--flush">Replicate / OpenAI 按协议分别解析 models 响应</span>
</div>
<h4 class="section-title">上游模型只读</h4>
<div class="table-scroll--sm">
<el-table :data="models.upstream" size="small" empty-text="暂无请点击从上游同步">
<el-table-column prop="id" label="Model ID" min-width="180" show-overflow-tooltip />
<el-table-column prop="name" label="名称" min-width="140" show-overflow-tooltip />
</el-table>
</div>
<h4 class="section-title">手动补充</h4>
<div class="page-toolbar">
<el-input v-model="newModel.model_id" placeholder="model_id" class="toolbar-input" />
<el-input v-model="newModel.name" placeholder="显示名称" class="toolbar-input" />
<el-button type="primary" size="small" @click="addManualModel">添加</el-button>
</div>
<el-table :data="models.manual" size="small" empty-text="暂无手动模型" class="u-mt-3">
<el-table-column prop="id" label="Model ID" min-width="180" />
<el-table-column prop="name" label="名称" min-width="140" />
<el-table-column label="操作" width="80">
<template #default="{ row }">
<el-button link type="danger" @click="removeManual(row.id)">删除</el-button>
</template>
</el-table-column>
</el-table>
<h4 class="section-title">用户端可见合并后</h4>
<div class="table-scroll--sm">
<el-table :data="models.effective" size="small" empty-text="请配置上游或手动模型">
<el-table-column prop="id" label="Model ID" min-width="180" />
<el-table-column prop="name" label="名称" min-width="140" />
</el-table>
</div>
</el-dialog>
</div>
</template>
<style scoped>
.models-toolbar {
padding: 0 0 var(--space-4);
margin-bottom: 0;
}
.cell-mono {
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
font-size: 0.8125rem;
color: var(--muted-foreground);
}
</style>
+287
View File
@@ -0,0 +1,287 @@
<script setup lang="ts">
import { computed, nextTick, onMounted, ref } from 'vue'
import { ChatDotRound, Delete, Plus } from '@element-plus/icons-vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import api from '@/api/client'
import PageHeader from '@/components/PageHeader.vue'
import StudioEmpty from '@/components/studio/StudioEmpty.vue'
import { useUserMeta } from '@/composables/useUserMeta'
interface Conversation {
id: number
title: string
model: string
}
interface Message {
id: number
role: string
content: string
}
const { load, getService } = useUserMeta()
const conversations = ref<Conversation[]>([])
const activeId = ref<number | null>(null)
const messages = ref<Message[]>([])
const input = ref('')
const model = ref('')
const streaming = ref(false)
const loadingConversations = ref(false)
const threadRef = ref<HTMLElement | null>(null)
const chatService = computed(() => getService('chat'))
const models = computed(() => chatService.value?.models ?? [])
const hasModels = computed(() => models.value.length > 0)
const canSend = computed(
() => hasModels.value && !!input.value.trim() && !streaming.value,
)
onMounted(async () => {
await load()
model.value = chatService.value?.default_model ?? models.value[0]?.id ?? ''
await refreshConversations()
})
async function refreshConversations() {
loadingConversations.value = true
try {
const res = await api.get<{ items: Conversation[] }>('/user/conversations')
conversations.value = res.data.items ?? []
if (activeId.value && !conversations.value.some((c) => c.id === activeId.value)) {
activeId.value = null
messages.value = []
}
if (activeId.value) {
await selectConversation(activeId.value, false)
}
} finally {
loadingConversations.value = false
}
}
function startNewChat() {
activeId.value = null
messages.value = []
}
async function selectConversation(id: number, scroll = true) {
activeId.value = id
const res = await api.get<{ items: Message[] }>(`/user/conversations/${id}/messages`)
messages.value = res.data.items ?? []
if (scroll) await scrollToBottom()
}
async function removeConversation(id: number) {
await ElMessageBox.confirm('确定删除该会话?删除后无法恢复。', '删除会话', { type: 'warning' })
await api.delete(`/user/conversations/${id}`)
if (activeId.value === id) {
startNewChat()
}
ElMessage.success('会话已删除')
await refreshConversations()
}
async function scrollToBottom() {
await nextTick()
const el = threadRef.value
if (el) el.scrollTop = el.scrollHeight
}
async function send() {
if (!canSend.value) return
const content = input.value.trim()
input.value = ''
try {
const isNew = !activeId.value
const url = isNew
? '/api/v1/user/conversations/messages'
: `/api/v1/user/conversations/${activeId.value}/messages`
messages.value.push({ id: Date.now(), role: 'user', content })
messages.value.push({ id: Date.now() + 1, role: 'assistant', content: '' })
await scrollToBottom()
streaming.value = true
const res = await fetch(url, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
credentials: 'include',
body: JSON.stringify({ content, model: model.value }),
})
if (!res.ok) {
const err = await res.json().catch(() => ({}))
throw new Error(err.error || `HTTP ${res.status}`)
}
const convHeader = res.headers.get('X-Conversation-Id')
if (convHeader) {
activeId.value = Number(convHeader)
}
const reader = res.body?.getReader()
const decoder = new TextDecoder()
if (!reader) throw new Error('无法读取流式响应')
let assistant = ''
let buffer = ''
while (true) {
const { done, value } = await reader.read()
if (done) break
buffer += decoder.decode(value, { stream: true })
const lines = buffer.split('\n')
buffer = lines.pop() ?? ''
for (const line of lines) {
if (!line.startsWith('data: ') || line.includes('[DONE]')) continue
try {
const data = JSON.parse(line.slice(6))
if (data.error) throw new Error(data.error)
const delta = data.choices?.[0]?.delta?.content ?? ''
assistant += delta
messages.value[messages.value.length - 1].content = assistant
} catch (e) {
if (e instanceof Error && e.message !== 'Unexpected end of JSON input') {
throw e
}
}
}
await scrollToBottom()
}
await refreshConversations()
} catch (e) {
messages.value = messages.value.filter((m) => m.content || m.role === 'user')
if (activeId.value && conversations.value.some((c) => c.id === activeId.value)) {
await refreshConversations()
} else {
startNewChat()
}
ElMessage.error(e instanceof Error ? e.message : '发送失败')
} finally {
streaming.value = false
}
}
function onComposerKeydown(e: KeyboardEvent) {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault()
send()
}
}
</script>
<template>
<div class="studio-page">
<PageHeader title="对话" description="多轮 AI 对话,Enter 发送;发送首条消息时自动创建会话">
<template #actions>
<el-select
v-model="model"
size="small"
filterable
placeholder="选择模型"
:disabled="!hasModels"
style="width: 220px"
>
<el-option v-for="m in models" :key="m.id" :label="m.name" :value="m.id" />
</el-select>
<el-button :icon="Plus" @click="startNewChat">新对话</el-button>
</template>
</PageHeader>
<el-alert
v-if="!hasModels"
type="warning"
show-icon
:closable="false"
title="尚未配置对话模型"
description="请先在管理端添加 OpenAI 协议 Provider,并同步或手动添加模型。"
class="u-mb-4"
/>
<div class="content-grid content-grid--2-1 studio-chat-layout">
<section class="studio-panel">
<div class="studio-panel__header">
<h3 class="studio-panel__title">消息</h3>
<span v-if="streaming" class="chat-composer__hint">Atlas 正在回复</span>
</div>
<div class="studio-panel__body">
<div ref="threadRef" class="chat-thread custom-scrollbar">
<StudioEmpty
v-if="!messages.length"
title="开始一段新对话"
description="在下方输入问题并发送,系统将自动创建会话。"
>
<template #icon><el-icon :size="28"><ChatDotRound /></el-icon></template>
</StudioEmpty>
<div
v-for="(m, idx) in messages"
:key="m.id"
class="chat-message"
:class="{
'is-user': m.role === 'user',
'is-assistant': m.role === 'assistant',
'is-streaming': streaming && m.role === 'assistant' && idx === messages.length - 1,
}"
>
<div class="chat-message__avatar">{{ m.role === 'user' ? '你' : 'A' }}</div>
<div class="chat-message__bubble">{{ m.content }}</div>
</div>
</div>
<div class="chat-composer">
<div class="chat-composer__toolbar">
<span class="chat-composer__hint">Enter 发送 · Shift + Enter 换行</span>
</div>
<div class="chat-composer__actions">
<el-input
v-model="input"
type="textarea"
:autosize="{ minRows: 2, maxRows: 6 }"
placeholder="输入你的问题…"
:disabled="!hasModels || streaming"
@keydown="onComposerKeydown"
/>
<el-button type="primary" :loading="streaming" :disabled="!canSend" @click="send">
发送
</el-button>
</div>
</div>
</div>
</section>
<section class="studio-panel conv-sidebar">
<div class="conv-sidebar__actions">
<el-button type="primary" class="w-full" :icon="Plus" @click="startNewChat">
新对话
</el-button>
</div>
<div v-loading="loadingConversations" class="conv-list custom-scrollbar">
<StudioEmpty
v-if="!conversations.length"
title="暂无会话"
description="发送第一条消息后将出现在这里。"
/>
<div
v-for="c in conversations"
:key="c.id"
class="conv-item"
:class="{ active: c.id === activeId }"
>
<button type="button" class="conv-item__main" @click="selectConversation(c.id)">
<div class="conv-item__title">{{ c.title || '未命名会话' }}</div>
<div class="conv-item__meta">{{ c.model || '默认模型' }}</div>
</button>
<el-button
class="conv-item__delete"
link
type="danger"
:icon="Delete"
aria-label="删除会话"
@click="removeConversation(c.id)"
/>
</div>
</div>
</section>
</div>
</div>
</template>
+47
View File
@@ -0,0 +1,47 @@
<script setup lang="ts">
import { onMounted, ref, watch } from 'vue'
import PageHeader from '@/components/PageHeader.vue'
import AppCard from '@/components/AppCard.vue'
import { useGenerations } from '@/composables/useGenerations'
import { formatGenerationType, summarizeGeneration } from '@/utils/generationSummary'
const filter = ref('')
const { items, total, loading, load, remove } = useGenerations()
onMounted(() => load(20, 0, filter.value || undefined))
watch(filter, () => load(20, 0, filter.value || undefined))
</script>
<template>
<div>
<PageHeader title="历史记录" description="查看与管理你的创作历史" />
<AppCard>
<div class="card-toolbar">
<el-select v-model="filter" clearable placeholder="全部类型" class="toolbar-select--wide">
<el-option label="对话" value="chat" />
<el-option label="文生图" value="text_to_image" />
<el-option label="图生图" value="image_to_image" />
</el-select>
<span class="form-hint form-hint--flush"> {{ total }} </span>
</div>
<el-table v-loading="loading" :data="items" size="small">
<el-table-column prop="id" label="ID" width="80" />
<el-table-column label="类型" width="100">
<template #default="{ row }">
<el-tag size="small" effect="plain">{{ formatGenerationType(row.service_type) }}</el-tag>
</template>
</el-table-column>
<el-table-column prop="model" label="模型" width="140" show-overflow-tooltip />
<el-table-column label="摘要" min-width="220" show-overflow-tooltip>
<template #default="{ row }">{{ summarizeGeneration(row) }}</template>
</el-table-column>
<el-table-column prop="created_at" label="时间" width="180" />
<el-table-column label="操作" width="100" fixed="right">
<template #default="{ row }">
<el-button link type="danger" @click="remove(row.id)">删除</el-button>
</template>
</el-table-column>
</el-table>
</AppCard>
</div>
</template>
+149
View File
@@ -0,0 +1,149 @@
<script setup lang="ts">
import { computed, onMounted, ref, watch } from 'vue'
import { EditPen, Picture } from '@element-plus/icons-vue'
import { ElMessage } from 'element-plus'
import PageHeader from '@/components/PageHeader.vue'
import AppCard from '@/components/AppCard.vue'
import ModelField from '@/components/studio/ModelField.vue'
import ModelParamForm from '@/components/studio/ModelParamForm.vue'
import StudioEmpty from '@/components/studio/StudioEmpty.vue'
import TaskProgressList from '@/components/studio/TaskProgressList.vue'
import {
modelKindLabel,
useImageStudio,
} from '@/composables/useImageStudio'
import { defaultParamsFromSchema, mergeParams, validateParams } from '@/utils/modelSchema'
import { parseImageUrl } from '@/utils/output'
const { load, models, defaultModel, activeTasks, generate } = useImageStudio()
const model = ref('')
const params = ref<Record<string, unknown>>({})
const loading = ref(false)
const lastOutput = ref<unknown>(null)
const hasModels = computed(() => models.value.length > 0)
const selectedModel = computed(() => models.value.find((m) => m.id === model.value))
const selectedKind = computed(() => selectedModel.value?.kind ?? 'text_to_image')
const schema = computed(() => selectedModel.value?.input_schema ?? [])
const isImageToImage = computed(() => selectedKind.value === 'image_to_image')
const pageDescription = computed(() =>
isImageToImage.value
? '上传参考图并编辑,生成新的画面'
: '输入提示词,由 AI 生成图片',
)
const emptyTitle = computed(() => (isImageToImage.value ? '等待编辑结果' : '等待生成结果'))
const emptyDescription = computed(() =>
isImageToImage.value
? '选择图生图模型、上传参考图并填写参数后,结果将显示在这里。'
: '选择文生图模型、填写参数并点击「开始生成」,图片将显示在这里。',
)
const previewUrl = computed(() => {
const fromOutput = parseImageUrl(lastOutput.value)
if (fromOutput) return fromOutput
const task = activeTasks.value.find((t) => t.status === 'succeeded')
if (task?.result_json) return parseImageUrl(task.result_json)
return ''
})
const isGenerating = computed(
() => loading.value || activeTasks.value.some((t) => ['pending', 'running'].includes(t.status)),
)
function applyModelDefaults(nextModel: string) {
const info = models.value.find((m) => m.id === nextModel)
params.value = defaultParamsFromSchema(info?.input_schema ?? [])
}
onMounted(async () => {
await load()
model.value = defaultModel.value
applyModelDefaults(model.value)
})
watch(model, (next) => {
applyModelDefaults(next)
})
watch(activeTasks, (tasks) => {
const done = tasks.find((t) => t.status === 'succeeded' && t.result_json)
if (done) lastOutput.value = done.result_json
})
async function submit() {
if (!hasModels.value) {
ElMessage.warning('尚未配置可用模型')
return
}
const validationError = validateParams(schema.value, params.value)
if (validationError) {
ElMessage.warning(validationError)
return
}
loading.value = true
lastOutput.value = null
try {
const payload = mergeParams(schema.value, params.value)
await generate(payload, model.value)
ElMessage.success('已开始生成')
} catch (e) {
ElMessage.error(e instanceof Error ? e.message : '生成失败')
} finally {
loading.value = false
}
}
</script>
<template>
<div class="studio-page">
<PageHeader title="生图" :description="pageDescription">
<template v-if="selectedModel" #actions>
<el-tag size="small" :type="isImageToImage ? 'warning' : 'success'">
{{ modelKindLabel(selectedModel.kind) }}
</el-tag>
</template>
</PageHeader>
<el-alert
v-if="!hasModels"
type="warning"
show-icon
:closable="false"
title="尚未配置生图模型"
description="请在管理端添加 Replicate 协议 Provider,并同步或手动添加模型。"
class="u-mb-4"
/>
<div class="content-grid content-grid--2">
<AppCard padded title="创作参数">
<el-form label-position="top" @submit.prevent="submit">
<ModelField v-model="model" :models="models" show-kind />
<ModelParamForm v-model="params" :schema="schema" />
<el-button type="primary" :loading="isGenerating" :disabled="!hasModels" @click="submit">
{{ isGenerating ? '生成中' : '开始生成' }}
</el-button>
</el-form>
<TaskProgressList :tasks="activeTasks" />
</AppCard>
<AppCard padded title="预览">
<div v-loading="isGenerating" class="image-preview-box">
<img v-if="previewUrl" :src="previewUrl" alt="生成结果" />
<StudioEmpty v-else :title="emptyTitle" :description="emptyDescription">
<template #icon>
<el-icon :size="28">
<EditPen v-if="isImageToImage" />
<Picture v-else />
</el-icon>
</template>
</StudioEmpty>
</div>
</AppCard>
</div>
</div>
</template>
+25 -5
View File
@@ -2,8 +2,10 @@
import { ref } from 'vue' import { ref } from 'vue'
import { useI18n } from 'vue-i18n' import { useI18n } from 'vue-i18n'
import { useRouter } from 'vue-router' import { useRouter } from 'vue-router'
import axios from 'axios'
import { ElMessage } from 'element-plus' import { ElMessage } from 'element-plus'
import { useAuthStore } from '@/stores/auth' import { useAuthStore } from '@/stores/auth'
import AtlasLogo from '@/components/AtlasLogo.vue'
const { t } = useI18n() const { t } = useI18n()
const router = useRouter() const router = useRouter()
@@ -15,9 +17,14 @@ async function submit() {
loading.value = true loading.value = true
try { try {
await auth.login(password.value) await auth.login(password.value)
router.push('/') router.push('/admin')
} catch { } catch (err) {
ElMessage.error(t('login.wrongPassword')) if (axios.isAxiosError(err) && err.response?.status === 429) {
const retryAfter = Number(err.response.data?.retry_after) || 900
ElMessage.error(t('login.locked', { minutes: Math.max(1, Math.ceil(retryAfter / 60)) }))
} else {
ElMessage.error(t('login.wrongPassword'))
}
} finally { } finally {
loading.value = false loading.value = false
} }
@@ -27,8 +34,10 @@ async function submit() {
<template> <template>
<div class="login-page"> <div class="login-page">
<div class="login-wrap"> <div class="login-wrap">
<div class="login-brand-icon">A</div> <div class="login-brand-icon">
<h1 class="page-title">App</h1> <AtlasLogo :size="48" />
</div>
<h1 class="page-title">Atlas</h1>
<p class="page-desc">{{ t('login.subtitle') }}</p> <p class="page-desc">{{ t('login.subtitle') }}</p>
<div class="login-card app-card"> <div class="login-card app-card">
<h3 class="app-card__title">{{ t('login.title') }}</h3> <h3 class="app-card__title">{{ t('login.title') }}</h3>
@@ -50,3 +59,14 @@ async function submit() {
</div> </div>
</div> </div>
</template> </template>
<style scoped>
.login-brand-icon {
display: inline-flex;
margin-bottom: var(--space-4);
}
.login-brand-icon :deep(.atlas-logo) {
width: 48px;
height: 48px;
}
</style>
+5 -5
View File
@@ -12,7 +12,7 @@ const localeStore = useLocaleStore()
const loading = ref(true) const loading = ref(true)
const saving = ref(false) const saving = ref(false)
const form = reactive<SettingsData>({ const form = reactive<SettingsData>({
app_name: 'App', app_name: 'Atlas',
api_port: '8080', api_port: '8080',
admin_password: '', admin_password: '',
auth_enabled: 'true', auth_enabled: 'true',
@@ -44,9 +44,9 @@ async function save() {
<template> <template>
<div> <div>
<PageHeader :title="t('settings.title')" /> <PageHeader :title="t('settings.title')" :description="t('settings.description')" />
<AppCard v-loading="loading"> <AppCard padded :title="t('settings.basic')">
<el-form label-width="120px"> <el-form v-loading="loading" label-width="140px" class="settings-form">
<el-form-item :label="t('settings.appName')"> <el-form-item :label="t('settings.appName')">
<el-input v-model="form.app_name" /> <el-input v-model="form.app_name" />
</el-form-item> </el-form-item>
@@ -68,4 +68,4 @@ async function save() {
</el-form> </el-form>
</AppCard> </AppCard>
</div> </div>
</template> </template>