实现 Atlas AI 平台一期能力并完成品牌化改造。
CI / docker (push) Failing after 2m7s

引入用户端对话/生图与管理端 Provider 配置,统一 invoke 编排、Gateway、请求日志与模型管理;前端按设计规范拆分布局,Go module 更名为 github.com/rose_cat707/Atlas;生图长任务进度改为 SSE 推送,后端仍轮询上游。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
renjue
2026-06-29 20:14:26 +08:00
parent 6073cbd885
commit f26f334a2d
70 changed files with 7362 additions and 107 deletions
+1 -1
View File
@@ -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
+2 -2
View File
@@ -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
+2 -2
View File
@@ -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 \
+11 -4
View File
@@ -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
View File
@@ -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
+2 -2
View File
@@ -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
+189
View File
@@ -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
}
+25
View File
@@ -0,0 +1,25 @@
package api
import (
"github.com/gin-gonic/gin"
)
func (s *Server) registerGatewayRoutes(r *gin.Engine) {
v1 := r.Group("/v1")
v1.Use(s.sessionMiddleware())
{
v1.GET("/models", s.gatewayModels)
v1.POST("/chat/completions", s.gatewayChatCompletions)
v1.POST("/responses", s.gatewayResponses)
v1.POST("/completions", s.gatewayProxyOpenAI("/v1/completions"))
v1.POST("/embeddings", s.gatewayProxyOpenAI("/v1/embeddings"))
v1.POST("/rerank", s.gatewayProxyOpenAI("/v1/rerank"))
v1.POST("/predictions", s.gatewayCreatePrediction)
v1.GET("/predictions", s.gatewayListPredictions)
v1.GET("/predictions/:id", s.gatewayGetPrediction)
v1.POST("/predictions/:id/cancel", s.gatewayCancelPrediction)
v1.GET("/predictions/:id/stream", s.gatewayStreamPrediction)
}
}
+210
View File
@@ -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")
}
+153
View File
@@ -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()})
}
}
+8 -2
View File
@@ -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
}
+40
View File
@@ -0,0 +1,40 @@
package api
import (
"net/http"
"time"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
)
const sessionCookie = "Atlas-Session"
func (s *Server) sessionMiddleware() gin.HandlerFunc {
return func(c *gin.Context) {
sid, err := c.Cookie(sessionCookie)
if err != nil || sid == "" {
sid = uuid.New().String()
http.SetCookie(c.Writer, &http.Cookie{
Name: sessionCookie,
Value: sid,
Path: "/",
MaxAge: int((30 * 24 * time.Hour).Seconds()),
HttpOnly: true,
SameSite: http.SameSiteLaxMode,
})
}
_ = s.app.Store.UpsertSession(c.Request.Context(), sid)
c.Set("session_id", sid)
c.Next()
}
}
func sessionID(c *gin.Context) string {
if v, ok := c.Get("session_id"); ok {
if s, ok := v.(string); ok {
return s
}
}
return ""
}
+84
View File
@@ -0,0 +1,84 @@
package api
import (
"context"
"encoding/json"
"fmt"
"net/http"
"strconv"
"time"
"github.com/gin-gonic/gin"
"github.com/rose_cat707/Atlas/internal/service/taskevents"
"github.com/rose_cat707/Atlas/internal/store"
)
func (s *Server) streamTaskEvents(c *gin.Context) {
id, err := strconv.ParseInt(c.Param("id"), 10, 64)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid task id"})
return
}
ctx := c.Request.Context()
sid := sessionID(c)
task, err := s.resolveTask(ctx, sid, id)
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "not found"})
return
}
c.Header("Content-Type", "text/event-stream")
c.Header("Cache-Control", "no-cache")
c.Header("Connection", "keep-alive")
c.Status(http.StatusOK)
writeTaskSSE(c, task)
if taskevents.Terminal(task.Status) {
writeSSEDone(c)
return
}
ch := s.app.TaskEvents.Subscribe(id)
defer s.app.TaskEvents.Unsubscribe(id, ch)
heartbeat := time.NewTicker(15 * time.Second)
defer heartbeat.Stop()
for {
select {
case <-ctx.Done():
return
case t := <-ch:
writeTaskSSE(c, &t)
if taskevents.Terminal(t.Status) {
writeSSEDone(c)
return
}
case <-heartbeat.C:
fmt.Fprintf(c.Writer, ": heartbeat\n\n")
c.Writer.Flush()
}
}
}
func (s *Server) resolveTask(ctx context.Context, sessionID string, id int64) (*store.Task, error) {
key := "task:" + strconv.FormatInt(id, 10)
if v, ok := s.app.Store.Cache.Get(key); ok {
if t, ok := v.(*store.Task); ok {
return t, nil
}
}
return s.app.Store.GetTask(ctx, sessionID, id)
}
func writeTaskSSE(c *gin.Context, task *store.Task) {
data, _ := json.Marshal(task)
fmt.Fprintf(c.Writer, "event: task\ndata: %s\n\n", data)
c.Writer.Flush()
}
func writeSSEDone(c *gin.Context) {
fmt.Fprintf(c.Writer, "event: done\ndata: [DONE]\n\n")
c.Writer.Flush()
}
+391
View File
@@ -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})
}
+52
View File
@@ -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:"-"`
}
+30
View File
@@ -0,0 +1,30 @@
package domain
import "time"
type StreamCapability struct{}
type SyncCapability struct{}
type AsyncCapability struct {
Driver string
PollInterval time.Duration
}
type InvokeCapabilities struct {
Stream *StreamCapability
Sync *SyncCapability
Async *AsyncCapability
}
type ModelInfo struct {
ID string `json:"id"`
Name string `json:"name"`
}
type ServiceTypeSpec struct {
Type ServiceType
Capabilities InvokeCapabilities
DefaultParams func() map[string]any
ValidateParams func(params map[string]any) error
}
+229
View File
@@ -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)
}
+95
View File
@@ -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
}
+143
View File
@@ -0,0 +1,143 @@
package provider
import (
"bufio"
"bytes"
"context"
"fmt"
"io"
"net/http"
"strings"
"time"
"github.com/rose_cat707/Atlas/internal/service/reqlog"
)
type CallMeta struct {
SessionID string
Method string
Path string
Model string
}
func (r *Router) ProxyWithMeta(ctx context.Context, cfg *Config, method, path string, body []byte, headers map[string]string, meta CallMeta) (*ProxyResult, error) {
start := time.Now()
res, err := r.proxy(ctx, cfg, method, path, body, headers)
r.record(meta, cfg, start, res, err, body, "")
return res, err
}
func (r *Router) ProxyStream(ctx context.Context, cfg *Config, method, path string, body []byte, headers map[string]string, meta CallMeta, w io.Writer, flush func()) error {
start := time.Now()
url := cfg.BaseURL + path
req, err := http.NewRequestWithContext(ctx, method, url, bytes.NewReader(body))
if err != nil {
r.record(meta, cfg, start, nil, err, body, "")
return err
}
req.Header.Set("Authorization", "Bearer "+cfg.APIKey)
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Accept", "text/event-stream")
for k, v := range headers {
req.Header.Set(k, v)
}
resp, err := r.streamClient.Do(req)
if err != nil {
r.record(meta, cfg, start, nil, err, body, "")
return err
}
defer resp.Body.Close()
if resp.StatusCode >= 400 {
data, _ := io.ReadAll(resp.Body)
res := &ProxyResult{StatusCode: resp.StatusCode, Body: data, Headers: resp.Header}
r.record(meta, cfg, start, res, fmt.Errorf("upstream %d", resp.StatusCode), body, "")
return fmt.Errorf("upstream %d: %s", resp.StatusCode, string(data))
}
var capture strings.Builder
reader := bufio.NewReader(resp.Body)
for {
line, readErr := reader.ReadString('\n')
if len(line) > 0 {
capture.WriteString(line)
if _, err := io.WriteString(w, line); err != nil {
return err
}
if flush != nil && (line == "\n" || strings.HasPrefix(line, "data:")) {
flush()
}
}
if readErr == io.EOF {
break
}
if readErr != nil {
r.record(meta, cfg, start, &ProxyResult{StatusCode: resp.StatusCode}, readErr, body, capture.String())
return readErr
}
}
r.record(meta, cfg, start, &ProxyResult{StatusCode: resp.StatusCode}, nil, body, capture.String())
return nil
}
func (r *Router) record(meta CallMeta, cfg *Config, start time.Time, res *ProxyResult, callErr error, reqBody []byte, streamCapture string) {
if r.logger == nil {
return
}
entry := reqlog.Entry{
SessionID: meta.SessionID,
Method: meta.Method,
Path: meta.Path,
Protocol: cfg.Protocol,
ProviderID: reqlog.ProviderIDPtr(cfg.ID),
Model: meta.Model,
LatencyMS: reqlog.Since(start),
RequestSummary: reqlog.Summarize(reqBody, 512),
}
if res != nil {
entry.StatusCode = res.StatusCode
if streamCapture != "" {
entry.ResponseSummary = reqlog.SummarizeSSE(streamCapture, 512)
} else {
entry.ResponseSummary = reqlog.Summarize(res.Body, 512)
}
}
if callErr != nil {
entry.Error = callErr.Error()
if entry.StatusCode == 0 {
entry.StatusCode = 502
}
}
r.logger.Log(context.Background(), entry)
}
func (r *Router) proxy(ctx context.Context, cfg *Config, method, path string, body []byte, headers map[string]string) (*ProxyResult, error) {
url := cfg.BaseURL + path
req, err := http.NewRequestWithContext(ctx, method, url, bytes.NewReader(body))
if err != nil {
return nil, err
}
req.Header.Set("Authorization", "Bearer "+cfg.APIKey)
if len(body) > 0 {
req.Header.Set("Content-Type", "application/json")
}
for k, v := range headers {
req.Header.Set(k, v)
}
resp, err := r.client.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
data, err := io.ReadAll(resp.Body)
if err != nil {
return nil, err
}
return &ProxyResult{StatusCode: resp.StatusCode, Body: data, Headers: resp.Header}, nil
}
// Proxy keeps backward compatibility without logging.
func (r *Router) Proxy(ctx context.Context, cfg *Config, method, path string, body []byte, headers map[string]string) (*ProxyResult, error) {
return r.proxy(ctx, cfg, method, path, body, headers)
}
+34 -1
View File
@@ -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
Router *provider.Router
Models *provider.ModelsService
TaskEvents *taskevents.Hub
Invoke *invoke.Service
Chat *chat.Service
Assets *asset.Signer
ReqLog *reqlog.Logger
}
func NewApp(st *store.Store, dataDir string, signSecret string) *App {
logger := reqlog.New(st)
router := provider.NewRouter(st, logger)
models := provider.NewModelsService(st, router)
taskEvents := taskevents.NewHub()
return &App{
Store: st,
DataDir: dataDir,
Router: router,
Models: models,
TaskEvents: taskEvents,
Invoke: invoke.NewService(st, router, models, taskEvents),
Chat: chat.NewService(st, router, models),
Assets: asset.NewSigner(signSecret),
ReqLog: logger,
}
}
+85
View File
@@ -0,0 +1,85 @@
package asset
import (
"crypto/hmac"
"crypto/sha256"
"encoding/hex"
"fmt"
"io"
"mime"
"os"
"path/filepath"
"strconv"
"strings"
"time"
"github.com/google/uuid"
)
type Signer struct {
secret string
}
func NewSigner(secret string) *Signer {
return &Signer{secret: secret}
}
func (s *Signer) Sign(fileID int64, exp int64) string {
mac := hmac.New(sha256.New, []byte(s.secret))
mac.Write([]byte(fmt.Sprintf("%d:%d", fileID, exp)))
return hex.EncodeToString(mac.Sum(nil))
}
func (s *Signer) Verify(fileID int64, exp int64, sig string) bool {
if time.Now().Unix() > exp {
return false
}
expected := s.Sign(fileID, exp)
return hmac.Equal([]byte(expected), []byte(sig))
}
func (s *Signer) SignedURL(basePath string, fileID int64, ttl time.Duration) string {
exp := time.Now().Add(ttl).Unix()
sig := s.Sign(fileID, exp)
return fmt.Sprintf("%s/user/files/%d?exp=%d&sig=%s", basePath, fileID, exp, sig)
}
func SaveUpload(dataDir, sessionID string, filename string, r io.Reader, maxBytes int64) (string, int64, string, error) {
ext := filepath.Ext(filename)
if ext == "" {
ext = ".bin"
}
id := uuid.New().String()
dir := filepath.Join(dataDir, "files", sessionID, "uploads")
if err := os.MkdirAll(dir, 0o755); err != nil {
return "", 0, "", err
}
path := filepath.Join(dir, id+ext)
f, err := os.Create(path)
if err != nil {
return "", 0, "", err
}
defer f.Close()
n, err := io.Copy(f, io.LimitReader(r, maxBytes+1))
if err != nil {
os.Remove(path)
return "", 0, "", err
}
if n > maxBytes {
os.Remove(path)
return "", 0, "", fmt.Errorf("file too large")
}
mimeType := mime.TypeByExtension(ext)
if mimeType == "" {
mimeType = "application/octet-stream"
}
return path, n, mimeType, nil
}
func ParseSignedQuery(expStr, sig string) (int64, string, error) {
exp, err := strconv.ParseInt(expStr, 10, 64)
if err != nil {
return 0, "", err
}
return exp, strings.TrimSpace(sig), nil
}
+154
View File
@@ -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
}
+140
View File
@@ -0,0 +1,140 @@
package invoke
import (
"context"
"encoding/json"
"fmt"
"time"
"github.com/rose_cat707/Atlas/internal/provider"
"github.com/rose_cat707/Atlas/internal/store"
)
type asyncWorker struct {
svc *Service
}
func newAsyncWorker(svc *Service) *asyncWorker {
return &asyncWorker{svc: svc}
}
func (w *asyncWorker) run() {
ticker := time.NewTicker(2 * time.Second)
defer ticker.Stop()
for range ticker.C {
w.pollOnce()
}
}
func (w *asyncWorker) pollOnce() {
ctx := context.Background()
tasks, err := w.svc.store.ListPendingTasks(ctx)
if err != nil {
return
}
for _, task := range tasks {
w.pollTask(ctx, task)
}
}
func (w *asyncWorker) pollTask(ctx context.Context, task store.Task) {
cfg, err := w.svc.store.GetProvider(ctx, task.ProviderID)
if err != nil {
return
}
pcfg := &provider.Config{
ID: cfg.ID,
Protocol: cfg.Protocol,
BaseURL: cfg.BaseURL,
APIKey: cfg.APIKey,
}
path := fmt.Sprintf("/v1/predictions/%s", task.ExternalID)
res, err := w.svc.router.Proxy(ctx, pcfg, "GET", path, nil, nil)
if err != nil {
return
}
if res.StatusCode >= 400 {
task.Status = "failed"
task.Error = string(res.Body)
w.svc.store.WriteQueue.Enqueue(func(ctx context.Context, st *store.Store) error {
return st.UpdateTask(ctx, &task)
})
w.svc.notifyTask(task)
return
}
var pred struct {
Status string `json:"status"`
Output any `json:"output"`
Error string `json:"error"`
Progress int `json:"progress"`
}
_ = json.Unmarshal(res.Body, &pred)
task.Progress = pred.Progress
switch pred.Status {
case "succeeded":
task.Status = "succeeded"
now := time.Now().UTC().Format(time.RFC3339)
task.CompletedAt = &now
out, _ := json.Marshal(map[string]any{"output": pred.Output})
task.ResultJSON = string(out)
pid := task.ProviderID
gen := &store.Generation{
SessionID: task.SessionID,
ServiceType: task.ServiceType,
ProviderID: &pid,
InputJSON: task.InputJSON,
OutputJSON: task.ResultJSON,
TaskID: &task.ID,
}
w.svc.store.WriteQueue.Enqueue(func(ctx context.Context, st *store.Store) error {
_, err := st.InsertGeneration(ctx, gen)
return err
})
case "failed", "canceled":
task.Status = pred.Status
task.Error = pred.Error
now := time.Now().UTC().Format(time.RFC3339)
task.CompletedAt = &now
case "processing", "starting":
task.Status = "running"
default:
task.Status = "running"
}
w.svc.store.WriteQueue.Enqueue(func(ctx context.Context, st *store.Store) error {
return st.UpdateTask(ctx, &task)
})
w.svc.notifyTask(task)
}
func (s *Service) CancelTask(ctx context.Context, sessionID string, taskID int64) error {
task, err := s.store.GetTask(ctx, sessionID, taskID)
if err != nil {
return err
}
cfg, err := s.store.GetProvider(ctx, task.ProviderID)
if err != nil {
return err
}
pcfg := &provider.Config{BaseURL: cfg.BaseURL, APIKey: cfg.APIKey}
path := fmt.Sprintf("/v1/predictions/%s/cancel", task.ExternalID)
meta := provider.CallMeta{
SessionID: sessionID,
Method: "POST",
Path: "/v1/predictions/:id/cancel",
}
res, err := s.router.ProxyWithMeta(ctx, pcfg, "POST", path, nil, nil, meta)
if err != nil {
return err
}
if res.StatusCode >= 400 {
return fmt.Errorf("cancel failed: %s", string(res.Body))
}
task.Status = "canceled"
now := time.Now().UTC().Format(time.RFC3339)
task.CompletedAt = &now
if err := s.store.UpdateTask(ctx, task); err != nil {
return err
}
s.notifyTask(*task)
return nil
}
+255
View File
@@ -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, &params); err != nil {
return nil, fmt.Errorf("invalid params: %w", err)
}
}
if spec.ValidateParams != nil {
if err := spec.ValidateParams(params); err != nil {
return nil, err
}
}
cfg, err := s.router.Resolve(ctx, string(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
},
},
}
}
+84
View File
@@ -0,0 +1,84 @@
package reqlog
import (
"context"
"strings"
"time"
"github.com/rose_cat707/Atlas/internal/store"
)
type Logger struct {
store *store.Store
}
func New(st *store.Store) *Logger {
return &Logger{store: st}
}
type Entry struct {
SessionID string
Method string
Path string
Protocol string
ProviderID *int64
Model string
StatusCode int
LatencyMS int
Error string
RequestSummary string
ResponseSummary string
}
func (l *Logger) Log(ctx context.Context, e Entry) {
if l == nil || l.store == nil {
return
}
entry := e
l.store.WriteQueue.Enqueue(func(ctx context.Context, st *store.Store) error {
_, err := st.InsertRequestLog(ctx, &store.RequestLog{
SessionID: entry.SessionID,
Method: entry.Method,
Path: entry.Path,
Protocol: entry.Protocol,
ProviderID: entry.ProviderID,
Model: entry.Model,
StatusCode: entry.StatusCode,
LatencyMS: entry.LatencyMS,
Error: entry.Error,
RequestSummary: entry.RequestSummary,
ResponseSummary: entry.ResponseSummary,
})
return err
})
}
func Summarize(body []byte, max int) string {
s := strings.TrimSpace(string(body))
if len(s) <= max {
return s
}
return s[:max] + "..."
}
func SummarizeSSE(raw string, max int) string {
var b strings.Builder
for _, line := range strings.Split(raw, "\n") {
if strings.HasPrefix(line, "data: ") && !strings.Contains(line, "[DONE]") {
b.WriteString(strings.TrimPrefix(line, "data: "))
b.WriteByte(' ')
}
}
return Summarize([]byte(strings.TrimSpace(b.String())), max)
}
func Since(start time.Time) int {
return int(time.Since(start).Milliseconds())
}
func ProviderIDPtr(id int64) *int64 {
if id <= 0 {
return nil
}
return &id
}
+58
View File
@@ -0,0 +1,58 @@
package taskevents
import (
"sync"
"github.com/rose_cat707/Atlas/internal/store"
)
type Hub struct {
mu sync.RWMutex
subs map[int64]map[chan store.Task]struct{}
}
func NewHub() *Hub {
return &Hub{subs: make(map[int64]map[chan store.Task]struct{})}
}
func (h *Hub) Subscribe(taskID int64) chan store.Task {
ch := make(chan store.Task, 4)
h.mu.Lock()
if h.subs[taskID] == nil {
h.subs[taskID] = make(map[chan store.Task]struct{})
}
h.subs[taskID][ch] = struct{}{}
h.mu.Unlock()
return ch
}
func (h *Hub) Unsubscribe(taskID int64, ch chan store.Task) {
h.mu.Lock()
if m, ok := h.subs[taskID]; ok {
delete(m, ch)
if len(m) == 0 {
delete(h.subs, taskID)
}
}
h.mu.Unlock()
}
func (h *Hub) Publish(task store.Task) {
h.mu.RLock()
defer h.mu.RUnlock()
for ch := range h.subs[task.ID] {
select {
case ch <- task:
default:
}
}
}
func Terminal(status string) bool {
switch status {
case "succeeded", "failed", "canceled":
return true
default:
return false
}
}
+3 -1
View File
@@ -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",
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).
+46
View File
@@ -0,0 +1,46 @@
package store
import (
"sync"
"time"
)
type HotCache struct {
mu sync.RWMutex
entries map[string]cacheEntry
ttl time.Duration
}
type cacheEntry struct {
value any
expiresAt time.Time
}
func NewHotCache(ttl time.Duration) *HotCache {
return &HotCache{
entries: make(map[string]cacheEntry),
ttl: ttl,
}
}
func (c *HotCache) Get(key string) (any, bool) {
c.mu.RLock()
defer c.mu.RUnlock()
e, ok := c.entries[key]
if !ok || time.Now().After(e.expiresAt) {
return nil, false
}
return e.value, true
}
func (c *HotCache) Set(key string, value any) {
c.mu.Lock()
defer c.mu.Unlock()
c.entries[key] = cacheEntry{value: value, expiresAt: time.Now().Add(c.ttl)}
}
func (c *HotCache) Delete(key string) {
c.mu.Lock()
defer c.mu.Unlock()
delete(c.entries, key)
}
+117
View File
@@ -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
}
+44
View File
@@ -0,0 +1,44 @@
package store
import "context"
type FileRecord struct {
ID int64 `json:"id"`
SessionID string `json:"session_id"`
Kind string `json:"kind"`
Mime string `json:"mime"`
StoragePath string `json:"storage_path"`
SizeBytes int64 `json:"size_bytes"`
SHA256 string `json:"sha256"`
ExpiresAt *string `json:"expires_at"`
CreatedAt string `json:"created_at"`
}
func (s *Store) InsertFile(ctx context.Context, f *FileRecord) (int64, error) {
res, err := s.db.ExecContext(ctx,
`INSERT INTO files (session_id, kind, mime, storage_path, size_bytes, sha256, expires_at) VALUES (?, ?, ?, ?, ?, ?, ?)`,
f.SessionID, f.Kind, f.Mime, f.StoragePath, f.SizeBytes, f.SHA256, f.ExpiresAt,
)
if err != nil {
return 0, err
}
return res.LastInsertId()
}
func (s *Store) GetFile(ctx context.Context, sessionID string, id int64) (*FileRecord, error) {
var f FileRecord
err := s.db.QueryRowContext(ctx,
`SELECT id, session_id, kind, mime, storage_path, size_bytes, sha256, expires_at, created_at FROM files WHERE id = ? AND session_id = ?`, id, sessionID,
).Scan(&f.ID, &f.SessionID, &f.Kind, &f.Mime, &f.StoragePath, &f.SizeBytes, &f.SHA256, &f.ExpiresAt, &f.CreatedAt)
if err != nil {
return nil, err
}
return &f, nil
}
func (s *Store) CountFiles(ctx context.Context) (int, int64, error) {
var count int
var total int64
err := s.db.QueryRowContext(ctx, `SELECT COUNT(*), COALESCE(SUM(size_bytes),0) FROM files`).Scan(&count, &total)
return count, total, err
}
+77
View File
@@ -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
}
+131
View File
@@ -0,0 +1,131 @@
-- +goose Up
CREATE TABLE IF NOT EXISTS providers (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
protocol TEXT NOT NULL,
base_url TEXT NOT NULL,
api_key TEXT NOT NULL DEFAULT '',
service_type TEXT NOT NULL,
is_default INTEGER NOT NULL DEFAULT 0,
enabled INTEGER NOT NULL DEFAULT 1,
extra_json TEXT NOT NULL DEFAULT '{}',
created_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE TABLE IF NOT EXISTS user_sessions (
id TEXT PRIMARY KEY,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
last_seen_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE TABLE IF NOT EXISTS chat_conversations (
id INTEGER PRIMARY KEY AUTOINCREMENT,
session_id TEXT NOT NULL,
title TEXT NOT NULL DEFAULT '新对话',
model TEXT NOT NULL DEFAULT '',
params_json TEXT NOT NULL DEFAULT '{}',
created_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE INDEX IF NOT EXISTS idx_chat_conversations_session ON chat_conversations(session_id);
CREATE TABLE IF NOT EXISTS chat_messages (
id INTEGER PRIMARY KEY AUTOINCREMENT,
conversation_id INTEGER NOT NULL,
role TEXT NOT NULL,
content TEXT NOT NULL,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
FOREIGN KEY (conversation_id) REFERENCES chat_conversations(id) ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS idx_chat_messages_conv ON chat_messages(conversation_id);
CREATE TABLE IF NOT EXISTS generations (
id INTEGER PRIMARY KEY AUTOINCREMENT,
session_id TEXT NOT NULL,
service_type TEXT NOT NULL,
provider_id INTEGER,
model TEXT NOT NULL DEFAULT '',
input_json TEXT NOT NULL DEFAULT '{}',
output_json TEXT NOT NULL DEFAULT '{}',
task_id INTEGER,
created_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE INDEX IF NOT EXISTS idx_generations_session ON generations(session_id, created_at DESC);
CREATE TABLE IF NOT EXISTS presets (
id INTEGER PRIMARY KEY AUTOINCREMENT,
session_id TEXT NOT NULL,
name TEXT NOT NULL,
service_type TEXT NOT NULL,
params_json TEXT NOT NULL DEFAULT '{}',
created_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE INDEX IF NOT EXISTS idx_presets_session ON presets(session_id, service_type);
CREATE TABLE IF NOT EXISTS tasks (
id INTEGER PRIMARY KEY AUTOINCREMENT,
session_id TEXT NOT NULL,
service_type TEXT NOT NULL,
protocol TEXT NOT NULL,
provider_id INTEGER NOT NULL,
status TEXT NOT NULL DEFAULT 'pending',
progress INTEGER NOT NULL DEFAULT 0,
input_json TEXT NOT NULL DEFAULT '{}',
result_json TEXT NOT NULL DEFAULT '{}',
error TEXT NOT NULL DEFAULT '',
external_id TEXT NOT NULL DEFAULT '',
created_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
completed_at TEXT
);
CREATE INDEX IF NOT EXISTS idx_tasks_session_status ON tasks(session_id, status);
CREATE TABLE IF NOT EXISTS files (
id INTEGER PRIMARY KEY AUTOINCREMENT,
session_id TEXT NOT NULL,
kind TEXT NOT NULL,
mime TEXT NOT NULL DEFAULT '',
storage_path TEXT NOT NULL,
size_bytes INTEGER NOT NULL DEFAULT 0,
sha256 TEXT NOT NULL DEFAULT '',
expires_at TEXT,
created_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE INDEX IF NOT EXISTS idx_files_session ON files(session_id);
CREATE TABLE IF NOT EXISTS request_logs (
id INTEGER PRIMARY KEY AUTOINCREMENT,
session_id TEXT NOT NULL DEFAULT '',
method TEXT NOT NULL,
path TEXT NOT NULL,
protocol TEXT NOT NULL DEFAULT '',
provider_id INTEGER,
model TEXT NOT NULL DEFAULT '',
status_code INTEGER NOT NULL DEFAULT 0,
latency_ms INTEGER NOT NULL DEFAULT 0,
error TEXT NOT NULL DEFAULT '',
request_summary TEXT NOT NULL DEFAULT '',
response_summary TEXT NOT NULL DEFAULT '',
created_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE INDEX IF NOT EXISTS idx_request_logs_created ON request_logs(created_at DESC);
INSERT OR IGNORE INTO settings (key, value) VALUES
('file_sign_secret', 'change-me-atlas-file-sign'),
('write_flush_interval_ms', '200'),
('task_progress_flush_interval_ms', '1000'),
('cache_ttl_seconds', '30');
UPDATE settings SET value = 'Atlas' WHERE key = 'app_name';
-- +goose Down
DROP TABLE IF EXISTS request_logs;
DROP TABLE IF EXISTS files;
DROP TABLE IF EXISTS tasks;
DROP TABLE IF EXISTS presets;
DROP TABLE IF EXISTS generations;
DROP TABLE IF EXISTS chat_messages;
DROP TABLE IF EXISTS chat_conversations;
DROP TABLE IF EXISTS user_sessions;
DROP TABLE IF EXISTS providers;
@@ -0,0 +1,14 @@
-- +goose Up
CREATE TABLE IF NOT EXISTS provider_models (
id INTEGER PRIMARY KEY AUTOINCREMENT,
provider_id INTEGER NOT NULL,
model_id TEXT NOT NULL,
name TEXT NOT NULL,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
UNIQUE(provider_id, model_id),
FOREIGN KEY (provider_id) REFERENCES providers(id) ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS idx_provider_models_provider ON provider_models(provider_id);
-- +goose Down
DROP TABLE IF EXISTS provider_models;
+60
View File
@@ -0,0 +1,60 @@
package store
import "context"
type Preset struct {
ID int64 `json:"id"`
SessionID string `json:"session_id"`
Name string `json:"name"`
ServiceType string `json:"service_type"`
ParamsJSON string `json:"params_json"`
CreatedAt string `json:"created_at"`
}
func (s *Store) ListPresets(ctx context.Context, sessionID, serviceType string) ([]Preset, error) {
q := `SELECT id, session_id, name, service_type, params_json, created_at FROM presets WHERE session_id = ?`
args := []any{sessionID}
if serviceType != "" {
q += ` AND service_type = ?`
args = append(args, serviceType)
}
q += ` ORDER BY id DESC`
rows, err := s.db.QueryContext(ctx, q, args...)
if err != nil {
return nil, err
}
defer rows.Close()
var items []Preset
for rows.Next() {
var p Preset
if err := rows.Scan(&p.ID, &p.SessionID, &p.Name, &p.ServiceType, &p.ParamsJSON, &p.CreatedAt); err != nil {
return nil, err
}
items = append(items, p)
}
return items, rows.Err()
}
func (s *Store) CreatePreset(ctx context.Context, p *Preset) (int64, error) {
res, err := s.db.ExecContext(ctx,
`INSERT INTO presets (session_id, name, service_type, params_json) VALUES (?, ?, ?, ?)`,
p.SessionID, p.Name, p.ServiceType, p.ParamsJSON,
)
if err != nil {
return 0, err
}
return res.LastInsertId()
}
func (s *Store) UpdatePreset(ctx context.Context, p *Preset) error {
_, err := s.db.ExecContext(ctx,
`UPDATE presets SET name=?, params_json=? WHERE id=? AND session_id=?`,
p.Name, p.ParamsJSON, p.ID, p.SessionID,
)
return err
}
func (s *Store) DeletePreset(ctx context.Context, sessionID string, id int64) error {
_, err := s.db.ExecContext(ctx, `DELETE FROM presets WHERE id = ? AND session_id = ?`, id, sessionID)
return err
}
+53
View File
@@ -0,0 +1,53 @@
package store
import "context"
type ProviderModel struct {
ID int64 `json:"id"`
ProviderID int64 `json:"provider_id"`
ModelID string `json:"model_id"`
Name string `json:"name"`
CreatedAt string `json:"created_at"`
}
func (s *Store) ListProviderModels(ctx context.Context, providerID int64) ([]ProviderModel, error) {
rows, err := s.db.QueryContext(ctx,
`SELECT id, provider_id, model_id, name, created_at FROM provider_models WHERE provider_id = ? ORDER BY id ASC`, providerID,
)
if err != nil {
return nil, err
}
defer rows.Close()
var items []ProviderModel
for rows.Next() {
var m ProviderModel
if err := rows.Scan(&m.ID, &m.ProviderID, &m.ModelID, &m.Name, &m.CreatedAt); err != nil {
return nil, err
}
items = append(items, m)
}
return items, rows.Err()
}
func (s *Store) CreateProviderModel(ctx context.Context, m *ProviderModel) (int64, error) {
res, err := s.db.ExecContext(ctx,
`INSERT INTO provider_models (provider_id, model_id, name) VALUES (?, ?, ?)`,
m.ProviderID, m.ModelID, m.Name,
)
if err != nil {
return 0, err
}
return res.LastInsertId()
}
func (s *Store) DeleteProviderModel(ctx context.Context, providerID int64, modelID string) error {
_, err := s.db.ExecContext(ctx,
`DELETE FROM provider_models WHERE provider_id = ? AND model_id = ?`, providerID, modelID,
)
return err
}
func (s *Store) DeleteProviderModelsByProvider(ctx context.Context, providerID int64) error {
_, err := s.db.ExecContext(ctx, `DELETE FROM provider_models WHERE provider_id = ?`, providerID)
return err
}
+134
View File
@@ -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
}
+101
View File
@@ -0,0 +1,101 @@
package store
import "context"
type RequestLog struct {
ID int64 `json:"id"`
SessionID string `json:"session_id"`
Method string `json:"method"`
Path string `json:"path"`
Protocol string `json:"protocol"`
ProviderID *int64 `json:"provider_id"`
Model string `json:"model"`
StatusCode int `json:"status_code"`
LatencyMS int `json:"latency_ms"`
Error string `json:"error"`
RequestSummary string `json:"request_summary"`
ResponseSummary string `json:"response_summary"`
CreatedAt string `json:"created_at"`
}
func (s *Store) InsertRequestLog(ctx context.Context, l *RequestLog) (int64, error) {
res, err := s.db.ExecContext(ctx,
`INSERT INTO request_logs (session_id, method, path, protocol, provider_id, model, status_code, latency_ms, error, request_summary, response_summary) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
l.SessionID, l.Method, l.Path, l.Protocol, l.ProviderID, l.Model, l.StatusCode, l.LatencyMS, l.Error, l.RequestSummary, l.ResponseSummary,
)
if err != nil {
return 0, err
}
return res.LastInsertId()
}
func (s *Store) ListRequestLogs(ctx context.Context, filter RequestLogFilter, limit, offset int) ([]RequestLog, int, error) {
where := `WHERE 1=1`
args := []any{}
if filter.Path != "" {
where += ` AND path LIKE ?`
args = append(args, "%"+filter.Path+"%")
}
if filter.StatusCode > 0 {
where += ` AND status_code = ?`
args = append(args, filter.StatusCode)
}
if filter.ProviderID > 0 {
where += ` AND provider_id = ?`
args = append(args, filter.ProviderID)
}
var total int
if err := s.db.QueryRowContext(ctx, `SELECT COUNT(*) FROM request_logs `+where, args...).Scan(&total); err != nil {
return nil, 0, err
}
q := `SELECT id, session_id, method, path, protocol, provider_id, model, status_code, latency_ms, error, request_summary, response_summary, created_at FROM request_logs ` + where + ` ORDER BY id DESC LIMIT ? OFFSET ?`
qArgs := append(args, limit, offset)
rows, err := s.db.QueryContext(ctx, q, qArgs...)
if err != nil {
return nil, 0, err
}
defer rows.Close()
var items []RequestLog
for rows.Next() {
var l RequestLog
if err := rows.Scan(&l.ID, &l.SessionID, &l.Method, &l.Path, &l.Protocol, &l.ProviderID, &l.Model, &l.StatusCode, &l.LatencyMS, &l.Error, &l.RequestSummary, &l.ResponseSummary, &l.CreatedAt); err != nil {
return nil, 0, err
}
items = append(items, l)
}
return items, total, rows.Err()
}
type RequestLogFilter struct {
Path string
StatusCode int
ProviderID int64
}
type DashboardStats struct {
TotalRequests int `json:"total_requests"`
SuccessRate float64 `json:"success_rate"`
AvgLatencyMS float64 `json:"avg_latency_ms"`
P50LatencyMS float64 `json:"p50_latency_ms"`
P95LatencyMS float64 `json:"p95_latency_ms"`
ByServiceType map[string]int `json:"by_service_type"`
ByProvider map[string]int `json:"by_provider"`
FileCount int `json:"file_count"`
FileBytes int64 `json:"file_bytes"`
}
func (s *Store) DashboardStats(ctx context.Context) (*DashboardStats, error) {
stats := &DashboardStats{ByServiceType: map[string]int{}, ByProvider: map[string]int{}}
var success int
err := s.db.QueryRowContext(ctx,
`SELECT COUNT(*), COALESCE(AVG(latency_ms),0), SUM(CASE WHEN status_code >= 200 AND status_code < 400 THEN 1 ELSE 0 END) FROM request_logs WHERE created_at >= datetime('now','-7 days')`,
).Scan(&stats.TotalRequests, &stats.AvgLatencyMS, &success)
if err != nil {
return nil, err
}
if stats.TotalRequests > 0 {
stats.SuccessRate = float64(success) / float64(stats.TotalRequests)
}
stats.FileCount, stats.FileBytes, _ = s.CountFiles(ctx)
return stats, nil
}
+21
View File
@@ -0,0 +1,21 @@
package store
import "context"
type UserSession struct {
ID string `json:"id"`
CreatedAt string `json:"created_at"`
LastSeenAt string `json:"last_seen_at"`
}
func (s *Store) UpsertSession(ctx context.Context, id string) error {
_, err := s.db.ExecContext(ctx,
`INSERT INTO user_sessions (id, last_seen_at) VALUES (?, datetime('now'))
ON CONFLICT(id) DO UPDATE SET last_seen_at = datetime('now')`, id,
)
return err
}
func (s *Store) TouchSession(ctx context.Context, id string) error {
return s.UpsertSession(ctx, id)
}
+13 -2
View File
@@ -7,6 +7,7 @@ import (
"fmt"
"os"
"path/filepath"
"time"
"github.com/pressly/goose/v3"
_ "modernc.org/sqlite"
@@ -17,6 +18,8 @@ var migrationsFS embed.FS
type Store struct {
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)
+93
View File
@@ -0,0 +1,93 @@
package store
import "context"
type Task struct {
ID int64 `json:"id"`
SessionID string `json:"session_id"`
ServiceType string `json:"service_type"`
Protocol string `json:"protocol"`
ProviderID int64 `json:"provider_id"`
Status string `json:"status"`
Progress int `json:"progress"`
InputJSON string `json:"input_json"`
ResultJSON string `json:"result_json"`
Error string `json:"error"`
ExternalID string `json:"external_id"`
CreatedAt string `json:"created_at"`
UpdatedAt string `json:"updated_at"`
CompletedAt *string `json:"completed_at"`
}
func (s *Store) InsertTask(ctx context.Context, t *Task) (int64, error) {
res, err := s.db.ExecContext(ctx,
`INSERT INTO tasks (session_id, service_type, protocol, provider_id, status, progress, input_json, result_json, error, external_id) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
t.SessionID, t.ServiceType, t.Protocol, t.ProviderID, t.Status, t.Progress, t.InputJSON, t.ResultJSON, t.Error, t.ExternalID,
)
if err != nil {
return 0, err
}
return res.LastInsertId()
}
func (s *Store) UpdateTask(ctx context.Context, t *Task) error {
_, err := s.db.ExecContext(ctx,
`UPDATE tasks SET status=?, progress=?, result_json=?, error=?, external_id=?, updated_at=datetime('now'), completed_at=? WHERE id=? AND session_id=?`,
t.Status, t.Progress, t.ResultJSON, t.Error, t.ExternalID, t.CompletedAt, t.ID, t.SessionID,
)
return err
}
func (s *Store) GetTask(ctx context.Context, sessionID string, id int64) (*Task, error) {
var t Task
err := s.db.QueryRowContext(ctx,
`SELECT id, session_id, service_type, protocol, provider_id, status, progress, input_json, result_json, error, external_id, created_at, updated_at, completed_at FROM tasks WHERE id = ? AND session_id = ?`, id, sessionID,
).Scan(&t.ID, &t.SessionID, &t.ServiceType, &t.Protocol, &t.ProviderID, &t.Status, &t.Progress, &t.InputJSON, &t.ResultJSON, &t.Error, &t.ExternalID, &t.CreatedAt, &t.UpdatedAt, &t.CompletedAt)
if err != nil {
return nil, err
}
return &t, nil
}
func (s *Store) ListActiveTasks(ctx context.Context, sessionID, serviceType string) ([]Task, error) {
q := `SELECT id, session_id, service_type, protocol, provider_id, status, progress, input_json, result_json, error, external_id, created_at, updated_at, completed_at FROM tasks WHERE session_id = ? AND status IN ('pending','running')`
args := []any{sessionID}
if serviceType != "" {
q += ` AND service_type = ?`
args = append(args, serviceType)
}
q += ` ORDER BY id DESC`
rows, err := s.db.QueryContext(ctx, q, args...)
if err != nil {
return nil, err
}
defer rows.Close()
var items []Task
for rows.Next() {
var t Task
if err := rows.Scan(&t.ID, &t.SessionID, &t.ServiceType, &t.Protocol, &t.ProviderID, &t.Status, &t.Progress, &t.InputJSON, &t.ResultJSON, &t.Error, &t.ExternalID, &t.CreatedAt, &t.UpdatedAt, &t.CompletedAt); err != nil {
return nil, err
}
items = append(items, t)
}
return items, rows.Err()
}
func (s *Store) ListPendingTasks(ctx context.Context) ([]Task, error) {
rows, err := s.db.QueryContext(ctx,
`SELECT id, session_id, service_type, protocol, provider_id, status, progress, input_json, result_json, error, external_id, created_at, updated_at, completed_at FROM tasks WHERE status IN ('pending','running') ORDER BY id ASC`,
)
if err != nil {
return nil, err
}
defer rows.Close()
var items []Task
for rows.Next() {
var t Task
if err := rows.Scan(&t.ID, &t.SessionID, &t.ServiceType, &t.Protocol, &t.ProviderID, &t.Status, &t.Progress, &t.InputJSON, &t.ResultJSON, &t.Error, &t.ExternalID, &t.CreatedAt, &t.UpdatedAt, &t.CompletedAt); err != nil {
return nil, err
}
items = append(items, t)
}
return items, rows.Err()
}
+90
View File
@@ -0,0 +1,90 @@
package store
import (
"context"
"log"
"sync"
"time"
)
type writeJob func(ctx context.Context, db *Store) error
type WriteQueue struct {
store *Store
ch chan writeJob
wg sync.WaitGroup
interval time.Duration
stopCh chan struct{}
}
func NewWriteQueue(s *Store, flushInterval time.Duration) *WriteQueue {
wq := &WriteQueue{
store: s,
ch: make(chan writeJob, 256),
interval: flushInterval,
stopCh: make(chan struct{}),
}
wq.wg.Add(1)
go wq.run()
return wq
}
func (wq *WriteQueue) Enqueue(job writeJob) {
select {
case wq.ch <- job:
default:
go func() { wq.ch <- job }()
}
}
func (wq *WriteQueue) ForceFlush(ctx context.Context, job writeJob) error {
return job(ctx, wq.store)
}
func (wq *WriteQueue) run() {
defer wq.wg.Done()
ticker := time.NewTicker(wq.interval)
defer ticker.Stop()
pending := make([]writeJob, 0, 32)
flush := func() {
if len(pending) == 0 {
return
}
jobs := pending
pending = pending[:0]
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
for _, job := range jobs {
if err := job(ctx, wq.store); err != nil {
log.Printf("write queue: %v", err)
}
}
}
for {
select {
case <-wq.stopCh:
flush()
for {
select {
case job := <-wq.ch:
pending = append(pending, job)
default:
flush()
return
}
}
case job := <-wq.ch:
pending = append(pending, job)
if len(pending) >= 32 {
flush()
}
case <-ticker.C:
flush()
}
}
}
func (wq *WriteQueue) Close() {
close(wq.stopCh)
wq.wg.Wait()
}
+3 -1
View File
@@ -2,8 +2,10 @@
<html lang="zh-CN">
<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>
+7
View File
@@ -0,0 +1,7 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32" fill="none">
<rect width="32" height="32" rx="8" fill="#0f7a62"/>
<circle cx="16" cy="16" r="9" stroke="#fafafa" stroke-width="1.5" opacity="0.95"/>
<path d="M7 16c3.2-5 14.8-5 18 0M7 16c3.2 5 14.8 5 18 0M16 7v18" stroke="#fafafa" stroke-width="1.2" stroke-linecap="round" opacity="0.85"/>
<path d="M12.5 21.5 16 10l3.5 11.5" stroke="#fafafa" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M13.8 18.2h4.4" stroke="#0f7a62" stroke-width="1.6" stroke-linecap="round"/>
</svg>

After

Width:  |  Height:  |  Size: 574 B

+2 -2
View File
@@ -19,8 +19,8 @@ api.interceptors.response.use(
(err) => {
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)
+11
View File
@@ -1,5 +1,16 @@
<script setup lang="ts">
defineProps<{ title?: string; padded?: boolean }>()
</script>
<template>
<div class="app-card">
<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>
+43
View File
@@ -0,0 +1,43 @@
<script setup lang="ts">
withDefaults(defineProps<{ size?: number; variant?: 'mark' | 'full' }>(), {
size: 32,
variant: 'mark',
})
</script>
<template>
<svg
class="atlas-logo"
:width="size"
:height="size"
viewBox="0 0 32 32"
fill="none"
xmlns="http://www.w3.org/2000/svg"
aria-hidden="true"
>
<rect width="32" height="32" rx="8" fill="#0f7a62" />
<circle cx="16" cy="16" r="9" stroke="#fafafa" stroke-width="1.5" opacity="0.95" />
<path
d="M7 16c3.2-5 14.8-5 18 0M7 16c3.2 5 14.8 5 18 0M16 7v18"
stroke="#fafafa"
stroke-width="1.2"
stroke-linecap="round"
opacity="0.85"
/>
<path
d="M12.5 21.5 16 10l3.5 11.5"
stroke="#fafafa"
stroke-width="1.8"
stroke-linecap="round"
stroke-linejoin="round"
/>
<path d="M13.8 18.2h4.4" stroke="#0f7a62" stroke-width="1.6" stroke-linecap="round" />
</svg>
</template>
<style scoped>
.atlas-logo {
display: block;
flex-shrink: 0;
}
</style>
+4 -4
View File
@@ -1,14 +1,14 @@
<script setup lang="ts">
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>
+10
View File
@@ -0,0 +1,10 @@
<script setup lang="ts">
defineProps<{ label: string; value: string | number }>()
</script>
<template>
<div class="stat-card">
<div class="stat-card__label">{{ label }}</div>
<div class="stat-card__value">{{ value }}</div>
</div>
</template>
+37
View File
@@ -0,0 +1,37 @@
import { ref } from 'vue'
import api from '@/api/client'
export interface Generation {
id: number
service_type: string
model: string
input_json: string
output_json: string
created_at: string
}
export function useGenerations(serviceType?: string) {
const items = ref<Generation[]>([])
const total = ref(0)
const loading = ref(false)
async function load(limit = 20, offset = 0, typeOverride?: string) {
loading.value = true
try {
const res = await api.get<{ items: Generation[]; total: number }>('/user/generations', {
params: { service_type: typeOverride ?? serviceType ?? undefined, limit, offset },
})
items.value = res.data.items
total.value = res.data.total
} finally {
loading.value = false
}
}
async function remove(id: number) {
await api.delete(`/user/generations/${id}`)
await load()
}
return { items, total, loading, load, remove }
}
+82
View File
@@ -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 }
}
+36
View File
@@ -0,0 +1,36 @@
import { ref } from 'vue'
import api from '@/api/client'
export interface Preset {
id: number
name: string
service_type: string
params_json: string
}
export function usePresets(serviceType: string) {
const items = ref<Preset[]>([])
async function load() {
const res = await api.get<{ items: Preset[] }>('/user/presets', {
params: { service_type: serviceType },
})
items.value = res.data.items
}
async function save(name: string, params: Record<string, unknown>) {
await api.post('/user/presets', {
name,
service_type: serviceType,
params_json: JSON.stringify(params),
})
await load()
}
async function remove(id: number) {
await api.delete(`/user/presets/${id}`)
await load()
}
return { items, load, save, remove }
}
+37
View File
@@ -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
View File
@@ -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
View File
@@ -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: '文件数量',
},
},
}
+89
View File
@@ -0,0 +1,89 @@
<script setup lang="ts">
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import { useRoute, useRouter } from 'vue-router'
import { Odometer, Setting, Connection, 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>
-58
View File
@@ -1,58 +0,0 @@
<script setup lang="ts">
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import { useRoute, useRouter } from 'vue-router'
import { Odometer, Setting } from '@element-plus/icons-vue'
import { useAuthStore } from '@/stores/auth'
const { t } = useI18n()
const route = useRoute()
const router = useRouter()
const auth = useAuthStore()
const navItems = [
{ path: '/', key: 'nav.dashboard', icon: Odometer },
{ path: '/settings', key: 'nav.settings', icon: Setting },
] as const
const pageTitle = computed(() => {
const hit = navItems.find((item) => item.path === route.path)
return hit ? t(hit.key) : 'App'
})
function logout() {
auth.logout()
router.push('/login')
}
</script>
<template>
<div class="layout">
<aside class="sidebar">
<div class="sidebar-brand">
<div class="brand-icon">A</div>
<span class="brand-name">App</span>
</div>
<div class="nav-group-label">{{ t('common.management') }}</div>
<router-link
v-for="item in navItems"
:key="item.path"
:to="item.path"
class="nav-item"
:class="{ active: route.path === item.path }"
>
<el-icon :size="16"><component :is="item.icon" /></el-icon>
{{ t(item.key) }}
</router-link>
</aside>
<div class="main-area">
<header class="topbar">
<span class="topbar-title">{{ pageTitle }}</span>
<el-button text @click="logout">{{ t('common.logout') }}</el-button>
</header>
<main class="main-content">
<router-view />
</main>
</div>
</div>
</template>
+61
View File
@@ -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
View File
@@ -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
View File
@@ -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
+517
View File
@@ -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; }
+57
View File
@@ -0,0 +1,57 @@
<script setup lang="ts">
import { onMounted, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import api from '@/api/client'
import PageHeader from '@/components/PageHeader.vue'
import StatCard from '@/components/StatCard.vue'
import AppCard from '@/components/AppCard.vue'
const { t } = useI18n()
const loading = ref(true)
const stats = ref({
total_requests: 0,
success_rate: 0,
avg_latency_ms: 0,
file_count: 0,
file_bytes: 0,
})
const appInfo = ref({ app_name: 'Atlas', data_dir: '' })
onMounted(async () => {
try {
const [dash, st] = await Promise.all([
api.get('/dashboard'),
api.get('/admin/dashboard/stats'),
])
appInfo.value = dash.data
stats.value = st.data
} finally {
loading.value = false
}
})
function formatBytes(n: number) {
if (n < 1024) return `${n} B`
if (n < 1024 * 1024) return `${(n / 1024).toFixed(1)} KB`
return `${(n / 1024 / 1024).toFixed(1)} MB`
}
</script>
<template>
<div>
<PageHeader :title="t('dashboard.title')" />
<div v-loading="loading" class="stat-grid">
<StatCard :label="t('admin.stats.requests')" :value="stats.total_requests" />
<StatCard :label="t('admin.stats.successRate')" :value="`${Math.round(stats.success_rate * 100)}%`" />
<StatCard :label="t('admin.stats.latency')" :value="`${Math.round(stats.avg_latency_ms)} ms`" />
<StatCard :label="t('admin.stats.files')" :value="formatBytes(stats.file_bytes)" />
</div>
<AppCard padded :title="t('dashboard.title')" style="margin-top: 16px">
<el-descriptions :column="1" border>
<el-descriptions-item :label="t('dashboard.appName')">{{ appInfo.app_name }}</el-descriptions-item>
<el-descriptions-item :label="t('dashboard.dataDir')">{{ appInfo.data_dir }}</el-descriptions-item>
<el-descriptions-item :label="t('admin.stats.fileCount')">{{ stats.file_count }}</el-descriptions-item>
</el-descriptions>
</AppCard>
</div>
</template>
+55
View File
@@ -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>
+260
View File
@@ -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>
+175
View File
@@ -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>
+65
View File
@@ -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>
+131
View File
@@ -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>
+17 -3
View File
@@ -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>
+1 -1
View File
@@ -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',
+114
View File
@@ -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>