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
|
# 建议:生产 release,开发 debug
|
||||||
GIN_MODE=release
|
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 客服模式)**
|
**客服小窗(AI 客服模式)**
|
||||||
|
|
||||||

|

|
||||||
|
|
||||||
## 在线演示
|
## 在线演示
|
||||||
|
|
||||||
@@ -33,6 +34,7 @@
|
|||||||
- 可选“本回合联网搜索”开关(是否对访客展示可在后台控制)
|
- 可选“本回合联网搜索”开关(是否对访客展示可在后台控制)
|
||||||
- **客服侧(工作台)**
|
- **客服侧(工作台)**
|
||||||
- 会话列表、实时消息(WebSocket)、未读角标提示
|
- 会话列表、实时消息(WebSocket)、未读角标提示
|
||||||
|
- 支持“实时共享草稿输入”(双方未发送内容可实时可见)
|
||||||
- 多模型管理(文本/绘画等)与对话配置
|
- 多模型管理(文本/绘画等)与对话配置
|
||||||
- **提示词配置**(Prompt 管理)
|
- **提示词配置**(Prompt 管理)
|
||||||
- **知识库管理 + RAG**(向量检索,可按需启用;向量库不可用时可不影响启动)
|
- **知识库管理 + RAG**(向量检索,可按需启用;向量库不可用时可不影响启动)
|
||||||
@@ -147,6 +149,7 @@ npm run dev
|
|||||||
| `SERVER_HOST` | 后端监听地址 | 是 | `0.0.0.0` | `127.0.0.1` |
|
| `SERVER_HOST` | 后端监听地址 | 是 | `0.0.0.0` | `127.0.0.1` |
|
||||||
| `SERVER_PORT` | 后端容器内端口 | 是 | `8080` | `8080` |
|
| `SERVER_PORT` | 后端容器内端口 | 是 | `8080` | `8080` |
|
||||||
| `GIN_MODE` | 后端模式 | 建议 | `release` | `debug` |
|
| `GIN_MODE` | 后端模式 | 建议 | `release` | `debug` |
|
||||||
|
| `SYSTEM_LOG_MIN_LEVEL` | 结构化日志最低落库级别(`system_logs`) | 否 | `info` | `warn` 可减少成功类写入;`none` 关闭落库;**客服端「日志中心」可改并持久化,覆盖本项直至恢复** |
|
||||||
| `DB_HOST` | 后端数据库地址 | 是 | `mysql` | `localhost` |
|
| `DB_HOST` | 后端数据库地址 | 是 | `mysql` | `localhost` |
|
||||||
| `DB_PORT` | 后端数据库端口 | 是 | `3306` | `3306` |
|
| `DB_PORT` | 后端数据库端口 | 是 | `3306` | `3306` |
|
||||||
| `DB_USER` | 数据库用户名 | 是 | `ai_cs_user` | `root` |
|
| `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 请求。
|
// MessageController 负责处理消息相关的 HTTP 请求。
|
||||||
type MessageController struct {
|
type MessageController struct {
|
||||||
messageService *service.MessageService
|
messageService *service.MessageService
|
||||||
conversationService *service.ConversationService
|
conversationService *service.ConversationService
|
||||||
userService *service.UserService
|
userService *service.UserService
|
||||||
storageService infra.StorageService
|
storageService infra.StorageService
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewMessageController 创建 MessageController 实例。
|
// NewMessageController 创建 MessageController 实例。
|
||||||
@@ -30,10 +30,10 @@ func NewMessageController(
|
|||||||
storageService infra.StorageService,
|
storageService infra.StorageService,
|
||||||
) *MessageController {
|
) *MessageController {
|
||||||
return &MessageController{
|
return &MessageController{
|
||||||
messageService: messageService,
|
messageService: messageService,
|
||||||
conversationService: conversationService,
|
conversationService: conversationService,
|
||||||
userService: userService,
|
userService: userService,
|
||||||
storageService: storageService,
|
storageService: storageService,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -42,11 +42,11 @@ type createMessageRequest struct {
|
|||||||
Content string `json:"content"`
|
Content string `json:"content"`
|
||||||
SenderIsAgent bool `json:"sender_is_agent"`
|
SenderIsAgent bool `json:"sender_is_agent"`
|
||||||
SenderID uint `json:"sender_id"`
|
SenderID uint `json:"sender_id"`
|
||||||
FileURL *string `json:"file_url"`
|
FileURL *string `json:"file_url"`
|
||||||
FileType *string `json:"file_type"`
|
FileType *string `json:"file_type"`
|
||||||
FileName *string `json:"file_name"`
|
FileName *string `json:"file_name"`
|
||||||
FileSize *int64 `json:"file_size"`
|
FileSize *int64 `json:"file_size"`
|
||||||
MimeType *string `json:"mime_type"`
|
MimeType *string `json:"mime_type"`
|
||||||
// 回复数据源开关(仅 AI 模式有效),不传则默认:知识库+大模型开,联网关
|
// 回复数据源开关(仅 AI 模式有效),不传则默认:知识库+大模型开,联网关
|
||||||
UseKnowledgeBase *bool `json:"use_knowledge_base"`
|
UseKnowledgeBase *bool `json:"use_knowledge_base"`
|
||||||
UseLLM *bool `json:"use_llm"`
|
UseLLM *bool `json:"use_llm"`
|
||||||
@@ -62,11 +62,8 @@ func (mc *MessageController) CreateMessage(c *gin.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
userID := getUserIDFromHeader(c)
|
userID := getUserIDFromHeader(c)
|
||||||
// 若带了客服身份头,则必须按客服消息处理,禁止伪装成访客消息。
|
// 兼容 demo 自测场景:已登录客服也允许按访客身份发送消息(sender_is_agent=false)。
|
||||||
if userID > 0 && !req.SenderIsAgent {
|
// 访客消息 sender_id 仍由服务端强制置 0,避免前端注入身份。
|
||||||
c.JSON(http.StatusForbidden, gin.H{"error": "已登录客服不允许以访客身份发送消息"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
// 客服消息必须绑定当前登录用户(X-User-Id),并以服务端用户 ID 为准,避免伪造 sender_id。
|
// 客服消息必须绑定当前登录用户(X-User-Id),并以服务端用户 ID 为准,避免伪造 sender_id。
|
||||||
if req.SenderIsAgent {
|
if req.SenderIsAgent {
|
||||||
if userID == 0 {
|
if userID == 0 {
|
||||||
@@ -261,6 +258,7 @@ func (mc *MessageController) MarkMessagesRead(c *gin.Context) {
|
|||||||
// 请求格式:multipart/form-data
|
// 请求格式:multipart/form-data
|
||||||
// - file: 文件内容(必需)
|
// - file: 文件内容(必需)
|
||||||
// - conversation_id: 对话ID(可选,用于组织目录)
|
// - conversation_id: 对话ID(可选,用于组织目录)
|
||||||
|
//
|
||||||
// 认证方式:
|
// 认证方式:
|
||||||
// - 方式1:提供 X-User-Id 请求头(客服上传)
|
// - 方式1:提供 X-User-Id 请求头(客服上传)
|
||||||
// - 方式2:提供 conversation_id 参数(访客上传,会验证对话是否存在且未关闭)
|
// - 方式2:提供 conversation_id 参数(访客上传,会验证对话是否存在且未关闭)
|
||||||
@@ -270,13 +268,13 @@ func (mc *MessageController) UploadFile(c *gin.Context) {
|
|||||||
// 2. 提供 conversation_id 参数(访客)
|
// 2. 提供 conversation_id 参数(访客)
|
||||||
userID := getUserIDFromHeader(c)
|
userID := getUserIDFromHeader(c)
|
||||||
conversationIDStr := c.PostForm("conversation_id")
|
conversationIDStr := c.PostForm("conversation_id")
|
||||||
|
|
||||||
// 如果既没有用户ID,也没有对话ID,拒绝访问
|
// 如果既没有用户ID,也没有对话ID,拒绝访问
|
||||||
if userID == 0 && conversationIDStr == "" {
|
if userID == 0 && conversationIDStr == "" {
|
||||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "未授权访问,请提供 X-User-Id 请求头或 conversation_id 参数"})
|
c.JSON(http.StatusUnauthorized, gin.H{"error": "未授权访问,请提供 X-User-Id 请求头或 conversation_id 参数"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// 如果是访客上传(没有用户ID,但有对话ID),验证对话是否存在且未关闭
|
// 如果是访客上传(没有用户ID,但有对话ID),验证对话是否存在且未关闭
|
||||||
if userID == 0 && conversationIDStr != "" {
|
if userID == 0 && conversationIDStr != "" {
|
||||||
convID, err := strconv.ParseUint(conversationIDStr, 10, 64)
|
convID, err := strconv.ParseUint(conversationIDStr, 10, 64)
|
||||||
@@ -295,7 +293,7 @@ func (mc *MessageController) UploadFile(c *gin.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 解析文件
|
// 解析文件
|
||||||
file, err := c.FormFile("file")
|
file, err := c.FormFile("file")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -331,15 +329,15 @@ func (mc *MessageController) UploadFile(c *gin.Context) {
|
|||||||
// ⚠️ 加强:验证 MIME 类型(防止伪造扩展名)
|
// ⚠️ 加强:验证 MIME 类型(防止伪造扩展名)
|
||||||
mimeType := file.Header.Get("Content-Type")
|
mimeType := file.Header.Get("Content-Type")
|
||||||
allowedMimeTypes := map[string]bool{
|
allowedMimeTypes := map[string]bool{
|
||||||
"image/jpeg": true,
|
"image/jpeg": true,
|
||||||
"image/jpg": true,
|
"image/jpg": true,
|
||||||
"image/png": true,
|
"image/png": true,
|
||||||
"image/gif": true,
|
"image/gif": true,
|
||||||
"image/webp": true,
|
"image/webp": true,
|
||||||
"application/pdf": true,
|
"application/pdf": true,
|
||||||
"application/msword": true,
|
"application/msword": true,
|
||||||
"application/vnd.openxmlformats-officedocument.wordprocessingml.document": true, // .docx
|
"application/vnd.openxmlformats-officedocument.wordprocessingml.document": true, // .docx
|
||||||
"text/plain": true,
|
"text/plain": true,
|
||||||
}
|
}
|
||||||
if !allowedMimeTypes[mimeType] {
|
if !allowedMimeTypes[mimeType] {
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": "不支持的文件 MIME 类型: " + 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
|
safeFilename = nameWithoutExt[:100-len(ext)] + ext
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ⚠️ 加强:验证文件内容(magic number 检查,防止伪造扩展名)
|
// ⚠️ 加强:验证文件内容(magic number 检查,防止伪造扩展名)
|
||||||
fileContent, err := file.Open()
|
fileContent, err := file.Open()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -376,7 +374,7 @@ func (mc *MessageController) UploadFile(c *gin.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
defer fileContent.Close()
|
defer fileContent.Close()
|
||||||
|
|
||||||
// 读取文件前几个字节(magic number)
|
// 读取文件前几个字节(magic number)
|
||||||
magicBytes := make([]byte, 12)
|
magicBytes := make([]byte, 12)
|
||||||
n, err := fileContent.Read(magicBytes)
|
n, err := fileContent.Read(magicBytes)
|
||||||
@@ -384,13 +382,13 @@ func (mc *MessageController) UploadFile(c *gin.Context) {
|
|||||||
c.JSON(http.StatusBadRequest, gin.H{"error": "无法读取文件内容"})
|
c.JSON(http.StatusBadRequest, gin.H{"error": "无法读取文件内容"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// 验证文件内容是否匹配扩展名
|
// 验证文件内容是否匹配扩展名
|
||||||
if !isValidFileContent(ext, magicBytes[:n]) {
|
if !isValidFileContent(ext, magicBytes[:n]) {
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": "文件内容与扩展名不匹配,可能是伪造的文件类型"})
|
c.JSON(http.StatusBadRequest, gin.H{"error": "文件内容与扩展名不匹配,可能是伪造的文件类型"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// 重置文件指针,以便后续保存
|
// 重置文件指针,以便后续保存
|
||||||
if _, err := fileContent.Seek(0, io.SeekStart); err != nil {
|
if _, err := fileContent.Seek(0, io.SeekStart); err != nil {
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "无法重置文件指针"})
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "无法重置文件指针"})
|
||||||
@@ -437,9 +435,9 @@ func isValidFileContent(ext string, magicBytes []byte) bool {
|
|||||||
if len(magicBytes) < 4 {
|
if len(magicBytes) < 4 {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
ext = strings.ToLower(ext)
|
ext = strings.ToLower(ext)
|
||||||
|
|
||||||
// 检查各种文件类型的 magic number
|
// 检查各种文件类型的 magic number
|
||||||
switch ext {
|
switch ext {
|
||||||
case ".jpg", ".jpeg":
|
case ".jpg", ".jpeg":
|
||||||
|
|||||||
@@ -5,17 +5,20 @@ import (
|
|||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
|
"github.com/2930134478/AI-CS/backend/models"
|
||||||
|
"github.com/2930134478/AI-CS/backend/repository"
|
||||||
"github.com/2930134478/AI-CS/backend/service"
|
"github.com/2930134478/AI-CS/backend/service"
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
)
|
)
|
||||||
|
|
||||||
type SystemLogController struct {
|
type SystemLogController struct {
|
||||||
logs *service.SystemLogService
|
logs *service.SystemLogService
|
||||||
users *service.UserService
|
users *service.UserService
|
||||||
|
appSettings *repository.AppSettingRepository
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewSystemLogController(logs *service.SystemLogService, users *service.UserService) *SystemLogController {
|
func NewSystemLogController(logs *service.SystemLogService, users *service.UserService, appSettings *repository.AppSettingRepository) *SystemLogController {
|
||||||
return &SystemLogController{logs: logs, users: users}
|
return &SystemLogController{logs: logs, users: users, appSettings: appSettings}
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetLogs 查询日志(客服端)。
|
// GetLogs 查询日志(客服端)。
|
||||||
@@ -105,3 +108,79 @@ func (lc *SystemLogController) ReportFrontendLog(c *gin.Context) {
|
|||||||
c.JSON(http.StatusOK, gin.H{"ok": true})
|
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"
|
"log"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/2930134478/AI-CS/backend/controller"
|
"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)
|
log.Fatalf("自动创建表失败: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -152,7 +153,19 @@ func main() {
|
|||||||
embeddingConfigRepo := repository.NewEmbeddingConfigRepository(db)
|
embeddingConfigRepo := repository.NewEmbeddingConfigRepository(db)
|
||||||
promptConfigRepo := repository.NewPromptConfigRepository(db)
|
promptConfigRepo := repository.NewPromptConfigRepository(db)
|
||||||
systemLogRepo := repository.NewSystemLogRepository(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)
|
initDefaultAdmin(userRepo)
|
||||||
@@ -490,7 +503,7 @@ func main() {
|
|||||||
widgetOpenRepo := repository.NewWidgetOpenRepository(db)
|
widgetOpenRepo := repository.NewWidgetOpenRepository(db)
|
||||||
analyticsService := service.NewAnalyticsService(db, widgetOpenRepo)
|
analyticsService := service.NewAnalyticsService(db, widgetOpenRepo)
|
||||||
analyticsController := controller.NewAnalyticsController(analyticsService, userService)
|
analyticsController := controller.NewAnalyticsController(analyticsService, userService)
|
||||||
systemLogController := controller.NewSystemLogController(systemLogService, userService)
|
systemLogController := controller.NewSystemLogController(systemLogService, userService, appSettingRepo)
|
||||||
|
|
||||||
appRouter.RegisterRoutes(
|
appRouter.RegisterRoutes(
|
||||||
r,
|
r,
|
||||||
|
|||||||
@@ -60,6 +60,9 @@ func StructuredHTTPLogger(logSvc *service.SystemLogService) gin.HandlerFunc {
|
|||||||
} else if status >= 400 || latencyMs >= 2000 {
|
} else if status >= 400 || latencyMs >= 2000 {
|
||||||
level = "warn"
|
level = "warn"
|
||||||
}
|
}
|
||||||
|
if !logSvc.ShouldPersistLevel(level) {
|
||||||
|
return
|
||||||
|
}
|
||||||
var userID *uint
|
var userID *uint
|
||||||
if v := c.GetHeader("X-User-Id"); v != "" {
|
if v := c.GetHeader("X-User-Id"); v != "" {
|
||||||
if id, err := strconv.ParseUint(v, 10, 64); err == nil && id > 0 {
|
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)
|
// Analytics(数据分析报表,需客服 X-User-Id)
|
||||||
routes.GET("/agent/analytics/summary", controllers.Analytics.GetSummary)
|
routes.GET("/agent/analytics/summary", controllers.Analytics.GetSummary)
|
||||||
routes.GET("/agent/logs/api", controllers.SystemLog.GetLogs) // 日志查询(避免与前端 /agent/logs 页面路径冲突)
|
routes.GET("/agent/logs/api", controllers.SystemLog.GetLogs) // 日志查询(避免与前端 /agent/logs 页面路径冲突)
|
||||||
routes.POST("/agent/logs/frontend", controllers.SystemLog.ReportFrontendLog) // 前端日志上报
|
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(健康检查)
|
// Health(健康检查)
|
||||||
routes.GET("/health", controllers.Health.HealthCheck) // 健康检查
|
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"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"strings"
|
"strings"
|
||||||
|
"sync/atomic"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/2930134478/AI-CS/backend/models"
|
"github.com/2930134478/AI-CS/backend/models"
|
||||||
@@ -46,11 +47,46 @@ type QuerySystemLogsResult struct {
|
|||||||
|
|
||||||
// SystemLogService 结构化日志服务(查询 + 写入)。
|
// SystemLogService 结构化日志服务(查询 + 写入)。
|
||||||
type SystemLogService struct {
|
type SystemLogService struct {
|
||||||
repo *repository.SystemLogRepository
|
repo *repository.SystemLogRepository
|
||||||
|
minPersistLevel atomic.Int32 // 见 SYSTEM_LOG_MIN_LEVEL / 数据库覆盖;logRankNone 表示关闭全部落库
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewSystemLogService(repo *repository.SystemLogRepository) *SystemLogService {
|
func NewSystemLogService(repo *repository.SystemLogRepository, minPersistLevel int) *SystemLogService {
|
||||||
return &SystemLogService{repo: repo}
|
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 {
|
func (s *SystemLogService) Create(input CreateSystemLogInput) error {
|
||||||
@@ -58,6 +94,9 @@ func (s *SystemLogService) Create(input CreateSystemLogInput) error {
|
|||||||
if level == "" {
|
if level == "" {
|
||||||
level = "info"
|
level = "info"
|
||||||
}
|
}
|
||||||
|
if !s.shouldPersistLevel(level) {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
category := strings.ToLower(strings.TrimSpace(input.Category))
|
category := strings.ToLower(strings.TrimSpace(input.Category))
|
||||||
if category == "" {
|
if category == "" {
|
||||||
category = "system"
|
category = "system"
|
||||||
|
|||||||
@@ -3,11 +3,17 @@ package websocket
|
|||||||
import (
|
import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"log"
|
"log"
|
||||||
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/gorilla/websocket"
|
"github.com/gorilla/websocket"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
type inboundWSMessage struct {
|
||||||
|
Type string `json:"type"`
|
||||||
|
Data map[string]interface{} `json:"data"`
|
||||||
|
}
|
||||||
|
|
||||||
const (
|
const (
|
||||||
// 客户端发送 ping 的最大等待时间
|
// 客户端发送 ping 的最大等待时间
|
||||||
writeWait = 10 * time.Second
|
writeWait = 10 * time.Second
|
||||||
@@ -71,15 +77,14 @@ func (c *Client) ReadPump() {
|
|||||||
|
|
||||||
// 持续读取消息
|
// 持续读取消息
|
||||||
for {
|
for {
|
||||||
_, _, err := c.conn.ReadMessage()
|
_, payload, err := c.conn.ReadMessage()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if websocket.IsUnexpectedCloseError(err, websocket.CloseGoingAway, websocket.CloseAbnormalClosure) {
|
if websocket.IsUnexpectedCloseError(err, websocket.CloseGoingAway, websocket.CloseAbnormalClosure) {
|
||||||
log.Printf("⚠️ WebSocket 读取错误: 对话ID=%d, 错误=%v", c.conversationID, err)
|
log.Printf("⚠️ WebSocket 读取错误: 对话ID=%d, 错误=%v", c.conversationID, err)
|
||||||
}
|
}
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
// 目前我们不需要处理客户端发送的消息,只接收心跳包
|
c.handleIncoming(payload)
|
||||||
// 如果需要双向通信,可以在这里处理客户端消息
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -135,3 +140,43 @@ func (c *Client) SendMessage(messageType string, data interface{}) error {
|
|||||||
c.conn.SetWriteDeadline(time.Now().Add(writeWait))
|
c.conn.SetWriteDeadline(time.Now().Add(writeWait))
|
||||||
return c.conn.WriteMessage(websocket.TextMessage, messageJSON)
|
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 { useCallback, useEffect, useMemo, useState } from "react";
|
||||||
import { Button } from "@/components/ui/button";
|
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 { toast } from "@/hooks/useToast";
|
||||||
import {
|
import {
|
||||||
Dialog,
|
Dialog,
|
||||||
@@ -46,9 +53,30 @@ export default function LogsPage({ embedded = false }: { embedded?: boolean }) {
|
|||||||
const [page, setPage] = useState(1);
|
const [page, setPage] = useState(1);
|
||||||
const pageSize = 50;
|
const pageSize = 50;
|
||||||
const [selected, setSelected] = useState<(QuerySystemLogsResult["items"][number]) | null>(null);
|
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 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 () => {
|
const load = useCallback(async () => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
try {
|
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>
|
<p className="text-sm text-muted-foreground mt-1">按分类查看 AI / RAG / 系统 / 前端日志,用于排障定位。</p>
|
||||||
</div>
|
</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">
|
<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" />
|
<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>
|
<span className="text-xs text-muted-foreground">到</span>
|
||||||
|
|||||||
@@ -152,6 +152,9 @@ export function DashboardShell() {
|
|||||||
aiThinking,
|
aiThinking,
|
||||||
needWebSearch,
|
needWebSearch,
|
||||||
setNeedWebSearch,
|
setNeedWebSearch,
|
||||||
|
remoteTypingDraft,
|
||||||
|
sendTypingDraft,
|
||||||
|
sendTypingStop,
|
||||||
} = useMessages({
|
} = useMessages({
|
||||||
conversationId: selectedConversationId,
|
conversationId: selectedConversationId,
|
||||||
agentId: agent?.id ?? null,
|
agentId: agent?.id ?? null,
|
||||||
@@ -180,11 +183,32 @@ export function DashboardShell() {
|
|||||||
const content = messageInput.trim();
|
const content = messageInput.trim();
|
||||||
try {
|
try {
|
||||||
await sendMessage(content, fileInfo);
|
await sendMessage(content, fileInfo);
|
||||||
|
sendTypingStop();
|
||||||
setMessageInput("");
|
setMessageInput("");
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
toast.error((error as Error).message);
|
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(() => {
|
const handleMarkAllRead = useCallback(() => {
|
||||||
@@ -341,16 +365,30 @@ export function DashboardShell() {
|
|||||||
onMarkMessagesRead={markMessagesAsRead}
|
onMarkMessagesRead={markMessagesAsRead}
|
||||||
internalChatMode={isInternalChat}
|
internalChatMode={isInternalChat}
|
||||||
bottomSlot={
|
bottomSlot={
|
||||||
isInternalChat && aiThinking ? (
|
<>
|
||||||
<div className="flex justify-start mt-2">
|
{!isInternalChat && remoteTypingDraft ? (
|
||||||
<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">
|
<div className="flex justify-start mt-2">
|
||||||
<Loader2 className="w-4 h-4 animate-spin flex-shrink-0" />
|
<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>AI 正在思考...</span>
|
<span>{remoteTypingDraft}</span>
|
||||||
</div>
|
<span className="inline-flex items-center ml-1 align-middle">
|
||||||
</div>
|
<span className="w-1 h-1 rounded-full bg-muted-foreground/60 animate-bounce [animation-duration:1.2s]" />
|
||||||
) : null
|
<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 && (
|
{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">
|
<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 { API_BASE_URL } from "@/lib/config";
|
||||||
import { getAvatarUrl } from "@/utils/avatar";
|
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 {
|
interface MessageListProps {
|
||||||
messages: MessageItem[];
|
messages: MessageItem[];
|
||||||
loading: boolean;
|
loading: boolean;
|
||||||
@@ -401,6 +441,15 @@ export function MessageList({
|
|||||||
? highlightText(message.content, keyword)
|
? highlightText(message.content, keyword)
|
||||||
: message.content;
|
: 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") {
|
if (message.message_type === "system_message") {
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
@@ -503,7 +552,11 @@ export function MessageList({
|
|||||||
{/* 文本内容 */}
|
{/* 文本内容 */}
|
||||||
{message.content && (
|
{message.content && (
|
||||||
<div className="whitespace-pre-wrap break-words text-sm">
|
<div className="whitespace-pre-wrap break-words text-sm">
|
||||||
{bubbleContent}
|
{shouldTypewriter ? (
|
||||||
|
<TypewriterText text={message.content} animateKey={message.id} />
|
||||||
|
) : (
|
||||||
|
bubbleContent
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||||
|
import { createPortal } from "react-dom";
|
||||||
import { MessageList } from "@/components/dashboard/MessageList";
|
import { MessageList } from "@/components/dashboard/MessageList";
|
||||||
import { OnlineAgentsList, type OnlineAgent } from "./OnlineAgentsList";
|
import { OnlineAgentsList, type OnlineAgent } from "./OnlineAgentsList";
|
||||||
import { VisitorMessageInput } from "./VisitorMessageInput";
|
import { VisitorMessageInput } from "./VisitorMessageInput";
|
||||||
@@ -11,6 +12,7 @@ import {
|
|||||||
ChatWebSocketPayload,
|
ChatWebSocketPayload,
|
||||||
MessageItem,
|
MessageItem,
|
||||||
MessagesReadPayload,
|
MessagesReadPayload,
|
||||||
|
TypingDraftPayload,
|
||||||
} from "@/features/agent/types";
|
} from "@/features/agent/types";
|
||||||
import {
|
import {
|
||||||
fetchMessages,
|
fetchMessages,
|
||||||
@@ -29,10 +31,13 @@ import {
|
|||||||
fetchVisitorWidgetConfig,
|
fetchVisitorWidgetConfig,
|
||||||
type VisitorWidgetConfig,
|
type VisitorWidgetConfig,
|
||||||
} from "@/features/agent/services/embeddingConfigApi";
|
} from "@/features/agent/services/embeddingConfigApi";
|
||||||
|
import { TYPING_DRAFT_TTL_MS } from "@/lib/constants/typing-draft";
|
||||||
import { useWebSocket } from "@/features/agent/hooks/useWebSocket";
|
import { useWebSocket } from "@/features/agent/hooks/useWebSocket";
|
||||||
import type { WSMessage } from "@/lib/websocket";
|
import type { WSMessage } from "@/lib/websocket";
|
||||||
import { useSoundNotification } from "@/hooks/useSoundNotification";
|
import { useSoundNotification } from "@/hooks/useSoundNotification";
|
||||||
import { playNotificationSound } from "@/utils/sound";
|
import { playNotificationSound } from "@/utils/sound";
|
||||||
|
import { getAvatarUrl } from "@/utils/avatar";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
import { Check, ChevronDown, Loader2 } from "lucide-react";
|
import { Check, ChevronDown, Loader2 } from "lucide-react";
|
||||||
|
|
||||||
interface ChatWidgetProps {
|
interface ChatWidgetProps {
|
||||||
@@ -71,6 +76,18 @@ function parseUserAgent(userAgent: string) {
|
|||||||
return { browser, os };
|
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);
|
const [loadingAgents, setLoadingAgents] = useState(false);
|
||||||
/** AI 模式下发消息后等待回复时显示「正在输入」提示 */
|
/** AI 模式下发消息后等待回复时显示「正在输入」提示 */
|
||||||
const [aiTyping, setAiTyping] = useState(false);
|
const [aiTyping, setAiTyping] = useState(false);
|
||||||
|
const [agentTypingDraft, setAgentTypingDraft] = useState("");
|
||||||
|
const [agentTypingSenderId, setAgentTypingSenderId] = useState<number | null>(null);
|
||||||
/** 联网搜索:本回合是否使用联网(访客可勾选) */
|
/** 联网搜索:本回合是否使用联网(访客可勾选) */
|
||||||
const [needWebSearch, setNeedWebSearch] = useState(false);
|
const [needWebSearch, setNeedWebSearch] = useState(false);
|
||||||
/** 访客小窗配置(由配置页控制是否显示联网设置) */
|
/** 访客小窗配置(由配置页控制是否显示联网设置) */
|
||||||
const [widgetConfig, setWidgetConfig] = useState<VisitorWidgetConfig | null>(null);
|
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);
|
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) {
|
if (event.type === "new_message" && event.data) {
|
||||||
const msg = event.data as MessageItem;
|
const msg = event.data as MessageItem;
|
||||||
handleNewMessage(msg);
|
handleNewMessage(msg);
|
||||||
|
if (msg.sender_is_agent) {
|
||||||
|
setAgentTypingDraft("");
|
||||||
|
}
|
||||||
// AI 模式下收到对方(客服/AI)回复时关闭「正在输入」提示
|
// AI 模式下收到对方(客服/AI)回复时关闭「正在输入」提示
|
||||||
if (chatMode === "ai" && msg.sender_is_agent) {
|
if (chatMode === "ai" && msg.sender_is_agent) {
|
||||||
setAiTyping(false);
|
setAiTyping(false);
|
||||||
@@ -508,12 +532,41 @@ export function ChatWidget({ visitorId, isOpen, onToggle }: ChatWidgetProps) {
|
|||||||
payload.conversation_id = event.conversation_id;
|
payload.conversation_id = event.conversation_id;
|
||||||
}
|
}
|
||||||
handleMessagesReadEvent(payload);
|
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,
|
conversationId,
|
||||||
enabled: Boolean(conversationId) && isOpen,
|
enabled: Boolean(conversationId) && isOpen,
|
||||||
isVisitor: true,
|
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(
|
const handleSendMessage = useCallback(
|
||||||
async (fileInfo?: UploadFileResult) => {
|
async (fileInfo?: UploadFileResult) => {
|
||||||
if (!conversationId || sending) {
|
if (!conversationId || sending) {
|
||||||
@@ -554,6 +664,7 @@ export function ChatWidget({ visitorId, isOpen, onToggle }: ChatWidgetProps) {
|
|||||||
// 立即添加到消息列表
|
// 立即添加到消息列表
|
||||||
setMessages((prev) => [...prev, tempMessage]);
|
setMessages((prev) => [...prev, tempMessage]);
|
||||||
setInput("");
|
setInput("");
|
||||||
|
sendTypingStop();
|
||||||
setSending(true);
|
setSending(true);
|
||||||
if (chatMode === "ai") {
|
if (chatMode === "ai") {
|
||||||
setAiTyping(true);
|
setAiTyping(true);
|
||||||
@@ -588,7 +699,7 @@ export function ChatWidget({ visitorId, isOpen, onToggle }: ChatWidgetProps) {
|
|||||||
setSending(false);
|
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 null;
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
// 挂到 body,避免页面内祖先的 transform/filter/backdrop-filter 在 Chrome 等浏览器中
|
||||||
<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">
|
// 成为 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="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">
|
<div className="flex items-center gap-2.5 min-w-0">
|
||||||
@@ -748,14 +873,47 @@ export function ChatWidget({ visitorId, isOpen, onToggle }: ChatWidgetProps) {
|
|||||||
onMarkMessagesRead={handleMarkAgentMessagesRead}
|
onMarkMessagesRead={handleMarkAgentMessagesRead}
|
||||||
leftAvatarBySenderId={chatMode === "human" ? agentAvatarMap : undefined}
|
leftAvatarBySenderId={chatMode === "human" ? agentAvatarMap : undefined}
|
||||||
bottomSlot={
|
bottomSlot={
|
||||||
chatMode === "ai" && aiTyping ? (
|
<>
|
||||||
<div className="flex justify-start mt-2">
|
{chatMode === "human" && agentTypingDraft ? (
|
||||||
<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">
|
<div className="flex justify-start mt-2">
|
||||||
<Loader2 className="w-4 h-4 animate-spin flex-shrink-0" />
|
<div className="w-7 h-7 rounded-full overflow-hidden bg-slate-200 border border-slate-300 flex-shrink-0">
|
||||||
<span>AI 正在思考...</span>
|
{(() => {
|
||||||
|
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>
|
||||||
</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>
|
</div>
|
||||||
@@ -833,7 +991,8 @@ export function ChatWidget({ visitorId, isOpen, onToggle }: ChatWidgetProps) {
|
|||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</Card>
|
</Card>,
|
||||||
|
document.body
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import { useRouter } from "next/navigation";
|
|||||||
|
|
||||||
import type { AgentUser } from "../../agent/types";
|
import type { AgentUser } from "../../agent/types";
|
||||||
import { logout } from "../../agent/services/authApi";
|
import { logout } from "../../agent/services/authApi";
|
||||||
import { clearAgentUser, getAgentUser } from "@/utils/storage";
|
import { clearAgentUser, getAgentUser, getAgentWSToken } from "@/utils/storage";
|
||||||
|
|
||||||
export function useAuth() {
|
export function useAuth() {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
@@ -16,7 +16,14 @@ export function useAuth() {
|
|||||||
const current = getAgentUser();
|
const current = getAgentUser();
|
||||||
if (!current) {
|
if (!current) {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
router.push("/");
|
router.push("/agent/login");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// 客服端实时链路依赖 ws_token;缺失/过期时强制重新登录,避免前端持续 401 重连且功能失效。
|
||||||
|
if (!getAgentWSToken()) {
|
||||||
|
clearAgentUser();
|
||||||
|
setLoading(false);
|
||||||
|
router.push("/agent/login");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
setAgent(current);
|
setAgent(current);
|
||||||
|
|||||||
@@ -20,8 +20,10 @@ import {
|
|||||||
MessagesReadPayload,
|
MessagesReadPayload,
|
||||||
ChatWebSocketPayload,
|
ChatWebSocketPayload,
|
||||||
VisitorStatusUpdatePayload,
|
VisitorStatusUpdatePayload,
|
||||||
|
TypingDraftPayload,
|
||||||
} from "../../agent/types";
|
} from "../../agent/types";
|
||||||
import { useWebSocket } from "./useWebSocket";
|
import { useWebSocket } from "./useWebSocket";
|
||||||
|
import { TYPING_DRAFT_TTL_MS } from "@/lib/constants/typing-draft";
|
||||||
import { WSMessage } from "@/lib/websocket";
|
import { WSMessage } from "@/lib/websocket";
|
||||||
import { buildMessagePreview } from "@/utils/format";
|
import { buildMessagePreview } from "@/utils/format";
|
||||||
import { playNotificationSound } from "@/utils/sound";
|
import { playNotificationSound } from "@/utils/sound";
|
||||||
@@ -61,7 +63,10 @@ export function useMessages({
|
|||||||
const [aiThinking, setAiThinking] = useState(false);
|
const [aiThinking, setAiThinking] = useState(false);
|
||||||
/** 知识库测试:联网选项 */
|
/** 知识库测试:联网选项 */
|
||||||
const [needWebSearch, setNeedWebSearch] = useState(false);
|
const [needWebSearch, setNeedWebSearch] = useState(false);
|
||||||
|
const [remoteTypingDraft, setRemoteTypingDraft] = useState<string>("");
|
||||||
const wsToken = getAgentWSToken() ?? undefined;
|
const wsToken = getAgentWSToken() ?? undefined;
|
||||||
|
const typingSeqRef = useRef(0);
|
||||||
|
const typingTimerRef = useRef<NodeJS.Timeout | null>(null);
|
||||||
|
|
||||||
const refreshConversationDetail = useCallback(
|
const refreshConversationDetail = useCallback(
|
||||||
async (id: number) => {
|
async (id: number) => {
|
||||||
@@ -503,6 +508,30 @@ export function useMessages({
|
|||||||
refreshConversationDetail(payload.conversation_id);
|
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,
|
conversationId,
|
||||||
enabled: Boolean(conversationId),
|
enabled: Boolean(conversationId),
|
||||||
isVisitor: false, // 客服端设置为 false
|
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 消息显示/隐藏
|
// 切换 AI 消息显示/隐藏
|
||||||
const toggleAIMessages = useCallback(async () => {
|
const toggleAIMessages = useCallback(async () => {
|
||||||
const newValue = !includeAIMessages;
|
const newValue = !includeAIMessages;
|
||||||
@@ -556,6 +632,9 @@ export function useMessages({
|
|||||||
aiThinking,
|
aiThinking,
|
||||||
needWebSearch,
|
needWebSearch,
|
||||||
setNeedWebSearch,
|
setNeedWebSearch,
|
||||||
|
remoteTypingDraft,
|
||||||
|
sendTypingDraft,
|
||||||
|
sendTypingStop,
|
||||||
}),
|
}),
|
||||||
[
|
[
|
||||||
conversationDetail,
|
conversationDetail,
|
||||||
@@ -572,6 +651,9 @@ export function useMessages({
|
|||||||
forceIncludeAIMessages,
|
forceIncludeAIMessages,
|
||||||
aiThinking,
|
aiThinking,
|
||||||
needWebSearch,
|
needWebSearch,
|
||||||
|
remoteTypingDraft,
|
||||||
|
sendTypingDraft,
|
||||||
|
sendTypingStop,
|
||||||
]
|
]
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useEffect, useRef } from "react";
|
import { useCallback, useEffect, useRef } from "react";
|
||||||
import { WSClient, WSMessage } from "@/lib/websocket";
|
import { WSClient, WSMessage } from "@/lib/websocket";
|
||||||
|
|
||||||
interface UseWebSocketOptions<T> {
|
interface UseWebSocketOptions<T> {
|
||||||
@@ -28,6 +28,7 @@ export function useWebSocket<T>({
|
|||||||
const onMessageRef = useRef(onMessage);
|
const onMessageRef = useRef(onMessage);
|
||||||
const onErrorRef = useRef(onError);
|
const onErrorRef = useRef(onError);
|
||||||
const onCloseRef = useRef(onClose);
|
const onCloseRef = useRef(onClose);
|
||||||
|
const clientRef = useRef<WSClient<T> | null>(null);
|
||||||
|
|
||||||
// 更新 ref 的值
|
// 更新 ref 的值
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -38,6 +39,14 @@ export function useWebSocket<T>({
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!conversationId || !enabled) {
|
if (!conversationId || !enabled) {
|
||||||
|
clientRef.current?.disconnect();
|
||||||
|
clientRef.current = null;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// 客服端必须带 wsToken;否则后端会 401,且所有实时能力(新消息/草稿/已读)都不可用。
|
||||||
|
if (!isVisitor && !wsToken) {
|
||||||
|
clientRef.current?.disconnect();
|
||||||
|
clientRef.current = null;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -53,14 +62,24 @@ export function useWebSocket<T>({
|
|||||||
: undefined,
|
: undefined,
|
||||||
onClose: onCloseRef.current ? () => onCloseRef.current?.() : undefined,
|
onClose: onCloseRef.current ? () => onCloseRef.current?.() : undefined,
|
||||||
});
|
});
|
||||||
|
clientRef.current = client;
|
||||||
|
|
||||||
client.connect();
|
client.connect();
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
client.disconnect();
|
client.disconnect();
|
||||||
|
if (clientRef.current === client) {
|
||||||
|
clientRef.current = null;
|
||||||
|
}
|
||||||
};
|
};
|
||||||
// 只依赖 conversationId、enabled、isVisitor 和 agentId,不依赖回调函数
|
// 只依赖 conversationId、enabled、isVisitor 和 agentId,不依赖回调函数
|
||||||
// 回调函数通过 useRef 存储,不会导致重新连接
|
// 回调函数通过 useRef 存储,不会导致重新连接
|
||||||
}, [conversationId, enabled, isVisitor, agentId, wsToken]);
|
}, [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> {
|
): Promise<MarkMessagesReadResult | null> {
|
||||||
const res = await fetch(apiUrl("/messages/read"), {
|
const res = await fetch(apiUrl("/messages/read"), {
|
||||||
method: "PUT",
|
method: "PUT",
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json", ...getAgentHeaders() },
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
conversation_id: conversationId,
|
conversation_id: conversationId,
|
||||||
reader_is_agent: readerIsAgent,
|
reader_is_agent: readerIsAgent,
|
||||||
|
|||||||
@@ -36,6 +36,51 @@ export interface QuerySystemLogsParams {
|
|||||||
pageSize?: number;
|
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> {
|
export async function fetchSystemLogs(params: QuerySystemLogsParams): Promise<QuerySystemLogsResult> {
|
||||||
const q = new URLSearchParams();
|
const q = new URLSearchParams();
|
||||||
if (params.from) q.set("from", params.from);
|
if (params.from) q.set("from", params.from);
|
||||||
|
|||||||
@@ -90,8 +90,16 @@ export interface VisitorStatusUpdatePayload {
|
|||||||
visitor_count?: number;
|
visitor_count?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface TypingDraftPayload {
|
||||||
|
sender_id?: number;
|
||||||
|
sender_is_agent?: boolean;
|
||||||
|
text?: string;
|
||||||
|
seq?: number;
|
||||||
|
}
|
||||||
|
|
||||||
export type ChatWebSocketPayload =
|
export type ChatWebSocketPayload =
|
||||||
| MessageItem
|
| MessageItem
|
||||||
| MessagesReadPayload
|
| 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 {
|
isConnected(): boolean {
|
||||||
return this.ws !== null && this.ws.readyState === WebSocket.OPEN;
|
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",
|
source: "/agent/logs/frontend",
|
||||||
destination: `http://${backendHost}:${backendPort}/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).*)",
|
source: "/:path((?!_next|agent|chat|favicon.ico).*)",
|
||||||
destination: `http://${backendHost}:${backendPort}/:path*`,
|
destination: `http://${backendHost}:${backendPort}/:path*`,
|
||||||
|
|||||||
@@ -51,6 +51,10 @@ const nextConfig: NextConfig = {
|
|||||||
source: "/agent/logs/frontend",
|
source: "/agent/logs/frontend",
|
||||||
destination: `http://${backendHost}:${backendPort}/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 路径(不以 /_next、/agent、/api、/chat 开头的路径)
|
||||||
// /api/agent/prompts 由 app/api/agent/prompts/route.ts 代理,不在此转发
|
// /api/agent/prompts 由 app/api/agent/prompts/route.ts 代理,不在此转发
|
||||||
{
|
{
|
||||||
|
|||||||
Reference in New Issue
Block a user