diff --git a/KimmyXYC/.env.example b/KimmyXYC/.env.example new file mode 100644 index 0000000..47b64e1 --- /dev/null +++ b/KimmyXYC/.env.example @@ -0,0 +1,16 @@ +# PostgreSQL connection string +DATABASE_URL=postgres://postgres:postgres@localhost:5432/aibackend?sslmode=disable + +# JWT secret for signing tokens +JWT_SECRET=change-me + +# HTTP listen address +ADDR=:8080 + +# OpenAI provider configuration (optional; if OPENAI_API_KEY is set, OpenAI provider is used) +OPENAI_API_KEY= +# Custom API base, e.g. https://api.openai.com or your proxy endpoint +OPENAI_API_BASE= + +# (Optional) provider keys +# VOLC_API_KEY=your-volcengine-key diff --git a/KimmyXYC/README.md b/KimmyXYC/README.md new file mode 100644 index 0000000..385147e --- /dev/null +++ b/KimmyXYC/README.md @@ -0,0 +1,99 @@ +AIBackend - 简易AI问答后端 (Golang + PostgreSQL) + +简介 +- 使用 Gin + GORM + PostgreSQL 实现的简易问答后端,支持: + - 用户注册/登录(JWT) + - 会话与消息存储(PostgreSQL) + - 基于角色的模型访问控制(free/pro/admin) + - 上下文对话与流式输出(SSE) + - 可插拔的模型提供方接口(默认 Mock,便于本地演示) + +快速开始 +1) 准备 Postgres 并创建数据库,例如:aibackend + +2) 配置环境变量(可创建 .env 文件) +- DATABASE_URL=postgres://:@localhost:5432/aibackend?sslmode=disable +- JWT_SECRET=change-me +- ADDR=:8080 + +3) 运行 +- go run ./cmd/server + +4) 健康检查 +- GET http://localhost:8080/health -> {"status":"ok"} + +API 文档 +- 见 docs/api.md,包含注册、登录、会话、聊天(支持 SSE 流式)等接口说明与 curl 示例。 + +模型提供方 +- 默认使用 MockProvider(无需外部 Key,本地直接演示)。 +- OpenAI 兼容:当设置 OPENAI_API_KEY 时,自动切换为 OpenAI Chat Completions 协议(支持流式)。 + - 环境变量: + - OPENAI_API_KEY=你的密钥 + - OPENAI_API_BASE=自定义 Endpoint(可选,默认 https://api.openai.com) + - Chat 请求将发送到 {OPENAI_API_BASE}/v1/chat/completions(stream=true),并解析 SSE 的 data: 行,提取 choices[0].delta.content。 +- 若需接入火山引擎(Volcengine)或其他厂商: + 1. 在 internal/provider 中实现 LLMProvider 接口。 + 2. 在 NewProviderFromEnv 中根据环境变量选择对应 Provider。 + 3. 在 ChatService 中无需改动,保持调用接口不变。 + +项目结构 +- cmd/server/main.go 程序入口 +- internal/db 数据库连接与迁移 +- internal/models GORM 模型(User/Conversation/Message) +- internal/provider 模型提供方接口与 Mock 实现 +- internal/services Auth 与 Chat 业务逻辑 +- internal/httpserver Gin 路由与 HTTP 处理器 +- pkg/auth JWT 生成与解析 +- pkg/middleware Gin 中间件(鉴权与模型权限) +- docs/api.md API 文档 + +角色与模型权限(示例) +- free: [mock-mini] +- pro: [mock-mini, mock-pro] +- admin: [mock-mini, mock-pro, mock-admin] + +注意 +- 初次运行会自动迁移数据库表结构。 +- 流式输出采用 SSE(text/event-stream)。 +- 若未设置 DATABASE_URL,程序会尝试使用本地默认串,但数据库必须实际可连接。 + + + +前端(Web) +- 本项目内置了一个简单的 Web 前端,覆盖了后端的全部核心功能: + - 注册/登录(JWT 持久化在 localStorage) + - 查看会话列表、查看会话消息 + - 发起聊天(支持非流式与流式输出) + - 模型选择(会提示当前角色可用的模型范围,越权将被后端拦截) +- 代码位置:web/ + - index.html 页面骨架 + - css/styles.css 样式 + - js/state.js 本地状态与 Token 管理 + - js/api.js 封装所有后端 API 调用(含流式解析) + - js/auth.js 登录与注册表单逻辑 + - js/chat.js 会话/消息渲染与发送消息(含流式处理) + - js/main.js 应用入口与视图切换 + +如何使用前端 +1) 启动后端: + - go run ./cmd/server +2) 打开浏览器访问: + - http://localhost:8080 +3) 在首页进行注册或登录: + - 你可以选择角色(free/pro/admin),不同角色允许的模型不同: + - free: [mock-mini] + - pro: [mock-mini, mock-pro] + - admin:[mock-mini, mock-pro, mock-admin] +4) 进入应用后: + - 左侧为会话列表,可点击切换; + - 右侧为消息区与输入框; + - 顶部可切换模型、开启/关闭“流式输出”; + - 点击“+ 新建对话”开始新的会话; + - 输入问题后按“发送”即可,开启“流式输出”时可看到回复逐步出现。 + +注意事项 +- 前端与后端同域部署(由 Gin 静态服务提供),无需额外配置 CORS; +- 流式回复采用 fetch ReadableStream 解析服务端的 text/event-stream(后端使用 POST 返回 SSE 风格数据,无法直接用 EventSource,因此用 fetch 流解析); +- 若模型越权,后端会返回 403,前端会在输入区下方给出提示; +- Mock 模型会回显你的输入,便于本地验证体验。 diff --git a/KimmyXYC/cmd/server/main.go b/KimmyXYC/cmd/server/main.go new file mode 100644 index 0000000..0af455f --- /dev/null +++ b/KimmyXYC/cmd/server/main.go @@ -0,0 +1,44 @@ +package main + +import ( + "log" + "os" + + "github.com/joho/godotenv" + + "AIBackend/internal/db" + "AIBackend/internal/httpserver" + "AIBackend/internal/provider" +) + +func main() { + // Load .env if present (dev convenience) + _ = godotenv.Load() + + // Initialize DB + pgURL := os.Getenv("DATABASE_URL") + if pgURL == "" { + log.Println("WARNING: DATABASE_URL is not set. The server may fail to start when DB is required.") + } + gormDB, err := db.Connect(pgURL) + if err != nil { + log.Fatalf("failed to connect database: %v", err) + } + if err := db.AutoMigrate(gormDB); err != nil { + log.Fatalf("failed to migrate database: %v", err) + } + + // Initialize LLM provider (Mock by default) + llm := provider.NewProviderFromEnv() + + // Start HTTP server + r := httpserver.NewRouter(gormDB, llm) + addr := os.Getenv("ADDR") + if addr == "" { + addr = ":8080" + } + log.Printf("Server listening on %s", addr) + if err := r.Run(addr); err != nil { + log.Fatalf("server error: %v", err) + } +} diff --git a/KimmyXYC/docs/api.md b/KimmyXYC/docs/api.md new file mode 100644 index 0000000..b52b6a1 --- /dev/null +++ b/KimmyXYC/docs/api.md @@ -0,0 +1,49 @@ +API Documentation + +Base URL: http://localhost:8080 + +Auth +- POST /api/auth/register + Request JSON: { "email": "user@example.com", "password": "pass123", "role": "free|pro|admin" } + Response: { "user": {id, email, role, created_at}, "token": "JWT" } + +- POST /api/auth/login + Request JSON: { "email": "user@example.com", "password": "pass123" } + Response: { "user": {...}, "token": "JWT" } + +Use Authorization: Bearer for all protected endpoints below. + +User +- GET /api/me + Response: { "user_id": 1, "user_email": "user@example.com", "user_role": "free" } + +Conversations +- GET /api/conversations + Response: { "conversations": [ {id, title, model, created_at, updated_at}, ... ] } + +- GET /api/conversations/:id/messages + Response: { "messages": [ {id, role, content, created_at}, ... ] } + +Chat +- POST /api/chat + Request JSON: { "conversation_id": 0, "model": "mock-mini", "message": "hello", "stream": false } + Response (non-stream): { "conversation_id": 1, "reply": "..." } + + Streaming: set stream=true or ?stream=1 and use text/event-stream (SSE). + Example curl: + curl -N -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \ + -d '{"model":"mock-mini","message":"你好","stream":true}' \ + http://localhost:8080/api/chat + +Models and Permissions +- Roles and allowed models: + - free: [mock-mini] + - pro: [mock-mini, mock-pro] + - admin: [mock-mini, mock-pro, mock-admin] + +Notes +- Default provider is Mock (no external key). If OPENAI_API_KEY is set, the backend switches to OpenAI-compatible Chat Completions API. + - Env: + - OPENAI_API_KEY=your-key + - OPENAI_API_BASE=custom endpoint (optional; default https://api.openai.com) +- To integrate another provider (e.g., 火山引擎/Volcengine), implement provider.LLMProvider and update provider.NewProviderFromEnv(). diff --git a/KimmyXYC/go.mod b/KimmyXYC/go.mod new file mode 100644 index 0000000..87a7c63 --- /dev/null +++ b/KimmyXYC/go.mod @@ -0,0 +1,49 @@ +module AIBackend + +go 1.23 + +require ( + github.com/gin-gonic/gin v1.10.0 + github.com/golang-jwt/jwt/v5 v5.2.2 + github.com/joho/godotenv v1.5.1 + golang.org/x/crypto v0.42.0 + gorm.io/driver/postgres v1.5.9 + gorm.io/gorm v1.25.11 +) + +require ( + github.com/bytedance/sonic v1.11.6 // indirect + github.com/bytedance/sonic/loader v0.1.1 // indirect + github.com/cloudwego/base64x v0.1.4 // indirect + github.com/cloudwego/iasm v0.2.0 // indirect + github.com/gabriel-vasile/mimetype v1.4.3 // indirect + github.com/gin-contrib/sse v0.1.0 // indirect + github.com/go-playground/locales v0.14.1 // indirect + github.com/go-playground/universal-translator v0.18.1 // indirect + github.com/go-playground/validator/v10 v10.20.0 // indirect + github.com/goccy/go-json v0.10.2 // indirect + github.com/jackc/pgpassfile v1.0.0 // indirect + github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a // indirect + github.com/jackc/pgx/v5 v5.5.5 // indirect + github.com/jackc/puddle/v2 v2.2.1 // indirect + github.com/jinzhu/inflection v1.0.0 // indirect + github.com/jinzhu/now v1.1.5 // indirect + github.com/json-iterator/go v1.1.12 // indirect + github.com/klauspost/cpuid/v2 v2.2.7 // indirect + github.com/kr/text v0.2.0 // indirect + github.com/leodido/go-urn v1.4.0 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect + github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect + github.com/modern-go/reflect2 v1.0.2 // indirect + github.com/pelletier/go-toml/v2 v2.2.2 // indirect + github.com/rogpeppe/go-internal v1.14.1 // indirect + github.com/twitchyliquid64/golang-asm v0.15.1 // indirect + github.com/ugorji/go/codec v1.2.12 // indirect + golang.org/x/arch v0.8.0 // indirect + golang.org/x/net v0.25.0 // indirect + golang.org/x/sync v0.8.0 // indirect + golang.org/x/sys v0.26.0 // indirect + golang.org/x/text v0.17.0 // indirect + google.golang.org/protobuf v1.34.1 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect +) diff --git a/KimmyXYC/internal/db/db.go b/KimmyXYC/internal/db/db.go new file mode 100644 index 0000000..856501b --- /dev/null +++ b/KimmyXYC/internal/db/db.go @@ -0,0 +1,32 @@ +package db + +import ( + "fmt" + + "gorm.io/driver/postgres" + "gorm.io/gorm" + + "AIBackend/internal/models" +) + +// Connect opens a PostgreSQL connection using DATABASE_URL. +func Connect(databaseURL string) (*gorm.DB, error) { + if databaseURL == "" { + return nil, fmt.Errorf("DATABASE_URL is required") + } + dsn := databaseURL + db, err := gorm.Open(postgres.Open(dsn), &gorm.Config{}) + if err != nil { + return nil, fmt.Errorf("connect postgres: %w", err) + } + return db, nil +} + +// AutoMigrate applies database schema for all models. +func AutoMigrate(db *gorm.DB) error { + return db.AutoMigrate( + &models.User{}, + &models.Conversation{}, + &models.Message{}, + ) +} diff --git a/KimmyXYC/internal/httpserver/router.go b/KimmyXYC/internal/httpserver/router.go new file mode 100644 index 0000000..cdfcacd --- /dev/null +++ b/KimmyXYC/internal/httpserver/router.go @@ -0,0 +1,199 @@ +package httpserver + +import ( + "net/http" + "strconv" + "time" + + "github.com/gin-gonic/gin" + "gorm.io/gorm" + + "AIBackend/internal/provider" + "AIBackend/internal/services" + "AIBackend/pkg/middleware" +) + +type Server struct { + Auth *services.AuthService + Chat *services.ChatService +} + +func NewRouter(db *gorm.DB, llm provider.LLMProvider) *gin.Engine { + g := gin.Default() + + server := &Server{ + Auth: services.NewAuthService(db), + Chat: services.NewChatService(db, llm), + } + + g.GET("/health", func(c *gin.Context) { c.JSON(http.StatusOK, gin.H{"status": "ok"}) }) + + api := g.Group("/api") + { + auth := api.Group("/auth") + auth.POST("/register", server.handleRegister) + auth.POST("/login", server.handleLogin) + } + + protected := api.Group("") + protected.Use(middleware.AuthRequired()) + { + protected.GET("/me", server.handleMe) + protected.GET("/conversations", server.handleListConversations) + protected.GET("/conversations/:id/messages", server.handleGetMessages) + protected.POST("/chat", middleware.ModelAccess(), server.handleChat) + } + + // Serve static frontend files without conflicting wildcard + g.StaticFile("/", "./web/index.html") + g.Static("/css", "./web/css") + g.Static("/js", "./web/js") + + return g +} + +type registerReq struct { + Email string `json:"email" binding:"required"` + Password string `json:"password" binding:"required"` + Role string `json:"role"` +} + +type loginReq struct { + Email string `json:"email" binding:"required"` + Password string `json:"password" binding:"required"` +} + +func (s *Server) handleRegister(c *gin.Context) { + var req registerReq + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + user, token, err := s.Auth.Register(req.Email, req.Password, req.Role) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + c.JSON(http.StatusOK, gin.H{"user": user, "token": token}) +} + +func (s *Server) handleLogin(c *gin.Context) { + var req loginReq + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + user, token, err := s.Auth.Login(req.Email, req.Password) + if err != nil { + c.JSON(http.StatusUnauthorized, gin.H{"error": err.Error()}) + return + } + c.JSON(http.StatusOK, gin.H{"user": user, "token": token}) +} + +func (s *Server) handleMe(c *gin.Context) { + c.JSON(http.StatusOK, gin.H{ + "user_id": c.GetUint("user_id"), + "user_email": c.GetString("user_email"), + "user_role": c.GetString("user_role"), + }) +} + +func (s *Server) handleListConversations(c *gin.Context) { + uid := c.GetUint("user_id") + convs, err := s.Chat.ListConversations(uid) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + c.JSON(http.StatusOK, gin.H{"conversations": convs}) +} + +func (s *Server) handleGetMessages(c *gin.Context) { + uid := c.GetUint("user_id") + idStr := c.Param("id") + id64, _ := strconv.ParseUint(idStr, 10, 64) + msgs, err := s.Chat.GetMessages(uid, uint(id64)) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + c.JSON(http.StatusOK, gin.H{"messages": msgs}) +} + +type chatReq struct { + ConversationID uint `json:"conversation_id"` + Model string `json:"model"` + Message string `json:"message" binding:"required"` + Stream *bool `json:"stream"` +} + +func (s *Server) handleChat(c *gin.Context) { + uid := c.GetUint("user_id") + var req chatReq + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + // Fallback to query param model for middleware check compatibility + if req.Model == "" { + req.Model = c.Query("model") + } + // Enforce model access if provided in body + role := c.GetString("user_role") + if !middleware.CheckModelAccess(role, req.Model) { + c.JSON(http.StatusForbidden, gin.H{"error": "model access denied for role"}) + return + } + streaming := false + if req.Stream != nil { + streaming = *req.Stream + } + if c.Query("stream") == "1" || c.Query("stream") == "true" { + streaming = true + } + if !streaming { + convID, reply, err := s.Chat.SendMessage(c.Request.Context(), uid, req.ConversationID, req.Model, req.Message, nil) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + c.JSON(http.StatusOK, gin.H{"conversation_id": convID, "reply": reply}) + return + } + // Streaming via SSE + w := c.Writer + c.Header("Content-Type", "text/event-stream") + c.Header("Cache-Control", "no-cache") + c.Header("Connection", "keep-alive") + c.Status(http.StatusOK) + flusher, _ := w.(http.Flusher) + sentAny := false + convID, _, err := s.Chat.SendMessage(c.Request.Context(), uid, req.ConversationID, req.Model, req.Message, func(chunk string) error { + sentAny = true + _, err := w.Write([]byte("data: " + chunk + "\n\n")) + if err == nil && flusher != nil { + flusher.Flush() + } + return err + }) + if err != nil { + // send error as SSE comment and 0-length event end + _, _ = w.Write([]byte(": error: " + err.Error() + "\n\n")) + if flusher != nil { + flusher.Flush() + } + return + } + if !sentAny { + // send at least one empty event to keep clients happy + _, _ = w.Write([]byte("data: \n\n")) + } + // end event + _, _ = w.Write([]byte("event: done\n" + "data: {\"conversation_id\": " + strconv.FormatUint(uint64(convID), 10) + "}\n\n")) + if flusher != nil { + flusher.Flush() + } + // allow connection to close shortly after + time.Sleep(50 * time.Millisecond) +} diff --git a/KimmyXYC/internal/models/models.go b/KimmyXYC/internal/models/models.go new file mode 100644 index 0000000..aec0193 --- /dev/null +++ b/KimmyXYC/internal/models/models.go @@ -0,0 +1,45 @@ +package models + +import ( + "time" + + "gorm.io/gorm" +) + +// User represents a registered account. +type User struct { + ID uint `gorm:"primaryKey" json:"id"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` + DeletedAt gorm.DeletedAt `gorm:"index" json:"-"` + + Email string `gorm:"uniqueIndex;size:255;not null" json:"email"` + PasswordHash string `json:"-"` + Role string `gorm:"size:20;not null;default:free" json:"role"` // free, pro, admin +} + +// Conversation stores a chat session for a user. +type Conversation struct { + ID uint `gorm:"primaryKey" json:"id"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` + DeletedAt gorm.DeletedAt `gorm:"index" json:"-"` + + UserID uint `gorm:"index" json:"user_id"` + Title string `gorm:"size:255" json:"title"` + Model string `gorm:"size:100" json:"model"` + + Messages []Message `json:"messages"` +} + +// Message stores individual messages in a conversation. +type Message struct { + ID uint `gorm:"primaryKey" json:"id"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` + DeletedAt gorm.DeletedAt `gorm:"index" json:"-"` + + ConversationID uint `gorm:"index" json:"conversation_id"` + Role string `gorm:"size:20;not null" json:"role"` // user or assistant + Content string `gorm:"type:text" json:"content"` +} diff --git a/KimmyXYC/internal/provider/openai.go b/KimmyXYC/internal/provider/openai.go new file mode 100644 index 0000000..9fc09ac --- /dev/null +++ b/KimmyXYC/internal/provider/openai.go @@ -0,0 +1,173 @@ +package provider + +import ( + "bufio" + "bytes" + "context" + "encoding/json" + "errors" + "io" + "net/http" + "os" + "strings" + "time" +) + +// OpenAIProvider implements LLMProvider using OpenAI-compatible Chat Completions API. +// It supports custom endpoint and token via environment variables: +// OPENAI_API_KEY - required to enable this provider +// OPENAI_API_BASE - optional, defaults to https://api.openai.com +// The API path used is {BASE}/v1/chat/completions with stream=true. +// The "model" passed from caller is forwarded as-is. + +type OpenAIProvider struct { + BaseURL string + APIKey string + Client *http.Client +} + +func NewOpenAIProviderFromEnv() *OpenAIProvider { + key := os.Getenv("OPENAI_API_KEY") + if key == "" { + return nil + } + base := os.Getenv("OPENAI_API_BASE") + if base == "" { + base = "https://api.openai.com" + } + return &OpenAIProvider{ + BaseURL: strings.TrimRight(base, "/"), + APIKey: key, + Client: &http.Client{Timeout: 90 * time.Second}, + } +} + +type openAIChatRequest struct { + Model string `json:"model"` + Messages []openAIChatMessage `json:"messages"` + Stream bool `json:"stream"` +} + +type openAIChatMessage struct { + Role string `json:"role"` + Content string `json:"content"` +} + +type openAIStreamChunk struct { + ID string `json:"id"` + Object string `json:"object"` + Created int64 `json:"created"` + Model string `json:"model"` + Choices []openAIStreamChunkChoice `json:"choices"` +} + +type openAIStreamChunkChoice struct { + Index int `json:"index"` + Delta openAIStreamDelta `json:"delta"` + // finish_reason may be "stop" etc. + FinishReason *string `json:"finish_reason"` +} + +type openAIStreamDelta struct { + Role string `json:"role,omitempty"` + Content string `json:"content,omitempty"` +} + +// ChatCompletionStream implements streaming chat using OpenAI SSE. +func (p *OpenAIProvider) ChatCompletionStream(ctx context.Context, model string, messages []ChatMessage) (<-chan StreamChunk, error) { + if p == nil || p.APIKey == "" { + return nil, errors.New("openai provider not configured") + } + url := p.BaseURL + "/v1/chat/completions" + + reqPayload := openAIChatRequest{ + Model: model, + Stream: true, + } + for _, m := range messages { + role := strings.ToLower(m.Role) + if role == "assistant" || role == "user" || role == "system" { + // ok + } else { + // map unknown roles to user to avoid API errors + role = "user" + } + reqPayload.Messages = append(reqPayload.Messages, openAIChatMessage{Role: role, Content: m.Content}) + } + buf, err := json.Marshal(reqPayload) + if err != nil { + return nil, err + } + + httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(buf)) + if err != nil { + return nil, err + } + httpReq.Header.Set("Authorization", "Bearer "+p.APIKey) + httpReq.Header.Set("Content-Type", "application/json") + httpReq.Header.Set("Accept", "text/event-stream") + + resp, err := p.Client.Do(httpReq) + if err != nil { + return nil, err + } + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + defer resp.Body.Close() + b, _ := io.ReadAll(resp.Body) + return nil, errors.New(strings.TrimSpace(string(b))) + } + + ch := make(chan StreamChunk) + go func() { + defer close(ch) + defer resp.Body.Close() + r := bufio.NewReader(resp.Body) + for { + select { + case <-ctx.Done(): + select { + case ch <- StreamChunk{Err: ctx.Err()}: + default: + } + return + default: + } + line, err := r.ReadString('\n') + if err != nil { + if errors.Is(err, io.EOF) { + // end of stream + ch <- StreamChunk{Done: true} + } + return + } + line = strings.TrimRight(line, "\r\n") + if line == "" || strings.HasPrefix(line, ":") { // comments/keepalive + continue + } + if !strings.HasPrefix(line, "data:") { + continue + } + data := strings.TrimSpace(strings.TrimPrefix(line, "data:")) + if data == "[DONE]" { + ch <- StreamChunk{Done: true} + return + } + var chunk openAIStreamChunk + if err := json.Unmarshal([]byte(data), &chunk); err != nil { + // send as raw text if JSON parse fails + ch <- StreamChunk{Content: data} + continue + } + for _, choice := range chunk.Choices { + if choice.Delta.Content != "" { + ch <- StreamChunk{Content: choice.Delta.Content} + } + if choice.FinishReason != nil && *choice.FinishReason != "" { + // when finish reason received, mark done soon + // we won't break immediately because there could be other choices + } + } + } + }() + return ch, nil +} diff --git a/KimmyXYC/internal/provider/provider.go b/KimmyXYC/internal/provider/provider.go new file mode 100644 index 0000000..8cb1eba --- /dev/null +++ b/KimmyXYC/internal/provider/provider.go @@ -0,0 +1,72 @@ +package provider + +import ( + "context" + "os" + "strings" + "time" +) + +// ChatMessage represents a message sent to/from the model. +type ChatMessage struct { + Role string + Content string +} + +// StreamChunk represents a chunk of streamed content. +type StreamChunk struct { + Content string + Done bool + Err error +} + +// LLMProvider is an abstraction over an AI chat model provider. +type LLMProvider interface { + // ChatCompletionStream streams the assistant reply for given messages and model. + ChatCompletionStream(ctx context.Context, model string, messages []ChatMessage) (<-chan StreamChunk, error) +} + +// NewProviderFromEnv selects a provider based on environment variables. +// If OPENAI_API_KEY is set, uses OpenAI-compatible provider, otherwise falls back to Mock. +func NewProviderFromEnv() LLMProvider { + if p := NewOpenAIProviderFromEnv(); p != nil { + return p + } + _ = os.Getenv("VOLC_API_KEY") // reserved for future real provider + return &MockProvider{} +} + +// MockProvider is a simple echo-based provider with fake streaming. +type MockProvider struct{} + +func (m *MockProvider) ChatCompletionStream(ctx context.Context, model string, messages []ChatMessage) (<-chan StreamChunk, error) { + ch := make(chan StreamChunk) + go func() { + defer close(ch) + // naive: concatenate last user message and reply with a friendly echo + var prompt string + for i := len(messages) - 1; i >= 0; i-- { + if strings.ToLower(messages[i].Role) == "user" { + prompt = messages[i].Content + break + } + } + if prompt == "" { + prompt = "Hello! Ask me anything." + } + reply := "[Mock-" + model + "] " + "You said: " + prompt + // stream in word chunks + words := strings.Split(reply, " ") + for i, w := range words { + select { + case <-ctx.Done(): + ch <- StreamChunk{Err: ctx.Err()} + return + case ch <- StreamChunk{Content: func() string { if i == 0 { return w } ; return " " + w }()}: + time.Sleep(50 * time.Millisecond) + } + } + ch <- StreamChunk{Done: true} + }() + return ch, nil +} diff --git a/KimmyXYC/internal/services/auth_service.go b/KimmyXYC/internal/services/auth_service.go new file mode 100644 index 0000000..7aa7708 --- /dev/null +++ b/KimmyXYC/internal/services/auth_service.go @@ -0,0 +1,69 @@ +package services + +import ( + "errors" + "strings" + "time" + + "golang.org/x/crypto/bcrypt" + "gorm.io/gorm" + + "AIBackend/internal/models" + "AIBackend/pkg/auth" +) + +type AuthService struct { + DB *gorm.DB +} + +func NewAuthService(db *gorm.DB) *AuthService { + return &AuthService{DB: db} +} + +func (s *AuthService) Register(email, password, role string) (*models.User, string, error) { + email = strings.TrimSpace(strings.ToLower(email)) + if email == "" || password == "" { + return nil, "", errors.New("email and password required") + } + if role == "" { + role = "free" + } + var existing models.User + if err := s.DB.Where("email = ?", email).First(&existing).Error; err == nil { + return nil, "", errors.New("email already registered") + } else if !errors.Is(err, gorm.ErrRecordNotFound) { + return nil, "", err + } + hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost) + if err != nil { + return nil, "", err + } + user := &models.User{Email: email, PasswordHash: string(hash), Role: role} + if err := s.DB.Create(user).Error; err != nil { + return nil, "", err + } + token, err := auth.CreateToken(user.ID, user.Email, user.Role, 24*time.Hour) + if err != nil { + return nil, "", err + } + return user, token, nil +} + +func (s *AuthService) Login(email, password string) (*models.User, string, error) { + email = strings.TrimSpace(strings.ToLower(email)) + var user models.User + if err := s.DB.Where("email = ?", email).First(&user).Error; err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, "", errors.New("invalid credentials") + } + return nil, "", err + } + if err := bcrypt.CompareHashAndPassword([]byte(user.PasswordHash), []byte(password)); err != nil { + return nil, "", errors.New("invalid credentials") + } + token, err := auth.CreateToken(user.ID, user.Email, user.Role, 24*time.Hour) + if err != nil { + return nil, "", err + } + return &user, token, nil +} diff --git a/KimmyXYC/internal/services/chat_service.go b/KimmyXYC/internal/services/chat_service.go new file mode 100644 index 0000000..6a485ba --- /dev/null +++ b/KimmyXYC/internal/services/chat_service.go @@ -0,0 +1,130 @@ +package services + +import ( + "context" + "errors" + "strings" + "time" + + "gorm.io/gorm" + + "AIBackend/internal/models" + "AIBackend/internal/provider" +) + +type ChatService struct { + DB *gorm.DB + LLM provider.LLMProvider + MaxTurns int // number of last messages to keep in context +} + +func NewChatService(db *gorm.DB, llm provider.LLMProvider) *ChatService { + return &ChatService{DB: db, LLM: llm, MaxTurns: 10} +} + +// EnsureConversation ensures conversation exists (and belongs to user), creating if needed. +func (s *ChatService) EnsureConversation(userID uint, convID uint, model string, title string) (*models.Conversation, error) { + if convID != 0 { + var conv models.Conversation + if err := s.DB.Where("id = ? AND user_id = ?", convID, userID).First(&conv).Error; err != nil { + return nil, err + } + return &conv, nil + } + conv := &models.Conversation{UserID: userID, Title: title, Model: model} + if conv.Title == "" { + conv.Title = "New Chat" + } + if err := s.DB.Create(conv).Error; err != nil { + return nil, err + } + return conv, nil +} + +// ListConversations returns user's conversations. +func (s *ChatService) ListConversations(userID uint) ([]models.Conversation, error) { + var convs []models.Conversation + if err := s.DB.Where("user_id = ?", userID).Order("updated_at desc").Find(&convs).Error; err != nil { + return nil, err + } + return convs, nil +} + +// GetMessages returns messages for a conversation if owned by user. +func (s *ChatService) GetMessages(userID, convID uint) ([]models.Message, error) { + var conv models.Conversation + if err := s.DB.Where("id = ? AND user_id = ?", convID, userID).First(&conv).Error; err != nil { + return nil, err + } + var msgs []models.Message + if err := s.DB.Where("conversation_id = ?", convID).Order("id asc").Find(&msgs).Error; err != nil { + return nil, err + } + return msgs, nil +} + +// SendMessage adds a user message, streams assistant reply via callback, and persists the assistant message. +func (s *ChatService) SendMessage(ctx context.Context, userID uint, convID uint, model string, userText string, stream func(chunk string) error) (uint, string, error) { + userText = strings.TrimSpace(userText) + if userText == "" { + return 0, "", errors.New("message content required") + } + conv, err := s.EnsureConversation(userID, convID, model, "") + if err != nil { + return 0, "", err + } + // Save user message + um := &models.Message{ConversationID: conv.ID, Role: "user", Content: userText} + if err := s.DB.Create(um).Error; err != nil { + return 0, "", err + } + // Load recent messages for context + var msgs []models.Message + if err := s.DB.Where("conversation_id = ?", conv.ID). + Order("id desc"). + Limit(s.MaxTurns * 2). + Find(&msgs).Error; err != nil { + return conv.ID, "", err + } + // reverse to chronological + for i, j := 0, len(msgs)-1; i < j; i, j = i+1, j-1 { + msgs[i], msgs[j] = msgs[j], msgs[i] + } + llmMsgs := make([]provider.ChatMessage, 0, len(msgs)) + for _, m := range msgs { + llmMsgs = append(llmMsgs, provider.ChatMessage{Role: m.Role, Content: m.Content}) + } + // Stream assistant reply + ctx, cancel := context.WithTimeout(ctx, 60*time.Second) + defer cancel() + ch, err := s.LLM.ChatCompletionStream(ctx, conv.Model, llmMsgs) + if err != nil { + return 0, "", err + } + assistantContent := strings.Builder{} + for chunk := range ch { + if chunk.Err != nil { + return conv.ID, "", chunk.Err + } + if chunk.Content != "" { + assistantContent.WriteString(chunk.Content) + if stream != nil { + if err := stream(chunk.Content); err != nil { + return conv.ID, "", err + } + } + } + if chunk.Done { + break + } + } + // Save assistant message + am := &models.Message{ConversationID: conv.ID, Role: "assistant", Content: assistantContent.String()} + if err := s.DB.Create(am).Error; err != nil { + return conv.ID, "", err + } + if err := s.DB.Model(conv).UpdateColumn("updated_at", time.Now()).Error; err != nil { + return conv.ID, "", err + } + return conv.ID, am.Content, nil +} diff --git a/KimmyXYC/pkg/auth/token.go b/KimmyXYC/pkg/auth/token.go new file mode 100644 index 0000000..25ed6f9 --- /dev/null +++ b/KimmyXYC/pkg/auth/token.go @@ -0,0 +1,66 @@ +package auth + +import ( + "errors" + "os" + "time" + + "github.com/golang-jwt/jwt/v5" +) + +func jwtSecret() ([]byte, error) { + s := os.Getenv("JWT_SECRET") + if s == "" { + return nil, errors.New("JWT_SECRET is not configured") + } + return []byte(s), nil +} + +// Claims represents JWT claims for a user session. +type Claims struct { + UserID uint `json:"user_id"` + Email string `json:"email"` + Role string `json:"role"` + jwt.RegisteredClaims +} + +// CreateToken issues a signed JWT for the given user. +func CreateToken(userID uint, email, role string, ttl time.Duration) (string, error) { + claims := Claims{ + UserID: userID, + Email: email, + Role: role, + RegisteredClaims: jwt.RegisteredClaims{ + ExpiresAt: jwt.NewNumericDate(time.Now().Add(ttl)), + IssuedAt: jwt.NewNumericDate(time.Now()), + }, + } + key, err := jwtSecret() + if err != nil { + return "", err + } + t := jwt.NewWithClaims(jwt.SigningMethodHS256, claims) + return t.SignedString(key) +} + +// ParseToken parses and validates a JWT, returning claims if valid. +func ParseToken(token string) (*Claims, error) { + key, err := jwtSecret() + if err != nil { + return nil, err + } + tok, err := jwt.ParseWithClaims(token, &Claims{}, func(t *jwt.Token) (interface{}, error) { + return key, nil + }) + if err != nil { + return nil, err + } + if !tok.Valid { + return nil, errors.New("invalid token") + } + claims, ok := tok.Claims.(*Claims) + if !ok { + return nil, errors.New("invalid claims") + } + return claims, nil +} diff --git a/KimmyXYC/pkg/middleware/auth.go b/KimmyXYC/pkg/middleware/auth.go new file mode 100644 index 0000000..e741c03 --- /dev/null +++ b/KimmyXYC/pkg/middleware/auth.go @@ -0,0 +1,74 @@ +package middleware + +import ( + "net/http" + "strings" + + "github.com/gin-gonic/gin" + + "AIBackend/pkg/auth" +) + +// Allowed models by role (exported for reuse) +var AllowedModelsByRole = map[string][]string{ + "free": {"mock-mini", "gpt-4o-mini"}, + "pro": {"mock-mini", "mock-pro", "gpt-4o-mini", "gpt-4o"}, + "admin": {"mock-mini", "mock-pro", "mock-admin", "gpt-4o-mini", "gpt-4o", "gpt-4.1"}, +} + +// CheckModelAccess returns true if the role is allowed to use the model. +func CheckModelAccess(role, model string) bool { + if model == "" { + return true + } + list := AllowedModelsByRole[role] + for _, m := range list { + if m == model { + return true + } + } + return false +} + +// AuthRequired validates JWT and sets user info in context. +func AuthRequired() gin.HandlerFunc { + return func(c *gin.Context) { + h := c.GetHeader("Authorization") + if h == "" || !strings.HasPrefix(h, "Bearer ") { + c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "missing bearer token"}) + return + } + token := strings.TrimPrefix(h, "Bearer ") + claims, err := auth.ParseToken(token) + if err != nil { + c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "invalid token"}) + return + } + c.Set("user_id", claims.UserID) + c.Set("user_email", claims.Email) + c.Set("user_role", claims.Role) + c.Next() + } +} + +// ModelAccess enforces role-based access to models using query parameter if present. +func ModelAccess() gin.HandlerFunc { + return func(c *gin.Context) { + role, _ := c.Get("user_role") + roleStr := "free" + if r, ok := role.(string); ok && r != "" { + roleStr = r + } + reqModel := c.Query("model") + if reqModel == "" { + // body may contain model; handler should validate with CheckModelAccess + c.Next() + return + } + if !CheckModelAccess(roleStr, reqModel) { + c.AbortWithStatusJSON(http.StatusForbidden, gin.H{"error": "model access denied for role"}) + return + } + c.Next() + } +} diff --git a/KimmyXYC/web/css/styles.css b/KimmyXYC/web/css/styles.css new file mode 100644 index 0000000..5a74111 --- /dev/null +++ b/KimmyXYC/web/css/styles.css @@ -0,0 +1,45 @@ +* { box-sizing: border-box; } +html, body { height: 100%; } +body { margin: 0; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, 'Noto Sans', 'PingFang SC', 'Hiragino Sans GB', 'Microsoft YaHei', sans-serif; background: #0b1020; color: #e6e6e6; } + +.view { padding: 24px; } +.hidden { display: none; } + +.auth-panels { display: grid; grid-template-columns: repeat(auto-fit, minmax(280px, 1fr)); gap: 16px; } +.panel { background: #111936; border: 1px solid #1d2a53; border-radius: 12px; padding: 16px; } +.panel h2 { margin-top: 0; } +label { display: block; margin-bottom: 8px; } +input, select, textarea { width: 100%; padding: 10px 12px; border-radius: 8px; border: 1px solid #2a3a6b; background: #0f1733; color: #e6e6e6; } +button { padding: 10px 16px; border: 1px solid #375bd2; background: #2742a5; color: #fff; border-radius: 8px; cursor: pointer; } +button:hover { background: #3051c4; } +.error { color: #ff6b6b; margin-top: 8px; min-height: 20px; } + +.topbar { display: flex; justify-content: space-between; align-items: center; padding: 12px 16px; background: #0f1733; border-bottom: 1px solid #1d2a53; position: sticky; top: 0; z-index: 10; } +.topbar .role { margin-left: 8px; padding: 2px 8px; border: 1px solid #2a3a6b; border-radius: 999px; font-size: 12px; color: #9fb2ff; } +.controls { display: flex; gap: 12px; align-items: center; } +.stream-toggle { user-select: none; } + +.layout { display: grid; grid-template-columns: 280px 1fr; gap: 0; height: calc(100vh - 72px); } +.sidebar { border-right: 1px solid #1d2a53; display: flex; flex-direction: column; } +.sidebar-header { padding: 12px; border-bottom: 1px solid #1d2a53; } +.list { list-style: none; margin: 0; padding: 0; overflow-y: auto; flex: 1; } +.list li { padding: 12px; border-bottom: 1px solid #151f40; cursor: pointer; } +.list li.active { background: #0f1733; } +.list li small { display: block; color: #9aa6d1; } + +.chat { display: flex; flex-direction: column; height: 100%; } +.messages { flex: 1; overflow-y: auto; padding: 16px; display: flex; flex-direction: column; gap: 12px; } +.message { background: #0f1733; border: 1px solid #1d2a53; padding: 12px; border-radius: 12px; max-width: 900px; white-space: pre-wrap; } +.message.user { align-self: flex-end; background: #13204d; } +.message.assistant { align-self: flex-start; } +.message .meta { color: #9aa6d1; font-size: 12px; margin-bottom: 6px; } + +.chat-input { padding: 12px; border-top: 1px solid #1d2a53; display: grid; grid-template-columns: 1fr auto; gap: 12px; } +.chat-input textarea { resize: vertical; } +.chat-actions { display: flex; align-items: center; gap: 12px; } +.hint { color: #9aa6d1; font-size: 12px; } + +@media (max-width: 800px) { + .layout { grid-template-columns: 1fr; } + .sidebar { display: none; } +} diff --git a/KimmyXYC/web/index.html b/KimmyXYC/web/index.html new file mode 100644 index 0000000..581d667 --- /dev/null +++ b/KimmyXYC/web/index.html @@ -0,0 +1,101 @@ + + + + + + AIBackend 前端演示 + + + +
+ +
+

登录 / 注册

+
+
+

登录

+ + + +
+
+ +
+

注册

+ + + + +
+
+
+
+ + + +
+ + + + diff --git a/KimmyXYC/web/js/api.js b/KimmyXYC/web/js/api.js new file mode 100644 index 0000000..70fd15b --- /dev/null +++ b/KimmyXYC/web/js/api.js @@ -0,0 +1,121 @@ +import { getToken } from './state.js'; + +async function api(path, { method = 'GET', headers = {}, body = undefined } = {}) { + const h = { 'Accept': 'application/json', ...headers }; + const token = getToken(); + if (token) h['Authorization'] = `Bearer ${token}`; + const res = await fetch(path, { method, headers: h, body }); + if (!res.ok) { + let errText = await res.text().catch(() => ''); + try { const j = JSON.parse(errText); errText = j.error || errText; } catch {} + throw new Error(errText || `${res.status} ${res.statusText}`); + } + const ct = res.headers.get('Content-Type') || ''; + if (ct.includes('application/json')) return res.json(); + return res.text(); +} + +export async function register(email, password, role = 'free') { + return api('/api/auth/register', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ email, password, role }), + }); +} + +export async function login(email, password) { + return api('/api/auth/login', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ email, password }), + }); +} + +export async function me() { + return api('/api/me'); +} + +export async function listConversations() { + return api('/api/conversations'); +} + +export async function getMessages(convId) { + return api(`/api/conversations/${convId}/messages`); +} + +export async function sendChat({ conversation_id = 0, model = 'mock-mini', message = '' }) { + return api('/api/chat', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ conversation_id, model, message, stream: false }), + }); +} + +// Streaming via fetch ReadableStream. Parses text/event-stream (SSE-like) lines. +export async function chatStream({ conversation_id = 0, model = 'mock-mini', message = '' }, { onChunk, onDone } = {}) { + const res = await fetch('/api/chat?stream=1', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Authorization': `Bearer ${getToken()}`, + }, + body: JSON.stringify({ conversation_id, model, message, stream: true }), + }); + if (!res.ok || !res.body) { + let t = await res.text().catch(() => ''); + try { const j = JSON.parse(t); t = j.error || t; } catch {} + throw new Error(t || `${res.status} ${res.statusText}`); + } + + const reader = res.body.getReader(); + const decoder = new TextDecoder(); + let buffer = ''; + let convId = conversation_id; + + const flushEvents = () => { + let idx; + while ((idx = buffer.indexOf('\n\n')) >= 0) { + const evt = buffer.slice(0, idx); + buffer = buffer.slice(idx + 2); + const lines = evt.split('\n'); + let eventName = 'message'; + const dataLines = []; + for (const line of lines) { + if (line.startsWith('event:')) eventName = line.slice(6).trim(); + else if (line.startsWith('data:')) dataLines.push(line.slice(5).replace(/^\s*/, '')); + } + const data = dataLines.join('\n'); + if (eventName === 'done') { + try { + const obj = JSON.parse(data); + if (obj.conversation_id) convId = obj.conversation_id; + } catch {} + if (onDone) onDone({ conversation_id: convId }); + } else { + if (onChunk) onChunk(data); + } + } + }; + + while (true) { + const { value, done } = await reader.read(); + if (done) break; + buffer += decoder.decode(value, { stream: true }); + flushEvents(); + } + // flush any remainder + flushEvents(); + return { conversation_id: convId }; +} + +export const AllowedModelsByRole = { + free: ['mock-mini', 'gpt-4o-mini'], + pro: ['mock-mini', 'mock-pro', 'gpt-4o-mini', 'gpt-4o'], + admin: ['mock-mini', 'mock-pro', 'mock-admin', 'gpt-4o-mini', 'gpt-4o', 'gpt-4.1'], +}; + +export function roleAllowsModel(role, model) { + if (!model) return true; + const list = AllowedModelsByRole[role] || []; + return list.includes(model); +} diff --git a/KimmyXYC/web/js/auth.js b/KimmyXYC/web/js/auth.js new file mode 100644 index 0000000..6f55b00 --- /dev/null +++ b/KimmyXYC/web/js/auth.js @@ -0,0 +1,44 @@ +import { setToken, setUser } from './state.js'; +import { login, register, me } from './api.js'; + +export function initAuthUI({ onAuthenticated } = {}) { + const loginForm = document.getElementById('login-form'); + const loginEmail = document.getElementById('login-email'); + const loginPassword = document.getElementById('login-password'); + const loginError = document.getElementById('login-error'); + + const regForm = document.getElementById('register-form'); + const regEmail = document.getElementById('register-email'); + const regPassword = document.getElementById('register-password'); + const regRole = document.getElementById('register-role'); + const regError = document.getElementById('register-error'); + + loginForm.addEventListener('submit', async (e) => { + e.preventDefault(); + loginError.textContent = ''; + try { + const resp = await login(loginEmail.value.trim(), loginPassword.value); + setToken(resp.token); + // Get me to store role/email consistently + const profile = await me(); + setUser({ email: profile.user_email, role: profile.user_role, id: profile.user_id }); + if (onAuthenticated) onAuthenticated(); + } catch (err) { + loginError.textContent = err.message || '登录失败'; + } + }); + + regForm.addEventListener('submit', async (e) => { + e.preventDefault(); + regError.textContent = ''; + try { + const resp = await register(regEmail.value.trim(), regPassword.value, regRole.value); + setToken(resp.token); + const profile = await me(); + setUser({ email: profile.user_email, role: profile.user_role, id: profile.user_id }); + if (onAuthenticated) onAuthenticated(); + } catch (err) { + regError.textContent = err.message || '注册失败'; + } + }); +} diff --git a/KimmyXYC/web/js/chat.js b/KimmyXYC/web/js/chat.js new file mode 100644 index 0000000..1463e4e --- /dev/null +++ b/KimmyXYC/web/js/chat.js @@ -0,0 +1,146 @@ +import { chatStream, sendChat, listConversations, getMessages, roleAllowsModel } from './api.js'; +import { getUser } from './state.js'; + +export function initChatUI() { + const userInfoEmail = document.getElementById('user-email'); + const userInfoRole = document.getElementById('user-role'); + const modelSelect = document.getElementById('model-select'); + const streamToggle = document.getElementById('stream-toggle'); + const convList = document.getElementById('conv-list'); + const newChatBtn = document.getElementById('new-chat-btn'); + const messagesEl = document.getElementById('messages'); + const chatForm = document.getElementById('chat-form'); + const chatInput = document.getElementById('chat-input'); + const sendHint = document.getElementById('send-hint'); + + const user = getUser(); + userInfoEmail.textContent = user?.email || ''; + userInfoRole.textContent = user?.role || 'free'; + + let currentConv = 0; + let sending = false; + + const updateModelHint = () => { + const model = modelSelect.value; + const allowed = roleAllowsModel(user?.role || 'free', model); + if (!allowed) { + sendHint.textContent = `当前角色无权使用 ${model},尝试发送将被后端拒绝`; + } else { + sendHint.textContent = ''; + } + }; + modelSelect.addEventListener('change', updateModelHint); + updateModelHint(); + + const scrollToBottom = () => { + messagesEl.scrollTop = messagesEl.scrollHeight; + }; + + const fmtTime = (iso) => { + try { return new Date(iso).toLocaleString(); } catch { return ''; } + }; + + const renderMessage = (m) => { + const div = document.createElement('div'); + div.className = `message ${m.role}`; + const meta = document.createElement('div'); + meta.className = 'meta'; + meta.textContent = `${m.role}`; + const content = document.createElement('div'); + content.className = 'content'; + content.textContent = m.content || ''; + div.appendChild(meta); + div.appendChild(content); + messagesEl.appendChild(div); + scrollToBottom(); + return content; // return content node for streaming update + }; + + const clearMessages = () => { messagesEl.innerHTML = ''; }; + + async function loadConversations() { + convList.innerHTML = ''; + try { + const resp = await listConversations(); + const convs = resp.conversations || []; + for (const c of convs) { + const li = document.createElement('li'); + li.dataset.id = c.id; + li.className = (c.id === currentConv) ? 'active' : ''; + const title = c.title || `对话 #${c.id}`; + const titleDiv = document.createElement('div'); + titleDiv.textContent = title; + const modelSmall = document.createElement('small'); + modelSmall.textContent = c.model || ''; + li.appendChild(titleDiv); + li.appendChild(modelSmall); + li.addEventListener('click', async () => { + currentConv = c.id; + document.querySelectorAll('#conv-list li').forEach(x => x.classList.remove('active')); + li.classList.add('active'); + await loadMessages(c.id); + }); + convList.appendChild(li); + } + } catch (err) { + console.error('加载会话失败', err); + } + } + + async function loadMessages(convId) { + clearMessages(); + if (!convId) return; + try { + const resp = await getMessages(convId); + const msgs = resp.messages || []; + for (const m of msgs) renderMessage(m); + } catch (err) { + console.error('加载消息失败', err); + } + } + + newChatBtn.addEventListener('click', () => { + currentConv = 0; // backend will create on first send + document.querySelectorAll('#conv-list li').forEach(x => x.classList.remove('active')); + clearMessages(); + chatInput.focus(); + }); + + chatForm.addEventListener('submit', async (e) => { + e.preventDefault(); + if (sending) return; + const text = chatInput.value.trim(); + if (!text) return; + sending = true; + try { + const model = modelSelect.value || 'mock-mini'; + // render user message immediately + renderMessage({ role: 'user', content: text }); + chatInput.value = ''; + + const doStream = streamToggle.checked; + if (!doStream) { + const r = await sendChat({ conversation_id: currentConv, model, message: text }); + currentConv = r.conversation_id || currentConv; + renderMessage({ role: 'assistant', content: r.reply || '' }); + } else { + let assistantNode = renderMessage({ role: 'assistant', content: '' }); + await chatStream( + { conversation_id: currentConv, model, message: text }, + { + onChunk: (chunk) => { assistantNode.textContent += chunk; scrollToBottom(); }, + onDone: ({ conversation_id }) => { if (conversation_id) currentConv = conversation_id; }, + } + ); + } + await loadConversations(); + } catch (err) { + renderMessage({ role: 'assistant', content: `错误:${err.message || err}` }); + } finally { + sending = false; + } + }); + + // Load initial + loadConversations(); +} diff --git a/KimmyXYC/web/js/main.js b/KimmyXYC/web/js/main.js new file mode 100644 index 0000000..2ba573e --- /dev/null +++ b/KimmyXYC/web/js/main.js @@ -0,0 +1,49 @@ +import { isLoggedIn, clearToken, clearUser, setUser } from './state.js'; +import { me } from './api.js'; +import { initAuthUI } from './auth.js'; +import { initChatUI } from './chat.js'; + +function show(id) { + document.querySelectorAll('.view').forEach(v => v.classList.add('hidden')); + document.getElementById(id).classList.remove('hidden'); +} + +async function enterApp() { + show('app-view'); + initChatUI(); +} + +async function enterAuth() { + show('auth-view'); + initAuthUI({ onAuthenticated: enterApp }); +} + +async function bootstrap() { + const logoutBtn = document.getElementById('logout-btn'); + if (logoutBtn) { + logoutBtn.addEventListener('click', () => { + clearToken(); + clearUser(); + location.reload(); + }); + } + + if (!isLoggedIn()) { + await enterAuth(); + return; + } + + // Validate token and fetch profile + try { + const profile = await me(); + setUser({ email: profile.user_email, role: profile.user_role, id: profile.user_id }); + await enterApp(); + } catch (err) { + console.warn('Token invalid, returning to auth', err); + clearToken(); + clearUser(); + await enterAuth(); + } +} + +window.addEventListener('DOMContentLoaded', bootstrap); diff --git a/KimmyXYC/web/js/state.js b/KimmyXYC/web/js/state.js new file mode 100644 index 0000000..ac4a7a6 --- /dev/null +++ b/KimmyXYC/web/js/state.js @@ -0,0 +1,32 @@ +const TOKEN_KEY = 'aib_token'; +const USER_KEY = 'aib_user'; + +export function getToken() { + return localStorage.getItem(TOKEN_KEY) || ''; +} + +export function setToken(t) { + if (t) localStorage.setItem(TOKEN_KEY, t); +} + +export function clearToken() { + localStorage.removeItem(TOKEN_KEY); +} + +export function getUser() { + const raw = localStorage.getItem(USER_KEY); + if (!raw) return null; + try { return JSON.parse(raw); } catch { return null; } +} + +export function setUser(u) { + if (u) localStorage.setItem(USER_KEY, JSON.stringify(u)); +} + +export function clearUser() { + localStorage.removeItem(USER_KEY); +} + +export function isLoggedIn() { + return !!getToken(); +}