mirror of
https://github.com/2930134478/AI-CS.git
synced 2026-06-15 00:44:30 +08:00
共享草稿+日志级别+ui优化+bug修复
This commit is contained in:
@@ -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
|
||||
|
||||
# =========================
|
||||
# 数据库(必填)
|
||||
# =========================
|
||||
|
||||
@@ -5,19 +5,20 @@
|
||||
|
||||
## 界面预览
|
||||
|
||||
> 以下为当前版本关键界面截图(本地预览路径)。
|
||||
> 截图放在仓库内的 **`assets/readme/`**(随 Git 提交),README 里使用**相对路径**引用。
|
||||
> 请勿使用 `file:///...` 或仅本机存在的路径,否则在 GitHub / Gitee 等页面上无法显示。
|
||||
|
||||
**官网首页(核心能力模块)**
|
||||
|
||||

|
||||

|
||||
|
||||
**客服小窗(人工客服模式)**
|
||||
|
||||

|
||||

|
||||
|
||||
**客服小窗(AI 客服模式)**
|
||||
|
||||

|
||||

|
||||
|
||||
## 在线演示
|
||||
|
||||
@@ -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` 说明)
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 38 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 172 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 56 KiB |
@@ -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 参数(访客上传,会验证对话是否存在且未关闭)
|
||||
@@ -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})
|
||||
|
||||
@@ -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
@@ -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,
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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"
|
||||
)
|
||||
@@ -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
|
||||
}
|
||||
@@ -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) // 健康检查
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
|
||||
@@ -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:
|
||||
// 忽略未知客户端事件,避免污染服务端日志。
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
|
||||
@@ -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
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
]
|
||||
);
|
||||
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
/** 共享草稿在对方无新输入时自动隐藏的时长(毫秒) */
|
||||
export const TYPING_DRAFT_TTL_MS = 15_000;
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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*`,
|
||||
|
||||
@@ -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 代理,不在此转发
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user