Sfoglia il codice sorgente

共享草稿+日志级别+ui优化+bug修复

537yaha 3 mesi fa
parent
commit
241de158bf

+ 8 - 0
.env.example

@@ -22,6 +22,14 @@ SERVER_PORT=8080
 # 建议:生产 release,开发 debug
 GIN_MODE=release
 
+# =========================
+# 结构化日志(日志中心 / system_logs 表)
+# =========================
+# 仅将「不低于该级别」的日志写入数据库,减轻成功类 info 的写入量。
+# 可选:debug | info(默认)| warn | error | none(关闭全部落库,日志中心无新记录)
+# 若在「日志中心」保存了落库级别,会写入 app_settings 表并覆盖此处,直至在页面点「恢复环境变量」
+SYSTEM_LOG_MIN_LEVEL=info
+
 # =========================
 # 数据库(必填)
 # =========================

+ 8 - 5
README.md

@@ -5,19 +5,20 @@
 
 ## 界面预览
 
-> 以下为当前版本关键界面截图(本地预览路径)。
+> 截图放在仓库内的 **`assets/readme/`**(随 Git 提交),README 里使用**相对路径**引用。  
+> 请勿使用 `file:///...` 或仅本机存在的路径,否则在 GitHub / Gitee 等页面上无法显示。
 
 **官网首页(核心能力模块)**
 
-![官网首页(核心能力模块)](file:///C:/Users/29301/.cursor/projects/d-tools-AI-CS/assets/c__Users_29301_AppData_Roaming_Cursor_User_workspaceStorage_9cb4ac4ea85a91ea0567ed8202874002_images_image-c4852687-51e5-47ec-b85c-35413a8b19b6.png)
+![官网首页(核心能力模块)](assets/readme/home-features.png)
 
 **客服小窗(人工客服模式)**
 
-![客服小窗(人工客服模式)](file:///C:/Users/29301/.cursor/projects/d-tools-AI-CS/assets/c__Users_29301_AppData_Roaming_Cursor_User_workspaceStorage_9cb4ac4ea85a91ea0567ed8202874002_images_image-81a593c2-6820-40da-907d-118032ed5e54.png)
+![客服小窗(人工客服模式)](assets/readme/widget-human.png)
 
 **客服小窗(AI 客服模式)**
 
-![客服小窗(AI 客服模式)](file:///C:/Users/29301/.cursor/projects/d-tools-AI-CS/assets/c__Users_29301_AppData_Roaming_Cursor_User_workspaceStorage_9cb4ac4ea85a91ea0567ed8202874002_images_image-65d7a08f-e812-4d40-95ed-24f10df81516.png)
+![客服小窗(AI 客服模式)](assets/readme/widget-ai.png)
 
 ## 在线演示
 
@@ -33,6 +34,7 @@
   - 可选“本回合联网搜索”开关(是否对访客展示可在后台控制)
 - **客服侧(工作台)**
   - 会话列表、实时消息(WebSocket)、未读角标提示
+  - 支持“实时共享草稿输入”(双方未发送内容可实时可见)
   - 多模型管理(文本/绘画等)与对话配置
   - **提示词配置**(Prompt 管理)
   - **知识库管理 + RAG**(向量检索,可按需启用;向量库不可用时可不影响启动)
@@ -147,6 +149,7 @@ npm run dev
 | `SERVER_HOST` | 后端监听地址 | 是 | `0.0.0.0` | `127.0.0.1` |
 | `SERVER_PORT` | 后端容器内端口 | 是 | `8080` | `8080` |
 | `GIN_MODE` | 后端模式 | 建议 | `release` | `debug` |
+| `SYSTEM_LOG_MIN_LEVEL` | 结构化日志最低落库级别(`system_logs`) | 否 | `info` | `warn` 可减少成功类写入;`none` 关闭落库;**客服端「日志中心」可改并持久化,覆盖本项直至恢复** |
 | `DB_HOST` | 后端数据库地址 | 是 | `mysql` | `localhost` |
 | `DB_PORT` | 后端数据库端口 | 是 | `3306` | `3306` |
 | `DB_USER` | 数据库用户名 | 是 | `ai_cs_user` | `root` |
@@ -243,4 +246,4 @@ npm run dev
 
 ---
 
-**最后更新**:2026-03-27
+**最后更新**:2026-04-02(含 `SYSTEM_LOG_MIN_LEVEL` 说明)

BIN
assets/readme/home-features.png


BIN
assets/readme/widget-ai.png


BIN
assets/readme/widget-human.png


+ 30 - 32
backend/controller/message_controller.go

@@ -16,10 +16,10 @@ import (
 
 // MessageController 负责处理消息相关的 HTTP 请求。
 type MessageController struct {
-	messageService     *service.MessageService
+	messageService      *service.MessageService
 	conversationService *service.ConversationService
-	userService        *service.UserService
-	storageService     infra.StorageService
+	userService         *service.UserService
+	storageService      infra.StorageService
 }
 
 // NewMessageController 创建 MessageController 实例。
@@ -30,10 +30,10 @@ func NewMessageController(
 	storageService infra.StorageService,
 ) *MessageController {
 	return &MessageController{
-		messageService:     messageService,
+		messageService:      messageService,
 		conversationService: conversationService,
-		userService:        userService,
-		storageService:     storageService,
+		userService:         userService,
+		storageService:      storageService,
 	}
 }
 
@@ -42,11 +42,11 @@ type createMessageRequest struct {
 	Content        string  `json:"content"`
 	SenderIsAgent  bool    `json:"sender_is_agent"`
 	SenderID       uint    `json:"sender_id"`
-	FileURL  *string `json:"file_url"`
-	FileType *string `json:"file_type"`
-	FileName *string `json:"file_name"`
-	FileSize *int64  `json:"file_size"`
-	MimeType *string `json:"mime_type"`
+	FileURL        *string `json:"file_url"`
+	FileType       *string `json:"file_type"`
+	FileName       *string `json:"file_name"`
+	FileSize       *int64  `json:"file_size"`
+	MimeType       *string `json:"mime_type"`
 	// 回复数据源开关(仅 AI 模式有效),不传则默认:知识库+大模型开,联网关
 	UseKnowledgeBase *bool `json:"use_knowledge_base"`
 	UseLLM           *bool `json:"use_llm"`
@@ -62,11 +62,8 @@ func (mc *MessageController) CreateMessage(c *gin.Context) {
 		return
 	}
 	userID := getUserIDFromHeader(c)
-	// 若带了客服身份头,则必须按客服消息处理,禁止伪装成访客消息。
-	if userID > 0 && !req.SenderIsAgent {
-		c.JSON(http.StatusForbidden, gin.H{"error": "已登录客服不允许以访客身份发送消息"})
-		return
-	}
+	// 兼容 demo 自测场景:已登录客服也允许按访客身份发送消息(sender_is_agent=false)。
+	// 访客消息 sender_id 仍由服务端强制置 0,避免前端注入身份。
 	// 客服消息必须绑定当前登录用户(X-User-Id),并以服务端用户 ID 为准,避免伪造 sender_id。
 	if req.SenderIsAgent {
 		if userID == 0 {
@@ -261,6 +258,7 @@ func (mc *MessageController) MarkMessagesRead(c *gin.Context) {
 // 请求格式:multipart/form-data
 //   - file: 文件内容(必需)
 //   - conversation_id: 对话ID(可选,用于组织目录)
+//
 // 认证方式:
 //   - 方式1:提供 X-User-Id 请求头(客服上传)
 //   - 方式2:提供 conversation_id 参数(访客上传,会验证对话是否存在且未关闭)
@@ -270,13 +268,13 @@ func (mc *MessageController) UploadFile(c *gin.Context) {
 	// 2. 提供 conversation_id 参数(访客)
 	userID := getUserIDFromHeader(c)
 	conversationIDStr := c.PostForm("conversation_id")
-	
+
 	// 如果既没有用户ID,也没有对话ID,拒绝访问
 	if userID == 0 && conversationIDStr == "" {
 		c.JSON(http.StatusUnauthorized, gin.H{"error": "未授权访问,请提供 X-User-Id 请求头或 conversation_id 参数"})
 		return
 	}
-	
+
 	// 如果是访客上传(没有用户ID,但有对话ID),验证对话是否存在且未关闭
 	if userID == 0 && conversationIDStr != "" {
 		convID, err := strconv.ParseUint(conversationIDStr, 10, 64)
@@ -295,7 +293,7 @@ func (mc *MessageController) UploadFile(c *gin.Context) {
 			return
 		}
 	}
-	
+
 	// 解析文件
 	file, err := c.FormFile("file")
 	if err != nil {
@@ -331,15 +329,15 @@ func (mc *MessageController) UploadFile(c *gin.Context) {
 	// ⚠️ 加强:验证 MIME 类型(防止伪造扩展名)
 	mimeType := file.Header.Get("Content-Type")
 	allowedMimeTypes := map[string]bool{
-		"image/jpeg":      true,
-		"image/jpg":       true,
-		"image/png":       true,
-		"image/gif":       true,
-		"image/webp":      true,
-		"application/pdf": true,
+		"image/jpeg":         true,
+		"image/jpg":          true,
+		"image/png":          true,
+		"image/gif":          true,
+		"image/webp":         true,
+		"application/pdf":    true,
 		"application/msword": true,
 		"application/vnd.openxmlformats-officedocument.wordprocessingml.document": true, // .docx
-		"text/plain":     true,
+		"text/plain": true,
 	}
 	if !allowedMimeTypes[mimeType] {
 		c.JSON(http.StatusBadRequest, gin.H{"error": "不支持的文件 MIME 类型: " + mimeType})
@@ -368,7 +366,7 @@ func (mc *MessageController) UploadFile(c *gin.Context) {
 			safeFilename = nameWithoutExt[:100-len(ext)] + ext
 		}
 	}
-	
+
 	// ⚠️ 加强:验证文件内容(magic number 检查,防止伪造扩展名)
 	fileContent, err := file.Open()
 	if err != nil {
@@ -376,7 +374,7 @@ func (mc *MessageController) UploadFile(c *gin.Context) {
 		return
 	}
 	defer fileContent.Close()
-	
+
 	// 读取文件前几个字节(magic number)
 	magicBytes := make([]byte, 12)
 	n, err := fileContent.Read(magicBytes)
@@ -384,13 +382,13 @@ func (mc *MessageController) UploadFile(c *gin.Context) {
 		c.JSON(http.StatusBadRequest, gin.H{"error": "无法读取文件内容"})
 		return
 	}
-	
+
 	// 验证文件内容是否匹配扩展名
 	if !isValidFileContent(ext, magicBytes[:n]) {
 		c.JSON(http.StatusBadRequest, gin.H{"error": "文件内容与扩展名不匹配,可能是伪造的文件类型"})
 		return
 	}
-	
+
 	// 重置文件指针,以便后续保存
 	if _, err := fileContent.Seek(0, io.SeekStart); err != nil {
 		c.JSON(http.StatusInternalServerError, gin.H{"error": "无法重置文件指针"})
@@ -437,9 +435,9 @@ func isValidFileContent(ext string, magicBytes []byte) bool {
 	if len(magicBytes) < 4 {
 		return false
 	}
-	
+
 	ext = strings.ToLower(ext)
-	
+
 	// 检查各种文件类型的 magic number
 	switch ext {
 	case ".jpg", ".jpeg":

+ 83 - 4
backend/controller/system_log_controller.go

@@ -5,17 +5,20 @@ import (
 	"strconv"
 	"strings"
 
+	"github.com/2930134478/AI-CS/backend/models"
+	"github.com/2930134478/AI-CS/backend/repository"
 	"github.com/2930134478/AI-CS/backend/service"
 	"github.com/gin-gonic/gin"
 )
 
 type SystemLogController struct {
-	logs *service.SystemLogService
-	users *service.UserService
+	logs        *service.SystemLogService
+	users       *service.UserService
+	appSettings *repository.AppSettingRepository
 }
 
-func NewSystemLogController(logs *service.SystemLogService, users *service.UserService) *SystemLogController {
-	return &SystemLogController{logs: logs, users: users}
+func NewSystemLogController(logs *service.SystemLogService, users *service.UserService, appSettings *repository.AppSettingRepository) *SystemLogController {
+	return &SystemLogController{logs: logs, users: users, appSettings: appSettings}
 }
 
 // GetLogs 查询日志(客服端)。
@@ -105,3 +108,79 @@ func (lc *SystemLogController) ReportFrontendLog(c *gin.Context) {
 	c.JSON(http.StatusOK, gin.H{"ok": true})
 }
 
+// GetLogMinLevel 返回当前生效的落库级别、环境变量默认值、是否由数据库覆盖。
+func (lc *SystemLogController) GetLogMinLevel(c *gin.Context) {
+	if !requirePermission(c, lc.users, string(service.PermLogs)) {
+		return
+	}
+	envRank := service.SystemLogMinPersistLevelFromEnv()
+	effective := lc.logs.MinPersistLevelRank()
+	var persisted bool
+	if lc.appSettings != nil {
+		if row, err := lc.appSettings.Get(models.AppSettingKeySystemLogMinLevel); err == nil && row != nil && strings.TrimSpace(row.Value) != "" {
+			persisted = true
+		}
+	}
+	c.JSON(http.StatusOK, gin.H{
+		"effective_min_level":   service.SystemLogMinLevelLabel(effective),
+		"env_min_level":         service.SystemLogMinLevelLabel(envRank),
+		"persisted_in_database": persisted,
+	})
+}
+
+type putLogMinLevelBody struct {
+	MinLevel string `json:"min_level"`
+}
+
+// PutLogMinLevel 将最低落库级别写入数据库并立即生效(覆盖 .env,直至删除该配置)。
+func (lc *SystemLogController) PutLogMinLevel(c *gin.Context) {
+	if !requirePermission(c, lc.users, string(service.PermLogs)) {
+		return
+	}
+	if lc.appSettings == nil {
+		c.JSON(http.StatusInternalServerError, gin.H{"error": "配置存储不可用"})
+		return
+	}
+	var body putLogMinLevelBody
+	if err := c.ShouldBindJSON(&body); err != nil {
+		c.JSON(http.StatusBadRequest, gin.H{"error": "请求体无效"})
+		return
+	}
+	rank, err := service.ParseSystemLogMinPersistLevelStrict(body.MinLevel)
+	if err != nil {
+		c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
+		return
+	}
+	label := service.SystemLogMinLevelLabel(rank)
+	if err := lc.appSettings.SetValue(models.AppSettingKeySystemLogMinLevel, label); err != nil {
+		c.JSON(http.StatusInternalServerError, gin.H{"error": "保存配置失败"})
+		return
+	}
+	lc.logs.SetMinPersistLevelRank(rank)
+	c.JSON(http.StatusOK, gin.H{
+		"ok":                  true,
+		"effective_min_level": label,
+	})
+}
+
+// DeleteLogMinLevel 删除数据库中的覆盖项,恢复为环境变量 SYSTEM_LOG_MIN_LEVEL。
+func (lc *SystemLogController) DeleteLogMinLevel(c *gin.Context) {
+	if !requirePermission(c, lc.users, string(service.PermLogs)) {
+		return
+	}
+	if lc.appSettings == nil {
+		c.JSON(http.StatusInternalServerError, gin.H{"error": "配置存储不可用"})
+		return
+	}
+	if err := lc.appSettings.Delete(models.AppSettingKeySystemLogMinLevel); err != nil {
+		c.JSON(http.StatusInternalServerError, gin.H{"error": "删除配置失败"})
+		return
+	}
+	envRank := service.SystemLogMinPersistLevelFromEnv()
+	lc.logs.SetMinPersistLevelRank(envRank)
+	c.JSON(http.StatusOK, gin.H{
+		"ok":                  true,
+		"effective_min_level": service.SystemLogMinLevelLabel(envRank),
+	})
+}
+

+ 16 - 3
backend/main.go

@@ -5,6 +5,7 @@ import (
 	"log"
 	"os"
 	"path/filepath"
+	"strings"
 	"time"
 
 	"github.com/2930134478/AI-CS/backend/controller"
@@ -138,7 +139,7 @@ func main() {
 	}
 
 	//根据结构体定义自动创建更新表
-	if err := db.AutoMigrate(&models.User{}, &models.Conversation{}, &models.Message{}, &models.AIConfig{}, &models.FAQ{}, &models.KnowledgeBase{}, &models.Document{}, &models.EmbeddingConfig{}, &models.PromptConfig{}, &models.WidgetOpenEvent{}, &models.SystemLog{}); err != nil {
+	if err := db.AutoMigrate(&models.User{}, &models.Conversation{}, &models.Message{}, &models.AIConfig{}, &models.FAQ{}, &models.KnowledgeBase{}, &models.Document{}, &models.EmbeddingConfig{}, &models.PromptConfig{}, &models.WidgetOpenEvent{}, &models.SystemLog{}, &models.AppSetting{}); err != nil {
 		log.Fatalf("自动创建表失败: %v", err)
 	}
 
@@ -152,7 +153,19 @@ func main() {
 	embeddingConfigRepo := repository.NewEmbeddingConfigRepository(db)
 	promptConfigRepo := repository.NewPromptConfigRepository(db)
 	systemLogRepo := repository.NewSystemLogRepository(db)
-	systemLogService := service.NewSystemLogService(systemLogRepo)
+	appSettingRepo := repository.NewAppSettingRepository(db)
+	systemLogMin := service.SystemLogMinPersistLevelFromEnv()
+	systemLogService := service.NewSystemLogService(systemLogRepo, systemLogMin)
+	if row, err := appSettingRepo.Get(models.AppSettingKeySystemLogMinLevel); err == nil && row != nil && strings.TrimSpace(row.Value) != "" {
+		dbRank := service.ParseSystemLogMinPersistLevel(row.Value)
+		systemLogService.SetMinPersistLevelRank(dbRank)
+		log.Printf("ℹ️ 结构化日志最低落库级别: %s(数据库覆盖,环境变量默认 %s)",
+			service.SystemLogMinLevelLabel(dbRank), service.SystemLogMinLevelLabel(systemLogMin))
+	} else if systemLogMin == -1 {
+		log.Println("ℹ️ SYSTEM_LOG_MIN_LEVEL=none,已关闭结构化日志写入数据库(日志中心将无新记录)")
+	} else {
+		log.Printf("ℹ️ 结构化日志最低落库级别: %s(SYSTEM_LOG_MIN_LEVEL)", service.SystemLogMinLevelLabel(systemLogMin))
+	}
 
 	// 初始化默认管理员账号(如果不存在)
 	initDefaultAdmin(userRepo)
@@ -490,7 +503,7 @@ func main() {
 	widgetOpenRepo := repository.NewWidgetOpenRepository(db)
 	analyticsService := service.NewAnalyticsService(db, widgetOpenRepo)
 	analyticsController := controller.NewAnalyticsController(analyticsService, userService)
-	systemLogController := controller.NewSystemLogController(systemLogService, userService)
+	systemLogController := controller.NewSystemLogController(systemLogService, userService, appSettingRepo)
 
 	appRouter.RegisterRoutes(
 		r,

+ 3 - 0
backend/middleware/middleware.go

@@ -60,6 +60,9 @@ func StructuredHTTPLogger(logSvc *service.SystemLogService) gin.HandlerFunc {
 		} else if status >= 400 || latencyMs >= 2000 {
 			level = "warn"
 		}
+		if !logSvc.ShouldPersistLevel(level) {
+			return
+		}
 		var userID *uint
 		if v := c.GetHeader("X-User-Id"); v != "" {
 			if id, err := strconv.ParseUint(v, 10, 64); err == nil && id > 0 {

+ 15 - 0
backend/models/app_setting.go

@@ -0,0 +1,15 @@
+package models
+
+import "time"
+
+// AppSetting 通用键值配置(少量平台级开关,避免为单项配置建新表)。
+type AppSetting struct {
+	Key       string    `json:"key" gorm:"primaryKey;type:varchar(64)"`
+	Value     string    `json:"value" gorm:"type:text"`
+	UpdatedAt time.Time `json:"updated_at"`
+}
+
+const (
+	// AppSettingKeySystemLogMinLevel 结构化日志最低落库级别(值:debug/info/warn/error/none)
+	AppSettingKeySystemLogMinLevel = "system_log_min_level"
+)

+ 34 - 0
backend/repository/app_setting_repository.go

@@ -0,0 +1,34 @@
+package repository
+
+import (
+	"github.com/2930134478/AI-CS/backend/models"
+	"gorm.io/gorm"
+)
+
+type AppSettingRepository struct {
+	db *gorm.DB
+}
+
+func NewAppSettingRepository(db *gorm.DB) *AppSettingRepository {
+	return &AppSettingRepository{db: db}
+}
+
+func (r *AppSettingRepository) Get(key string) (*models.AppSetting, error) {
+	var m models.AppSetting
+	err := r.db.Where("`key` = ?", key).First(&m).Error
+	if err == gorm.ErrRecordNotFound {
+		return nil, nil
+	}
+	if err != nil {
+		return nil, err
+	}
+	return &m, nil
+}
+
+func (r *AppSettingRepository) SetValue(key, value string) error {
+	return r.db.Save(&models.AppSetting{Key: key, Value: value}).Error
+}
+
+func (r *AppSettingRepository) Delete(key string) error {
+	return r.db.Where("`key` = ?", key).Delete(&models.AppSetting{}).Error
+}

+ 5 - 2
backend/router/router.go

@@ -117,8 +117,11 @@ func RegisterRoutes(r *gin.Engine, controllers ControllerSet, wsHandler gin.Hand
 
 		// Analytics(数据分析报表,需客服 X-User-Id)
 		routes.GET("/agent/analytics/summary", controllers.Analytics.GetSummary)
-		routes.GET("/agent/logs/api", controllers.SystemLog.GetLogs)                 // 日志查询(避免与前端 /agent/logs 页面路径冲突)
-		routes.POST("/agent/logs/frontend", controllers.SystemLog.ReportFrontendLog) // 前端日志上报
+		routes.GET("/agent/logs/api", controllers.SystemLog.GetLogs)                   // 日志查询(避免与前端 /agent/logs 页面路径冲突)
+		routes.GET("/agent/logs/min-level", controllers.SystemLog.GetLogMinLevel)      // 最低落库级别(读)
+		routes.PUT("/agent/logs/min-level", controllers.SystemLog.PutLogMinLevel)       // 最低落库级别(写库并生效)
+		routes.DELETE("/agent/logs/min-level", controllers.SystemLog.DeleteLogMinLevel) // 恢复为 .env
+		routes.POST("/agent/logs/frontend", controllers.SystemLog.ReportFrontendLog)   // 前端日志上报
 
 		// Health(健康检查)
 		routes.GET("/health", controllers.Health.HealthCheck)     // 健康检查

+ 95 - 0
backend/service/system_log_policy.go

@@ -0,0 +1,95 @@
+package service
+
+import (
+	"fmt"
+	"os"
+	"strings"
+)
+
+// 数据库落库最低级别(数值越大越严重)。低于 min 的日志不写 system_logs。
+const (
+	logRankDebug = 0
+	logRankInfo  = 1
+	logRankWarn  = 2
+	logRankError = 3
+	logRankNone  = -1 // 环境变量 none/off:全部不落库
+)
+
+func logLevelRank(level string) int {
+	switch strings.ToLower(strings.TrimSpace(level)) {
+	case "debug", "trace":
+		return logRankDebug
+	case "info":
+		return logRankInfo
+	case "warn", "warning":
+		return logRankWarn
+	case "error", "err", "fatal", "critical":
+		return logRankError
+	default:
+		return logRankInfo
+	}
+}
+
+// ParseSystemLogMinPersistLevel 解析 SYSTEM_LOG_MIN_LEVEL。
+// debug|info|warn|error|none;空默认为 info;无法识别时回退为 info。
+func ParseSystemLogMinPersistLevel(s string) int {
+	switch strings.ToLower(strings.TrimSpace(s)) {
+	case "", "info":
+		return logRankInfo
+	case "debug", "trace":
+		return logRankDebug
+	case "warn", "warning":
+		return logRankWarn
+	case "error", "err":
+		return logRankError
+	case "none", "off", "disable", "false", "0":
+		return logRankNone
+	default:
+		return logRankInfo
+	}
+}
+
+// ParseSystemLogMinPersistLevelStrict 用于 API:非法取值返回错误。
+func ParseSystemLogMinPersistLevelStrict(s string) (int, error) {
+	v := strings.ToLower(strings.TrimSpace(s))
+	if v == "" {
+		return 0, fmt.Errorf("min_level 不能为空")
+	}
+	switch v {
+	case "debug", "trace":
+		return logRankDebug, nil
+	case "info":
+		return logRankInfo, nil
+	case "warn", "warning":
+		return logRankWarn, nil
+	case "error", "err":
+		return logRankError, nil
+	case "none", "off", "disable", "false", "0":
+		return logRankNone, nil
+	default:
+		return 0, fmt.Errorf("无效的 min_level,可选: debug, info, warn, error, none")
+	}
+}
+
+// SystemLogMinPersistLevelFromEnv 读取环境变量 SYSTEM_LOG_MIN_LEVEL。
+func SystemLogMinPersistLevelFromEnv() int {
+	return ParseSystemLogMinPersistLevel(os.Getenv("SYSTEM_LOG_MIN_LEVEL"))
+}
+
+// SystemLogMinLevelLabel 用于启动日志说明。
+func SystemLogMinLevelLabel(min int) string {
+	switch min {
+	case logRankDebug:
+		return "debug"
+	case logRankInfo:
+		return "info"
+	case logRankWarn:
+		return "warn"
+	case logRankError:
+		return "error"
+	case logRankNone:
+		return "none"
+	default:
+		return "info"
+	}
+}

+ 42 - 3
backend/service/system_log_service.go

@@ -4,6 +4,7 @@ import (
 	"encoding/json"
 	"fmt"
 	"strings"
+	"sync/atomic"
 	"time"
 
 	"github.com/2930134478/AI-CS/backend/models"
@@ -46,11 +47,46 @@ type QuerySystemLogsResult struct {
 
 // SystemLogService 结构化日志服务(查询 + 写入)。
 type SystemLogService struct {
-	repo *repository.SystemLogRepository
+	repo            *repository.SystemLogRepository
+	minPersistLevel atomic.Int32 // 见 SYSTEM_LOG_MIN_LEVEL / 数据库覆盖;logRankNone 表示关闭全部落库
 }
 
-func NewSystemLogService(repo *repository.SystemLogRepository) *SystemLogService {
-	return &SystemLogService{repo: repo}
+func NewSystemLogService(repo *repository.SystemLogRepository, minPersistLevel int) *SystemLogService {
+	s := &SystemLogService{repo: repo}
+	s.SetMinPersistLevelRank(minPersistLevel)
+	return s
+}
+
+// SetMinPersistLevelRank 运行时调整最低落库级别(线程安全)。
+func (s *SystemLogService) SetMinPersistLevelRank(rank int) {
+	if s == nil {
+		return
+	}
+	s.minPersistLevel.Store(int32(rank))
+}
+
+// MinPersistLevelRank 当前生效的最低落库级别数值。
+func (s *SystemLogService) MinPersistLevelRank() int {
+	if s == nil {
+		return logRankInfo
+	}
+	return int(s.minPersistLevel.Load())
+}
+
+func (s *SystemLogService) shouldPersistLevel(level string) bool {
+	if s == nil {
+		return false
+	}
+	cur := int(s.minPersistLevel.Load())
+	if cur == logRankNone {
+		return false
+	}
+	return logLevelRank(level) >= cur
+}
+
+// ShouldPersistLevel 供中间件等在组装大 payload 前判断,避免无谓开销。
+func (s *SystemLogService) ShouldPersistLevel(level string) bool {
+	return s.shouldPersistLevel(strings.ToLower(strings.TrimSpace(level)))
 }
 
 func (s *SystemLogService) Create(input CreateSystemLogInput) error {
@@ -58,6 +94,9 @@ func (s *SystemLogService) Create(input CreateSystemLogInput) error {
 	if level == "" {
 		level = "info"
 	}
+	if !s.shouldPersistLevel(level) {
+		return nil
+	}
 	category := strings.ToLower(strings.TrimSpace(input.Category))
 	if category == "" {
 		category = "system"

+ 48 - 3
backend/websocket/client.go

@@ -3,11 +3,17 @@ package websocket
 import (
 	"encoding/json"
 	"log"
+	"strings"
 	"time"
 
 	"github.com/gorilla/websocket"
 )
 
+type inboundWSMessage struct {
+	Type string                 `json:"type"`
+	Data map[string]interface{} `json:"data"`
+}
+
 const (
 	// 客户端发送 ping 的最大等待时间
 	writeWait = 10 * time.Second
@@ -71,15 +77,14 @@ func (c *Client) ReadPump() {
 
 	// 持续读取消息
 	for {
-		_, _, err := c.conn.ReadMessage()
+		_, payload, err := c.conn.ReadMessage()
 		if err != nil {
 			if websocket.IsUnexpectedCloseError(err, websocket.CloseGoingAway, websocket.CloseAbnormalClosure) {
 				log.Printf("⚠️ WebSocket 读取错误: 对话ID=%d, 错误=%v", c.conversationID, err)
 			}
 			break
 		}
-		// 目前我们不需要处理客户端发送的消息,只接收心跳包
-		// 如果需要双向通信,可以在这里处理客户端消息
+		c.handleIncoming(payload)
 	}
 }
 
@@ -135,3 +140,43 @@ func (c *Client) SendMessage(messageType string, data interface{}) error {
 	c.conn.SetWriteDeadline(time.Now().Add(writeWait))
 	return c.conn.WriteMessage(websocket.TextMessage, messageJSON)
 }
+
+func (c *Client) handleIncoming(payload []byte) {
+	var in inboundWSMessage
+	if err := json.Unmarshal(payload, &in); err != nil {
+		return
+	}
+
+	switch in.Type {
+	case "typing_draft":
+		text, _ := in.Data["text"].(string)
+		text = strings.TrimSpace(text)
+		if text == "" {
+			c.hub.BroadcastMessage(c.conversationID, "typing_stop", map[string]interface{}{
+				"sender_id":       c.agentID,
+				"sender_is_agent": !c.isVisitor,
+			})
+			return
+		}
+		// 控制草稿长度,避免超长输入导致 WS 事件过大。
+		if len(text) > 300 {
+			text = text[:300]
+		}
+		out := map[string]interface{}{
+			"sender_id":       c.agentID,
+			"sender_is_agent": !c.isVisitor,
+			"text":            text,
+		}
+		if seq, ok := in.Data["seq"]; ok {
+			out["seq"] = seq
+		}
+		c.hub.BroadcastMessage(c.conversationID, "typing_draft", out)
+	case "typing_stop":
+		c.hub.BroadcastMessage(c.conversationID, "typing_stop", map[string]interface{}{
+			"sender_id":       c.agentID,
+			"sender_is_agent": !c.isVisitor,
+		})
+	default:
+		// 忽略未知客户端事件,避免污染服务端日志。
+	}
+}

+ 103 - 1
frontend/app/agent/logs/page.tsx

@@ -2,7 +2,14 @@
 
 import { useCallback, useEffect, useMemo, useState } from "react";
 import { Button } from "@/components/ui/button";
-import { fetchSystemLogs, type QuerySystemLogsResult } from "@/features/agent/services/systemLogApi";
+import {
+  deleteLogMinLevelPolicy,
+  fetchLogMinLevelPolicy,
+  fetchSystemLogs,
+  putLogMinLevelPolicy,
+  type LogMinLevelPolicy,
+  type QuerySystemLogsResult,
+} from "@/features/agent/services/systemLogApi";
 import { toast } from "@/hooks/useToast";
 import {
   Dialog,
@@ -46,9 +53,30 @@ export default function LogsPage({ embedded = false }: { embedded?: boolean }) {
   const [page, setPage] = useState(1);
   const pageSize = 50;
   const [selected, setSelected] = useState<(QuerySystemLogsResult["items"][number]) | null>(null);
+  const [policy, setPolicy] = useState<LogMinLevelPolicy | null>(null);
+  const [policyDraft, setPolicyDraft] = useState("info");
+  const [policyLoading, setPolicyLoading] = useState(false);
 
   const selectedMeta = useMemo(() => tryFormatJSON(selected?.meta_json), [selected]);
 
+  const loadPolicy = useCallback(async () => {
+    setPolicyLoading(true);
+    try {
+      const p = await fetchLogMinLevelPolicy();
+      setPolicy(p);
+      setPolicyDraft(p.effective_min_level);
+    } catch (e) {
+      toast.error((e as Error).message || "加载落库策略失败");
+      setPolicy(null);
+    } finally {
+      setPolicyLoading(false);
+    }
+  }, []);
+
+  useEffect(() => {
+    void loadPolicy();
+  }, [loadPolicy]);
+
   const load = useCallback(async () => {
     setLoading(true);
     try {
@@ -90,6 +118,80 @@ export default function LogsPage({ embedded = false }: { embedded?: boolean }) {
         <p className="text-sm text-muted-foreground mt-1">按分类查看 AI / RAG / 系统 / 前端日志,用于排障定位。</p>
       </div>
 
+      <div className="rounded-xl border border-border/60 bg-card p-4 mb-4 space-y-3">
+        <div className="flex flex-wrap items-start justify-between gap-3">
+          <div>
+            <h2 className="text-sm font-semibold">落库级别(性能)</h2>
+            <p className="text-xs text-muted-foreground mt-1 max-w-xl">
+              仅将不低于所选级别的记录写入数据库。设为 <code className="text-foreground">warn</code> 可大幅减少成功类{" "}
+              <code className="text-foreground">info</code> 写入。也可在根目录{" "}
+              <code className="text-foreground">SYSTEM_LOG_MIN_LEVEL</code> 配置默认值;此处保存后会写入数据库并覆盖环境变量,直至点击「恢复环境变量」。
+            </p>
+            {policy ? (
+              <p className="text-xs text-muted-foreground mt-2">
+                当前生效:<span className="font-medium text-foreground">{policy.effective_min_level}</span>
+                {" · "}
+                环境变量默认:<span className="font-medium text-foreground">{policy.env_min_level}</span>
+                {policy.persisted_in_database ? (
+                  <span className="text-amber-700 dark:text-amber-500">(已由控制台覆盖)</span>
+                ) : null}
+              </p>
+            ) : null}
+          </div>
+          <div className="flex flex-wrap items-center gap-2">
+            <select
+              value={policyDraft}
+              onChange={(e) => setPolicyDraft(e.target.value)}
+              disabled={policyLoading}
+              className="rounded-md border px-2 py-1.5 text-sm min-w-[140px]"
+            >
+              <option value="debug">debug</option>
+              <option value="info">info</option>
+              <option value="warn">warn</option>
+              <option value="error">error</option>
+              <option value="none">none(关闭落库)</option>
+            </select>
+            <Button
+              size="sm"
+              disabled={policyLoading}
+              onClick={async () => {
+                setPolicyLoading(true);
+                try {
+                  await putLogMinLevelPolicy(policyDraft);
+                  toast.success("已保存并生效");
+                  await loadPolicy();
+                } catch (e) {
+                  toast.error((e as Error).message || "保存失败");
+                } finally {
+                  setPolicyLoading(false);
+                }
+              }}
+            >
+              保存到服务器
+            </Button>
+            <Button
+              size="sm"
+              variant="outline"
+              disabled={policyLoading}
+              onClick={async () => {
+                setPolicyLoading(true);
+                try {
+                  await deleteLogMinLevelPolicy();
+                  toast.success("已恢复为环境变量默认值");
+                  await loadPolicy();
+                } catch (e) {
+                  toast.error((e as Error).message || "恢复失败");
+                } finally {
+                  setPolicyLoading(false);
+                }
+              }}
+            >
+              恢复环境变量
+            </Button>
+          </div>
+        </div>
+      </div>
+
       <div className="rounded-xl border border-border/60 bg-card p-3 mb-4 flex flex-wrap gap-2 items-center">
         <input type="date" value={from} onChange={(e) => setFrom(e.target.value)} className="rounded-md border px-2 py-1 text-sm" />
         <span className="text-xs text-muted-foreground">到</span>

+ 49 - 11
frontend/components/dashboard/DashboardShell.tsx

@@ -152,6 +152,9 @@ export function DashboardShell() {
     aiThinking,
     needWebSearch,
     setNeedWebSearch,
+    remoteTypingDraft,
+    sendTypingDraft,
+    sendTypingStop,
   } = useMessages({
     conversationId: selectedConversationId,
     agentId: agent?.id ?? null,
@@ -180,11 +183,32 @@ export function DashboardShell() {
     const content = messageInput.trim();
     try {
       await sendMessage(content, fileInfo);
+      sendTypingStop();
       setMessageInput("");
     } catch (error) {
       toast.error((error as Error).message);
     }
-  }, [messageInput, sendMessage]);
+  }, [messageInput, sendMessage, sendTypingStop]);
+
+  useEffect(() => {
+    if (!selectedConversationId || isInternalChat) {
+      return;
+    }
+    const timer = setTimeout(() => {
+      if (messageInput.trim()) {
+        sendTypingDraft(messageInput);
+      } else {
+        sendTypingStop();
+      }
+    }, 350);
+    return () => clearTimeout(timer);
+  }, [isInternalChat, messageInput, selectedConversationId, sendTypingDraft, sendTypingStop]);
+
+  useEffect(() => {
+    return () => {
+      sendTypingStop();
+    };
+  }, [sendTypingStop]);
 
   // 标记当前会话全部消息为已读
   const handleMarkAllRead = useCallback(() => {
@@ -341,16 +365,30 @@ export function DashboardShell() {
               onMarkMessagesRead={markMessagesAsRead}
               internalChatMode={isInternalChat}
               bottomSlot={
-                isInternalChat && aiThinking ? (
-                  <div className="flex justify-start mt-2">
-                    <div className="inline-flex items-center gap-2 px-4 py-2 rounded-2xl rounded-bl-none bg-card border border-border/50 shadow-sm text-sm text-muted-foreground">
-                      <Loader2 className="w-4 h-4 animate-spin flex-shrink-0" />
-                      <span>AI 正在思考...</span>
-                    </div>
-                  </div>
-                ) : null
-              }
-            />
+                  <>
+                    {!isInternalChat && remoteTypingDraft ? (
+                      <div className="flex justify-start mt-2">
+                        <div className="px-3.5 py-2.5 rounded-[18px] rounded-bl-md bg-muted/45 text-muted-foreground border border-solid border-border/70 shadow-[0_1px_3px_rgba(15,23,42,0.04)] text-sm max-w-[72%] break-words">
+                          <span>{remoteTypingDraft}</span>
+                          <span className="inline-flex items-center ml-1 align-middle">
+                            <span className="w-1 h-1 rounded-full bg-muted-foreground/60 animate-bounce [animation-duration:1.2s]" />
+                            <span className="w-1 h-1 rounded-full bg-muted-foreground/60 animate-bounce [animation-duration:1.2s] [animation-delay:0.15s] mx-0.5" />
+                            <span className="w-1 h-1 rounded-full bg-muted-foreground/60 animate-bounce [animation-duration:1.2s] [animation-delay:0.3s]" />
+                          </span>
+                        </div>
+                      </div>
+                    ) : null}
+                    {isInternalChat && aiThinking ? (
+                      <div className="flex justify-start mt-2">
+                        <div className="inline-flex items-center gap-2 px-4 py-2 rounded-2xl rounded-bl-none bg-card border border-border/50 shadow-sm text-sm text-muted-foreground">
+                          <Loader2 className="w-4 h-4 animate-spin flex-shrink-0" />
+                          <span>AI 正在思考...</span>
+                        </div>
+                      </div>
+                    ) : null}
+                  </>
+                }
+              />
             {/* 知识库测试:联网选项 */}
             {isInternalChat && (
               <div className="flex flex-wrap items-center gap-x-4 gap-y-2 px-2 py-2 border-t border-border/50 bg-muted/30 text-xs text-muted-foreground">

+ 54 - 1
frontend/components/dashboard/MessageList.tsx

@@ -11,6 +11,46 @@ import { Paperclip, Download, X } from "lucide-react";
 import { API_BASE_URL } from "@/lib/config";
 import { getAvatarUrl } from "@/utils/avatar";
 
+function TypewriterText({
+  text,
+  animateKey,
+  speedMs = 18,
+}: {
+  text: string;
+  animateKey: number | string;
+  speedMs?: number;
+}) {
+  const [shown, setShown] = useState("");
+
+  useEffect(() => {
+    setShown("");
+  }, [animateKey, text]);
+
+  useEffect(() => {
+    if (!text) return;
+
+    const len = text.length;
+    // 性能优先:很长的文本不可能真的每 1 个字符 setState 一次
+    // 但短文本保持更细粒度,让你看到“逐字打出”的效果。
+    const chunkSize = len < 250 ? 1 : len < 800 ? 2 : 4;
+
+    let idx = 0;
+    const timer = window.setInterval(() => {
+      idx = Math.min(len, idx + chunkSize);
+      setShown(text.slice(0, idx));
+      if (idx >= len) {
+        window.clearInterval(timer);
+      }
+    }, speedMs);
+
+    return () => {
+      window.clearInterval(timer);
+    };
+  }, [text, animateKey, speedMs]);
+
+  return <>{shown}</>;
+}
+
 interface MessageListProps {
   messages: MessageItem[];
   loading: boolean;
@@ -401,6 +441,15 @@ export function MessageList({
               ? highlightText(message.content, keyword)
               : message.content;
 
+          const isAIMessage = Boolean(message.sender_is_agent) && message.sender_id === 0;
+          // 仅当不需要高亮搜索关键词、且该消息为 AI 回复时才启用逐字显示
+          const shouldTypewriter =
+            isAIMessage &&
+            keyword === "" &&
+            !message.file_url &&
+            typeof message.content === "string" &&
+            message.content.length > 0;
+
           if (message.message_type === "system_message") {
             return (
               <div
@@ -503,7 +552,11 @@ export function MessageList({
                   {/* 文本内容 */}
                   {message.content && (
                     <div className="whitespace-pre-wrap break-words text-sm">
-                      {bubbleContent}
+                      {shouldTypewriter ? (
+                        <TypewriterText text={message.content} animateKey={message.id} />
+                      ) : (
+                        bubbleContent
+                      )}
                     </div>
                   )}
                   

+ 174 - 15
frontend/components/visitor/ChatWidget.tsx

@@ -1,6 +1,7 @@
 "use client";
 
 import { useCallback, useEffect, useMemo, useRef, useState } from "react";
+import { createPortal } from "react-dom";
 import { MessageList } from "@/components/dashboard/MessageList";
 import { OnlineAgentsList, type OnlineAgent } from "./OnlineAgentsList";
 import { VisitorMessageInput } from "./VisitorMessageInput";
@@ -11,6 +12,7 @@ import {
   ChatWebSocketPayload,
   MessageItem,
   MessagesReadPayload,
+  TypingDraftPayload,
 } from "@/features/agent/types";
 import {
   fetchMessages,
@@ -29,10 +31,13 @@ import {
   fetchVisitorWidgetConfig,
   type VisitorWidgetConfig,
 } from "@/features/agent/services/embeddingConfigApi";
+import { TYPING_DRAFT_TTL_MS } from "@/lib/constants/typing-draft";
 import { useWebSocket } from "@/features/agent/hooks/useWebSocket";
 import type { WSMessage } from "@/lib/websocket";
 import { useSoundNotification } from "@/hooks/useSoundNotification";
 import { playNotificationSound } from "@/utils/sound";
+import { getAvatarUrl } from "@/utils/avatar";
+import { cn } from "@/lib/utils";
 import { Check, ChevronDown, Loader2 } from "lucide-react";
 
 interface ChatWidgetProps {
@@ -71,6 +76,18 @@ function parseUserAgent(userAgent: string) {
   return { browser, os };
 }
 
+/** 与顶栏(Header h-16 / md:h-20 + 间距)、底栏(bottom-20 / sm:bottom-24)对齐,矮视口下限制高度 */
+const CHAT_WIDGET_PANEL_MAX_H =
+  "max-h-[min(680px,calc(100dvh-5rem-4.5rem-env(safe-area-inset-top,0px)))] sm:max-h-[min(680px,calc(100dvh-6rem-4.5rem-env(safe-area-inset-top,0px)))] md:max-h-[min(680px,calc(100dvh-6rem-5.5rem-env(safe-area-inset-top,0px)))]";
+const CHAT_WIDGET_PANEL_H =
+  "h-[min(540px,calc(100dvh-5rem-4.5rem-env(safe-area-inset-top,0px)))] sm:h-[min(620px,calc(100dvh-6rem-4.5rem-env(safe-area-inset-top,0px)))] md:h-[min(680px,calc(100dvh-6rem-5.5rem-env(safe-area-inset-top,0px)))]";
+
+/** 视口偏矮时略收窄,避免「又矮又宽」的观感;大屏高度充足时仍为 420px */
+const CHAT_WIDGET_PANEL_WIDTH =
+  "w-[min(420px,calc(100vw-1.5rem))] [@media(max-height:780px)]:w-[min(372px,calc(100vw-1.5rem))] [@media(max-height:680px)]:w-[min(340px,calc(100vw-1.5rem))]";
+const CHAT_WIDGET_PANEL_MAX_W =
+  "max-w-[420px] [@media(max-height:780px)]:max-w-[372px] [@media(max-height:680px)]:max-w-[340px]";
+
 /**
  * 聊天小窗组件
  * 提供小窗形式的聊天界面,支持展开/收起
@@ -109,10 +126,14 @@ export function ChatWidget({ visitorId, isOpen, onToggle }: ChatWidgetProps) {
   const [loadingAgents, setLoadingAgents] = useState(false);
   /** AI 模式下发消息后等待回复时显示「正在输入」提示 */
   const [aiTyping, setAiTyping] = useState(false);
+  const [agentTypingDraft, setAgentTypingDraft] = useState("");
+  const [agentTypingSenderId, setAgentTypingSenderId] = useState<number | null>(null);
   /** 联网搜索:本回合是否使用联网(访客可勾选) */
   const [needWebSearch, setNeedWebSearch] = useState(false);
   /** 访客小窗配置(由配置页控制是否显示联网设置) */
   const [widgetConfig, setWidgetConfig] = useState<VisitorWidgetConfig | null>(null);
+  const typingSeqRef = useRef(0);
+  const typingTimerRef = useRef<NodeJS.Timeout | null>(null);
 
   // 声音通知开关(访客端)
   const { enabled: soundEnabled, toggle: toggleSound } = useSoundNotification(true);
@@ -498,6 +519,9 @@ export function ChatWidget({ visitorId, isOpen, onToggle }: ChatWidgetProps) {
       if (event.type === "new_message" && event.data) {
         const msg = event.data as MessageItem;
         handleNewMessage(msg);
+        if (msg.sender_is_agent) {
+          setAgentTypingDraft("");
+        }
         // AI 模式下收到对方(客服/AI)回复时关闭「正在输入」提示
         if (chatMode === "ai" && msg.sender_is_agent) {
           setAiTyping(false);
@@ -508,12 +532,41 @@ export function ChatWidget({ visitorId, isOpen, onToggle }: ChatWidgetProps) {
           payload.conversation_id = event.conversation_id;
         }
         handleMessagesReadEvent(payload);
+      } else if (event.type === "typing_draft" && event.data) {
+        const payload = event.data as TypingDraftPayload;
+        // 访客侧只展示客服草稿。
+        if (!payload.sender_is_agent) {
+          return;
+        }
+        const text = typeof payload.text === "string" ? payload.text : "";
+        setAgentTypingDraft(text);
+        setAgentTypingSenderId(
+          typeof payload.sender_id === "number" ? payload.sender_id : null
+        );
+        if (typingTimerRef.current) {
+          clearTimeout(typingTimerRef.current);
+        }
+        typingTimerRef.current = setTimeout(() => {
+          setAgentTypingDraft("");
+          setAgentTypingSenderId(null);
+        }, TYPING_DRAFT_TTL_MS);
+      } else if (event.type === "typing_stop") {
+        const payload = (event.data || {}) as TypingDraftPayload;
+        if (!payload.sender_is_agent) {
+          return;
+        }
+        setAgentTypingDraft("");
+        setAgentTypingSenderId(null);
+        if (typingTimerRef.current) {
+          clearTimeout(typingTimerRef.current);
+          typingTimerRef.current = null;
+        }
       }
-        },
-        [handleMessagesReadEvent, handleNewMessage, soundEnabled, chatMode]
-      );
+    },
+    [handleMessagesReadEvent, handleNewMessage, chatMode]
+  );
 
-  useWebSocket<ChatWebSocketPayload>({
+  const { send: sendWebSocketEvent } = useWebSocket<ChatWebSocketPayload>({
     conversationId,
     enabled: Boolean(conversationId) && isOpen,
     isVisitor: true,
@@ -523,6 +576,63 @@ export function ChatWidget({ visitorId, isOpen, onToggle }: ChatWidgetProps) {
     },
   });
 
+  const sendTypingDraft = useCallback(
+    (text: string) => {
+      if (!conversationId) {
+        return;
+      }
+      const content = text.slice(0, 300);
+      if (!content.trim()) {
+        sendWebSocketEvent("typing_stop", { sender_is_agent: false });
+        return;
+      }
+      typingSeqRef.current += 1;
+      sendWebSocketEvent("typing_draft", {
+        sender_is_agent: false,
+        text: content,
+        seq: typingSeqRef.current,
+      });
+    },
+    [conversationId, sendWebSocketEvent]
+  );
+
+  const sendTypingStop = useCallback(() => {
+    if (!conversationId) {
+      return;
+    }
+    sendWebSocketEvent("typing_stop", { sender_is_agent: false });
+  }, [conversationId, sendWebSocketEvent]);
+
+  useEffect(() => {
+    if (!conversationId || !isOpen || chatMode !== "human") {
+      return;
+    }
+    const timer = setTimeout(() => {
+      if (input.trim()) {
+        sendTypingDraft(input);
+      } else {
+        sendTypingStop();
+      }
+    }, 350);
+    return () => clearTimeout(timer);
+  }, [chatMode, conversationId, input, isOpen, sendTypingDraft, sendTypingStop]);
+
+  useEffect(() => {
+    if (!isOpen || chatMode !== "human") {
+      setAgentTypingDraft("");
+      setAgentTypingSenderId(null);
+    }
+  }, [chatMode, isOpen]);
+
+  useEffect(() => {
+    return () => {
+      if (typingTimerRef.current) {
+        clearTimeout(typingTimerRef.current);
+      }
+      sendTypingStop();
+    };
+  }, [sendTypingStop]);
+
   const handleSendMessage = useCallback(
     async (fileInfo?: UploadFileResult) => {
       if (!conversationId || sending) {
@@ -554,6 +664,7 @@ export function ChatWidget({ visitorId, isOpen, onToggle }: ChatWidgetProps) {
       // 立即添加到消息列表
       setMessages((prev) => [...prev, tempMessage]);
       setInput("");
+      sendTypingStop();
       setSending(true);
       if (chatMode === "ai") {
         setAiTyping(true);
@@ -588,7 +699,7 @@ export function ChatWidget({ visitorId, isOpen, onToggle }: ChatWidgetProps) {
         setSending(false);
       }
     },
-    [conversationId, input, sending, visitorId, chatMode, needWebSearch, widgetConfig]
+    [conversationId, input, sending, visitorId, chatMode, needWebSearch, sendTypingStop, widgetConfig]
   );
 
   // 如果不打开,不渲染内容
@@ -596,8 +707,22 @@ export function ChatWidget({ visitorId, isOpen, onToggle }: ChatWidgetProps) {
     return null;
   }
 
-  return (
-    <Card className="fixed bottom-20 right-4 sm:bottom-24 sm:right-6 w-[calc(100vw-1.5rem)] max-w-[420px] h-[540px] sm:w-[420px] sm:h-[620px] md:h-[680px] flex flex-col shadow-[0_24px_60px_-24px_rgba(2,6,23,0.35)] z-40 border border-slate-200 overflow-hidden rounded-2xl bg-white text-slate-900 ring-1 ring-slate-200/80">
+  // 挂到 body,避免页面内祖先的 transform/filter/backdrop-filter 在 Chrome 等浏览器中
+  // 成为 fixed 定位的包含块,导致小窗错位到视口中上部而右下角按钮仍正常。
+  if (typeof document === "undefined") {
+    return null;
+  }
+
+  return createPortal(
+    <Card
+      className={cn(
+        "fixed bottom-20 right-4 sm:bottom-24 sm:right-6 flex flex-col shadow-[0_24px_60px_-24px_rgba(2,6,23,0.35)] z-40 border border-slate-200 overflow-hidden rounded-2xl bg-white text-slate-900 ring-1 ring-slate-200/80",
+        CHAT_WIDGET_PANEL_MAX_W,
+        CHAT_WIDGET_PANEL_WIDTH,
+        CHAT_WIDGET_PANEL_MAX_H,
+        CHAT_WIDGET_PANEL_H
+      )}
+    >
       {/* 头部:回归品牌蓝色系,保持轻量与一致 */}
       <div className="bg-gradient-to-r from-[#2563eb] to-[#3b82f6] border-b border-blue-300/40 px-4 py-3.5 flex items-center justify-between rounded-t-2xl">
         <div className="flex items-center gap-2.5 min-w-0">
@@ -748,14 +873,47 @@ export function ChatWidget({ visitorId, isOpen, onToggle }: ChatWidgetProps) {
           onMarkMessagesRead={handleMarkAgentMessagesRead}
           leftAvatarBySenderId={chatMode === "human" ? agentAvatarMap : undefined}
           bottomSlot={
-            chatMode === "ai" && aiTyping ? (
-              <div className="flex justify-start mt-2">
-                <div className="inline-flex items-center gap-2 px-4 py-2 rounded-2xl rounded-bl-none bg-white border border-slate-200 shadow-sm text-sm text-slate-500">
-                  <Loader2 className="w-4 h-4 animate-spin flex-shrink-0" />
-                  <span>AI 正在思考...</span>
+            <>
+              {chatMode === "human" && agentTypingDraft ? (
+                <div className="flex justify-start mt-2">
+                  <div className="w-7 h-7 rounded-full overflow-hidden bg-slate-200 border border-slate-300 flex-shrink-0">
+                    {(() => {
+                      const draftAvatar =
+                        agentTypingSenderId != null
+                          ? getAvatarUrl(agentAvatarMap[agentTypingSenderId])
+                          : null;
+                      return draftAvatar ? (
+                        <img
+                          src={draftAvatar}
+                          alt="客服头像"
+                          className="w-full h-full object-cover"
+                        />
+                      ) : (
+                        <div className="w-full h-full flex items-center justify-center text-[10px] text-slate-600">
+                          客
+                        </div>
+                      );
+                    })()}
+                  </div>
+                  <div className="px-3.5 py-2.5 rounded-[18px] rounded-bl-md bg-slate-100/80 border border-solid border-slate-300 shadow-[0_1px_3px_rgba(15,23,42,0.04)] text-sm text-slate-500 max-w-[72%] break-words">
+                    <span>{agentTypingDraft}</span>
+                    <span className="inline-flex items-center ml-1 align-middle">
+                      <span className="w-1 h-1 rounded-full bg-slate-400 animate-bounce [animation-duration:1.2s]" />
+                      <span className="w-1 h-1 rounded-full bg-slate-400 animate-bounce [animation-duration:1.2s] [animation-delay:0.15s] mx-0.5" />
+                      <span className="w-1 h-1 rounded-full bg-slate-400 animate-bounce [animation-duration:1.2s] [animation-delay:0.3s]" />
+                    </span>
+                  </div>
                 </div>
-              </div>
-            ) : null
+              ) : null}
+              {chatMode === "ai" && aiTyping ? (
+                <div className="flex justify-start mt-2">
+                  <div className="inline-flex items-center gap-2 px-4 py-2 rounded-2xl rounded-bl-none bg-white border border-slate-200 shadow-sm text-sm text-slate-500">
+                    <Loader2 className="w-4 h-4 animate-spin flex-shrink-0" />
+                    <span>AI 正在思考...</span>
+                  </div>
+                </div>
+              ) : null}
+            </>
           }
         />
       </div>
@@ -833,7 +991,8 @@ export function ChatWidget({ visitorId, isOpen, onToggle }: ChatWidgetProps) {
           }
         />
       </div>
-    </Card>
+    </Card>,
+    document.body
   );
 }
 

+ 9 - 2
frontend/features/agent/hooks/useAuth.ts

@@ -5,7 +5,7 @@ import { useRouter } from "next/navigation";
 
 import type { AgentUser } from "../../agent/types";
 import { logout } from "../../agent/services/authApi";
-import { clearAgentUser, getAgentUser } from "@/utils/storage";
+import { clearAgentUser, getAgentUser, getAgentWSToken } from "@/utils/storage";
 
 export function useAuth() {
   const router = useRouter();
@@ -16,7 +16,14 @@ export function useAuth() {
     const current = getAgentUser();
     if (!current) {
       setLoading(false);
-      router.push("/");
+      router.push("/agent/login");
+      return;
+    }
+    // 客服端实时链路依赖 ws_token;缺失/过期时强制重新登录,避免前端持续 401 重连且功能失效。
+    if (!getAgentWSToken()) {
+      clearAgentUser();
+      setLoading(false);
+      router.push("/agent/login");
       return;
     }
     setAgent(current);

+ 83 - 1
frontend/features/agent/hooks/useMessages.ts

@@ -20,8 +20,10 @@ import {
   MessagesReadPayload,
   ChatWebSocketPayload,
   VisitorStatusUpdatePayload,
+  TypingDraftPayload,
 } from "../../agent/types";
 import { useWebSocket } from "./useWebSocket";
+import { TYPING_DRAFT_TTL_MS } from "@/lib/constants/typing-draft";
 import { WSMessage } from "@/lib/websocket";
 import { buildMessagePreview } from "@/utils/format";
 import { playNotificationSound } from "@/utils/sound";
@@ -61,7 +63,10 @@ export function useMessages({
   const [aiThinking, setAiThinking] = useState(false);
   /** 知识库测试:联网选项 */
   const [needWebSearch, setNeedWebSearch] = useState(false);
+  const [remoteTypingDraft, setRemoteTypingDraft] = useState<string>("");
   const wsToken = getAgentWSToken() ?? undefined;
+  const typingSeqRef = useRef(0);
+  const typingTimerRef = useRef<NodeJS.Timeout | null>(null);
 
   const refreshConversationDetail = useCallback(
     async (id: number) => {
@@ -503,6 +508,30 @@ export function useMessages({
             refreshConversationDetail(payload.conversation_id);
           }
         }
+      } else if (event.type === "typing_draft" && event.data) {
+        const payload = event.data as TypingDraftPayload;
+        // 客服侧只显示访客草稿,忽略客服自身(或其他客服)草稿。
+        if (payload.sender_is_agent) {
+          return;
+        }
+        const text = typeof payload.text === "string" ? payload.text : "";
+        setRemoteTypingDraft(text);
+        if (typingTimerRef.current) {
+          clearTimeout(typingTimerRef.current);
+        }
+        typingTimerRef.current = setTimeout(() => {
+          setRemoteTypingDraft("");
+        }, TYPING_DRAFT_TTL_MS);
+      } else if (event.type === "typing_stop") {
+        const payload = (event.data || {}) as TypingDraftPayload;
+        if (payload.sender_is_agent) {
+          return;
+        }
+        setRemoteTypingDraft("");
+        if (typingTimerRef.current) {
+          clearTimeout(typingTimerRef.current);
+          typingTimerRef.current = null;
+        }
       }
     },
     [
@@ -514,7 +543,7 @@ export function useMessages({
     ]
   );
 
-  useWebSocket<ChatWebSocketPayload>({
+  const { send: sendWebSocketEvent } = useWebSocket<ChatWebSocketPayload>({
     conversationId,
     enabled: Boolean(conversationId),
     isVisitor: false, // 客服端设置为 false
@@ -529,6 +558,53 @@ export function useMessages({
     },
   });
 
+  const sendTypingDraft = useCallback(
+    (text: string) => {
+      if (!conversationId || !agentId) {
+        return;
+      }
+      const content = text.slice(0, 300);
+      if (!content.trim()) {
+        sendWebSocketEvent("typing_stop", {
+          sender_id: agentId,
+          sender_is_agent: true,
+        });
+        return;
+      }
+      typingSeqRef.current += 1;
+      sendWebSocketEvent("typing_draft", {
+        sender_id: agentId,
+        sender_is_agent: true,
+        text: content,
+        seq: typingSeqRef.current,
+      });
+    },
+    [agentId, conversationId, sendWebSocketEvent]
+  );
+
+  const sendTypingStop = useCallback(() => {
+    if (!conversationId || !agentId) {
+      return;
+    }
+    sendWebSocketEvent("typing_stop", {
+      sender_id: agentId,
+      sender_is_agent: true,
+    });
+  }, [agentId, conversationId, sendWebSocketEvent]);
+
+  useEffect(() => {
+    // 切会话时清空对端草稿状态,避免串会话显示。
+    setRemoteTypingDraft("");
+  }, [conversationId]);
+
+  useEffect(() => {
+    return () => {
+      if (typingTimerRef.current) {
+        clearTimeout(typingTimerRef.current);
+      }
+    };
+  }, []);
+
   // 切换 AI 消息显示/隐藏
   const toggleAIMessages = useCallback(async () => {
     const newValue = !includeAIMessages;
@@ -556,6 +632,9 @@ export function useMessages({
       aiThinking,
       needWebSearch,
       setNeedWebSearch,
+      remoteTypingDraft,
+      sendTypingDraft,
+      sendTypingStop,
     }),
     [
       conversationDetail,
@@ -572,6 +651,9 @@ export function useMessages({
       forceIncludeAIMessages,
       aiThinking,
       needWebSearch,
+      remoteTypingDraft,
+      sendTypingDraft,
+      sendTypingStop,
     ]
   );
 

+ 20 - 1
frontend/features/agent/hooks/useWebSocket.ts

@@ -1,6 +1,6 @@
 "use client";
 
-import { useEffect, useRef } from "react";
+import { useCallback, useEffect, useRef } from "react";
 import { WSClient, WSMessage } from "@/lib/websocket";
 
 interface UseWebSocketOptions<T> {
@@ -28,6 +28,7 @@ export function useWebSocket<T>({
   const onMessageRef = useRef(onMessage);
   const onErrorRef = useRef(onError);
   const onCloseRef = useRef(onClose);
+  const clientRef = useRef<WSClient<T> | null>(null);
 
   // 更新 ref 的值
   useEffect(() => {
@@ -38,6 +39,14 @@ export function useWebSocket<T>({
 
   useEffect(() => {
     if (!conversationId || !enabled) {
+      clientRef.current?.disconnect();
+      clientRef.current = null;
+      return;
+    }
+    // 客服端必须带 wsToken;否则后端会 401,且所有实时能力(新消息/草稿/已读)都不可用。
+    if (!isVisitor && !wsToken) {
+      clientRef.current?.disconnect();
+      clientRef.current = null;
       return;
     }
 
@@ -53,14 +62,24 @@ export function useWebSocket<T>({
         : undefined,
       onClose: onCloseRef.current ? () => onCloseRef.current?.() : undefined,
     });
+    clientRef.current = client;
 
     client.connect();
 
     return () => {
       client.disconnect();
+      if (clientRef.current === client) {
+        clientRef.current = null;
+      }
     };
     // 只依赖 conversationId、enabled、isVisitor 和 agentId,不依赖回调函数
     // 回调函数通过 useRef 存储,不会导致重新连接
   }, [conversationId, enabled, isVisitor, agentId, wsToken]);
+
+  const send = useCallback((type: string, data?: unknown): boolean => {
+    return clientRef.current?.send(type, data) ?? false;
+  }, []);
+
+  return { send };
 }
 

+ 1 - 1
frontend/features/agent/services/messageApi.ts

@@ -211,7 +211,7 @@ export async function markMessagesRead(
 ): Promise<MarkMessagesReadResult | null> {
   const res = await fetch(apiUrl("/messages/read"), {
     method: "PUT",
-    headers: { "Content-Type": "application/json" },
+    headers: { "Content-Type": "application/json", ...getAgentHeaders() },
     body: JSON.stringify({
       conversation_id: conversationId,
       reader_is_agent: readerIsAgent,

+ 45 - 0
frontend/features/agent/services/systemLogApi.ts

@@ -36,6 +36,51 @@ export interface QuerySystemLogsParams {
   pageSize?: number;
 }
 
+export interface LogMinLevelPolicy {
+  effective_min_level: string;
+  env_min_level: string;
+  persisted_in_database: boolean;
+}
+
+export async function fetchLogMinLevelPolicy(): Promise<LogMinLevelPolicy> {
+  const res = await fetch(apiUrl("/agent/logs/min-level"), {
+    headers: getAgentHeaders(),
+  });
+  if (!res.ok) {
+    const j = await res.json().catch(() => ({}));
+    throw new Error((j as { error?: string }).error || `加载策略失败(${res.status})`);
+  }
+  return res.json();
+}
+
+export async function putLogMinLevelPolicy(minLevel: string): Promise<{ effective_min_level: string }> {
+  const res = await fetch(apiUrl("/agent/logs/min-level"), {
+    method: "PUT",
+    headers: {
+      "Content-Type": "application/json",
+      ...getAgentHeaders(),
+    },
+    body: JSON.stringify({ min_level: minLevel }),
+  });
+  if (!res.ok) {
+    const j = await res.json().catch(() => ({}));
+    throw new Error((j as { error?: string }).error || `保存失败(${res.status})`);
+  }
+  return res.json();
+}
+
+export async function deleteLogMinLevelPolicy(): Promise<{ effective_min_level: string }> {
+  const res = await fetch(apiUrl("/agent/logs/min-level"), {
+    method: "DELETE",
+    headers: getAgentHeaders(),
+  });
+  if (!res.ok) {
+    const j = await res.json().catch(() => ({}));
+    throw new Error((j as { error?: string }).error || `恢复失败(${res.status})`);
+  }
+  return res.json();
+}
+
 export async function fetchSystemLogs(params: QuerySystemLogsParams): Promise<QuerySystemLogsResult> {
   const q = new URLSearchParams();
   if (params.from) q.set("from", params.from);

+ 9 - 1
frontend/features/agent/types.ts

@@ -90,8 +90,16 @@ export interface VisitorStatusUpdatePayload {
   visitor_count?: number;
 }
 
+export interface TypingDraftPayload {
+  sender_id?: number;
+  sender_is_agent?: boolean;
+  text?: string;
+  seq?: number;
+}
+
 export type ChatWebSocketPayload =
   | MessageItem
   | MessagesReadPayload
-  | VisitorStatusUpdatePayload;
+  | VisitorStatusUpdatePayload
+  | TypingDraftPayload;
 

+ 2 - 0
frontend/lib/constants/typing-draft.ts

@@ -0,0 +1,2 @@
+/** 共享草稿在对方无新输入时自动隐藏的时长(毫秒) */
+export const TYPING_DRAFT_TTL_MS = 15_000;

+ 18 - 0
frontend/lib/websocket.ts

@@ -172,5 +172,23 @@ export class WSClient<T = unknown> {
   isConnected(): boolean {
     return this.ws !== null && this.ws.readyState === WebSocket.OPEN;
   }
+
+  send(type: string, data?: unknown): boolean {
+    if (!this.ws || this.ws.readyState !== WebSocket.OPEN) {
+      return false;
+    }
+    try {
+      this.ws.send(
+        JSON.stringify({
+          type,
+          conversation_id: this.conversationId,
+          data: data ?? {},
+        })
+      );
+      return true;
+    } catch {
+      return false;
+    }
+  }
 }
 

+ 4 - 0
frontend/next.config.mjs

@@ -46,6 +46,10 @@ const nextConfig = {
           source: "/agent/logs/frontend",
           destination: `http://${backendHost}:${backendPort}/agent/logs/frontend`,
         },
+        {
+          source: "/agent/logs/min-level",
+          destination: `http://${backendHost}:${backendPort}/agent/logs/min-level`,
+        },
         {
           source: "/:path((?!_next|agent|chat|favicon.ico).*)",
           destination: `http://${backendHost}:${backendPort}/:path*`,

+ 4 - 0
frontend/next.config.ts

@@ -51,6 +51,10 @@ const nextConfig: NextConfig = {
           source: "/agent/logs/frontend",
           destination: `http://${backendHost}:${backendPort}/agent/logs/frontend`,
         },
+        {
+          source: "/agent/logs/min-level",
+          destination: `http://${backendHost}:${backendPort}/agent/logs/min-level`,
+        },
         // 匹配其他 API 路径(不以 /_next、/agent、/api、/chat 开头的路径)
         // /api/agent/prompts 由 app/api/agent/prompts/route.ts 代理,不在此转发
         {