引入用户端对话/生图与管理端 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:
+2
-2
@@ -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,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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
@@ -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
|
||||||
|
|||||||
@@ -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 \
|
||||||
|
|||||||
@@ -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/*` 透传 OpenAI(Chat、Embeddings 等)与 Replicate(Predictions)
|
||||||
- **开箱即用**:登录鉴权、设置页、审计日志、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 SQL(002_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 Token,401 全局跳转 |
|
|
||||||
| i18n | vue-i18n | 与 Element Plus locale 联动 |
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## API 规范
|
## 功能模块
|
||||||
|
|
||||||
|
### 用户端(`/chat`、`/image/*`、`/history`)
|
||||||
|
|
||||||
|
- 多轮对话,SSE 流式输出
|
||||||
|
- 文生图、图生图(异步任务 + SSE 进度)
|
||||||
|
- 生成历史、参数预设
|
||||||
|
- 基于 `Atlas-Session` Cookie 的会话隔离
|
||||||
|
|
||||||
|
### 管理端(`/admin/*`)
|
||||||
|
|
||||||
|
- Provider CRUD(OpenAI / 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/app(embed 前端)
|
|
||||||
```
|
|
||||||
|
|
||||||
### 常用命令
|
|
||||||
|
|
||||||
| 命令 | 作用 |
|
| 命令 | 作用 |
|
||||||
|------|------|
|
|------|------|
|
||||||
| `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
@@ -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
@@ -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
@@ -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
|
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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=
|
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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")
|
||||||
|
}
|
||||||
@@ -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()})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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()
|
||||||
|
}
|
||||||
@@ -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")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 ""
|
||||||
|
}
|
||||||
@@ -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()
|
||||||
|
}
|
||||||
@@ -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})
|
||||||
|
}
|
||||||
@@ -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:"-"`
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -0,0 +1,271 @@
|
|||||||
|
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.
|
||||||
|
func (m ModelDetail) PredictionVersion() string {
|
||||||
|
if m.ModelName != "" {
|
||||||
|
return m.ModelName
|
||||||
|
}
|
||||||
|
if idx := strings.LastIndex(m.ID, "/"); idx >= 0 && idx < len(m.ID)-1 {
|
||||||
|
return m.ID[idx+1:]
|
||||||
|
}
|
||||||
|
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: modelName,
|
||||||
|
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
|
||||||
|
}
|
||||||
@@ -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() != "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")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 ©, 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 ""
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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
@@ -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
@@ -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,
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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, ¶ms); 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{} },
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
package invoke
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestBuildPredictionBody(t *testing.T) {
|
||||||
|
body, err := buildPredictionBody("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"] != "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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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).
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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.
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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
@@ -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)
|
||||||
|
|||||||
@@ -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()
|
||||||
|
}
|
||||||
@@ -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
@@ -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>
|
||||||
|
|||||||
@@ -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 |
@@ -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)
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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 }
|
||||||
|
}
|
||||||
@@ -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' ? '图生图' : '文生图'
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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 }
|
||||||
|
}
|
||||||
@@ -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 }
|
||||||
|
}
|
||||||
@@ -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
@@ -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
@@ -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} 条',
|
||||||
|
},
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
@@ -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>
|
|
||||||
@@ -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
@@ -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
@@ -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
|
||||||
|
|||||||
@@ -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; }
|
||||||
@@ -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 ''
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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 ''
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
Reference in New Issue
Block a user