引入用户端对话/生图与管理端 Provider 配置,统一 invoke 编排、Gateway、请求日志与模型管理;前端按设计规范拆分布局,Go module 更名为 github.com/rose_cat707/Atlas;生图长任务进度改为 SSE 推送;重写 README;移除 Dockerfile syntax 指令以修复 CI 构建超时。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -1,3 +1,3 @@
|
||||
GITEA_INSTANCE_URL=https://git.example.com
|
||||
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:
|
||||
act-runner:
|
||||
image: docker.io/gitea/act_runner:0.2.12
|
||||
container_name: go-vue-template-act-runner
|
||||
container_name: atlas-act-runner
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
CONFIG_FILE: /config.yaml
|
||||
GITEA_INSTANCE_URL: ${GITEA_INSTANCE_URL:-https://git.example.com}
|
||||
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:
|
||||
- ./config.yaml:/config.yaml:ro
|
||||
- act-runner-data:/data
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
# syntax=docker/dockerfile:1
|
||||
|
||||
ARG IMAGE_PREFIX=
|
||||
|
||||
FROM ${IMAGE_PREFIX}node:20-alpine AS web-builder
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
export GOPROXY ?= https://goproxy.cn,direct
|
||||
export GOSUMDB ?= sum.golang.google.cn
|
||||
export IMAGE_PREFIX ?= docker.1panel.live/library/
|
||||
export APP_IMAGE ?= go-vue-template:latest
|
||||
export APP_IMAGE ?= atlas:latest
|
||||
|
||||
dev:
|
||||
@echo "Run backend and frontend in separate terminals:"
|
||||
@@ -32,7 +32,7 @@ test:
|
||||
|
||||
clean:
|
||||
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 \
|
||||
|
||||
@@ -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
|
||||
- **分层清晰**:`cmd` → `api` → `service` → `store`,职责边界明确
|
||||
- **开箱即用**:登录鉴权、设置页、审计日志、i18n、Docker 多阶段构建
|
||||
- **纯 Go 编译**:SQLite 使用 `modernc.org/sqlite`,`CGO_ENABLED=0` 可交叉编译
|
||||
- **可演进**:goose SQL 迁移、settings KV 配置、Pinia 状态管理
|
||||
- **多协议 Gateway**:`/v1/*` 透传 OpenAI(Chat、Embeddings 等)与 Replicate(Predictions)
|
||||
- **统一 invoke 编排**:同步/异步调用、任务队列、上游轮询 + 前端 SSE 进度推送
|
||||
- **Provider 管理**:管理端配置上游地址与密钥;模型优先从上游 `/v1/models` 同步,也可手动维护
|
||||
- **请求日志**:Gateway 与用户 invoke 全链路记录,管理端可检索
|
||||
- **用户会话隔离**:Cookie 会话区分用户数据;管理端独立 Bearer 鉴权
|
||||
- **纯 Go 编译**:SQLite(modernc.org/sqlite),`CGO_ENABLED=0` 可交叉编译
|
||||
|
||||
## 快速开始
|
||||
|
||||
@@ -25,63 +27,79 @@ make build
|
||||
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" }` |
|
||||
|
||||
## 使用本模板
|
||||
|
||||
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' {} +
|
||||
```
|
||||
默认管理员密码:`admin`(可在管理端设置页修改)。
|
||||
|
||||
---
|
||||
|
||||
## 架构
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────┐
|
||||
│ 单一 Go 进程 │
|
||||
│ ┌──────────────┐ ┌──────────────┐ ┌───────────────┐ │
|
||||
│ │ /api/v1/* │ │ /health │ │ /* (SPA) │ │
|
||||
│ │ REST API │ │ 健康检查 │ │ embed 静态资源 │ │
|
||||
│ └──────┬───────┘ └──────────────┘ └───────────────┘ │
|
||||
│ │ │
|
||||
│ ┌──────▼──────────────────────────────────────────┐ │
|
||||
│ │ api (HTTP) → service (编排) → store (持久化) │ │
|
||||
│ └─────────────────────────────────────────────────┘ │
|
||||
│ │ │
|
||||
│ ┌──────▼──────┐ │
|
||||
│ │ SQLite WAL │ {data}/app.db │
|
||||
│ └─────────────┘ │
|
||||
└─────────────────────────────────────────────────────────┘
|
||||
▲ ▲
|
||||
│ 生产:同一端口 │ 开发:Vite :5173 代理 /api
|
||||
└──────────────────────────┘
|
||||
┌─────────────────────────────────────────────────────────────────────┐
|
||||
│ 单一 Go 进程 │
|
||||
│ ┌─────────────┐ ┌─────────────┐ ┌──────────────┐ ┌──────────┐ │
|
||||
│ │ /api/v1/* │ │ /v1/* │ │ /health │ │ /* SPA │ │
|
||||
│ │ 用户 + 管理 │ │ Gateway │ │ │ │ embed │ │
|
||||
│ └──────┬──────┘ └──────┬──────┘ └──────────────┘ └──────────┘ │
|
||||
│ │ │ │
|
||||
│ ┌──────▼────────────────▼──────────────────────────────────────┐ │
|
||||
│ │ api → service (invoke / chat / reqlog / taskevents) → store │ │
|
||||
│ └──────────────────────────┬───────────────────────────────────┘ │
|
||||
│ │ │
|
||||
│ ┌──────────────────────────▼───────────────────────────────────┐ │
|
||||
│ │ provider (Router / ModelsService / ProxyStream) → 上游 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、优雅关闭 |
|
||||
| HTTP | `internal/api/` | 路由注册、中间件、请求/响应、SPA 静态服务 |
|
||||
| 业务 | `internal/service/` | 跨模块编排,注入 Store 与领域服务 |
|
||||
| 持久化 | `internal/store/` | 数据库连接、迁移、仓储方法 |
|
||||
| 配置 | `internal/settings/` | 配置键常量、默认值、展示脱敏、提交校验 |
|
||||
| 前端 | `web/` | Vue SPA 源码,构建产物复制到 `internal/api/static/` |
|
||||
|
||||
**原则:**
|
||||
- Handler 只做 HTTP 适配,不写复杂业务逻辑
|
||||
- Store 封装 SQL,不暴露 `*sql.DB` 给 Handler
|
||||
- 前端构建产物不提交 dist,仅保留 `static/index.html` 占位以保证 `go test` / CI 可通过
|
||||
| 入口 | `cmd/app/` | 参数解析、依赖初始化、HTTP 服务、优雅关闭 |
|
||||
| HTTP | `internal/api/` | 路由、中间件、用户/管理/Gateway Handler、SPA |
|
||||
| 编排 | `internal/service/` | invoke、chat、reqlog、taskevents、asset |
|
||||
| 上游 | `internal/provider/` | HTTP 代理、模型列表、流式转发、Provider 路由 |
|
||||
| 领域 | `internal/domain/` | 服务类型、invoke 请求/响应模型 |
|
||||
| 持久化 | `internal/store/` | SQLite、goose 迁移、仓储与 HotCache |
|
||||
| 配置 | `internal/settings/` | KV 配置键、默认值、脱敏 |
|
||||
| 前端 | `web/` | Vue SPA,构建产物嵌入 `internal/api/static/` |
|
||||
|
||||
---
|
||||
|
||||
@@ -89,96 +107,122 @@ find . -type f \( -name '*.go' -o -name 'go.mod' \) \
|
||||
|
||||
```
|
||||
.
|
||||
├── cmd/app/ # 进程入口
|
||||
├── cmd/app/ # 进程入口
|
||||
├── internal/
|
||||
│ ├── api/
|
||||
│ │ ├── handlers.go # 路由与 Handler
|
||||
│ │ ├── auth_token.go # Token 签发与校验
|
||||
│ │ ├── static.go # go:embed SPA
|
||||
│ │ └── static/ # 前端构建产物(占位 index.html 已提交)
|
||||
│ ├── service/ # 业务编排
|
||||
│ ├── store/
|
||||
│ │ ├── store.go
|
||||
│ │ └── migrations/ # goose SQL 迁移
|
||||
│ └── settings/ # 配置键与规范化
|
||||
├── web/
|
||||
│ ├── src/
|
||||
│ │ ├── api/ # axios 客户端与类型
|
||||
│ │ ├── components/ # 通用 UI 组件
|
||||
│ │ ├── i18n/ # 多语言
|
||||
│ │ ├── layouts/ # 布局
|
||||
│ │ ├── router/ # 路由与鉴权守卫
|
||||
│ │ ├── stores/ # Pinia
|
||||
│ │ ├── styles/ # 主题变量
|
||||
│ │ └── views/ # 页面
|
||||
│ ├── vite.config.ts
|
||||
│ └── package.json
|
||||
│ │ ├── handlers.go # 基础路由、鉴权、设置
|
||||
│ │ ├── user_handlers.go # 用户端 API
|
||||
│ │ ├── admin_handlers.go # 管理端 API
|
||||
│ │ ├── gateway*.go # /v1 Gateway
|
||||
│ │ ├── task_sse.go # 任务 SSE
|
||||
│ │ └── static/ # 前端 embed 产物
|
||||
│ ├── service/
|
||||
│ │ ├── invoke/ # 同步/异步 invoke + worker
|
||||
│ │ ├── chat/ # 对话编排
|
||||
│ │ ├── reqlog/ # 请求日志
|
||||
│ │ └── taskevents/ # 任务事件广播
|
||||
│ ├── provider/ # 上游 HTTP 代理与路由
|
||||
│ ├── domain/
|
||||
│ ├── store/migrations/ # goose SQL(002_atlas, 003_provider_models)
|
||||
│ └── settings/
|
||||
├── web/src/
|
||||
│ ├── views/ # Chat、生图、History、Admin 页面
|
||||
│ ├── layouts/ # UserLayout / AdminLayout
|
||||
│ ├── composables/ # useInvoke (SSE)、useUserMeta 等
|
||||
│ └── styles/global.css # 设计规范落地样式
|
||||
├── docs/UI-DESIGN-SYSTEM.md # UI 设计规范
|
||||
├── Makefile
|
||||
├── Dockerfile
|
||||
├── docker-compose.yml
|
||||
├── go.mod
|
||||
└── .gitea/ # Gitea Actions CI
|
||||
├── README.md
|
||||
├── workflows/ci.yml
|
||||
└── act-runner/ # runner 部署参考(可选)
|
||||
└── docker-compose.yml
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 技术栈
|
||||
|
||||
| 层级 | 选型 | 说明 |
|
||||
|------|------|------|
|
||||
| 后端 | Go 1.25 + Gin | HTTP 路由与中间件 |
|
||||
| 数据库 | SQLite (modernc.org/sqlite) | 纯 Go 驱动,无 CGO |
|
||||
| 迁移 | goose v3 (embed SQL) | 版本化、可回滚 |
|
||||
| 前端 | Vue 3 + Vite + TypeScript | Composition API + `<script setup>` |
|
||||
| UI | Element Plus | 组件库与图标 |
|
||||
| 状态 | Pinia | 全局状态(认证、语言等) |
|
||||
| 路由 | vue-router (History) | 路由守卫 + `/auth/status` |
|
||||
| HTTP | axios | Bearer Token,401 全局跳转 |
|
||||
| i18n | vue-i18n | 与 Element Plus locale 联动 |
|
||||
| 层级 | 选型 |
|
||||
|------|------|
|
||||
| 后端 | Go 1.25 + Gin |
|
||||
| 数据库 | SQLite (modernc.org/sqlite) |
|
||||
| 迁移 | goose v3 (embed SQL) |
|
||||
| 前端 | Vue 3 + Vite + TypeScript |
|
||||
| UI | Element Plus |
|
||||
| 状态 | Pinia |
|
||||
| HTTP | axios(管理端)/ fetch + EventSource(用户端 SSE) |
|
||||
|
||||
---
|
||||
|
||||
## 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`
|
||||
|
||||
### 鉴权
|
||||
### 鉴权(管理端)
|
||||
|
||||
| 接口 | 说明 |
|
||||
|------|------|
|
||||
| `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>` |
|
||||
|
||||
当 `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
|
||||
// 成功 — 按场景选用
|
||||
{ "ok": true }
|
||||
{ "items": [...], "total": 42 }
|
||||
{ "app_name": "App", "data_dir": "./data" }
|
||||
|
||||
// 错误 — 统一字段
|
||||
{ "error": "human readable message" }
|
||||
```
|
||||
|
||||
### HTTP 状态码
|
||||
|
||||
| 码 | 含义 |
|
||||
|----|------|
|
||||
| 400 | 请求参数错误 |
|
||||
| 401 | 未授权 |
|
||||
| 404 | 资源不存在 |
|
||||
| 500 | 服务器内部错误 |
|
||||
|
||||
### 分页
|
||||
|
||||
列表接口支持 `?limit=`(默认 50,最大 500)与 `?offset=`,响应含 `items` 与 `total`。
|
||||
列表接口支持 `?limit=`(默认 50,最大 500)与 `?offset=`。
|
||||
|
||||
---
|
||||
|
||||
@@ -188,58 +232,42 @@ find . -type f \( -name '*.go' -o -name 'go.mod' \) \
|
||||
|
||||
| 来源 | 名称 | 默认 | 说明 |
|
||||
|------|------|------|------|
|
||||
| CLI | `-data` | `./data` | 数据目录(SQLite 等) |
|
||||
| CLI | `-data` | `./data` | 数据目录(SQLite、上传文件等) |
|
||||
| CLI | `-addr` | `:8080` | HTTP 监听地址 |
|
||||
| Env | `APP_DATA` | — | 覆盖 `-data` |
|
||||
| Env | `APP_ADDR` | — | 覆盖 `-addr` |
|
||||
|
||||
### 运行时配置(SQLite KV)
|
||||
|
||||
业务配置存储在 `settings` 表,可通过 Web 设置页修改,无需重启:
|
||||
|
||||
| 键 | 说明 |
|
||||
|----|------|
|
||||
| `app_name` | 应用名称 |
|
||||
| `admin_password` | 管理员密码(GET 时脱敏) |
|
||||
| `auth_enabled` | 是否启用认证(`true` / `false`) |
|
||||
| `app_name` | 应用名称(默认 `Atlas`) |
|
||||
| `admin_password` | 管理员密码 |
|
||||
| `auth_enabled` | 是否启用管理端认证 |
|
||||
| `ui_locale` | 界面语言(`zh-CN` / `en`) |
|
||||
| `file_sign_secret` | 文件访问签名密钥 |
|
||||
|
||||
新增配置项:在 `internal/settings/keys.go` 定义键与默认值,并编写 goose 迁移插入初始值。
|
||||
可通过管理端设置页修改,无需重启。
|
||||
|
||||
---
|
||||
|
||||
## 开发工作流
|
||||
|
||||
### 双进程开发(推荐)
|
||||
|
||||
```bash
|
||||
# 终端 1 — 后端
|
||||
make run
|
||||
|
||||
# 终端 2 — 前端热更新
|
||||
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 dev-web` | 启动 Vite 开发服务器 |
|
||||
| `make run` | 运行后端 |
|
||||
| `make dev-web` | Vite 开发服务器 |
|
||||
| `make build` | 构建含 embed 前端的完整二进制 |
|
||||
| `make test` | 运行 Go 测试 |
|
||||
| `make test` | Go 测试 |
|
||||
| `make tidy` | `go mod tidy` |
|
||||
| `make clean` | 清理构建产物 |
|
||||
|
||||
@@ -249,58 +277,28 @@ make build
|
||||
|
||||
```bash
|
||||
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
|
||||
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 时自动:
|
||||
|
||||
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)。
|
||||
仓库包含 `.gitea/workflows/ci.yml`:push 到 `main` 或打 `v*` tag 时运行测试并构建 Docker 镜像。详见 [.gitea/README.md](.gitea/README.md)。
|
||||
|
||||
---
|
||||
|
||||
## 扩展指南
|
||||
## UI 设计
|
||||
|
||||
### 新增 REST 接口
|
||||
|
||||
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`
|
||||
前端样式与布局规范见 [docs/UI-DESIGN-SYSTEM.md](docs/UI-DESIGN-SYSTEM.md)。页面统一使用 `PageHeader`、`AppCard`、`StatCard` 等壳层组件,品牌色与间距 Token 定义在 `web/src/styles/global.css`。
|
||||
|
||||
---
|
||||
|
||||
|
||||
+11
-4
@@ -10,9 +10,9 @@ import (
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"github.com/rose_cat707/go-vue-template/internal/api"
|
||||
"github.com/rose_cat707/go-vue-template/internal/service"
|
||||
"github.com/rose_cat707/go-vue-template/internal/store"
|
||||
"github.com/rose_cat707/Atlas/internal/api"
|
||||
"github.com/rose_cat707/Atlas/internal/service"
|
||||
"github.com/rose_cat707/Atlas/internal/store"
|
||||
)
|
||||
|
||||
func main() {
|
||||
@@ -30,7 +30,14 @@ func main() {
|
||||
}
|
||||
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)
|
||||
|
||||
httpServer := &http.Server{
|
||||
|
||||
+1
-1
@@ -1,7 +1,7 @@
|
||||
services:
|
||||
app:
|
||||
build: .
|
||||
image: go-vue-template:latest
|
||||
image: atlas:latest
|
||||
ports:
|
||||
- "8080:8080"
|
||||
volumes:
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,9 +1,10 @@
|
||||
module github.com/rose_cat707/go-vue-template
|
||||
module github.com/rose_cat707/Atlas
|
||||
|
||||
go 1.25.7
|
||||
|
||||
require (
|
||||
github.com/gin-gonic/gin v1.12.0
|
||||
github.com/google/uuid v1.6.0
|
||||
github.com/pressly/goose/v3 v3.27.1
|
||||
golang.org/x/crypto v0.53.0
|
||||
modernc.org/sqlite v1.52.0
|
||||
@@ -22,7 +23,6 @@ require (
|
||||
github.com/go-playground/validator/v10 v10.30.1 // indirect
|
||||
github.com/goccy/go-json v0.10.5 // indirect
|
||||
github.com/goccy/go-yaml v1.19.2 // indirect
|
||||
github.com/google/uuid v1.6.0 // indirect
|
||||
github.com/json-iterator/go v1.1.12 // indirect
|
||||
github.com/klauspost/cpuid/v2 v2.3.0 // indirect
|
||||
github.com/leodido/go-urn v1.4.0 // indirect
|
||||
|
||||
@@ -0,0 +1,189 @@
|
||||
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/logs", s.adminListLogs)
|
||||
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) adminListLogs(c *gin.Context) {
|
||||
limit, offset := parseLimitOffset(c, 50, 500)
|
||||
filter := store.RequestLogFilter{
|
||||
Path: c.Query("path"),
|
||||
ProviderID: parseInt64(c.Query("provider_id")),
|
||||
}
|
||||
if sc := c.Query("status_code"); sc != "" {
|
||||
filter.StatusCode, _ = strconv.Atoi(sc)
|
||||
}
|
||||
items, total, err := s.app.Store.ListRequestLogs(c.Request.Context(), filter, 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) 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)
|
||||
}
|
||||
|
||||
func parseInt64(v string) int64 {
|
||||
n, _ := strconv.ParseInt(v, 10, 64)
|
||||
return n
|
||||
}
|
||||
@@ -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,210 @@
|
||||
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", "openai")
|
||||
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 {
|
||||
res, err := s.app.Router.ProxyWithMeta(ctx, cfg, "GET", "/v1/models", nil, nil, provider.CallMeta{
|
||||
SessionID: sessionID(c),
|
||||
Method: "GET",
|
||||
Path: "/v1/models",
|
||||
})
|
||||
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,153 @@
|
||||
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)
|
||||
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: model,
|
||||
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.ServiceTextToImage), "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.ServiceTextToImage), "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.ServiceTextToImage), "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.ServiceTextToImage), "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,8 +9,8 @@ import (
|
||||
"github.com/gin-gonic/gin"
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
|
||||
"github.com/rose_cat707/go-vue-template/internal/service"
|
||||
"github.com/rose_cat707/go-vue-template/internal/settings"
|
||||
"github.com/rose_cat707/Atlas/internal/service"
|
||||
"github.com/rose_cat707/Atlas/internal/settings"
|
||||
)
|
||||
|
||||
type Server struct {
|
||||
@@ -41,8 +41,14 @@ func (s *Server) Router() *gin.Engine {
|
||||
protected.GET("/settings", s.getSettings)
|
||||
protected.PUT("/settings", s.putSettings)
|
||||
protected.GET("/audit-logs", s.listAuditLogs)
|
||||
s.registerAdminRoutes(protected)
|
||||
}
|
||||
|
||||
user := api.Group("")
|
||||
s.registerUserRoutes(user)
|
||||
|
||||
s.registerGatewayRoutes(r)
|
||||
|
||||
r.NoRoute(s.serveSPA)
|
||||
return r
|
||||
}
|
||||
|
||||
@@ -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,391 @@
|
||||
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.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()
|
||||
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 []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
|
||||
}
|
||||
allModels = append(allModels, models...)
|
||||
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 spec.Capabilities.Sync != nil {
|
||||
caps["sync"] = true
|
||||
}
|
||||
if spec.Capabilities.Async != nil {
|
||||
caps["async"] = true
|
||||
}
|
||||
out[string(st)] = gin.H{
|
||||
"capabilities": caps,
|
||||
"providers": provList,
|
||||
"models": allModels,
|
||||
"default_model": provider.DefaultModel(allModels),
|
||||
}
|
||||
}
|
||||
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) {
|
||||
var in struct {
|
||||
Title string `json:"title"`
|
||||
Model string `json:"model"`
|
||||
}
|
||||
_ = c.ShouldBindJSON(&in)
|
||||
if in.Title == "" {
|
||||
in.Title = "新对话"
|
||||
}
|
||||
conv := &store.Conversation{SessionID: sessionID(c), Title: in.Title, Model: in.Model, ParamsJSON: "{}"}
|
||||
id, err := s.app.Store.CreateConversation(c.Request.Context(), conv)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
conv.ID = id
|
||||
c.JSON(http.StatusOK, conv)
|
||||
}
|
||||
|
||||
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)
|
||||
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
|
||||
}
|
||||
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(c.Request.Context(), chat.StreamRequest{
|
||||
SessionID: sessionID(c),
|
||||
ConversationID: id,
|
||||
Content: in.Content,
|
||||
Model: in.Model,
|
||||
ProviderID: in.ProviderID,
|
||||
}, c.Writer, func() { c.Writer.Flush() })
|
||||
if err != nil {
|
||||
// headers already sent for SSE
|
||||
_, _ = 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"`
|
||||
Mode string `json:"mode"`
|
||||
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
|
||||
}
|
||||
mode := domain.InvokeMode(in.Mode)
|
||||
if mode == "" {
|
||||
mode = domain.ModeSync
|
||||
}
|
||||
result, err := s.app.Invoke.Run(c.Request.Context(), domain.InvokeRequest{
|
||||
SessionID: sessionID(c),
|
||||
ServiceType: domain.ServiceType(in.ServiceType),
|
||||
ProviderID: in.ProviderID,
|
||||
Model: in.Model,
|
||||
Params: in.Params,
|
||||
Mode: mode,
|
||||
})
|
||||
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,52 @@
|
||||
package domain
|
||||
|
||||
import "encoding/json"
|
||||
|
||||
type ServiceType string
|
||||
|
||||
const (
|
||||
ServiceChat ServiceType = "chat"
|
||||
ServiceTextToImage ServiceType = "text_to_image"
|
||||
ServiceImageToImage ServiceType = "image_to_image"
|
||||
)
|
||||
|
||||
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,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,229 @@
|
||||
package provider
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"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.store.Cache.Delete(cacheKey(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.store.Cache.Delete(cacheKey(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.store.Cache.Delete(cacheKey(providerID))
|
||||
return m.fetchUpstream(ctx, cfg)
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
func (m *ModelsService) fetchUpstream(ctx context.Context, cfg *Config) ([]ModelEntry, error) {
|
||||
key := cacheKey(cfg.ID)
|
||||
if v, ok := m.store.Cache.Get(key); ok {
|
||||
if models, ok := v.([]ModelEntry); ok {
|
||||
return models, nil
|
||||
}
|
||||
}
|
||||
meta := CallMeta{Method: "GET", Path: "/v1/models"}
|
||||
res, err := m.router.ProxyWithMeta(ctx, cfg, "GET", "/v1/models", nil, nil, meta)
|
||||
if err != nil || res.StatusCode >= 400 {
|
||||
return nil, err
|
||||
}
|
||||
models := parseUpstreamModels(res.Body)
|
||||
if len(models) > 0 {
|
||||
m.store.Cache.Set(key, models)
|
||||
}
|
||||
return models, nil
|
||||
}
|
||||
|
||||
func parseUpstreamModels(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 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)
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
package provider
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"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) {
|
||||
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)
|
||||
}
|
||||
providers, err := r.store.ListProviders(ctx, serviceType, true)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
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)
|
||||
}
|
||||
+36
-3
@@ -1,8 +1,41 @@
|
||||
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 {
|
||||
Store *store.Store
|
||||
DataDir string
|
||||
Store *store.Store
|
||||
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,154 @@
|
||||
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
|
||||
}
|
||||
if conv.Title == "新对话" && len(req.Content) > 0 {
|
||||
title := req.Content
|
||||
if len(title) > 30 {
|
||||
title = title[:30]
|
||||
}
|
||||
conv.Title = title
|
||||
_ = s.store.UpdateConversation(ctx, conv)
|
||||
}
|
||||
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.ID, assistant)
|
||||
}
|
||||
|
||||
func (s *Service) persistAssistant(ctx context.Context, req StreamRequest, model string, providerID int64, assistant string) error {
|
||||
asst := &store.ChatMessageRow{ConversationID: req.ConversationID, Role: "assistant", Content: assistant}
|
||||
s.store.WriteQueue.Enqueue(func(ctx context.Context, st *store.Store) error {
|
||||
_, err := st.InsertMessage(ctx, asst)
|
||||
return err
|
||||
})
|
||||
pid := providerID
|
||||
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 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,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,255 @@
|
||||
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) {
|
||||
spec, ok := s.specs[domain.ServiceType(req.ServiceType)]
|
||||
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(req.ServiceType), req.ProviderID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if req.Model == "" {
|
||||
models, _ := s.models.ListEffective(ctx, cfg)
|
||||
req.Model = provider.DefaultModel(models)
|
||||
}
|
||||
|
||||
switch req.Mode {
|
||||
case domain.ModeAsync:
|
||||
return s.runAsync(ctx, req, cfg, params)
|
||||
case domain.ModeSync:
|
||||
return s.runSync(ctx, req, cfg, params)
|
||||
default:
|
||||
return nil, fmt.Errorf("stream mode handled by chat service")
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Service) runSync(ctx context.Context, req domain.InvokeRequest, cfg *provider.Config, params map[string]any) (*domain.InvokeResult, error) {
|
||||
body, err := buildPredictionBody(req.Model, 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: string(req.ServiceType),
|
||||
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) (*domain.InvokeResult, error) {
|
||||
body, err := buildPredictionBody(req.Model, 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: string(req.ServiceType),
|
||||
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 buildPredictionBody(model string, params map[string]any) ([]byte, error) {
|
||||
input := map[string]any{"prompt": params["prompt"]}
|
||||
if v, ok := params["image"]; ok {
|
||||
input["image"] = v
|
||||
}
|
||||
if v, ok := params["strength"]; ok {
|
||||
input["strength"] = v
|
||||
}
|
||||
payload := map[string]any{
|
||||
"model": model,
|
||||
"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.ServiceTextToImage: {
|
||||
Type: domain.ServiceTextToImage,
|
||||
Capabilities: domain.InvokeCapabilities{
|
||||
Sync: &domain.SyncCapability{},
|
||||
Async: &domain.AsyncCapability{Driver: "replicate", PollInterval: 2 * time.Second},
|
||||
},
|
||||
DefaultParams: func() map[string]any { return map[string]any{"prompt": ""} },
|
||||
ValidateParams: func(params map[string]any) error {
|
||||
if p, _ := params["prompt"].(string); p == "" {
|
||||
return fmt.Errorf("prompt required")
|
||||
}
|
||||
return nil
|
||||
},
|
||||
},
|
||||
domain.ServiceImageToImage: {
|
||||
Type: domain.ServiceImageToImage,
|
||||
Capabilities: domain.InvokeCapabilities{
|
||||
Sync: &domain.SyncCapability{},
|
||||
Async: &domain.AsyncCapability{Driver: "replicate", PollInterval: 2 * time.Second},
|
||||
},
|
||||
DefaultParams: func() map[string]any { return map[string]any{"prompt": "", "strength": 0.75} },
|
||||
ValidateParams: func(params map[string]any) error {
|
||||
if p, _ := params["prompt"].(string); p == "" {
|
||||
return fmt.Errorf("prompt required")
|
||||
}
|
||||
if _, ok := params["image"]; !ok {
|
||||
return fmt.Errorf("image required")
|
||||
}
|
||||
return nil
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
KeyAuthEnabled = "auth_enabled"
|
||||
KeyUILocale = "ui_locale"
|
||||
KeyFileSignSecret = "file_sign_secret"
|
||||
)
|
||||
|
||||
var Defaults = map[string]string{
|
||||
KeyAppName: "App",
|
||||
KeyAPIPort: "8080",
|
||||
KeyAdminPassword: "admin",
|
||||
KeyAuthEnabled: "true",
|
||||
KeyUILocale: "zh-CN",
|
||||
KeyAppName: "Atlas",
|
||||
KeyAPIPort: "8080",
|
||||
KeyAdminPassword: "admin",
|
||||
KeyAuthEnabled: "true",
|
||||
KeyUILocale: "zh-CN",
|
||||
KeyFileSignSecret: "change-me-atlas-file-sign",
|
||||
}
|
||||
|
||||
// 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,117 @@
|
||||
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) {
|
||||
rows, err := s.db.QueryContext(ctx,
|
||||
`SELECT id, session_id, title, model, params_json, created_at, updated_at FROM chat_conversations WHERE session_id = ? ORDER BY 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) 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) 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,77 @@
|
||||
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
|
||||
}
|
||||
@@ -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,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,134 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"fmt"
|
||||
)
|
||||
|
||||
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 nil
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
+14
-3
@@ -7,6 +7,7 @@ import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"time"
|
||||
|
||||
"github.com/pressly/goose/v3"
|
||||
_ "modernc.org/sqlite"
|
||||
@@ -16,7 +17,9 @@ import (
|
||||
var migrationsFS embed.FS
|
||||
|
||||
type Store struct {
|
||||
db *sql.DB
|
||||
db *sql.DB
|
||||
Cache *HotCache
|
||||
WriteQueue *WriteQueue
|
||||
}
|
||||
|
||||
func Open(dataDir string) (*Store, error) {
|
||||
@@ -34,12 +37,19 @@ func Open(dataDir string) (*Store, error) {
|
||||
db.Close()
|
||||
return nil, err
|
||||
}
|
||||
s.Cache = NewHotCache(30 * time.Second)
|
||||
s.WriteQueue = NewWriteQueue(s, 200*time.Millisecond)
|
||||
return s, nil
|
||||
}
|
||||
|
||||
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 {
|
||||
goose.SetBaseFS(migrationsFS)
|
||||
@@ -143,7 +153,8 @@ func MaskSecret(value string) string {
|
||||
|
||||
func ValidateSettingKey(key string) error {
|
||||
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
|
||||
default:
|
||||
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">
|
||||
<head>
|
||||
<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" />
|
||||
<title>App</title>
|
||||
<meta name="description" content="Atlas — AI 创作与管理平台" />
|
||||
<title>Atlas</title>
|
||||
</head>
|
||||
<body>
|
||||
<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) => {
|
||||
if (err.response?.status === 401 && err.config?.url !== '/auth/login') {
|
||||
localStorage.removeItem('app_token')
|
||||
if (router.currentRoute.value.name !== 'login') {
|
||||
router.push('/login')
|
||||
if (!router.currentRoute.value.path.startsWith('/admin/login')) {
|
||||
router.push('/admin/login')
|
||||
}
|
||||
}
|
||||
return Promise.reject(err)
|
||||
|
||||
@@ -1,5 +1,16 @@
|
||||
<script setup lang="ts">
|
||||
defineProps<{ title?: string; padded?: boolean }>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<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>
|
||||
</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">
|
||||
defineProps<{ title: string; subtitle?: string }>()
|
||||
defineProps<{ title: string; description?: string }>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="page-header">
|
||||
<div>
|
||||
<h2 class="page-header__title">{{ title }}</h2>
|
||||
<p v-if="subtitle" class="page-header__subtitle">{{ subtitle }}</p>
|
||||
<h1 class="page-title">{{ title }}</h1>
|
||||
<p v-if="description" class="page-desc">{{ description }}</p>
|
||||
</div>
|
||||
<div class="page-header__actions">
|
||||
<div v-if="$slots.actions" class="page-actions">
|
||||
<slot name="actions" />
|
||||
</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,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,82 @@
|
||||
import { onMounted, onUnmounted, ref } from 'vue'
|
||||
import api from '@/api/client'
|
||||
|
||||
export interface TaskItem {
|
||||
id: number
|
||||
status: string
|
||||
progress: number
|
||||
result_json: string
|
||||
error: string
|
||||
}
|
||||
|
||||
const terminalStatuses = new Set(['succeeded', 'failed', 'canceled'])
|
||||
|
||||
export function useInvoke(serviceType: string) {
|
||||
const activeTasks = ref<TaskItem[]>([])
|
||||
const streams = new Map<number, EventSource>()
|
||||
|
||||
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 res = await api.get<{ items: TaskItem[] }>('/user/tasks/active', {
|
||||
params: { service_type: serviceType },
|
||||
})
|
||||
activeTasks.value = res.data.items
|
||||
for (const t of res.data.items) {
|
||||
watchTask(t.id)
|
||||
}
|
||||
}
|
||||
|
||||
async function generate(params: Record<string, unknown>, mode: 'sync' | 'async', model: string) {
|
||||
const res = await api.post('/user/generate', {
|
||||
service_type: serviceType,
|
||||
params,
|
||||
mode,
|
||||
model,
|
||||
})
|
||||
if (mode === 'async' && res.data.task_id) {
|
||||
watchTask(res.data.task_id)
|
||||
}
|
||||
return res.data
|
||||
}
|
||||
|
||||
async function cancelTask(taskId: number) {
|
||||
await api.post(`/user/tasks/${taskId}/cancel`)
|
||||
}
|
||||
|
||||
onMounted(loadActiveTasks)
|
||||
onUnmounted(() => {
|
||||
streams.forEach((es) => es.close())
|
||||
streams.clear()
|
||||
})
|
||||
|
||||
return { activeTasks, generate, loadActiveTasks, cancelTask, watchTask }
|
||||
}
|
||||
@@ -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,37 @@
|
||||
import { ref } from 'vue'
|
||||
import api from '@/api/client'
|
||||
|
||||
export interface ModelInfo {
|
||||
id: string
|
||||
name: string
|
||||
}
|
||||
|
||||
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 load() {
|
||||
if (meta.value) return meta.value
|
||||
loading.value = true
|
||||
try {
|
||||
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, getService }
|
||||
}
|
||||
+12
-1
@@ -7,11 +7,13 @@ export default {
|
||||
},
|
||||
nav: {
|
||||
dashboard: 'Dashboard',
|
||||
providers: 'Providers',
|
||||
logs: 'Request logs',
|
||||
settings: 'Settings',
|
||||
},
|
||||
login: {
|
||||
title: 'Sign in',
|
||||
subtitle: 'Admin Console',
|
||||
subtitle: 'Atlas Admin Console',
|
||||
password: 'Password',
|
||||
passwordPlaceholder: 'Enter admin password',
|
||||
submit: 'Sign in',
|
||||
@@ -32,4 +34,13 @@ export default {
|
||||
uiLocale: 'UI locale',
|
||||
saved: 'Settings saved',
|
||||
},
|
||||
admin: {
|
||||
stats: {
|
||||
requests: 'Requests (7d)',
|
||||
successRate: 'Success rate',
|
||||
latency: 'Avg latency',
|
||||
files: 'File storage',
|
||||
fileCount: 'File count',
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
+12
-1
@@ -7,11 +7,13 @@ export default {
|
||||
},
|
||||
nav: {
|
||||
dashboard: '概览',
|
||||
providers: 'Provider',
|
||||
logs: '请求日志',
|
||||
settings: '设置',
|
||||
},
|
||||
login: {
|
||||
title: '登录',
|
||||
subtitle: '管理控制台',
|
||||
subtitle: 'Atlas 管理控制台',
|
||||
password: '密码',
|
||||
passwordPlaceholder: '请输入管理员密码',
|
||||
submit: '登录',
|
||||
@@ -32,4 +34,13 @@ export default {
|
||||
uiLocale: '界面语言',
|
||||
saved: '设置已保存',
|
||||
},
|
||||
admin: {
|
||||
stats: {
|
||||
requests: '近 7 日请求',
|
||||
successRate: '成功率',
|
||||
latency: '平均延迟',
|
||||
files: '文件占用',
|
||||
fileCount: '文件数量',
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
@@ -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, Document } 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/logs', key: 'nav.logs', icon: Document, 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,61 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
import { ChatDotRound, Picture, EditPen, Clock } from '@element-plus/icons-vue'
|
||||
import AtlasLogo from '@/components/AtlasLogo.vue'
|
||||
|
||||
const route = useRoute()
|
||||
|
||||
const navItems = [
|
||||
{ path: '/chat', label: '对话', icon: ChatDotRound },
|
||||
{ path: '/image/text', label: '文生图', icon: Picture },
|
||||
{ path: '/image/edit', label: '图生图', icon: EditPen },
|
||||
{ path: '/history', label: '历史', icon: Clock },
|
||||
] as const
|
||||
|
||||
const pageTitle = computed(() => navItems.find((i) => route.path.startsWith(i.path))?.label ?? 'Atlas')
|
||||
</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">AI 创作</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>
|
||||
{{ item.label }}
|
||||
</router-link>
|
||||
</aside>
|
||||
<div class="main-area">
|
||||
<header class="topbar">
|
||||
<span class="topbar-title">{{ pageTitle }}</span>
|
||||
<router-link to="/admin" class="text-muted">管理端</router-link>
|
||||
</header>
|
||||
<main class="main-content">
|
||||
<div class="page">
|
||||
<router-view />
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.text-muted {
|
||||
color: var(--color-text-secondary);
|
||||
font-size: var(--text-sm);
|
||||
text-decoration: none;
|
||||
}
|
||||
.text-muted:hover {
|
||||
color: var(--color-primary);
|
||||
}
|
||||
</style>
|
||||
+1
-1
@@ -2,7 +2,7 @@ import { createApp } from 'vue'
|
||||
import { createPinia } from 'pinia'
|
||||
import ElementPlus from 'element-plus'
|
||||
import 'element-plus/dist/index.css'
|
||||
import '@/styles/theme.css'
|
||||
import '@/styles/global.css'
|
||||
|
||||
import App from './App.vue'
|
||||
import router from './router'
|
||||
|
||||
+27
-9
@@ -1,30 +1,48 @@
|
||||
import { createRouter, createWebHistory } from 'vue-router'
|
||||
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({
|
||||
history: createWebHistory(),
|
||||
routes: [
|
||||
{
|
||||
path: '/login',
|
||||
name: 'login',
|
||||
path: '/',
|
||||
component: UserLayout,
|
||||
meta: { public: true },
|
||||
children: [
|
||||
{ path: '', redirect: '/chat' },
|
||||
{ path: 'chat', name: 'chat', component: () => import('@/views/ChatView.vue') },
|
||||
{ path: 'image/text', name: 'text-to-image', component: () => import('@/views/TextToImageView.vue') },
|
||||
{ path: 'image/edit', name: 'image-to-image', component: () => import('@/views/ImageToImageView.vue') },
|
||||
{ path: 'history', name: 'history', component: () => import('@/views/HistoryView.vue') },
|
||||
],
|
||||
},
|
||||
{
|
||||
path: '/admin/login',
|
||||
name: 'admin-login',
|
||||
component: () => import('@/views/LoginView.vue'),
|
||||
meta: { public: true },
|
||||
},
|
||||
{
|
||||
path: '/',
|
||||
component: MainLayout,
|
||||
path: '/admin',
|
||||
component: AdminLayout,
|
||||
children: [
|
||||
{ path: '', name: 'dashboard', component: () => import('@/views/DashboardView.vue') },
|
||||
{ path: 'settings', name: 'settings', component: () => import('@/views/SettingsView.vue') },
|
||||
{ path: '', name: 'admin-dashboard', component: () => import('@/views/AdminDashboardView.vue') },
|
||||
{ path: 'providers', name: 'admin-providers', component: () => import('@/views/AdminProvidersView.vue') },
|
||||
{ path: 'logs', name: 'admin-logs', component: () => import('@/views/AdminLogsView.vue') },
|
||||
{ path: 'settings', name: 'admin-settings', component: () => import('@/views/SettingsView.vue') },
|
||||
],
|
||||
},
|
||||
{ path: '/login', redirect: '/admin/login' },
|
||||
],
|
||||
})
|
||||
|
||||
router.beforeEach(async (to) => {
|
||||
const auth = useAuthStore()
|
||||
if (to.meta.public) return true
|
||||
if (!to.path.startsWith('/admin')) return true
|
||||
|
||||
const auth = useAuthStore()
|
||||
if (auth.isLoggedIn()) return true
|
||||
try {
|
||||
const res = await fetch('/api/v1/auth/status')
|
||||
@@ -34,7 +52,7 @@ router.beforeEach(async (to) => {
|
||||
return true
|
||||
}
|
||||
} catch { /* ignore */ }
|
||||
return '/login'
|
||||
return '/admin/login'
|
||||
})
|
||||
|
||||
export default router
|
||||
|
||||
@@ -0,0 +1,517 @@
|
||||
:root {
|
||||
--background: #f4f4f5;
|
||||
--foreground: #18181b;
|
||||
--card: #ffffff;
|
||||
--card-foreground: #18181b;
|
||||
--primary: #0f7a62;
|
||||
--primary-hover: #0d6b56;
|
||||
--primary-foreground: #fafafa;
|
||||
--muted: #f4f4f5;
|
||||
--muted-foreground: #71717a;
|
||||
--border: #e4e4e7;
|
||||
--input: #e4e4e7;
|
||||
--destructive: #dc2626;
|
||||
--success: #047857;
|
||||
--warning: #b45309;
|
||||
--danger: #dc2626;
|
||||
--info: #a1a1aa;
|
||||
--sidebar: rgba(250, 250, 250, 0.85);
|
||||
--sidebar-foreground: #18181b;
|
||||
--sidebar-accent: #f4f4f5;
|
||||
--sidebar-border: #e4e4e7;
|
||||
--sidebar-width: 15rem;
|
||||
--radius: 0.5rem;
|
||||
--space-1: 4px;
|
||||
--space-2: 8px;
|
||||
--space-3: 12px;
|
||||
--space-4: 16px;
|
||||
--space-5: 20px;
|
||||
--space-6: 24px;
|
||||
--space-8: 32px;
|
||||
|
||||
--el-color-primary: #0f7a62;
|
||||
--el-color-primary-light-3: #4da08c;
|
||||
--el-color-primary-light-5: #76b8a8;
|
||||
--el-color-primary-light-7: #a0d0c4;
|
||||
--el-color-primary-light-8: #b8dcd2;
|
||||
--el-color-primary-light-9: #d0e8e1;
|
||||
--el-color-primary-dark-2: #0c624e;
|
||||
--el-color-success: #047857;
|
||||
--el-color-warning: #b45309;
|
||||
--el-color-danger: #dc2626;
|
||||
--el-color-info: #a1a1aa;
|
||||
--el-border-radius-base: 0.5rem;
|
||||
--el-border-color: #e4e4e7;
|
||||
--el-bg-color-page: #f4f4f5;
|
||||
--el-text-color-primary: #18181b;
|
||||
--el-text-color-regular: #3f3f46;
|
||||
--el-text-color-secondary: #71717a;
|
||||
--el-font-family: ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto, "Helvetica Neue", Arial, "PingFang SC", "Microsoft YaHei", "Noto Sans SC", sans-serif;
|
||||
}
|
||||
|
||||
* { box-sizing: border-box; }
|
||||
|
||||
html, body, #app {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
height: 100%;
|
||||
font-size: 14px;
|
||||
font-family: var(--el-font-family);
|
||||
background: var(--background);
|
||||
color: var(--foreground);
|
||||
}
|
||||
|
||||
.layout {
|
||||
display: flex;
|
||||
height: 100vh;
|
||||
}
|
||||
|
||||
.sidebar {
|
||||
width: var(--sidebar-width);
|
||||
flex-shrink: 0;
|
||||
background: var(--sidebar);
|
||||
border-right: 1px solid var(--sidebar-border);
|
||||
backdrop-filter: blur(8px);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.sidebar-brand {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-3);
|
||||
padding: var(--space-5) var(--space-4);
|
||||
}
|
||||
|
||||
.brand-icon {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border-radius: var(--radius);
|
||||
background: var(--primary);
|
||||
color: var(--primary-foreground);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.brand-name {
|
||||
font-size: 1.125rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.nav-group-label {
|
||||
font-size: 0.75rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
color: var(--muted-foreground);
|
||||
padding: var(--space-4) var(--space-4) var(--space-2);
|
||||
}
|
||||
|
||||
.nav-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
padding: var(--space-2) var(--space-3);
|
||||
margin: 0 var(--space-2);
|
||||
border-radius: var(--radius);
|
||||
color: var(--muted-foreground);
|
||||
text-decoration: none;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.nav-item:hover { background: var(--sidebar-accent); }
|
||||
|
||||
.nav-item.active {
|
||||
background: var(--sidebar-accent);
|
||||
color: var(--primary);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.main-area {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.topbar {
|
||||
height: 52px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 0 var(--space-6);
|
||||
background: var(--card);
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.topbar-title {
|
||||
font-size: 0.875rem;
|
||||
color: var(--muted-foreground);
|
||||
}
|
||||
|
||||
.main-content {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: var(--space-6);
|
||||
}
|
||||
|
||||
.page {
|
||||
max-width: 1400px;
|
||||
}
|
||||
|
||||
.page-header {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
margin-bottom: var(--space-6);
|
||||
gap: var(--space-4);
|
||||
}
|
||||
|
||||
.page-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
flex-shrink: 0;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.page-title {
|
||||
font-size: 1.5rem;
|
||||
font-weight: 600;
|
||||
letter-spacing: -0.02em;
|
||||
margin: 0 0 var(--space-1);
|
||||
}
|
||||
|
||||
.page-desc {
|
||||
font-size: 0.875rem;
|
||||
color: var(--muted-foreground);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.stat-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
gap: var(--space-4);
|
||||
margin-bottom: var(--space-6);
|
||||
}
|
||||
|
||||
.stat-card {
|
||||
background: var(--card);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
padding: var(--space-5);
|
||||
}
|
||||
|
||||
.stat-card__label {
|
||||
font-size: 0.875rem;
|
||||
color: var(--muted-foreground);
|
||||
margin-bottom: var(--space-2);
|
||||
}
|
||||
|
||||
.stat-card__value {
|
||||
font-size: 1.75rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.app-card {
|
||||
background: var(--card);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
margin-bottom: var(--space-4);
|
||||
}
|
||||
|
||||
.page > .app-card:last-child,
|
||||
.page > .content-grid:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.app-card__header {
|
||||
padding: var(--space-4) var(--space-5);
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.app-card__title {
|
||||
font-size: 0.9375rem;
|
||||
font-weight: 600;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.app-card__body--padded { padding: var(--space-5); }
|
||||
|
||||
.content-grid {
|
||||
display: grid;
|
||||
gap: var(--space-4);
|
||||
margin-bottom: var(--space-6);
|
||||
}
|
||||
|
||||
.content-grid--2-1 { grid-template-columns: 1.4fr 1fr; }
|
||||
.content-grid--2 { grid-template-columns: 1fr 1fr; }
|
||||
.content-grid--3 { grid-template-columns: repeat(3, 1fr); }
|
||||
|
||||
.card-toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
padding: var(--space-4) var(--space-5) 0;
|
||||
margin-bottom: var(--space-4);
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.card-toolbar--desc {
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.card-pager {
|
||||
padding: var(--space-4) var(--space-5);
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.card-footer-hint {
|
||||
padding: 0 var(--space-5) var(--space-3);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.card-footer-hint + .card-pager {
|
||||
padding-top: var(--space-3);
|
||||
}
|
||||
|
||||
.card-inset-block {
|
||||
margin: 0 var(--space-5) var(--space-4);
|
||||
padding: var(--space-3);
|
||||
background: var(--muted);
|
||||
border-radius: var(--radius);
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.tabs-bar {
|
||||
padding: 0 var(--space-5);
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.tabs-bar .el-tabs__header { margin-bottom: 0; }
|
||||
|
||||
.page-toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
margin-bottom: var(--space-4);
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.inline-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.card-header-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: var(--space-4);
|
||||
}
|
||||
|
||||
.form-hint {
|
||||
margin-top: 6px;
|
||||
font-size: 0.75rem;
|
||||
color: var(--muted-foreground);
|
||||
}
|
||||
|
||||
.form-hint--flush { margin-top: 0; }
|
||||
|
||||
.u-mt-3 { margin-top: var(--space-3); }
|
||||
.u-mt-4 { margin-top: var(--space-4); }
|
||||
.u-mb-3 { margin-bottom: var(--space-3); }
|
||||
.u-mb-4 { margin-bottom: var(--space-4); }
|
||||
.u-mb-6 { margin-bottom: var(--space-6); }
|
||||
.text-center { text-align: center; }
|
||||
.w-full { width: 100%; }
|
||||
|
||||
code.inline-code {
|
||||
font-size: 0.75rem;
|
||||
padding: 0 var(--space-1);
|
||||
background: var(--muted);
|
||||
border-radius: var(--radius);
|
||||
}
|
||||
|
||||
.section-title {
|
||||
font-size: 0.875rem;
|
||||
font-weight: 600;
|
||||
margin-bottom: var(--space-4);
|
||||
}
|
||||
|
||||
.section-title:not(:first-child) { margin-top: var(--space-6); }
|
||||
|
||||
.login-page {
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: var(--background);
|
||||
padding: var(--space-6);
|
||||
}
|
||||
|
||||
.login-wrap { max-width: 400px; width: 100%; text-align: center; }
|
||||
|
||||
.login-brand-icon {
|
||||
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;
|
||||
}
|
||||
|
||||
@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,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,55 @@
|
||||
<script setup lang="ts">
|
||||
import { onMounted, ref } from 'vue'
|
||||
import api from '@/api/client'
|
||||
import PageHeader from '@/components/PageHeader.vue'
|
||||
import AppCard from '@/components/AppCard.vue'
|
||||
|
||||
interface LogItem {
|
||||
id: number
|
||||
method: string
|
||||
path: string
|
||||
status_code: number
|
||||
latency_ms: number
|
||||
error: string
|
||||
created_at: string
|
||||
}
|
||||
|
||||
const loading = ref(true)
|
||||
const items = ref<LogItem[]>([])
|
||||
const total = ref(0)
|
||||
|
||||
onMounted(load)
|
||||
|
||||
async function load() {
|
||||
loading.value = true
|
||||
try {
|
||||
const res = await api.get<{ items: LogItem[]; total: number }>('/admin/logs', { params: { limit: 50 } })
|
||||
items.value = res.data.items
|
||||
total.value = res.data.total
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<PageHeader title="请求日志" description="近 7 天上游调用记录" />
|
||||
<AppCard padded>
|
||||
<el-table v-loading="loading" :data="items" size="small">
|
||||
<el-table-column prop="id" label="ID" width="70" />
|
||||
<el-table-column prop="method" label="方法" width="80" />
|
||||
<el-table-column prop="path" label="路径" show-overflow-tooltip />
|
||||
<el-table-column prop="status_code" label="状态" width="80" />
|
||||
<el-table-column prop="latency_ms" label="延迟(ms)" width="100" />
|
||||
<el-table-column prop="error" label="错误" show-overflow-tooltip />
|
||||
<el-table-column prop="created_at" label="时间" width="180" />
|
||||
</el-table>
|
||||
<div class="footer">共 {{ total }} 条</div>
|
||||
</AppCard>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.footer { margin-top: var(--space-3); color: var(--color-text-secondary); font-size: var(--text-sm); }
|
||||
</style>
|
||||
@@ -0,0 +1,260 @@
|
||||
<script setup lang="ts">
|
||||
import { onMounted, reactive, ref } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import api from '@/api/client'
|
||||
import PageHeader from '@/components/PageHeader.vue'
|
||||
import AppCard from '@/components/AppCard.vue'
|
||||
|
||||
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,
|
||||
})
|
||||
|
||||
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,
|
||||
})
|
||||
dialogVisible.value = true
|
||||
}
|
||||
|
||||
function openEdit(row: Provider) {
|
||||
Object.assign(form, row)
|
||||
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(id: number) {
|
||||
await api.delete(`/admin/providers/${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;模型优先从上游 /v1/models 获取,亦可手动补充">
|
||||
<template #actions>
|
||||
<el-button type="primary" @click="openCreate">新建 Provider</el-button>
|
||||
</template>
|
||||
</PageHeader>
|
||||
<AppCard padded>
|
||||
<el-table v-loading="loading" :data="items" size="small">
|
||||
<el-table-column prop="name" label="名称" />
|
||||
<el-table-column prop="protocol" label="协议" width="100" />
|
||||
<el-table-column prop="service_type" label="服务类型" width="120" />
|
||||
<el-table-column prop="base_url" label="Gateway URL" show-overflow-tooltip />
|
||||
<el-table-column label="默认" width="70">
|
||||
<template #default="{ row }">{{ row.is_default ? '是' : '否' }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="启用" width="70">
|
||||
<template #default="{ row }">{{ row.enabled ? '是' : '否' }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="200">
|
||||
<template #default="{ row }">
|
||||
<el-button text @click="openEdit(row)">编辑</el-button>
|
||||
<el-button text @click="openModels(row)">模型</el-button>
|
||||
<el-button text type="danger" @click="remove(row.id!)">删除</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</AppCard>
|
||||
|
||||
<el-dialog v-model="dialogVisible" title="Provider" width="520px">
|
||||
<el-form label-width="110px">
|
||||
<el-form-item label="名称"><el-input v-model="form.name" /></el-form-item>
|
||||
<el-form-item label="协议">
|
||||
<el-select v-model="form.protocol">
|
||||
<el-option label="OpenAI" value="openai" />
|
||||
<el-option label="Replicate" value="replicate" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="服务类型">
|
||||
<el-select v-model="form.service_type">
|
||||
<el-option label="对话" value="chat" />
|
||||
<el-option label="文生图" value="text_to_image" />
|
||||
<el-option label="图生图" value="image_to_image" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="Gateway URL"><el-input v-model="form.base_url" /></el-form-item>
|
||||
<el-form-item label="API Key"><el-input v-model="form.api_key" type="password" show-password /></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="640px">
|
||||
<div class="models-toolbar">
|
||||
<el-button size="small" :loading="syncing" @click="syncUpstream">从上游同步</el-button>
|
||||
<span class="hint">优先使用 Gateway 的 /v1/models 接口</span>
|
||||
</div>
|
||||
|
||||
<h4 class="section-title">上游模型(只读)</h4>
|
||||
<el-table :data="models.upstream" size="small" empty-text="暂无,请点击「从上游同步」">
|
||||
<el-table-column prop="id" label="Model ID" />
|
||||
<el-table-column prop="name" label="名称" />
|
||||
</el-table>
|
||||
|
||||
<h4 class="section-title">手动补充</h4>
|
||||
<div class="add-row">
|
||||
<el-input v-model="newModel.model_id" placeholder="model_id" style="width: 180px" />
|
||||
<el-input v-model="newModel.name" placeholder="显示名称" style="width: 180px" />
|
||||
<el-button type="primary" size="small" @click="addManualModel">添加</el-button>
|
||||
</div>
|
||||
<el-table :data="models.manual" size="small" empty-text="暂无手动模型" style="margin-top: 8px">
|
||||
<el-table-column prop="id" label="Model ID" />
|
||||
<el-table-column prop="name" label="名称" />
|
||||
<el-table-column label="操作" width="80">
|
||||
<template #default="{ row }">
|
||||
<el-button text type="danger" @click="removeManual(row.id)">删除</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
|
||||
<h4 class="section-title">用户端可见(合并后)</h4>
|
||||
<el-table :data="models.effective" size="small" empty-text="请配置上游或手动模型">
|
||||
<el-table-column prop="id" label="Model ID" />
|
||||
<el-table-column prop="name" label="名称" />
|
||||
</el-table>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.models-toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-3);
|
||||
margin-bottom: var(--space-4);
|
||||
}
|
||||
.hint {
|
||||
color: var(--color-text-secondary);
|
||||
font-size: var(--text-sm);
|
||||
}
|
||||
.section-title {
|
||||
margin: var(--space-4) 0 var(--space-2);
|
||||
font-size: var(--text-sm);
|
||||
font-weight: 600;
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
.add-row {
|
||||
display: flex;
|
||||
gap: var(--space-2);
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,175 @@
|
||||
<script setup lang="ts">
|
||||
import { onMounted, ref } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import api from '@/api/client'
|
||||
import PageHeader from '@/components/PageHeader.vue'
|
||||
import AppCard from '@/components/AppCard.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)
|
||||
|
||||
onMounted(async () => {
|
||||
await load()
|
||||
model.value = getService('chat')?.default_model ?? ''
|
||||
await refreshConversations()
|
||||
})
|
||||
|
||||
async function refreshConversations() {
|
||||
const res = await api.get<{ items: Conversation[] }>('/user/conversations')
|
||||
conversations.value = res.data.items
|
||||
if (!activeId.value && conversations.value.length) {
|
||||
await selectConversation(conversations.value[0].id)
|
||||
}
|
||||
}
|
||||
|
||||
async function newConversation() {
|
||||
const res = await api.post<Conversation>('/user/conversations', { title: '新对话', model: model.value })
|
||||
conversations.value.unshift(res.data)
|
||||
await selectConversation(res.data.id)
|
||||
}
|
||||
|
||||
async function selectConversation(id: number) {
|
||||
activeId.value = id
|
||||
const res = await api.get<{ items: Message[] }>(`/user/conversations/${id}/messages`)
|
||||
messages.value = res.data.items
|
||||
}
|
||||
|
||||
async function send() {
|
||||
if (!input.value.trim() || !activeId.value || streaming.value) return
|
||||
const content = input.value.trim()
|
||||
input.value = ''
|
||||
messages.value.push({ id: Date.now(), role: 'user', content })
|
||||
streaming.value = true
|
||||
let assistant = ''
|
||||
messages.value.push({ id: Date.now() + 1, role: 'assistant', content: '' })
|
||||
try {
|
||||
const res = await fetch(`/api/v1/user/conversations/${activeId.value}/messages`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
credentials: 'include',
|
||||
body: JSON.stringify({ content, model: model.value }),
|
||||
})
|
||||
const reader = res.body?.getReader()
|
||||
const decoder = new TextDecoder()
|
||||
if (!reader) throw new Error('no stream')
|
||||
while (true) {
|
||||
const { done, value } = await reader.read()
|
||||
if (done) break
|
||||
const chunk = decoder.decode(value)
|
||||
for (const line of chunk.split('\n')) {
|
||||
if (!line.startsWith('data: ') || line.includes('[DONE]')) continue
|
||||
try {
|
||||
const data = JSON.parse(line.slice(6))
|
||||
const delta = data.choices?.[0]?.delta?.content ?? ''
|
||||
assistant += delta
|
||||
messages.value[messages.value.length - 1].content = assistant
|
||||
} catch { /* ignore */ }
|
||||
}
|
||||
}
|
||||
await refreshConversations()
|
||||
} catch {
|
||||
ElMessage.error('发送失败')
|
||||
} finally {
|
||||
streaming.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<PageHeader title="对话" description="多会话 AI 对话,支持流式输出" />
|
||||
<div class="content-grid--2-1">
|
||||
<AppCard padded title="消息">
|
||||
<div class="chat-messages">
|
||||
<div v-for="m in messages" :key="m.id" class="chat-bubble" :class="m.role">
|
||||
<div class="chat-role">{{ m.role === 'user' ? '你' : 'Atlas' }}</div>
|
||||
<div class="chat-content">{{ m.content }}<span v-if="streaming && m.role === 'assistant' && !m.content">…</span></div>
|
||||
</div>
|
||||
<div v-if="!messages.length" class="empty-hint">选择或新建会话开始对话</div>
|
||||
</div>
|
||||
<div class="chat-input">
|
||||
<el-select v-model="model" size="small" style="width: 180px; margin-right: 8px">
|
||||
<el-option
|
||||
v-for="m in getService('chat')?.models ?? []"
|
||||
:key="m.id"
|
||||
:label="m.name"
|
||||
:value="m.id"
|
||||
/>
|
||||
</el-select>
|
||||
<el-input v-model="input" placeholder="输入消息…" @keyup.enter="send" />
|
||||
<el-button type="primary" :loading="streaming" @click="send">发送</el-button>
|
||||
</div>
|
||||
</AppCard>
|
||||
<AppCard padded title="会话">
|
||||
<el-button type="primary" size="small" class="w-full" @click="newConversation">新建会话</el-button>
|
||||
<div class="conv-list">
|
||||
<button
|
||||
v-for="c in conversations"
|
||||
:key="c.id"
|
||||
class="conv-item"
|
||||
:class="{ active: c.id === activeId }"
|
||||
@click="selectConversation(c.id)"
|
||||
>
|
||||
{{ c.title }}
|
||||
</button>
|
||||
</div>
|
||||
</AppCard>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.chat-messages {
|
||||
min-height: 360px;
|
||||
max-height: 520px;
|
||||
overflow-y: auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-3);
|
||||
margin-bottom: var(--space-4);
|
||||
}
|
||||
.chat-bubble {
|
||||
padding: var(--space-3);
|
||||
border-radius: var(--radius-md);
|
||||
border: 1px solid var(--color-border);
|
||||
}
|
||||
.chat-bubble.user { background: var(--color-bg-subtle); }
|
||||
.chat-bubble.assistant { background: #fff; }
|
||||
.chat-role {
|
||||
font-size: var(--text-xs);
|
||||
color: var(--color-text-secondary);
|
||||
margin-bottom: var(--space-1);
|
||||
}
|
||||
.chat-content { white-space: pre-wrap; line-height: 1.6; }
|
||||
.chat-input { display: flex; gap: var(--space-2); align-items: center; }
|
||||
.conv-list { margin-top: var(--space-3); display: flex; flex-direction: column; gap: var(--space-1); }
|
||||
.conv-item {
|
||||
text-align: left;
|
||||
padding: var(--space-2) var(--space-3);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-md);
|
||||
background: #fff;
|
||||
cursor: pointer;
|
||||
}
|
||||
.conv-item.active { border-color: var(--color-primary); color: var(--color-primary); }
|
||||
.empty-hint { color: var(--color-text-secondary); text-align: center; padding: var(--space-8); }
|
||||
.w-full { width: 100%; }
|
||||
</style>
|
||||
@@ -0,0 +1,65 @@
|
||||
<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'
|
||||
|
||||
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))
|
||||
|
||||
function summary(g: { service_type: string; input_json: string; output_json: string }) {
|
||||
if (g.service_type === 'chat') {
|
||||
try {
|
||||
return JSON.parse(g.input_json).content ?? g.input_json
|
||||
} catch {
|
||||
return g.input_json
|
||||
}
|
||||
}
|
||||
try {
|
||||
const out = JSON.parse(g.output_json)
|
||||
const val = out.output
|
||||
if (Array.isArray(val)) return val[0]
|
||||
return out.content ?? g.output_json.slice(0, 80)
|
||||
} catch {
|
||||
return g.output_json.slice(0, 80)
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<PageHeader title="历史记录" description="查看与管理生成历史" />
|
||||
<AppCard padded>
|
||||
<div class="toolbar">
|
||||
<el-select v-model="filter" clearable placeholder="全部类型" style="width: 180px">
|
||||
<el-option label="对话" value="chat" />
|
||||
<el-option label="文生图" value="text_to_image" />
|
||||
<el-option label="图生图" value="image_to_image" />
|
||||
</el-select>
|
||||
<span class="total">共 {{ total }} 条</span>
|
||||
</div>
|
||||
<el-table v-loading="loading" :data="items" size="small" style="margin-top: 16px">
|
||||
<el-table-column prop="id" label="ID" width="80" />
|
||||
<el-table-column prop="service_type" label="类型" width="120" />
|
||||
<el-table-column prop="model" label="模型" width="140" />
|
||||
<el-table-column label="摘要">
|
||||
<template #default="{ row }">{{ summary(row) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="created_at" label="时间" width="180" />
|
||||
<el-table-column label="操作" width="100">
|
||||
<template #default="{ row }">
|
||||
<el-button text type="danger" @click="remove(row.id)">删除</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</AppCard>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.toolbar { display: flex; align-items: center; gap: var(--space-3); }
|
||||
.total { color: var(--color-text-secondary); font-size: var(--text-sm); }
|
||||
</style>
|
||||
@@ -0,0 +1,131 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import PageHeader from '@/components/PageHeader.vue'
|
||||
import AppCard from '@/components/AppCard.vue'
|
||||
import { useUserMeta } from '@/composables/useUserMeta'
|
||||
import { useInvoke } from '@/composables/useInvoke'
|
||||
import api from '@/api/client'
|
||||
|
||||
const serviceType = 'image_to_image'
|
||||
const { load, getService } = useUserMeta()
|
||||
const { activeTasks, generate } = useInvoke(serviceType)
|
||||
|
||||
const prompt = ref('')
|
||||
const model = ref('')
|
||||
const strength = ref(0.75)
|
||||
const imageUrl = ref('')
|
||||
const asyncMode = ref(false)
|
||||
const loading = ref(false)
|
||||
const lastOutput = ref('')
|
||||
|
||||
onMounted(async () => {
|
||||
await load()
|
||||
model.value = getService(serviceType)?.default_model ?? ''
|
||||
})
|
||||
|
||||
const previewUrl = computed(() => {
|
||||
if (lastOutput.value) {
|
||||
try {
|
||||
const out = JSON.parse(lastOutput.value)
|
||||
const val = out.output
|
||||
if (Array.isArray(val)) return val[0]
|
||||
} catch { /* ignore */ }
|
||||
}
|
||||
return ''
|
||||
})
|
||||
|
||||
async function onUpload(file: File) {
|
||||
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' },
|
||||
})
|
||||
imageUrl.value = res.data.signed_url
|
||||
return false
|
||||
}
|
||||
|
||||
async function submit() {
|
||||
if (!prompt.value.trim() || !imageUrl.value) {
|
||||
ElMessage.warning('请上传参考图并填写提示词')
|
||||
return
|
||||
}
|
||||
loading.value = true
|
||||
try {
|
||||
const result = await generate(
|
||||
{ prompt: prompt.value, image: imageUrl.value, strength: strength.value },
|
||||
asyncMode.value ? 'async' : 'sync',
|
||||
model.value,
|
||||
)
|
||||
if (result.output) lastOutput.value = JSON.stringify(result.output)
|
||||
ElMessage.success('已提交')
|
||||
} catch {
|
||||
ElMessage.error('生成失败')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<PageHeader title="图生图" description="上传参考图并编辑生成" />
|
||||
<div class="content-grid--2">
|
||||
<AppCard padded title="参数">
|
||||
<el-form label-position="top">
|
||||
<el-form-item label="参考图">
|
||||
<el-upload drag :auto-upload="true" :show-file-list="false" :before-upload="onUpload">
|
||||
<div class="upload-hint">拖拽或点击上传</div>
|
||||
</el-upload>
|
||||
<img v-if="imageUrl" :src="imageUrl" alt="ref" class="ref-img" />
|
||||
</el-form-item>
|
||||
<el-form-item label="模型">
|
||||
<el-select v-model="model" style="width: 100%">
|
||||
<el-option
|
||||
v-for="m in getService(serviceType)?.models ?? []"
|
||||
:key="m.id"
|
||||
:label="m.name"
|
||||
:value="m.id"
|
||||
/>
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="提示词">
|
||||
<el-input v-model="prompt" type="textarea" :rows="4" />
|
||||
</el-form-item>
|
||||
<el-form-item label="强度">
|
||||
<el-slider v-model="strength" :min="0.1" :max="1" :step="0.05" />
|
||||
</el-form-item>
|
||||
<el-form-item label="异步模式">
|
||||
<el-switch v-model="asyncMode" />
|
||||
</el-form-item>
|
||||
<el-button type="primary" :loading="loading" @click="submit">生成</el-button>
|
||||
</el-form>
|
||||
<div v-if="activeTasks.length" class="task-list">
|
||||
<div v-for="t in activeTasks" :key="t.id">{{ t.status }} · {{ t.progress }}%</div>
|
||||
</div>
|
||||
</AppCard>
|
||||
<AppCard padded title="预览">
|
||||
<div class="preview-box">
|
||||
<img v-if="previewUrl" :src="previewUrl" alt="preview" class="preview-img" />
|
||||
<div v-else class="empty-hint">生成结果将显示在这里</div>
|
||||
</div>
|
||||
</AppCard>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.upload-hint { padding: var(--space-6); color: var(--color-text-secondary); }
|
||||
.ref-img { margin-top: var(--space-3); max-width: 100%; border-radius: var(--radius-md); }
|
||||
.preview-box {
|
||||
min-height: 360px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border: 1px dashed var(--color-border);
|
||||
border-radius: var(--radius-md);
|
||||
}
|
||||
.preview-img { max-width: 100%; }
|
||||
.empty-hint { color: var(--color-text-secondary); }
|
||||
.task-list { margin-top: var(--space-3); font-size: var(--text-sm); color: var(--color-text-secondary); }
|
||||
</style>
|
||||
@@ -4,6 +4,7 @@ import { useI18n } from 'vue-i18n'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import AtlasLogo from '@/components/AtlasLogo.vue'
|
||||
|
||||
const { t } = useI18n()
|
||||
const router = useRouter()
|
||||
@@ -15,7 +16,7 @@ async function submit() {
|
||||
loading.value = true
|
||||
try {
|
||||
await auth.login(password.value)
|
||||
router.push('/')
|
||||
router.push('/admin')
|
||||
} catch {
|
||||
ElMessage.error(t('login.wrongPassword'))
|
||||
} finally {
|
||||
@@ -27,8 +28,10 @@ async function submit() {
|
||||
<template>
|
||||
<div class="login-page">
|
||||
<div class="login-wrap">
|
||||
<div class="login-brand-icon">A</div>
|
||||
<h1 class="page-title">App</h1>
|
||||
<div class="login-brand-icon">
|
||||
<AtlasLogo :size="48" />
|
||||
</div>
|
||||
<h1 class="page-title">Atlas</h1>
|
||||
<p class="page-desc">{{ t('login.subtitle') }}</p>
|
||||
<div class="login-card app-card">
|
||||
<h3 class="app-card__title">{{ t('login.title') }}</h3>
|
||||
@@ -50,3 +53,14 @@ async function submit() {
|
||||
</div>
|
||||
</div>
|
||||
</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 saving = ref(false)
|
||||
const form = reactive<SettingsData>({
|
||||
app_name: 'App',
|
||||
app_name: 'Atlas',
|
||||
api_port: '8080',
|
||||
admin_password: '',
|
||||
auth_enabled: 'true',
|
||||
|
||||
@@ -0,0 +1,114 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import PageHeader from '@/components/PageHeader.vue'
|
||||
import AppCard from '@/components/AppCard.vue'
|
||||
import { useUserMeta } from '@/composables/useUserMeta'
|
||||
import { useInvoke } from '@/composables/useInvoke'
|
||||
|
||||
const serviceType = 'text_to_image'
|
||||
const { load, getService } = useUserMeta()
|
||||
const { activeTasks, generate } = useInvoke(serviceType)
|
||||
|
||||
const prompt = ref('')
|
||||
const model = ref('')
|
||||
const asyncMode = ref(false)
|
||||
const loading = ref(false)
|
||||
const lastOutput = ref('')
|
||||
|
||||
onMounted(async () => {
|
||||
await load()
|
||||
model.value = getService(serviceType)?.default_model ?? ''
|
||||
})
|
||||
|
||||
const previewUrl = computed(() => {
|
||||
if (lastOutput.value) {
|
||||
try {
|
||||
const out = JSON.parse(lastOutput.value)
|
||||
const val = out.output
|
||||
if (Array.isArray(val)) return val[0]
|
||||
if (typeof val === 'string') return val
|
||||
} catch { /* ignore */ }
|
||||
}
|
||||
const task = activeTasks.value[0]
|
||||
if (task?.result_json) {
|
||||
try {
|
||||
const out = JSON.parse(task.result_json)
|
||||
const val = out.output
|
||||
if (Array.isArray(val)) return val[0]
|
||||
} catch { /* ignore */ }
|
||||
}
|
||||
return ''
|
||||
})
|
||||
|
||||
async function submit() {
|
||||
if (!prompt.value.trim()) return
|
||||
loading.value = true
|
||||
try {
|
||||
const result = await generate({ prompt: prompt.value }, asyncMode.value ? 'async' : 'sync', model.value)
|
||||
if (result.output) lastOutput.value = JSON.stringify(result.output)
|
||||
ElMessage.success(asyncMode.value ? '任务已提交' : '生成完成')
|
||||
} catch {
|
||||
ElMessage.error('生成失败,请检查 Provider 配置')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<PageHeader title="文生图" description="输入提示词生成图片" />
|
||||
<div class="content-grid--2">
|
||||
<AppCard padded title="参数">
|
||||
<el-form label-position="top">
|
||||
<el-form-item label="模型">
|
||||
<el-select v-model="model" style="width: 100%">
|
||||
<el-option
|
||||
v-for="m in getService(serviceType)?.models ?? []"
|
||||
:key="m.id"
|
||||
:label="m.name"
|
||||
:value="m.id"
|
||||
/>
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="提示词">
|
||||
<el-input v-model="prompt" type="textarea" :rows="5" placeholder="描述你想生成的画面…" />
|
||||
</el-form-item>
|
||||
<el-form-item v-if="getService(serviceType)?.capabilities.async" label="异步模式">
|
||||
<el-switch v-model="asyncMode" />
|
||||
</el-form-item>
|
||||
<el-button type="primary" :loading="loading" @click="submit">生成</el-button>
|
||||
</el-form>
|
||||
<div v-if="activeTasks.length" class="task-list">
|
||||
<div v-for="t in activeTasks" :key="t.id" class="task-row">
|
||||
<span>#{{ t.id }}</span>
|
||||
<el-progress :percentage="t.progress || (t.status === 'running' ? 30 : 0)" />
|
||||
<span>{{ t.status }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</AppCard>
|
||||
<AppCard padded title="预览">
|
||||
<div class="preview-box">
|
||||
<img v-if="previewUrl" :src="previewUrl" alt="preview" class="preview-img" />
|
||||
<div v-else class="empty-hint">生成结果将显示在这里</div>
|
||||
</div>
|
||||
</AppCard>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.preview-box {
|
||||
min-height: 360px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border: 1px dashed var(--color-border);
|
||||
border-radius: var(--radius-md);
|
||||
}
|
||||
.preview-img { max-width: 100%; border-radius: var(--radius-md); }
|
||||
.empty-hint { color: var(--color-text-secondary); }
|
||||
.task-list { margin-top: var(--space-4); display: flex; flex-direction: column; gap: var(--space-2); }
|
||||
.task-row { display: grid; grid-template-columns: 48px 1fr 80px; gap: var(--space-2); align-items: center; font-size: var(--text-sm); }
|
||||
</style>
|
||||
Reference in New Issue
Block a user