ソースを参照

feat: 2026-06-11 批次 — 安全、RAG、离线邮件与工作台增强

- 访客会话 access_token,修复 IDOR

- 对话列表分页与性能优化、自动关闭 stale 会话

- 知识库:PDF/DOCX 导入、Chunk 分段、FAQ 优先

- 访客邮箱采集与离线 SMTP 邮件推送

- 修复 RAG 检索阈值与创建文档 X-User-Id

- 更新 README 与 .env.example
537yaha 6 日 前
コミット
aa3bfc126b
67 ファイル変更5233 行追加360 行削除
  1. 25 0
      .env.example
  2. 17 4
      README.en.md
  3. 41 3
      README.md
  4. 89 26
      backend/controller/conversation_controller.go
  5. 161 0
      backend/controller/document_chunk_controller.go
  6. 131 0
      backend/controller/email_notification_config_controller.go
  7. 21 0
      backend/controller/faq_controller.go
  8. 69 0
      backend/controller/helper.go
  9. 34 60
      backend/controller/message_controller.go
  10. 1 0
      backend/go.mod
  11. 2 0
      backend/go.sum
  12. 141 0
      backend/infra/smtp_mailer.go
  13. 54 3
      backend/infra/vector_store.go
  14. 36 5
      backend/main.go
  15. 2 0
      backend/models/app_setting.go
  16. 15 0
      backend/models/document_chunk.go
  17. 18 0
      backend/models/email_notification_config.go
  18. 15 0
      backend/models/offline_email_job.go
  19. 10 8
      backend/models/user.go
  20. 37 1
      backend/repository/conversation_repository.go
  21. 110 0
      backend/repository/document_chunk_repository.go
  22. 36 0
      backend/repository/email_notification_config_repository.go
  23. 16 0
      backend/repository/faq_repository.go
  24. 12 0
      backend/repository/message_repository.go
  25. 100 0
      backend/repository/message_repository_batch.go
  26. 52 0
      backend/repository/offline_email_job_repository.go
  27. 16 0
      backend/router/router.go
  28. 68 15
      backend/service/ai_service.go
  29. 279 0
      backend/service/chunk_service.go
  30. 59 0
      backend/service/conversation_access.go
  31. 282 0
      backend/service/conversation_list_batch.go
  32. 32 65
      backend/service/conversation_service.go
  33. 276 0
      backend/service/email_notification_config_service.go
  34. 16 0
      backend/service/faq_service.go
  35. 49 6
      backend/service/import/pdf_parser.go
  36. 85 6
      backend/service/import/word_parser.go
  37. 16 5
      backend/service/message_service.go
  38. 285 0
      backend/service/offline_email_service.go
  39. 18 10
      backend/service/rag/embedding.go
  40. 29 2
      backend/service/rag/retrieval.go
  41. 12 5
      backend/service/rag/vector_store.go
  42. 11 1
      backend/service/types.go
  43. 16 0
      backend/utils/conversation_token.go
  44. 18 1
      backend/websocket/handler.go
  45. 17 0
      backend/websocket/hub.go
  46. 510 0
      frontend/app/agent/knowledge/[docId]/page.tsx
  47. 42 0
      frontend/app/agent/knowledge/page.tsx
  48. 456 0
      frontend/app/agent/settings/page.tsx
  49. 56 2
      frontend/components/dashboard/ConversationList.tsx
  50. 9 0
      frontend/components/dashboard/ConversationSidebar.tsx
  51. 45 7
      frontend/components/dashboard/DashboardShell.tsx
  52. 210 0
      frontend/components/dashboard/FAQSearchDropdown.tsx
  53. 43 2
      frontend/components/dashboard/MessageInput.tsx
  54. 33 8
      frontend/components/visitor/ChatWidget.tsx
  55. 146 6
      frontend/components/visitor/VisitorMessageInput.tsx
  56. 187 84
      frontend/features/agent/hooks/useConversations.ts
  57. 9 1
      frontend/features/agent/hooks/useWebSocket.ts
  58. 117 0
      frontend/features/agent/services/chunkApi.ts
  59. 124 14
      frontend/features/agent/services/conversationApi.ts
  60. 1 1
      frontend/features/agent/services/documentApi.ts
  61. 88 0
      frontend/features/agent/services/emailNotificationApi.ts
  62. 29 0
      frontend/features/agent/services/faqApi.ts
  63. 31 5
      frontend/features/agent/services/messageApi.ts
  64. 36 1
      frontend/features/visitor/services/conversationApi.ts
  65. 157 3
      frontend/lib/i18n/dict.ts
  66. 69 0
      frontend/lib/visitor-session.ts
  67. 6 0
      frontend/lib/websocket.ts

+ 25 - 0
.env.example

@@ -66,6 +66,28 @@ REDIS_DB=0
 # WebSocket 分布式事件频道(一般无需修改)
 REDIS_WS_CHANNEL=ai_cs:ws_events
 
+# =========================
+# 会话维护(Demo/生产可选)
+# =========================
+# 自动关闭超过 N 天未更新的 open 访客会话;设为 0 禁用
+AUTO_CLOSE_CONVERSATION_DAYS=7
+
+# =========================
+# 离线邮件通知(按需配置)
+# 访客离线且已留邮箱时,客服人工消息延迟推送邮件;也可在「设置 → 离线邮件通知」页面配置
+# =========================
+# 是否启用(true/false/1/0)
+OFFLINE_EMAIL_ENABLED=false
+# 客服发消息后等待秒数再发邮件(默认 60);访客在此期间上线则取消
+OFFLINE_EMAIL_DELAY_SECONDS=60
+# 云厂商 SMTP(如阿里云邮件推送:smtpdm.aliyun.com:465)
+SMTP_HOST=
+SMTP_PORT=465
+SMTP_USER=
+SMTP_PASSWORD=
+SMTP_FROM_EMAIL=
+SMTP_FROM_NAME=
+
 # =========================
 # 访客 IP 地理位置(ip2region 离线库,可选)
 # =========================
@@ -99,6 +121,9 @@ VECTOR_STORE_DISABLED=false
 # true 表示强依赖 Milvus(连接失败则启动失败)
 MILVUS_REQUIRED=false
 
+# RAG 向量检索最低相似度(0~1,分段场景默认 0.22;过高会导致「搜不到」)
+# RAG_MIN_SCORE=0.22
+
 # =========================
 # 联网搜索(按需配置)
 # 使用联网搜索功能时至少配置一种

+ 17 - 4
README.en.md

@@ -64,12 +64,17 @@
   - Bottom-right chat window via iframe or `widget.js`
   - AI / human mode, sound notifications, file uploads
   - Optional per-turn web search (visibility configurable in admin)
+  - **Email capture** — expands on first keystroke (optional); auto-hides after submit or first message
+  - **Session security** — per-session `access_token` required for visitor API / WebSocket (IDOR fix)
 - **Agent dashboard**
-  - Conversation list, WebSocket messaging, unread badges
+  - **Paginated** conversation list, WebSocket messaging, unread badges
+  - Auto-close stale open visitor sessions (settings or `.env`)
   - Visitor **IP & approximate region** ([ip2region](https://github.com/lionsoul2014/ip2region), offline)
   - Live typing draft sync between visitor and agent
-  - Multi-model setup (text / image), prompts, knowledge base + RAG
-  - Log center, analytics (widget opens, messages, AI success rate, etc.)
+  - Multi-model setup (text / image); **OpenAI-compatible** Chat Completions APIs
+  - Prompts, knowledge base + RAG: **PDF/DOCX import**, **document chunks**, **FAQ-first** answers, `/` FAQ search
+  - **Offline email** — SMTP notify when visitor is offline and left email (human messages only; settings UI)
+  - Log center, analytics (widget opens, messages, AI success rate, KB hit rate, etc.)
 - **Marketing site & SEO** — metadata, OG, sitemap, robots.txt
 - **Optional web search** — Serper (API or MCP) or provider-native search
 
@@ -139,8 +144,10 @@ Required for most deployments: database credentials, `ADMIN_PASSWORD`, `ENCRYPTI
 
 ## Knowledge Base (RAG)
 
+- **Two configs**: AI chat (Settings → AI config, Chat Completions) vs embeddings (Settings → vector model, `/v1/embeddings`).
 - Disable Milvus: `MILVUS_DISABLED=true` — app still runs; RAG off  
 - Strict dependency: `MILVUS_REQUIRED=true` — exit if Milvus is unavailable  
+- Document **chunking** + tune **`RAG_MIN_SCORE`** (default `0.22`) if search returns empty after chunking.
 
 ## Multi-Instance WebSocket (Redis)
 
@@ -152,14 +159,20 @@ Paste before `</body>`. Point iframe `src` to `https://your-domain/chat`. The pa
 
 ## Documentation
 
-- None
+- [2026-06-11 release notes (Chinese)](doc/更新说明-2026-06-11.md)
+- [CHANGELOG](doc/CHANGELOG.md)
+- [Test checklist (Chinese)](doc/功能测试清单-2026-06-11.md)
+- Full details: [Chinese README](./README.md)
 
 ## FAQ & Troubleshooting
 
 - **No sound** — browser needs a user gesture before audio  
 - **Milvus startup failure** — check `MILVUS_REQUIRED`; use `MILVUS_DISABLED=true` if you do not need RAG  
+- **RAG returns nothing** — re-chunk/re-embed documents; lower `RAG_MIN_SCORE` (default `0.22`)  
+- **Docker: missing X-User-Id** — rebuild frontend; ensure reverse proxy forwards `X-User-Id`  
 - **SEO / OG wrong** — set `NEXT_PUBLIC_SITE_URL`  
 - **“Init failed” / MySQL** — `curl :18080/health`, `docker logs ai-cs-backend`; in Docker use `DB_HOST=mysql`  
+- **Offline email** — human agent messages only; visitor must be offline with email saved; configure SMTP in settings  
 
 ## Star History
 

+ 41 - 3
README.md

@@ -70,13 +70,20 @@
   - 右下角聊天小窗,可嵌入任意网站(iframe 方式)
   - 支持 AI 模式 / 人工模式切换、消息提示音、文件上传
   - 可选「本回合联网搜索」开关(是否对访客展示可在后台控制)
+  - **访客邮箱采集**:首次在消息框按键时展开邮箱行(可选填);填完或发首条消息后自动收起,便于离线触达
+  - **会话安全**:每个访客会话持有随机 `access_token`,读/发消息与 WebSocket 须携带令牌,防止仅凭会话 ID 越权访问
 - **客服侧(工作台)**
-  - 会话列表、实时消息(WebSocket)、未读角标提示
+  - 会话列表 **分页加载**、实时消息(WebSocket)、未读角标提示;大批量 open 会话下更流畅
+  - **自动关闭**长期未活跃的 open 访客会话(**设置 → 会话维护** 或 `.env` 可配天数)
   - 访客 **IP 与大致地理位置**(离线 [ip2region](https://github.com/lionsoul2014/ip2region),客服工作台访客详情展示)
   - 支持「实时共享草稿输入」(双方未发送内容可实时可见)
-  - 多模型管理(文本/绘画等)与对话配置
+  - 多模型管理(文本/绘画等)与对话配置;**OpenAI 兼容** Chat Completions 接口,填对 URL + 模型名 + Key 即可接入多数中转/官方服务
   - **提示词配置**(Prompt 管理)
   - **知识库管理 + RAG**(向量检索,可按需启用;向量库不可用时可不影响启动)
+    - **PDF / DOCX 导入**、**文档分段(Chunk)** 与逐段向量化
+    - **FAQ 优先**:命中 FAQ 直接返回答案;聊天输入 `/` 快捷搜索 FAQ
+    - 知识库测试窗口(内部会话),回复可标记 `sources_used`(知识库 / 大模型 / 联网)
+  - **离线邮件通知**:访客离线且已留邮箱时,客服发人工消息后延迟 SMTP 推送(设置页可配,访客上线自动取消、同会话合并)
   - **日志中心**:结构化日志落库,支持按级别/分类/事件/trace_id/关键字筛选排障
   - **数据报表**:按日/区间查看访客打开小窗、会话与消息、AI 回复与失败率、知识库命中率、转人工等指标
 - **官网与 SEO(面向获客)**
@@ -163,6 +170,8 @@ docker-compose -f docker-compose.prod.yml up -d
   - 用户名:`admin`(或 `.env` 中 `ADMIN_USERNAME`)
   - 密码:`.env` 中 `ADMIN_PASSWORD`
 
+> **生产部署提示**:前端生产镜像走同域 **`/api/*`**,需外层 **Nginx/Caddy** 将 `/api` 反向代理到后端,并转发 `X-User-Id`、`X-Conversation-Token` 等自定义请求头。本地 `npm run dev` 时由 Next.js rewrites 自动代理。
+
 #### 演示站管理员安全策略
 
 - `ADMIN_PASSWORD` 仅在首次创建管理员时生效;数据库里已有管理员后,重启服务不会覆盖其密码。
@@ -249,6 +258,13 @@ npm run dev
 | `MILVUS_DISABLED` | 禁用向量库(不连接) | 否 | `false` | `true` |
 | `VECTOR_STORE_DISABLED` | 同上(兼容开关) | 否 | `false` | `true` |
 | `MILVUS_REQUIRED` | 强依赖向量库(失败即退出) | 否 | `false` | `true` |
+| `RAG_MIN_SCORE` | RAG 向量检索最低相似度(0~1) | 否 | `0.22` | 分段场景可试 `0.2`~`0.35` |
+| `AUTO_CLOSE_CONVERSATION_DAYS` | 自动关闭 N 天未活跃 open 会话(0=关闭) | 否 | `7` | 也可在 **设置 → 会话维护** 配置 |
+| `OFFLINE_EMAIL_ENABLED` | 访客离线邮件推送总开关 | 否 | `false` | `true` |
+| `OFFLINE_EMAIL_DELAY_SECONDS` | 离线邮件延迟秒数 | 否 | `60` | `30` |
+| `SMTP_HOST` / `SMTP_PORT` | 离线邮件 SMTP 地址与端口 | 可选 | 空 / `465` | 优先在 **设置页** 配置 |
+| `SMTP_USER` / `SMTP_PASSWORD` | SMTP 账号与密码 | 可选 | 空 | 云厂商 SMTP |
+| `SMTP_FROM_EMAIL` / `SMTP_FROM_NAME` | 发件人邮箱与显示名 | 可选 | 空 | `noreply@example.com` |
 | `SERPER_MCP_URL` | 联网搜索 MCP 地址 | 可选(启用联网) | 空 | `http://host:3000/sse` |
 | `SERPER_API_KEY` | 联网搜索 API Key | 可选(启用联网) | 空 | `xxxxx` |
 | `NEXT_PUBLIC_SITE_URL` | 站点对外绝对地址(用于 SEO) | 否 | 空(默认 demo 域名) | `https://www.example.com` |
@@ -263,11 +279,26 @@ npm run dev
 
 ## 启用/关闭知识库(RAG)的推荐做法
 
+### 两套独立配置(不要混用)
+
+| 用途 | 配置位置 | 接口 |
+|------|----------|------|
+| **AI 对话**(大模型回复) | 设置 → **AI 配置** | OpenAI 兼容 **Chat Completions**(如 `…/v1/chat/completions`) |
+| **向量化 / RAG 检索** | 设置 → **知识库向量模型** | OpenAI 兼容 **Embeddings**(如 `…/v1/embeddings`) |
+
+### 开关 Milvus
+
 - **你暂时不想用知识库**:把 `.env` 里 `MILVUS_DISABLED=true`(或 `VECTOR_STORE_DISABLED=true`)
   - 应用仍可启动,AI 对话与人工客服不受影响
 - **你必须依赖知识库**(生产强约束):把 `.env` 里 `MILVUS_REQUIRED=true`
   - 此时如果 Milvus 不可用,会落库一条错误日志后退出,避免「半残服务上线」
 
+### 分段(Chunk)与检索调优
+
+- 长文档建议先 **分段** 再向量化;Milvus 集合含 `chunk_db_id` 字段,schema 变更后可能需要 **重新向量化**。
+- 分段后相似度分数通常低于整篇文档;若出现「搜不到」,可调低 `.env` 中的 **`RAG_MIN_SCORE`**(默认 `0.22`)。
+- FAQ 条目会 **优先于** 向量检索直接返回答案。
+
 <a id="redis"></a>
 
 ## 多实例实时消息一致性(Redis)
@@ -314,7 +345,10 @@ npm run dev
 
 ## 相关文档
 
-- 暂无
+- [2026-06-11 批次更新说明](doc/更新说明-2026-06-11.md)(新功能与部署要点)
+- [开发日志 / CHANGELOG](doc/CHANGELOG.md)
+- [功能测试清单(2026-06-11 批次)](doc/功能测试清单-2026-06-11.md)
+- [API 文档](doc/API文档.md)
 
 <a id="faq"></a>
 
@@ -322,8 +356,12 @@ npm run dev
 
 - **提示音听不到**:浏览器通常需要「用户一次交互」才能解锁音频;请先点一下页面任意按钮/再打开喇叭开关测试
 - **向量库连不上导致启动失败**:检查 `.env` 的 `MILVUS_REQUIRED` 是否误开;不需要知识库时建议 `MILVUS_DISABLED=true`
+- **知识库 / RAG 搜不到内容**:确认 Milvus 已启动、文档已 **分段并向量化**;分段场景可尝试调低 `RAG_MIN_SCORE`(默认 `0.22`)
+- **Docker 创建文档报「请提供 X-User-Id」**:需使用含最新修复的前端镜像;并确认 Nginx 转发了 `X-User-Id` 请求头
+- **API 401/403(访客)**:访客接口须带 `X-Conversation-Token`;客服接口须先登录并带 `X-User-Id`
 - **搜不到站点/分享卡片不正确**:设置 `NEXT_PUBLIC_SITE_URL=https://你的域名`,用于 canonical / OG / sitemap 生成
 - **弹窗「初始化失败」/ 后端连不上 MySQL**:先 `curl http://<host>:<BACKEND_PORT>/health`,再 `docker logs ai-cs-backend --tail 50`;Docker 部署时 `DB_HOST` 应为 `mysql` 而非 `localhost`
+- **离线邮件没收到**:仅 **人工客服消息** 触发(AI 自动回复不触发);访客须已留邮箱且 WebSocket 离线;SMTP 可在 **设置 → 离线邮件通知** 配置并发送测试信
 
 ## Star History
 

+ 89 - 26
backend/controller/conversation_controller.go

@@ -86,6 +86,7 @@ func (cc *ConversationController) InitConversation(c *gin.Context) {
 	c.JSON(http.StatusOK, gin.H{
 		"conversation_id": result.ConversationID,
 		"status":          result.Status,
+		"access_token":    result.AccessToken,
 	})
 }
 
@@ -126,7 +127,7 @@ func (cc *ConversationController) GetPublicAIModels(c *gin.Context) {
 	c.JSON(http.StatusOK, gin.H{"models": models})
 }
 
-// UpdateContactInfo 用于更新访客的联系信息。
+// UpdateContactInfo 用于更新访客的联系信息(访客持 access_token,客服持 X-User-Id)
 func (cc *ConversationController) UpdateContactInfo(c *gin.Context) {
 	id, err := parseUintParam(c, "id")
 	if err != nil {
@@ -134,6 +135,10 @@ func (cc *ConversationController) UpdateContactInfo(c *gin.Context) {
 		return
 	}
 
+	if _, ok := authorizeConversationAccess(c, cc.conversationService, cc.users, uint(id)); !ok {
+		return
+	}
+
 	var req updateContactRequest
 	if err := c.ShouldBindJSON(&req); err != nil {
 		c.JSON(http.StatusBadRequest, gin.H{"error": "请求参数错误"})
@@ -201,7 +206,20 @@ func (cc *ConversationController) ListConversations(c *gin.Context) {
 
 	conversationType := c.DefaultQuery("type", "visitor")
 	status := c.DefaultQuery("status", "open")
-	var conversations []service.ConversationSummary
+	page := 1
+	pageSize := 50
+	if p := c.Query("page"); p != "" {
+		if parsed, err := strconv.Atoi(p); err == nil {
+			page = parsed
+		}
+	}
+	if ps := c.Query("page_size"); ps != "" {
+		if parsed, err := strconv.Atoi(ps); err == nil {
+			pageSize = parsed
+		}
+	}
+
+	var listResult *service.ConversationListResult
 	var err error
 	if conversationType == "internal" {
 		if !requirePermission(c, cc.users, string(service.PermKBTest)) {
@@ -211,15 +229,27 @@ func (cc *ConversationController) ListConversations(c *gin.Context) {
 			c.JSON(http.StatusBadRequest, gin.H{"error": "内部对话列表需要 user_id"})
 			return
 		}
-		conversations, err = cc.conversationService.ListInternalConversations(userID, status)
+		listResult, err = cc.conversationService.ListInternalConversationsPaginated(userID, status, page, pageSize)
 	} else {
-		conversations, err = cc.conversationService.ListConversations(userID, status)
+		listResult, err = cc.conversationService.ListConversationsPaginated(userID, status, page, pageSize)
 	}
 	if err != nil {
 		c.JSON(http.StatusInternalServerError, gin.H{"error": "查询对话列表失败"})
 		return
 	}
 
+	items := formatConversationListItems(listResult.Items)
+	c.JSON(http.StatusOK, gin.H{
+		"items":        items,
+		"total":        listResult.Total,
+		"page":         listResult.Page,
+		"page_size":    listResult.PageSize,
+		"has_more":     listResult.HasMore,
+		"total_unread": listResult.TotalUnread,
+	})
+}
+
+func formatConversationListItems(conversations []service.ConversationSummary) []gin.H {
 	items := make([]gin.H, 0, len(conversations))
 	for _, conv := range conversations {
 		item := gin.H{
@@ -234,12 +264,9 @@ func (cc *ConversationController) ListConversations(c *gin.Context) {
 			"unread_count":      conv.UnreadCount,
 			"has_participated":  conv.HasParticipated,
 		}
-
-		// 添加 last_seen_at 字段(用于判断在线状态)
 		if lastSeen := formatTimePointer(conv.LastSeenAt); lastSeen != "" {
 			item["last_seen_at"] = lastSeen
 		}
-
 		if conv.LastMessage != nil {
 			item["last_message"] = gin.H{
 				"id":              conv.LastMessage.ID,
@@ -253,8 +280,7 @@ func (cc *ConversationController) ListConversations(c *gin.Context) {
 		}
 		items = append(items, item)
 	}
-
-	c.JSON(http.StatusOK, items)
+	return items
 }
 
 // GetConversationDetail 返回会话的详细信息。
@@ -265,22 +291,8 @@ func (cc *ConversationController) GetConversationDetail(c *gin.Context) {
 		return
 	}
 
-	// 从查询参数获取 user_id(可选,用于检查参与状态)
-	var userID uint
-	if userIDStr := c.Query("user_id"); userIDStr != "" {
-		// 使用 strconv 解析查询参数(不是路径参数)
-		if parsed, err := strconv.ParseUint(userIDStr, 10, 32); err == nil {
-			userID = uint(parsed)
-		}
-	}
-
-	detail, err := cc.conversationService.GetConversationDetail(uint(id), userID)
-	if err != nil {
-		if err == service.ErrConversationNotFound {
-			c.JSON(http.StatusNotFound, gin.H{"error": "会话不存在"})
-		} else {
-			c.JSON(http.StatusInternalServerError, gin.H{"error": "查询失败"})
-		}
+	detail, ok := authorizeConversationAccess(c, cc.conversationService, cc.users, uint(id))
+	if !ok {
 		return
 	}
 
@@ -329,6 +341,7 @@ func (cc *ConversationController) SearchConversations(c *gin.Context) {
 		return
 	}
 	status := c.DefaultQuery("status", "open")
+	convType := c.DefaultQuery("type", "visitor")
 
 	// 从查询参数获取 user_id(可选,用于检查参与状态)
 	var userID uint
@@ -339,7 +352,7 @@ func (cc *ConversationController) SearchConversations(c *gin.Context) {
 		}
 	}
 
-	conversations, err := cc.conversationService.SearchConversations(query, userID, status)
+	conversations, err := cc.conversationService.SearchConversations(query, userID, status, convType)
 	if err != nil {
 		c.JSON(http.StatusInternalServerError, gin.H{"error": "搜索失败"})
 		return
@@ -379,3 +392,53 @@ func (cc *ConversationController) SearchConversations(c *gin.Context) {
 
 	c.JSON(http.StatusOK, items)
 }
+
+type putAutoCloseDaysBody struct {
+	InactiveDays int `json:"inactive_days"`
+}
+
+// GetAutoCloseConversationDaysPolicy 读取自动关闭 stale 会话策略。
+func (cc *ConversationController) GetAutoCloseConversationDaysPolicy(c *gin.Context) {
+	if !requirePermission(c, cc.users, string(service.PermSettings)) {
+		return
+	}
+	policy := cc.conversationService.GetAutoCloseConversationDaysPolicy()
+	c.JSON(http.StatusOK, policy)
+}
+
+// PutAutoCloseConversationDaysPolicy 写入自动关闭 stale 会话天数(0=禁用)。
+func (cc *ConversationController) PutAutoCloseConversationDaysPolicy(c *gin.Context) {
+	if !requirePermission(c, cc.users, string(service.PermSettings)) {
+		return
+	}
+	var body putAutoCloseDaysBody
+	if err := c.ShouldBindJSON(&body); err != nil {
+		c.JSON(http.StatusBadRequest, gin.H{"error": "请求体无效"})
+		return
+	}
+	if err := cc.conversationService.SetAutoCloseConversationDaysPolicy(body.InactiveDays); err != nil {
+		c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
+		return
+	}
+	policy := cc.conversationService.GetAutoCloseConversationDaysPolicy()
+	c.JSON(http.StatusOK, gin.H{
+		"ok":             true,
+		"effective_days": policy.EffectiveDays,
+	})
+}
+
+// DeleteAutoCloseConversationDaysPolicy 删除数据库覆盖,恢复为 .env。
+func (cc *ConversationController) DeleteAutoCloseConversationDaysPolicy(c *gin.Context) {
+	if !requirePermission(c, cc.users, string(service.PermSettings)) {
+		return
+	}
+	if err := cc.conversationService.ClearAutoCloseConversationDaysPolicy(); err != nil {
+		c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
+		return
+	}
+	policy := cc.conversationService.GetAutoCloseConversationDaysPolicy()
+	c.JSON(http.StatusOK, gin.H{
+		"ok":             true,
+		"effective_days": policy.EffectiveDays,
+	})
+}

+ 161 - 0
backend/controller/document_chunk_controller.go

@@ -0,0 +1,161 @@
+package controller
+
+import (
+	"log"
+	"net/http"
+	"strconv"
+
+	"github.com/2930134478/AI-CS/backend/models"
+	"github.com/2930134478/AI-CS/backend/service"
+	"github.com/gin-gonic/gin"
+)
+
+// DocumentChunkController 文档分段控制器
+type DocumentChunkController struct {
+	chunkService *service.ChunkService
+	users        *service.UserService
+}
+
+// NewDocumentChunkController 创建文档分段控制器实例
+func NewDocumentChunkController(chunkService *service.ChunkService, users *service.UserService) *DocumentChunkController {
+	return &DocumentChunkController{
+		chunkService: chunkService,
+		users:        users,
+	}
+}
+
+// ExecuteChunking 执行分段
+// POST /api/documents/:id/chunks
+func (c *DocumentChunkController) ExecuteChunking(ctx *gin.Context) {
+	if !requirePermission(ctx, c.users, string(service.PermKnowledge)) {
+		return
+	}
+
+	idStr := ctx.Param("id")
+	id, err := strconv.ParseUint(idStr, 10, 64)
+	if err != nil || id == 0 {
+		ctx.JSON(http.StatusBadRequest, gin.H{"error": "文档 ID 不合法"})
+		return
+	}
+
+	var req service.ChunkRequest
+	if err := ctx.ShouldBindJSON(&req); err != nil {
+		ctx.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
+		return
+	}
+
+	if req.Method != "char_count" && req.Method != "separator" {
+		ctx.JSON(http.StatusBadRequest, gin.H{"error": "分段方式必须为 char_count 或 separator"})
+		return
+	}
+
+	chunks, err := c.chunkService.ExecuteChunking(ctx, uint(id), req)
+	if err != nil {
+		log.Printf("[分段] 执行分段失败 (doc=%d): %v", id, err)
+		ctx.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
+		return
+	}
+
+	ctx.JSON(http.StatusOK, gin.H{
+		"message":     "分段完成",
+		"chunk_count": len(chunks),
+		"chunks":      chunks,
+	})
+}
+
+// GetChunks 获取文档分段列表
+// GET /api/documents/:id/chunks?page=1&page_size=20
+func (c *DocumentChunkController) GetChunks(ctx *gin.Context) {
+	if !requirePermission(ctx, c.users, string(service.PermKnowledge)) {
+		return
+	}
+
+	idStr := ctx.Param("id")
+	id, err := strconv.ParseUint(idStr, 10, 64)
+	if err != nil || id == 0 {
+		ctx.JSON(http.StatusBadRequest, gin.H{"error": "文档 ID 不合法"})
+		return
+	}
+
+	page, _ := strconv.Atoi(ctx.DefaultQuery("page", "1"))
+	pageSize, _ := strconv.Atoi(ctx.DefaultQuery("page_size", "20"))
+
+	chunks, total, err := c.chunkService.GetChunks(uint(id), page, pageSize)
+	if err != nil {
+		log.Printf("[分段] 获取分段列表失败 (doc=%d): %v", id, err)
+		ctx.JSON(http.StatusNotFound, gin.H{"error": err.Error()})
+		return
+	}
+
+	if chunks == nil {
+		chunks = []models.DocumentChunk{}
+	}
+
+	totalPage := int(total) / pageSize
+	if int(total)%pageSize > 0 {
+		totalPage++
+	}
+
+	ctx.JSON(http.StatusOK, gin.H{
+		"chunks":     chunks,
+		"total":      int(total),
+		"page":       page,
+		"page_size":  pageSize,
+		"total_page": totalPage,
+	})
+}
+
+// UpdateChunk 更新单个分段
+// PUT /api/documents/:id/chunks/:chunkId
+func (c *DocumentChunkController) UpdateChunk(ctx *gin.Context) {
+	if !requirePermission(ctx, c.users, string(service.PermKnowledge)) {
+		return
+	}
+
+	chunkIDStr := ctx.Param("chunkId")
+	chunkID, err := strconv.ParseUint(chunkIDStr, 10, 64)
+	if err != nil || chunkID == 0 {
+		ctx.JSON(http.StatusBadRequest, gin.H{"error": "分段 ID 不合法"})
+		return
+	}
+
+	var req struct {
+		Content string `json:"content" binding:"required"`
+	}
+	if err := ctx.ShouldBindJSON(&req); err != nil {
+		ctx.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
+		return
+	}
+
+	chunk, err := c.chunkService.UpdateChunk(ctx, uint(chunkID), req.Content)
+	if err != nil {
+		log.Printf("[分段] 更新分段失败 (chunk=%d): %v", chunkID, err)
+		ctx.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
+		return
+	}
+
+	ctx.JSON(http.StatusOK, chunk)
+}
+
+// DeleteChunks 删除文档所有分段
+// DELETE /api/documents/:id/chunks
+func (c *DocumentChunkController) DeleteChunks(ctx *gin.Context) {
+	if !requirePermission(ctx, c.users, string(service.PermKnowledge)) {
+		return
+	}
+
+	idStr := ctx.Param("id")
+	id, err := strconv.ParseUint(idStr, 10, 64)
+	if err != nil || id == 0 {
+		ctx.JSON(http.StatusBadRequest, gin.H{"error": "文档 ID 不合法"})
+		return
+	}
+
+	if err := c.chunkService.DeleteChunks(ctx, uint(id)); err != nil {
+		log.Printf("[分段] 删除分段失败 (doc=%d): %v", id, err)
+		ctx.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
+		return
+	}
+
+	ctx.JSON(http.StatusOK, gin.H{"message": "分段已删除"})
+}

+ 131 - 0
backend/controller/email_notification_config_controller.go

@@ -0,0 +1,131 @@
+package controller
+
+import (
+	"net/http"
+	"strings"
+
+	"github.com/2930134478/AI-CS/backend/service"
+	"github.com/gin-gonic/gin"
+)
+
+// EmailNotificationConfigController 离线邮件通知配置
+type EmailNotificationConfigController struct {
+	configSvc *service.EmailNotificationConfigService
+	offline   *service.OfflineEmailService
+	users     *service.UserService
+}
+
+// NewEmailNotificationConfigController 创建控制器
+func NewEmailNotificationConfigController(
+	configSvc *service.EmailNotificationConfigService,
+	offline *service.OfflineEmailService,
+	users *service.UserService,
+) *EmailNotificationConfigController {
+	return &EmailNotificationConfigController{
+		configSvc: configSvc,
+		offline:   offline,
+		users:     users,
+	}
+}
+
+// Get GET /agent/email-notification-config
+func (e *EmailNotificationConfigController) Get(c *gin.Context) {
+	if !requirePermission(c, e.users, string(service.PermSettings)) {
+		return
+	}
+	if _, err := parseUintQuery(c, "user_id"); err != nil {
+		c.JSON(http.StatusBadRequest, gin.H{"error": "user_id 不合法"})
+		return
+	}
+	result, err := e.configSvc.GetForAPI()
+	if err != nil {
+		c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
+		return
+	}
+	c.JSON(http.StatusOK, result)
+}
+
+// Update PUT /agent/email-notification-config
+func (e *EmailNotificationConfigController) Update(c *gin.Context) {
+	if !requirePermission(c, e.users, string(service.PermSettings)) {
+		return
+	}
+	var req struct {
+		UserID              uint    `json:"user_id" binding:"required"`
+		Enabled             *bool   `json:"enabled"`
+		SMTPHost            *string `json:"smtp_host"`
+		SMTPPort            *int    `json:"smtp_port"`
+		SMTPUser            *string `json:"smtp_user"`
+		SMTPPassword        *string `json:"smtp_password"`
+		FromEmail           *string `json:"from_email"`
+		FromName            *string `json:"from_name"`
+		OfflineDelaySeconds *int    `json:"offline_delay_seconds"`
+	}
+	if err := c.ShouldBindJSON(&req); err != nil {
+		c.JSON(http.StatusBadRequest, gin.H{"error": "请求参数错误"})
+		return
+	}
+	result, err := e.configSvc.Update(req.UserID, service.UpdateEmailNotificationConfigInput{
+		Enabled:             req.Enabled,
+		SMTPHost:            req.SMTPHost,
+		SMTPPort:            req.SMTPPort,
+		SMTPUser:            req.SMTPUser,
+		SMTPPassword:        req.SMTPPassword,
+		FromEmail:           req.FromEmail,
+		FromName:            req.FromName,
+		OfflineDelaySeconds: req.OfflineDelaySeconds,
+	})
+	if err != nil {
+		c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
+		return
+	}
+	c.JSON(http.StatusOK, result)
+}
+
+// Reset DELETE /agent/email-notification-config
+func (e *EmailNotificationConfigController) Reset(c *gin.Context) {
+	if !requirePermission(c, e.users, string(service.PermSettings)) {
+		return
+	}
+	userID, err := parseUintQuery(c, "user_id")
+	if err != nil {
+		c.JSON(http.StatusBadRequest, gin.H{"error": "user_id 不合法"})
+		return
+	}
+	result, err := e.configSvc.ResetToEnv(uint(userID))
+	if err != nil {
+		c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
+		return
+	}
+	c.JSON(http.StatusOK, result)
+}
+
+// SendTest POST /agent/email-notification-config/test
+func (e *EmailNotificationConfigController) SendTest(c *gin.Context) {
+	if !requirePermission(c, e.users, string(service.PermSettings)) {
+		return
+	}
+	var req struct {
+		UserID uint   `json:"user_id" binding:"required"`
+		To     string `json:"to" binding:"required"`
+	}
+	if err := c.ShouldBindJSON(&req); err != nil {
+		c.JSON(http.StatusBadRequest, gin.H{"error": "请求参数错误"})
+		return
+	}
+	userSummary, err := e.users.GetUser(req.UserID)
+	if err != nil || userSummary == nil || userSummary.Role != "admin" {
+		c.JSON(http.StatusForbidden, gin.H{"error": "仅管理员可发送测试邮件"})
+		return
+	}
+	to := strings.TrimSpace(req.To)
+	if to == "" {
+		c.JSON(http.StatusBadRequest, gin.H{"error": "收件邮箱不能为空"})
+		return
+	}
+	if err := e.offline.SendTestEmail(to); err != nil {
+		c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
+		return
+	}
+	c.JSON(http.StatusOK, gin.H{"message": "测试邮件已发送"})
+}

+ 21 - 0
backend/controller/faq_controller.go

@@ -4,6 +4,7 @@ import (
 	"log"
 	"net/http"
 	"strconv"
+	"strings"
 
 	"github.com/2930134478/AI-CS/backend/service"
 	"github.com/gin-gonic/gin"
@@ -139,6 +140,26 @@ func (f *FAQController) UpdateFAQ(c *gin.Context) {
 	c.JSON(http.StatusOK, faq)
 }
 
+// QuickSearch 快速搜索 FAQ(客服聊天输入框 `/` 触发)。
+// GET /faqs-search?q=关键词&limit=10
+func (f *FAQController) QuickSearch(c *gin.Context) {
+	if !requirePermission(c, f.users, string(service.PermChat)) {
+		return
+	}
+	q := strings.TrimSpace(c.Query("q"))
+	limit := 10
+	if l, err := strconv.Atoi(c.DefaultQuery("limit", "10")); err == nil && l > 0 && l <= 50 {
+		limit = l
+	}
+	faqs, err := f.faqService.QuickSearch(q, limit)
+	if err != nil {
+		log.Printf("FAQ 快速搜索失败: %v", err)
+		c.JSON(http.StatusInternalServerError, gin.H{"error": "FAQ 搜索失败"})
+		return
+	}
+	c.JSON(http.StatusOK, gin.H{"faqs": faqs})
+}
+
 // DeleteFAQ 删除 FAQ 记录。
 // DELETE /faqs/:id
 func (f *FAQController) DeleteFAQ(c *gin.Context) {

+ 69 - 0
backend/controller/helper.go

@@ -1,11 +1,14 @@
 package controller
 
 import (
+	"errors"
 	"strconv"
+	"strings"
 	"time"
 
 	"github.com/2930134478/AI-CS/backend/service"
 	"github.com/gin-gonic/gin"
+	"gorm.io/gorm"
 )
 
 const timeFormat = "2006-01-02T15:04:05Z07:00"
@@ -77,3 +80,69 @@ func requirePermission(c *gin.Context, userSvc *service.UserService, perm string
 	}
 	return true
 }
+
+const conversationAccessTokenHeader = "X-Conversation-Token"
+
+// getConversationAccessToken 从 Header 或 Query 读取访客会话令牌。
+func getConversationAccessToken(c *gin.Context) string {
+	if token := strings.TrimSpace(c.GetHeader(conversationAccessTokenHeader)); token != "" {
+		return token
+	}
+	return strings.TrimSpace(c.Query("access_token"))
+}
+
+// authorizeConversationAccess 校验对会话的访问权限(客服或持 token 的访客)。
+// 失败时已写入 HTTP 响应;成功返回会话详情。
+func authorizeConversationAccess(
+	c *gin.Context,
+	convSvc *service.ConversationService,
+	userSvc *service.UserService,
+	conversationID uint,
+) (*service.ConversationDetail, bool) {
+	userID := getUserIDFromHeader(c)
+	accessToken := getConversationAccessToken(c)
+
+	detail, err := convSvc.GetConversationDetail(conversationID, userID)
+	if err != nil {
+		if errors.Is(err, gorm.ErrRecordNotFound) {
+			c.JSON(404, gin.H{"error": "会话不存在"})
+		} else {
+			c.JSON(500, gin.H{"error": "查询失败"})
+		}
+		return nil, false
+	}
+
+	if detail.ConversationType == "internal" {
+		if userID == 0 || detail.AgentID != userID {
+			c.JSON(403, gin.H{"error": "无权限访问内部会话"})
+			return nil, false
+		}
+		if userSvc != nil {
+			if err := userSvc.CheckPermission(userID, string(service.PermKBTest)); err != nil {
+				c.JSON(403, gin.H{"error": err.Error()})
+				return nil, false
+			}
+		}
+		return detail, true
+	}
+
+	if userID > 0 {
+		if userSvc != nil {
+			if err := userSvc.CheckPermission(userID, string(service.PermChat)); err != nil {
+				c.JSON(403, gin.H{"error": err.Error()})
+				return nil, false
+			}
+		}
+		return detail, true
+	}
+
+	if err := convSvc.ValidateVisitorAccessToken(conversationID, accessToken); err != nil {
+		if errors.Is(err, service.ErrConversationNotFound) {
+			c.JSON(404, gin.H{"error": "会话不存在"})
+		} else {
+			c.JSON(403, gin.H{"error": "无权限访问该会话"})
+		}
+		return nil, false
+	}
+	return detail, true
+}

+ 34 - 60
backend/controller/message_controller.go

@@ -2,6 +2,7 @@ package controller
 
 import (
 	"bytes"
+	"errors"
 	"io"
 	"log"
 	"net/http"
@@ -99,6 +100,17 @@ func (mc *MessageController) CreateMessage(c *gin.Context) {
 	} else {
 		// 访客消息的 sender_id 统一由服务端置 0,避免前端注入。
 		req.SenderID = 0
+		if userID == 0 {
+			token := getConversationAccessToken(c)
+			if err := mc.conversationService.ValidateVisitorAccessToken(req.ConversationID, token); err != nil {
+				if errors.Is(err, service.ErrConversationNotFound) {
+					c.JSON(http.StatusBadRequest, gin.H{"error": "会话不存在"})
+				} else {
+					c.JSON(http.StatusForbidden, gin.H{"error": "无权限访问该会话"})
+				}
+				return
+			}
+		}
 	}
 
 	// 验证:必须有内容或文件
@@ -155,30 +167,9 @@ func (mc *MessageController) ListMessages(c *gin.Context) {
 		c.JSON(http.StatusBadRequest, gin.H{"error": "会话ID不合法"})
 		return
 	}
-	if mc.userService != nil {
-		userID := getUserIDFromHeader(c)
-		detail, detailErr := mc.conversationService.GetConversationDetail(uint(conversationID), userID)
-		if detailErr != nil && userID > 0 {
-			c.JSON(http.StatusForbidden, gin.H{"error": "无权限访问该会话"})
-			return
-		}
-		if detail != nil {
-			if detail.ConversationType == "internal" {
-				if userID == 0 || detail.AgentID != userID {
-					c.JSON(http.StatusForbidden, gin.H{"error": "无权限访问内部会话"})
-					return
-				}
-				if err := mc.userService.CheckPermission(userID, string(service.PermKBTest)); err != nil {
-					c.JSON(http.StatusForbidden, gin.H{"error": err.Error()})
-					return
-				}
-			} else if userID > 0 {
-				if err := mc.userService.CheckPermission(userID, string(service.PermChat)); err != nil {
-					c.JSON(http.StatusForbidden, gin.H{"error": err.Error()})
-					return
-				}
-			}
-		}
+
+	if _, ok := authorizeConversationAccess(c, mc.conversationService, mc.userService, uint(conversationID)); !ok {
+		return
 	}
 
 	// 解析 include_ai_messages 参数(默认 false)
@@ -205,38 +196,17 @@ func (mc *MessageController) MarkMessagesRead(c *gin.Context) {
 		c.JSON(http.StatusBadRequest, gin.H{"error": "请求参数错误"})
 		return
 	}
-	if mc.userService != nil {
+
+	if _, ok := authorizeConversationAccess(c, mc.conversationService, mc.userService, req.ConversationID); !ok {
+		return
+	}
+
+	if req.ReaderIsAgent {
 		userID := getUserIDFromHeader(c)
-		detail, detailErr := mc.conversationService.GetConversationDetail(req.ConversationID, userID)
-		if detailErr != nil && userID > 0 {
-			c.JSON(http.StatusForbidden, gin.H{"error": "无权限访问该会话"})
+		if userID == 0 {
+			c.JSON(http.StatusForbidden, gin.H{"error": "未授权访问,请提供 X-User-Id 请求头"})
 			return
 		}
-		if detail != nil {
-			if detail.ConversationType == "internal" {
-				if userID == 0 || detail.AgentID != userID {
-					c.JSON(http.StatusForbidden, gin.H{"error": "无权限访问内部会话"})
-					return
-				}
-			}
-			if req.ReaderIsAgent {
-				if userID == 0 {
-					c.JSON(http.StatusForbidden, gin.H{"error": "未授权访问,请提供 X-User-Id 请求头"})
-					return
-				}
-				if detail.ConversationType == "internal" {
-					if err := mc.userService.CheckPermission(userID, string(service.PermKBTest)); err != nil {
-						c.JSON(http.StatusForbidden, gin.H{"error": err.Error()})
-						return
-					}
-				} else {
-					if err := mc.userService.CheckPermission(userID, string(service.PermChat)); err != nil {
-						c.JSON(http.StatusForbidden, gin.H{"error": err.Error()})
-						return
-					}
-				}
-			}
-		}
 	}
 
 	result, err := mc.messageService.MarkMessagesRead(req.ConversationID, req.ReaderIsAgent)
@@ -275,21 +245,25 @@ func (mc *MessageController) UploadFile(c *gin.Context) {
 		return
 	}
 
-	// 如果是访客上传(没有用户ID,但有对话ID),验证对话是否存在且未关闭
+	// 如果是访客上传(没有用户ID,但有对话ID),校验 access_token
 	if userID == 0 && conversationIDStr != "" {
 		convID, err := strconv.ParseUint(conversationIDStr, 10, 64)
 		if err != nil || convID == 0 {
 			c.JSON(http.StatusBadRequest, gin.H{"error": "对话ID不合法"})
 			return
 		}
-		// 验证对话是否存在且未关闭
-		conv, err := mc.conversationService.GetConversationDetail(uint(convID), 0)
-		if err != nil {
-			c.JSON(http.StatusForbidden, gin.H{"error": "对话不存在或已关闭"})
+		token := getConversationAccessToken(c)
+		if err := mc.conversationService.ValidateVisitorAccessToken(uint(convID), token); err != nil {
+			if errors.Is(err, service.ErrConversationNotFound) {
+				c.JSON(http.StatusForbidden, gin.H{"error": "对话不存在或已关闭"})
+			} else {
+				c.JSON(http.StatusForbidden, gin.H{"error": "无权限访问该会话"})
+			}
 			return
 		}
-		if conv.Status == "closed" {
-			c.JSON(http.StatusForbidden, gin.H{"error": "对话已关闭"})
+		conv, err := mc.conversationService.GetConversationDetail(uint(convID), 0)
+		if err != nil || conv.Status == "closed" {
+			c.JSON(http.StatusForbidden, gin.H{"error": "对话不存在或已关闭"})
 			return
 		}
 	}

+ 1 - 0
backend/go.mod

@@ -45,6 +45,7 @@ require (
 	github.com/klauspost/cpuid/v2 v2.3.0 // indirect
 	github.com/kr/pretty v0.3.0 // indirect
 	github.com/kr/text v0.2.0 // indirect
+	github.com/ledongthuc/pdf v0.0.0-20250511090121-5959a4027728 // indirect
 	github.com/leodido/go-urn v1.4.0 // indirect
 	github.com/lionsoul2014/ip2region/binding/golang v0.0.0-20260516030638-f4fcd5e900a9 // indirect
 	github.com/mattn/go-isatty v0.0.20 // indirect

+ 2 - 0
backend/go.sum

@@ -202,6 +202,8 @@ github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
 github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
 github.com/labstack/echo/v4 v4.5.0/go.mod h1:czIriw4a0C1dFun+ObrXp7ok03xON0N1awStJ6ArI7Y=
 github.com/labstack/gommon v0.3.0/go.mod h1:MULnywXg0yavhxWKc+lOruYdAhDwPK9wf0OL7NoOu+k=
+github.com/ledongthuc/pdf v0.0.0-20250511090121-5959a4027728 h1:QwWKgMY28TAXaDl+ExRDqGQltzXqN/xypdKP86niVn8=
+github.com/ledongthuc/pdf v0.0.0-20250511090121-5959a4027728/go.mod h1:1fEHWurg7pvf5SG6XNE5Q8UZmOwex51Mkx3SLhrW5B4=
 github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ=
 github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI=
 github.com/lionsoul2014/ip2region/binding/golang v0.0.0-20260516030638-f4fcd5e900a9 h1:4mP3vUJlSD6f9VBPzPQNXHOtAj4P7Fhuvl2whtUWRK4=

+ 141 - 0
backend/infra/smtp_mailer.go

@@ -0,0 +1,141 @@
+package infra
+
+import (
+	"crypto/tls"
+	"fmt"
+	"net"
+	"net/smtp"
+	"strings"
+)
+
+// SMTPMailConfig SMTP 发信参数
+type SMTPMailConfig struct {
+	Host      string
+	Port      int
+	User      string
+	Password  string
+	FromEmail string
+	FromName  string
+}
+
+// SendSMTPMail 通过 SMTP 发送纯文本邮件(支持 465 SSL / 587 STARTTLS)
+func SendSMTPMail(cfg SMTPMailConfig, to, subject, body string) error {
+	if cfg.Host == "" || cfg.FromEmail == "" {
+		return fmt.Errorf("SMTP 未配置完整")
+	}
+	to = strings.TrimSpace(to)
+	if to == "" {
+		return fmt.Errorf("收件人邮箱为空")
+	}
+	port := cfg.Port
+	if port <= 0 {
+		port = 465
+	}
+
+	from := cfg.FromEmail
+	fromHeader := from
+	if cfg.FromName != "" {
+		fromHeader = fmt.Sprintf("%s <%s>", cfg.FromName, from)
+	}
+
+	msg := buildPlainTextMessage(fromHeader, to, subject, body)
+	addr := fmt.Sprintf("%s:%d", cfg.Host, port)
+
+	if port == 465 {
+		return sendSMTPSImplicitTLS(addr, cfg, from, to, msg)
+	}
+	return sendSMTPStartTLS(addr, cfg, from, to, msg)
+}
+
+func buildPlainTextMessage(from, to, subject, body string) []byte {
+	var b strings.Builder
+	b.WriteString("From: " + from + "\r\n")
+	b.WriteString("To: " + to + "\r\n")
+	b.WriteString("Subject: " + subject + "\r\n")
+	b.WriteString("MIME-Version: 1.0\r\n")
+	b.WriteString("Content-Type: text/plain; charset=UTF-8\r\n")
+	b.WriteString("\r\n")
+	b.WriteString(body)
+	return []byte(b.String())
+}
+
+func sendSMTPSImplicitTLS(addr string, cfg SMTPMailConfig, from, to string, msg []byte) error {
+	tlsCfg := &tls.Config{ServerName: cfg.Host}
+	conn, err := tls.Dial("tcp", addr, tlsCfg)
+	if err != nil {
+		return fmt.Errorf("连接 SMTP 失败: %w", err)
+	}
+	defer conn.Close()
+
+	client, err := smtp.NewClient(conn, cfg.Host)
+	if err != nil {
+		return fmt.Errorf("创建 SMTP 客户端失败: %w", err)
+	}
+	defer client.Close()
+
+	if cfg.User != "" {
+		if err := client.Auth(smtp.PlainAuth("", cfg.User, cfg.Password, cfg.Host)); err != nil {
+			return fmt.Errorf("SMTP 认证失败: %w", err)
+		}
+	}
+	if err := client.Mail(from); err != nil {
+		return fmt.Errorf("设置发件人失败: %w", err)
+	}
+	if err := client.Rcpt(to); err != nil {
+		return fmt.Errorf("设置收件人失败: %w", err)
+	}
+	w, err := client.Data()
+	if err != nil {
+		return fmt.Errorf("打开邮件正文失败: %w", err)
+	}
+	if _, err := w.Write(msg); err != nil {
+		return fmt.Errorf("写入邮件正文失败: %w", err)
+	}
+	if err := w.Close(); err != nil {
+		return fmt.Errorf("关闭邮件正文失败: %w", err)
+	}
+	return client.Quit()
+}
+
+func sendSMTPStartTLS(addr string, cfg SMTPMailConfig, from, to string, msg []byte) error {
+	conn, err := net.Dial("tcp", addr)
+	if err != nil {
+		return fmt.Errorf("连接 SMTP 失败: %w", err)
+	}
+	defer conn.Close()
+
+	host := cfg.Host
+	client, err := smtp.NewClient(conn, host)
+	if err != nil {
+		return fmt.Errorf("创建 SMTP 客户端失败: %w", err)
+	}
+	defer client.Close()
+
+	if ok, _ := client.Extension("STARTTLS"); ok {
+		if err := client.StartTLS(&tls.Config{ServerName: host}); err != nil {
+			return fmt.Errorf("STARTTLS 失败: %w", err)
+		}
+	}
+	if cfg.User != "" {
+		if err := client.Auth(smtp.PlainAuth("", cfg.User, cfg.Password, host)); err != nil {
+			return fmt.Errorf("SMTP 认证失败: %w", err)
+		}
+	}
+	if err := client.Mail(from); err != nil {
+		return fmt.Errorf("设置发件人失败: %w", err)
+	}
+	if err := client.Rcpt(to); err != nil {
+		return fmt.Errorf("设置收件人失败: %w", err)
+	}
+	w, err := client.Data()
+	if err != nil {
+		return fmt.Errorf("打开邮件正文失败: %w", err)
+	}
+	if _, err := w.Write(msg); err != nil {
+		return fmt.Errorf("写入邮件正文失败: %w", err)
+	}
+	if err := w.Close(); err != nil {
+		return fmt.Errorf("关闭邮件正文失败: %w", err)
+	}
+	return client.Quit()
+}

+ 54 - 3
backend/infra/vector_store.go

@@ -53,6 +53,23 @@ func (vs *VectorStore) ensureCollectionLoaded(ctx context.Context) error {
 	return nil
 }
 
+// ensureChunkDBIDField 检查集合是否有 chunk_db_id 字段
+func (vs *VectorStore) ensureChunkDBIDField(ctx context.Context) error {
+	if err := vs.ensureCollectionLoaded(ctx); err != nil {
+		return err
+	}
+	collections, err := vs.client.DescribeCollection(ctx, vs.collection)
+	if err != nil {
+		return fmt.Errorf("获取集合信息失败: %w", err)
+	}
+	for _, field := range collections.Schema.Fields {
+		if field.Name == "chunk_db_id" {
+			return nil
+		}
+	}
+	return fmt.Errorf("缺少 chunk_db_id 字段")
+}
+
 // getCollectionDimension 获取集合的维度
 func (vs *VectorStore) getCollectionDimension(ctx context.Context) (int, error) {
 	// 确保集合已加载
@@ -266,6 +283,13 @@ func (vs *VectorStore) createCollectionWithName(ctx context.Context, collectionN
 					"max_length": "65535",
 				},
 			},
+			{
+				Name:     "chunk_db_id",
+				DataType: entity.FieldTypeVarChar,
+				TypeParams: map[string]string{
+					"max_length": "64",
+				},
+			},
 		},
 	}
 
@@ -328,6 +352,15 @@ func (vs *VectorStore) ensureCollection(ctx context.Context) error {
 		return vs.createCollectionWithName(ctx, vs.collection)
 	}
 
+	// 检查是否有 chunk_db_id 字段(分段向量化新增)
+	if err := vs.ensureChunkDBIDField(ctx); err != nil {
+		log.Printf("⚠️ 集合缺少 chunk_db_id 字段,重建集合: %v", err)
+		if dropErr := vs.client.DropCollection(ctx, vs.collection); dropErr != nil {
+			return fmt.Errorf("删除旧集合失败: %w", dropErr)
+		}
+		return vs.createCollectionWithName(ctx, vs.collection)
+	}
+
 	// 维度匹配,检查索引是否存在
 	if err := vs.ensureIndex(ctx); err != nil {
 		return fmt.Errorf("确保索引存在失败: %w", err)
@@ -371,7 +404,7 @@ func (vs *VectorStore) ensureIndex(ctx context.Context) error {
 }
 
 // UpsertVector 插入或更新单个向量
-func (vs *VectorStore) UpsertVector(ctx context.Context, documentID string, knowledgeBaseID string, content string, vector []float32) error {
+func (vs *VectorStore) UpsertVector(ctx context.Context, documentID string, knowledgeBaseID string, content string, chunkDBID string, vector []float32) error {
 	// 确保集合已加载
 	if err := vs.ensureCollectionLoaded(ctx); err != nil {
 		return err
@@ -381,6 +414,7 @@ func (vs *VectorStore) UpsertVector(ctx context.Context, documentID string, know
 		entity.NewColumnVarChar("document_id", []string{documentID}),
 		entity.NewColumnVarChar("knowledge_base_id", []string{knowledgeBaseID}),
 		entity.NewColumnVarChar("content", []string{content}),
+		entity.NewColumnVarChar("chunk_db_id", []string{chunkDBID}),
 		entity.NewColumnFloatVector("embedding", vs.dimension, [][]float32{vector}),
 	)
 	if err != nil {
@@ -390,13 +424,13 @@ func (vs *VectorStore) UpsertVector(ctx context.Context, documentID string, know
 }
 
 // UpsertVectors 批量插入或更新向量
-func (vs *VectorStore) UpsertVectors(ctx context.Context, documentIDs []string, knowledgeBaseIDs []string, contents []string, vectors [][]float32) error {
+func (vs *VectorStore) UpsertVectors(ctx context.Context, documentIDs []string, knowledgeBaseIDs []string, contents []string, vectors [][]float32, chunkDBIDs []string) error {
 	// 确保集合已加载
 	if err := vs.ensureCollectionLoaded(ctx); err != nil {
 		return err
 	}
 
-	if len(documentIDs) != len(knowledgeBaseIDs) || len(documentIDs) != len(contents) || len(documentIDs) != len(vectors) {
+	if len(documentIDs) != len(knowledgeBaseIDs) || len(documentIDs) != len(contents) || len(documentIDs) != len(vectors) || len(documentIDs) != len(chunkDBIDs) {
 		return fmt.Errorf("参数长度不匹配")
 	}
 
@@ -404,6 +438,7 @@ func (vs *VectorStore) UpsertVectors(ctx context.Context, documentIDs []string,
 		entity.NewColumnVarChar("document_id", documentIDs),
 		entity.NewColumnVarChar("knowledge_base_id", knowledgeBaseIDs),
 		entity.NewColumnVarChar("content", contents),
+		entity.NewColumnVarChar("chunk_db_id", chunkDBIDs),
 		entity.NewColumnFloatVector("embedding", vs.dimension, vectors),
 	)
 	if err != nil {
@@ -536,6 +571,22 @@ func (vs *VectorStore) DeleteVector(ctx context.Context, documentID string) erro
 	return nil
 }
 
+// DeleteVectorByChunkID 按 chunk_db_id 删除单条向量
+func (vs *VectorStore) DeleteVectorByChunkID(ctx context.Context, chunkDBID string) error {
+	if err := vs.ensureCollectionLoaded(ctx); err != nil {
+		return err
+	}
+	if chunkDBID == "" {
+		return nil
+	}
+	expr := fmt.Sprintf("chunk_db_id == \"%s\"", chunkDBID)
+	err := vs.client.Delete(ctx, vs.collection, "", expr)
+	if err != nil {
+		return fmt.Errorf("按 chunk_db_id 删除向量失败: %w", err)
+	}
+	return nil
+}
+
 // DeleteVectors 批量删除向量
 func (vs *VectorStore) DeleteVectors(ctx context.Context, documentIDs []string) error {
 	// 确保集合已加载

+ 36 - 5
backend/main.go

@@ -5,6 +5,7 @@ import (
 	"log"
 	"os"
 	"path/filepath"
+	"strconv"
 	"strings"
 	"time"
 
@@ -143,7 +144,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{}, &models.AppSetting{}); err != nil {
+	if err := db.AutoMigrate(&models.User{}, &models.Conversation{}, &models.Message{}, &models.AIConfig{}, &models.FAQ{}, &models.KnowledgeBase{}, &models.Document{}, &models.DocumentChunk{}, &models.EmbeddingConfig{}, &models.EmailNotificationConfig{}, &models.OfflineEmailJob{}, &models.PromptConfig{}, &models.WidgetOpenEvent{}, &models.SystemLog{}, &models.AppSetting{}); err != nil {
 		log.Fatalf("自动创建表失败: %v", err)
 	}
 
@@ -154,7 +155,10 @@ func main() {
 	faqRepo := repository.NewFAQRepository(db)
 	kbRepo := repository.NewKnowledgeBaseRepository(db)
 	docRepo := repository.NewDocumentRepository(db)
+	chunkRepo := repository.NewDocumentChunkRepository(db)
 	embeddingConfigRepo := repository.NewEmbeddingConfigRepository(db)
+	emailNotificationConfigRepo := repository.NewEmailNotificationConfigRepository(db)
+	offlineEmailJobRepo := repository.NewOfflineEmailJobRepository(db)
 	promptConfigRepo := repository.NewPromptConfigRepository(db)
 	systemLogRepo := repository.NewSystemLogRepository(db)
 	appSettingRepo := repository.NewAppSettingRepository(db)
@@ -335,6 +339,12 @@ func main() {
 	documentEmbeddingService := rag.NewDocumentEmbeddingService(vectorStoreService, embeddingProvider)
 	retrievalService := rag.NewRetrievalService(vectorStoreService, embeddingProvider, docRepo, kbRepo)
 	retrievalService.EnableCache(5 * time.Minute)
+	if v := os.Getenv("RAG_MIN_SCORE"); v != "" {
+		if score, err := strconv.ParseFloat(v, 32); err == nil {
+			retrievalService.SetMinScore(float32(score))
+			log.Printf("ℹ️ RAG 相似度阈值: %.2f(来自 RAG_MIN_SCORE)", score)
+		}
+	}
 	healthChecker := rag.NewHealthChecker(embeddingProvider, vectorStoreService)
 
 	// 联网搜索(可选):优先通过 MCP 调用 Serper(SERPER_MCP_URL),否则使用 Serper HTTP API(SERPER_API_KEY)
@@ -357,18 +367,22 @@ func main() {
 
 	// 初始化服务层
 	authService := service.NewAuthService(userRepo)
-	conversationService := service.NewConversationService(conversationRepo, messageRepo, aiConfigRepo, userRepo, systemLogService)
+	conversationService := service.NewConversationService(conversationRepo, messageRepo, aiConfigRepo, userRepo, systemLogService, appSettingRepo)
+	conversationService.StartStaleConversationCleanup()
 	profileService := service.NewProfileService(userRepo, storageService)
 	aiConfigService := service.NewAIConfigService(aiConfigRepo, userRepo)
-	aiService := service.NewAIService(aiConfigRepo, messageRepo, conversationRepo, retrievalService, webSearchProvider, embeddingConfigService, promptConfigService, storageService, systemLogService)
+	aiService := service.NewAIService(aiConfigRepo, messageRepo, conversationRepo, retrievalService, webSearchProvider, embeddingConfigService, promptConfigService, storageService, systemLogService, faqRepo)
 	userService := service.NewUserService(userRepo, aiConfigRepo)                                              // 用户管理服务
 	faqService := service.NewFAQService(faqRepo, retrievalService, documentEmbeddingService)                   // FAQ 管理服务
 	documentService := service.NewDocumentService(docRepo, kbRepo, documentEmbeddingService, retrievalService) // 文档管理服务
 	knowledgeBaseService := service.NewKnowledgeBaseService(kbRepo, docRepo)                                   // 知识库管理服务
 	importService := service.NewImportService(docRepo, kbRepo, documentService, documentEmbeddingService)      // 导入服务
+	chunkService := service.NewChunkService(docRepo, kbRepo, chunkRepo, documentEmbeddingService, vectorStoreService) // 分段服务
+	emailNotificationConfigService := service.NewEmailNotificationConfigService(emailNotificationConfigRepo, userRepo)
 
-	// 声明 Hub 变量(用于在回调函数中访问)
+	// 声明 Hub / 离线邮件变量(Hub 创建后完成注入
 	var wsHub *websocket.Hub
+	var offlineEmailSvc *service.OfflineEmailService
 
 	// 创建 WebSocket Hub,设置回调函数来处理客户端连接/断开事件
 	// 使用闭包来访问 conversationService、messageService、userRepo 和 wsHub
@@ -378,6 +392,9 @@ func main() {
 				log.Printf("更新访客在线状态失败: %v", err)
 				return
 			}
+			if offlineEmailSvc != nil {
+				offlineEmailSvc.CancelPending(conversationID)
+			}
 			// 广播状态更新到所有客服端(不管连接到哪个对话)
 			wsHub.BroadcastToAllAgents("visitor_status_update", map[string]interface{}{
 				"conversation_id": conversationID,
@@ -485,7 +502,17 @@ func main() {
 	wsHub = websocket.NewHub(onConnect, onDisconnect, wsBus)
 	go wsHub.Run() // 启动 Hub(在后台运行)
 
+	offlineEmailSvc = service.NewOfflineEmailService(
+		emailNotificationConfigService,
+		offlineEmailJobRepo,
+		conversationRepo,
+		messageRepo,
+		wsHub,
+	)
+	go offlineEmailSvc.StartWorker(context.Background())
+
 	messageService := service.NewMessageService(db, conversationRepo, messageRepo, wsHub, aiService)
+	messageService.SetOfflineEmailService(offlineEmailSvc)
 	visitorService := service.NewVisitorService(userRepo, wsHub)
 
 	// 初始化控制器
@@ -501,6 +528,8 @@ func main() {
 	promptConfigController := controller.NewPromptConfigController(promptConfigService, userService)
 	knowledgeBaseController := controller.NewKnowledgeBaseController(knowledgeBaseService, embeddingConfigService, userService)
 	importController := controller.NewImportController(importService, embeddingConfigService, userService) // 导入控制器
+	chunkController := controller.NewDocumentChunkController(chunkService, userService)                   // 分段控制器
+	emailNotificationController := controller.NewEmailNotificationConfigController(emailNotificationConfigService, offlineEmailSvc, userService)
 	visitorController := controller.NewVisitorController(visitorService, embeddingConfigService)
 	healthController := controller.NewHealthController(healthChecker, retrievalService) // 健康检查控制器
 
@@ -524,12 +553,14 @@ func main() {
 			Document:        documentController,
 			KnowledgeBase:   knowledgeBaseController,
 			Import:          importController, // 导入控制器
+			DocumentChunk:   chunkController,  // 分段控制器
+			EmailNotification: emailNotificationController,
 			Visitor:         visitorController,
 			Health:          healthController, // 健康检查控制器
 			Analytics:       analyticsController,
 			SystemLog:       systemLogController,
 		},
-		websocket.HandleWebSocket(wsHub, userRepo),
+		websocket.HandleWebSocket(wsHub, userRepo, conversationService),
 	)
 
 	// 配置静态文件服务(用于访问上传的头像等文件)

+ 2 - 0
backend/models/app_setting.go

@@ -12,4 +12,6 @@ type AppSetting struct {
 const (
 	// AppSettingKeySystemLogMinLevel 结构化日志最低落库级别(值:debug/info/warn/error/none)
 	AppSettingKeySystemLogMinLevel = "system_log_min_level"
+	// AppSettingKeyAutoCloseConversationDays 自动关闭长期未活跃 open 访客会话的天数(0=禁用)
+	AppSettingKeyAutoCloseConversationDays = "auto_close_conversation_days"
 )

+ 15 - 0
backend/models/document_chunk.go

@@ -0,0 +1,15 @@
+package models
+
+import "time"
+
+// DocumentChunk 文档分段
+type DocumentChunk struct {
+	ID              uint      `gorm:"primarykey" json:"id"`
+	DocumentID      uint      `gorm:"index;not null" json:"document_id"`
+	KnowledgeBaseID uint      `gorm:"index;not null" json:"knowledge_base_id"`
+	ChunkIndex      int       `gorm:"not null" json:"chunk_index"`
+	Content         string    `gorm:"type:text;not null" json:"content"`
+	EmbeddingStatus string    `gorm:"type:varchar(20);default:'pending'" json:"embedding_status"`
+	CreatedAt       time.Time `json:"created_at"`
+	UpdatedAt       time.Time `json:"updated_at"`
+}

+ 18 - 0
backend/models/email_notification_config.go

@@ -0,0 +1,18 @@
+package models
+
+import "time"
+
+// EmailNotificationConfig 离线邮件通知配置(平台级单例 id=1)
+type EmailNotificationConfig struct {
+	ID                  uint      `json:"id" gorm:"primaryKey"`
+	Enabled             bool      `json:"enabled" gorm:"default:false"`
+	SMTPHost            string    `json:"smtp_host" gorm:"type:varchar(255)"`
+	SMTPPort            int       `json:"smtp_port" gorm:"default:465"`
+	SMTPUser            string    `json:"smtp_user" gorm:"type:varchar(255)"`
+	SMTPPassword        string    `json:"-" gorm:"type:varchar(1000)"` // 加密存储
+	FromEmail           string    `json:"from_email" gorm:"type:varchar(255)"`
+	FromName            string    `json:"from_name" gorm:"type:varchar(100)"`
+	OfflineDelaySeconds int       `json:"offline_delay_seconds" gorm:"default:60"`
+	CreatedAt           time.Time `json:"created_at"`
+	UpdatedAt           time.Time `json:"updated_at"`
+}

+ 15 - 0
backend/models/offline_email_job.go

@@ -0,0 +1,15 @@
+package models
+
+import "time"
+
+// OfflineEmailJob 访客离线时延迟发送的邮件任务
+type OfflineEmailJob struct {
+	ID             uint      `json:"id" gorm:"primaryKey"`
+	ConversationID uint      `json:"conversation_id" gorm:"index;not null"`
+	MessageIDs     string    `json:"message_ids" gorm:"type:varchar(2000);not null"` // 逗号分隔的消息 ID
+	ScheduledAt    time.Time `json:"scheduled_at" gorm:"index;not null"`
+	Status         string    `json:"status" gorm:"type:varchar(20);default:'pending';index"` // pending/sent/cancelled/failed
+	LastError      string    `json:"last_error" gorm:"type:text"`
+	CreatedAt      time.Time `json:"created_at"`
+	UpdatedAt      time.Time `json:"updated_at"`
+}

+ 10 - 8
backend/models/user.go

@@ -23,12 +23,12 @@ type User struct {
 
 type Conversation struct {
 	ID               uint      `json:"id" gorm:"primaryKey"`
-	ConversationType string    `json:"conversation_type" gorm:"type:varchar(20);default:'visitor'"` // visitor(访客对话)、internal(内部/知识库测试)
+	ConversationType string    `json:"conversation_type" gorm:"type:varchar(20);default:'visitor';index:idx_conv_list,priority:1"` // visitor(访客对话)、internal(内部/知识库测试)
 	VisitorID        uint      `json:"visitor_id"`
 	AgentID          uint      `json:"agent_id"`
-	Status           string    `json:"status"`
+	Status           string    `json:"status" gorm:"index:idx_conv_list,priority:2"`
 	CreatedAt time.Time `json:"created_at"`
-	UpdatedAt time.Time `json:"updated_at"`
+	UpdatedAt time.Time `json:"updated_at" gorm:"index:idx_conv_list,priority:4"`
 	// 访客信息字段(自动收集)
 	Website   string `json:"website" gorm:"type:varchar(500)"`   // 网站(当前页面URL)
 	Referrer  string `json:"referrer" gorm:"type:varchar(500)"`  // 来源(referrer)
@@ -44,19 +44,21 @@ type Conversation struct {
 	// 在线状态
 	LastSeenAt *time.Time `json:"last_seen_at"` // 最后活跃时间
 	// AI 客服相关
-	ChatMode   string `json:"chat_mode" gorm:"type:varchar(20);default:'human'"` // 对话模式:human(人工客服)、ai(AI客服)
+	ChatMode   string `json:"chat_mode" gorm:"type:varchar(20);default:'human';index:idx_conv_list,priority:3"` // 对话模式:human(人工客服)、ai(AI客服)
 	AIConfigID *uint  `json:"ai_config_id"`                                      // AI 配置 ID(访客选择的模型配置)
+	// AccessToken 访客访问会话/消息的密钥;仅 init 时下发给对应访客,不在客服 API 中返回。
+	AccessToken string `json:"-" gorm:"type:varchar(64);index"`
 }
 
 type Message struct {
 	ID             uint       `json:"id" gorm:"primarykey"`
-	ConversationID uint       `json:"conversation_id"`
-	SenderID       uint       `json:"sender_id"`
-	SenderIsAgent  bool       `json:"sender_is_agent"`
+	ConversationID uint       `json:"conversation_id" gorm:"index:idx_msg_conv;index:idx_msg_conv_unread,priority:1;index:idx_msg_conv_sender,priority:1"`
+	SenderID       uint       `json:"sender_id" gorm:"index:idx_msg_conv_sender,priority:3"`
+	SenderIsAgent  bool       `json:"sender_is_agent" gorm:"index:idx_msg_conv_unread,priority:2;index:idx_msg_conv_sender,priority:2"`
 	Content        string     `json:"content" gorm:"type:text"`
 	MessageType    string     `json:"message_type" gorm:"type:varchar(20);default:'user_message'"` // 消息类型:user_message, system_message
 	ChatMode       string     `json:"chat_mode" gorm:"type:varchar(20);default:'human'"`           // 消息发送时的对话模式:human(人工客服)、ai(AI客服)
-	IsRead         bool       `json:"is_read"`
+	IsRead         bool       `json:"is_read" gorm:"index:idx_msg_conv_unread,priority:3"`
 	ReadAt         *time.Time `json:"read_at"`
 	CreatedAt      time.Time  `json:"created_at"`
 	// 文件相关字段(可选)

+ 37 - 1
backend/repository/conversation_repository.go

@@ -2,6 +2,7 @@ package repository
 
 import (
 	"errors"
+	"time"
 
 	"github.com/2930134478/AI-CS/backend/models"
 	"gorm.io/gorm"
@@ -138,7 +139,42 @@ func (r *ConversationRepository) SearchByIDOrVisitorLike(pattern string) ([]mode
 	return conversations, nil
 }
 
-// AssignAgent 为会话分配客服。
+// CloseStaleOpenVisitorConversations 关闭长期未更新的 open 访客会话。
+func (r *ConversationRepository) CloseStaleOpenVisitorConversations(olderThan time.Time) (int64, error) {
+	result := r.db.Model(&models.Conversation{}).
+		Where("conversation_type = ? AND status = ? AND updated_at < ?", "visitor", "open", olderThan).
+		Update("status", "closed")
+	return result.RowsAffected, result.Error
+}
+
+// ListVisitorForAgentList 分页查询客服列表可见的访客会话(排除 AI 模式,且需有访客侧消息)。
+func (r *ConversationRepository) ListVisitorForAgentList(status string, offset, limit int) ([]models.Conversation, int64, error) {
+	q := r.db.Model(&models.Conversation{}).
+		Where("conversation_type = ? AND chat_mode != ?", "visitor", "ai").
+		Where("EXISTS (SELECT 1 FROM messages WHERE messages.conversation_id = conversations.id AND messages.sender_is_agent = ?)", false)
+
+	switch status {
+	case "open":
+		q = q.Where("status = ?", "open")
+	case "closed":
+		q = q.Where("status = ?", "closed")
+	case "", "all":
+	default:
+		return nil, 0, errors.New("invalid status")
+	}
+
+	var total int64
+	if err := q.Count(&total).Error; err != nil {
+		return nil, 0, err
+	}
+
+	var conversations []models.Conversation
+	if err := q.Order("updated_at desc").Offset(offset).Limit(limit).Find(&conversations).Error; err != nil {
+		return nil, 0, err
+	}
+	return conversations, total, nil
+}
+
 func (r *ConversationRepository) AssignAgent(conversationID uint, agentID uint) error {
 	result := r.db.Model(&models.Conversation{}).
 		Where("id = ?", conversationID).

+ 110 - 0
backend/repository/document_chunk_repository.go

@@ -0,0 +1,110 @@
+package repository
+
+import (
+	"github.com/2930134478/AI-CS/backend/models"
+	"gorm.io/gorm"
+)
+
+// DocumentChunkRepository 封装与文档分段相关的数据库操作
+type DocumentChunkRepository struct {
+	db *gorm.DB
+}
+
+// NewDocumentChunkRepository 创建文档分段仓库实例
+func NewDocumentChunkRepository(db *gorm.DB) *DocumentChunkRepository {
+	return &DocumentChunkRepository{db: db}
+}
+
+// Create 创建分段
+func (r *DocumentChunkRepository) Create(chunk *models.DocumentChunk) error {
+	return r.db.Create(chunk).Error
+}
+
+// BatchCreate 批量创建分段
+func (r *DocumentChunkRepository) BatchCreate(chunks []*models.DocumentChunk) error {
+	return r.db.Create(&chunks).Error
+}
+
+// GetByDocumentID 获取文档的所有分段(按 chunk_index 排序)
+func (r *DocumentChunkRepository) GetByDocumentID(documentID uint) ([]models.DocumentChunk, error) {
+	var chunks []models.DocumentChunk
+	if err := r.db.Where("document_id = ?", documentID).Order("chunk_index ASC").Find(&chunks).Error; err != nil {
+		return nil, err
+	}
+	return chunks, nil
+}
+
+// GetByDocumentIDPaginated 分页获取文档的分段
+func (r *DocumentChunkRepository) GetByDocumentIDPaginated(documentID uint, offset, limit int) ([]models.DocumentChunk, int64, error) {
+	var total int64
+	if err := r.db.Model(&models.DocumentChunk{}).Where("document_id = ?", documentID).Count(&total).Error; err != nil {
+		return nil, 0, err
+	}
+	var chunks []models.DocumentChunk
+	if err := r.db.Where("document_id = ?", documentID).
+		Order("chunk_index ASC").
+		Offset(offset).
+		Limit(limit).
+		Find(&chunks).Error; err != nil {
+		return nil, 0, err
+	}
+	return chunks, total, nil
+}
+
+// GetByID 根据 ID 获取单个分段
+func (r *DocumentChunkRepository) GetByID(id uint) (*models.DocumentChunk, error) {
+	var chunk models.DocumentChunk
+	if err := r.db.Where("id = ?", id).First(&chunk).Error; err != nil {
+		return nil, err
+	}
+	return &chunk, nil
+}
+
+// Update 更新分段
+func (r *DocumentChunkRepository) Update(chunk *models.DocumentChunk) error {
+	return r.db.Save(chunk).Error
+}
+
+// UpdateContent 更新分段内容
+func (r *DocumentChunkRepository) UpdateContent(id uint, content string) error {
+	return r.db.Model(&models.DocumentChunk{}).Where("id = ?", id).Updates(map[string]interface{}{
+		"content":          content,
+		"embedding_status": "pending",
+	}).Error
+}
+
+// UpdateEmbeddingStatus 更新分段的向量化状态
+func (r *DocumentChunkRepository) UpdateEmbeddingStatus(id uint, status string) error {
+	return r.db.Model(&models.DocumentChunk{}).Where("id = ?", id).Update("embedding_status", status).Error
+}
+
+// DeleteByDocumentID 删除文档的所有分段
+func (r *DocumentChunkRepository) DeleteByDocumentID(documentID uint) error {
+	return r.db.Where("document_id = ?", documentID).Delete(&models.DocumentChunk{}).Error
+}
+
+// DeleteByID 按 ID 删除单个分段
+func (r *DocumentChunkRepository) DeleteByID(id uint) error {
+	return r.db.Delete(&models.DocumentChunk{}, id).Error
+}
+
+// CountByDocumentID 统计文档的分段数
+func (r *DocumentChunkRepository) CountByDocumentID(documentID uint) (int64, error) {
+	var count int64
+	if err := r.db.Model(&models.DocumentChunk{}).Where("document_id = ?", documentID).Count(&count).Error; err != nil {
+		return 0, err
+	}
+	return count, nil
+}
+
+// GetByIDs 根据 ID 列表查询分段
+func (r *DocumentChunkRepository) GetByIDs(ids []uint) ([]models.DocumentChunk, error) {
+	var chunks []models.DocumentChunk
+	if len(ids) == 0 {
+		return chunks, nil
+	}
+	if err := r.db.Where("id IN ?", ids).Find(&chunks).Error; err != nil {
+		return nil, err
+	}
+	return chunks, nil
+}

+ 36 - 0
backend/repository/email_notification_config_repository.go

@@ -0,0 +1,36 @@
+package repository
+
+import (
+	"github.com/2930134478/AI-CS/backend/models"
+	"gorm.io/gorm"
+)
+
+// EmailNotificationConfigRepository 离线邮件配置仓储(单例)
+type EmailNotificationConfigRepository struct {
+	db *gorm.DB
+}
+
+func NewEmailNotificationConfigRepository(db *gorm.DB) *EmailNotificationConfigRepository {
+	return &EmailNotificationConfigRepository{db: db}
+}
+
+func (r *EmailNotificationConfigRepository) Get() (*models.EmailNotificationConfig, error) {
+	var m models.EmailNotificationConfig
+	err := r.db.First(&m, 1).Error
+	if err == gorm.ErrRecordNotFound {
+		return nil, nil
+	}
+	if err != nil {
+		return nil, err
+	}
+	return &m, nil
+}
+
+func (r *EmailNotificationConfigRepository) Save(c *models.EmailNotificationConfig) error {
+	c.ID = 1
+	return r.db.Save(c).Error
+}
+
+func (r *EmailNotificationConfigRepository) Delete() error {
+	return r.db.Where("id = ?", 1).Delete(&models.EmailNotificationConfig{}).Error
+}

+ 16 - 0
backend/repository/faq_repository.go

@@ -72,3 +72,19 @@ func (r *FAQRepository) Delete(id uint) error {
 	return r.db.Delete(&models.FAQ{}, id).Error
 }
 
+// QuickSearch 快速搜索 FAQ(OR 匹配),用于聊天输入框 / 触发。
+func (r *FAQRepository) QuickSearch(q string, limit int) ([]models.FAQ, error) {
+	var faqs []models.FAQ
+	query := r.db.Model(&models.FAQ{}).
+		Where("question LIKE ? OR answer LIKE ? OR keywords LIKE ?",
+			"%"+q+"%", "%"+q+"%", "%"+q+"%").
+		Order("created_at DESC")
+	if limit > 0 {
+		query = query.Limit(limit)
+	}
+	if err := query.Find(&faqs).Error; err != nil {
+		return nil, err
+	}
+	return faqs, nil
+}
+

+ 12 - 0
backend/repository/message_repository.go

@@ -46,6 +46,18 @@ func (r *MessageRepository) LatestByConversationID(conversationID uint) (*models
 	return &message, nil
 }
 
+// GetByID 根据 ID 获取单条消息
+func (r *MessageRepository) GetByID(id uint) (*models.Message, error) {
+	var message models.Message
+	if err := r.db.First(&message, id).Error; err != nil {
+		if errors.Is(err, gorm.ErrRecordNotFound) {
+			return nil, nil
+		}
+		return nil, err
+	}
+	return &message, nil
+}
+
 // CountUnreadBySender 统计指定发送方的未读消息数量。
 func (r *MessageRepository) CountUnreadBySender(conversationID uint, senderIsAgent bool) (int64, error) {
 	var count int64

+ 100 - 0
backend/repository/message_repository_batch.go

@@ -0,0 +1,100 @@
+package repository
+
+import (
+	"github.com/2930134478/AI-CS/backend/models"
+)
+
+// BatchLatestByConversationIDs 批量查询各会话最新一条消息。
+func (r *MessageRepository) BatchLatestByConversationIDs(conversationIDs []uint) (map[uint]*models.Message, error) {
+	result := make(map[uint]*models.Message, len(conversationIDs))
+	if len(conversationIDs) == 0 {
+		return result, nil
+	}
+
+	var latestIDs []uint
+	if err := r.db.Model(&models.Message{}).
+		Select("MAX(id)").
+		Where("conversation_id IN ?", conversationIDs).
+		Group("conversation_id").
+		Pluck("MAX(id)", &latestIDs).Error; err != nil {
+		return nil, err
+	}
+	if len(latestIDs) == 0 {
+		return result, nil
+	}
+
+	var messages []models.Message
+	if err := r.db.Where("id IN ?", latestIDs).Find(&messages).Error; err != nil {
+		return nil, err
+	}
+	for i := range messages {
+		msg := messages[i]
+		result[msg.ConversationID] = &msg
+	}
+	return result, nil
+}
+
+type unreadCountRow struct {
+	ConversationID uint
+	Count          int64
+}
+
+// BatchCountUnreadBySender 批量统计各会话指定发送方未读数。
+func (r *MessageRepository) BatchCountUnreadBySender(conversationIDs []uint, senderIsAgent bool) (map[uint]int64, error) {
+	result := make(map[uint]int64, len(conversationIDs))
+	if len(conversationIDs) == 0 {
+		return result, nil
+	}
+
+	var rows []unreadCountRow
+	if err := r.db.Model(&models.Message{}).
+		Select("conversation_id, COUNT(*) as count").
+		Where("conversation_id IN ? AND sender_is_agent = ? AND is_read = ?", conversationIDs, senderIsAgent, false).
+		Group("conversation_id").
+		Scan(&rows).Error; err != nil {
+		return nil, err
+	}
+	for _, row := range rows {
+		result[row.ConversationID] = row.Count
+	}
+	return result, nil
+}
+
+// BatchHasAgentParticipated 批量检查客服是否参与过各会话。
+func (r *MessageRepository) BatchHasAgentParticipated(conversationIDs []uint, agentID uint) (map[uint]bool, error) {
+	result := make(map[uint]bool, len(conversationIDs))
+	if len(conversationIDs) == 0 || agentID == 0 {
+		return result, nil
+	}
+
+	var ids []uint
+	if err := r.db.Model(&models.Message{}).
+		Distinct("conversation_id").
+		Where("conversation_id IN ? AND sender_is_agent = ? AND sender_id = ?", conversationIDs, true, agentID).
+		Pluck("conversation_id", &ids).Error; err != nil {
+		return nil, err
+	}
+	for _, id := range ids {
+		result[id] = true
+	}
+	return result, nil
+}
+
+// CountTotalUnreadVisitorForAgentList 统计访客 open 人工列表中的访客未读总数(用于角标)。
+func (r *MessageRepository) CountTotalUnreadVisitorForAgentList(status string) (int64, error) {
+	var count int64
+	q := r.db.Model(&models.Message{}).
+		Joins("INNER JOIN conversations ON conversations.id = messages.conversation_id").
+		Where("conversations.conversation_type = ? AND conversations.chat_mode != ?", "visitor", "ai").
+		Where("messages.sender_is_agent = ? AND messages.is_read = ?", false, false).
+		Where("EXISTS (SELECT 1 FROM messages m2 WHERE m2.conversation_id = conversations.id AND m2.sender_is_agent = ?)", false)
+	if status == "open" {
+		q = q.Where("conversations.status = ?", "open")
+	} else if status == "closed" {
+		q = q.Where("conversations.status = ?", "closed")
+	}
+	if err := q.Count(&count).Error; err != nil {
+		return 0, err
+	}
+	return count, nil
+}

+ 52 - 0
backend/repository/offline_email_job_repository.go

@@ -0,0 +1,52 @@
+package repository
+
+import (
+	"time"
+
+	"github.com/2930134478/AI-CS/backend/models"
+	"gorm.io/gorm"
+)
+
+// OfflineEmailJobRepository 离线邮件任务仓储
+type OfflineEmailJobRepository struct {
+	db *gorm.DB
+}
+
+func NewOfflineEmailJobRepository(db *gorm.DB) *OfflineEmailJobRepository {
+	return &OfflineEmailJobRepository{db: db}
+}
+
+func (r *OfflineEmailJobRepository) Create(job *models.OfflineEmailJob) error {
+	return r.db.Create(job).Error
+}
+
+func (r *OfflineEmailJobRepository) Save(job *models.OfflineEmailJob) error {
+	return r.db.Save(job).Error
+}
+
+func (r *OfflineEmailJobRepository) GetPendingByConversationID(conversationID uint) (*models.OfflineEmailJob, error) {
+	var job models.OfflineEmailJob
+	err := r.db.Where("conversation_id = ? AND status = ?", conversationID, "pending").
+		Order("id DESC").
+		First(&job).Error
+	if err == gorm.ErrRecordNotFound {
+		return nil, nil
+	}
+	if err != nil {
+		return nil, err
+	}
+	return &job, nil
+}
+
+func (r *OfflineEmailJobRepository) ListDuePending(before time.Time, limit int) ([]models.OfflineEmailJob, error) {
+	var jobs []models.OfflineEmailJob
+	q := r.db.Where("status = ? AND scheduled_at <= ?", "pending", before).
+		Order("scheduled_at ASC")
+	if limit > 0 {
+		q = q.Limit(limit)
+	}
+	if err := q.Find(&jobs).Error; err != nil {
+		return nil, err
+	}
+	return jobs, nil
+}

+ 16 - 0
backend/router/router.go

@@ -14,11 +14,13 @@ type ControllerSet struct {
 	Profile          *controller.ProfileController
 	AIConfig         *controller.AIConfigController
 	EmbeddingConfig  *controller.EmbeddingConfigController
+	EmailNotification *controller.EmailNotificationConfigController
 	PromptConfig     *controller.PromptConfigController
 	FAQ              *controller.FAQController
 	Document         *controller.DocumentController
 	KnowledgeBase    *controller.KnowledgeBaseController
 	Import           *controller.ImportController
+	DocumentChunk    *controller.DocumentChunkController
 	Visitor          *controller.VisitorController
 	Health           *controller.HealthController
 	Analytics        *controller.AnalyticsController
@@ -41,6 +43,9 @@ func RegisterRoutes(r *gin.Engine, controllers ControllerSet, wsHandler gin.Hand
 		routes.PUT("/conversations/:id/contact", controllers.Conversation.UpdateContactInfo)
 		routes.GET("/conversations/search", controllers.Conversation.SearchConversations)
 		routes.GET("/conversations/ai-models", controllers.Conversation.GetPublicAIModels) // 获取开放的模型列表(供访客选择)
+		routes.GET("/conversations/maintenance/auto-close-days", controllers.Conversation.GetAutoCloseConversationDaysPolicy)
+		routes.PUT("/conversations/maintenance/auto-close-days", controllers.Conversation.PutAutoCloseConversationDaysPolicy)
+		routes.DELETE("/conversations/maintenance/auto-close-days", controllers.Conversation.DeleteAutoCloseConversationDaysPolicy)
 
 		// Message
 		routes.POST("/messages", controllers.Message.CreateMessage)
@@ -74,12 +79,19 @@ func RegisterRoutes(r *gin.Engine, controllers ControllerSet, wsHandler gin.Hand
 		routes.GET("/agent/embedding-config", controllers.EmbeddingConfig.Get)
 		routes.PUT("/agent/embedding-config", controllers.EmbeddingConfig.Update)
 
+		// Email Notification(离线邮件通知,平台级)
+		routes.GET("/agent/email-notification-config", controllers.EmailNotification.Get)
+		routes.PUT("/agent/email-notification-config", controllers.EmailNotification.Update)
+		routes.DELETE("/agent/email-notification-config", controllers.EmailNotification.Reset)
+		routes.POST("/agent/email-notification-config/test", controllers.EmailNotification.SendTest)
+
 		// Prompt Config(提示词配置,平台级,仅管理员可更新)
 		routes.GET("/agent/prompts", controllers.PromptConfig.Get)
 		routes.PUT("/agent/prompts", controllers.PromptConfig.Update)
 
 		// FAQ(事件管理/常见问题)
 		routes.GET("/faqs", controllers.FAQ.ListFAQs)         // 获取 FAQ 列表(支持关键词搜索)
+		routes.GET("/faqs-search", controllers.FAQ.QuickSearch) // FAQ 快速搜索(聊天 / 触发)
 		routes.GET("/faqs/:id", controllers.FAQ.GetFAQ)       // 获取 FAQ 详情
 		routes.POST("/faqs", controllers.FAQ.CreateFAQ)       // 创建 FAQ
 		routes.PUT("/faqs/:id", controllers.FAQ.UpdateFAQ)    // 更新 FAQ
@@ -96,6 +108,10 @@ func RegisterRoutes(r *gin.Engine, controllers ControllerSet, wsHandler gin.Hand
 		routes.PUT("/documents/:id/status", controllers.Document.UpdateDocumentStatus)     // 更新文档状态
 		routes.POST("/documents/:id/publish", controllers.Document.PublishDocument)        // 发布文档
 		routes.POST("/documents/:id/unpublish", controllers.Document.UnpublishDocument)    // 取消发布文档
+		routes.POST("/documents/:id/chunks", controllers.DocumentChunk.ExecuteChunking)       // 执行分段
+		routes.GET("/documents/:id/chunks", controllers.DocumentChunk.GetChunks)              // 获取分段列表
+		routes.PUT("/documents/:id/chunks/:chunkId", controllers.DocumentChunk.UpdateChunk)   // 更新单个分段
+		routes.DELETE("/documents/:id/chunks", controllers.DocumentChunk.DeleteChunks)        // 删除所有分段
 
 		// KnowledgeBase(知识库管理)
 		routes.GET("/knowledge-bases", controllers.KnowledgeBase.ListKnowledgeBases)                               // 获取知识库列表

+ 68 - 15
backend/service/ai_service.go

@@ -32,6 +32,7 @@ type AIService struct {
 	promptConfigSvc    *PromptConfigService     // 可选,提示词配置(为空则用代码内默认)
 	storageService     infra.StorageService     // 可选,用于多模态识图时读取消息附件
 	systemLogSvc       *SystemLogService        // 可选,结构化日志服务
+	faqRepo            *repository.FAQRepository // 可选,FAQ 优先匹配
 }
 
 // NewAIService 创建 AI 服务实例。webSearchProvider、storageService 可为 nil。
@@ -45,6 +46,7 @@ func NewAIService(
 	promptConfigSvc *PromptConfigService,
 	storageService infra.StorageService,
 	systemLogSvc *SystemLogService,
+	faqRepo *repository.FAQRepository,
 ) *AIService {
 	return &AIService{
 		aiConfigRepo:       aiConfigRepo,
@@ -57,6 +59,7 @@ func NewAIService(
 		promptConfigSvc:    promptConfigSvc,
 		storageService:     storageService,
 		systemLogSvc:       systemLogSvc,
+		faqRepo:            faqRepo,
 	}
 }
 
@@ -163,12 +166,36 @@ func (s *AIService) GenerateAIResponseWithOptions(conversationID uint, userMessa
 	}
 
 	var ragContext string
+	var faqHit bool
 	ragStartedAt := time.Now()
 	if useKB && s.retrievalService != nil {
-		ragContext, err = s.retrieveRAGContext(context.Background(), userMessage, conversation)
+		ragContext, faqHit, err = s.retrieveRAGContext(context.Background(), userMessage, conversation)
 		if err != nil {
 			log.Printf("⚠️ RAG 检索失败: %v", err)
 		}
+		// FAQ 精确命中:直接返回标准答案,跳过 LLM 调用
+		if faqHit && ragContext != "" {
+			if s.systemLogSvc != nil {
+				convID := conversationID
+				uID := userID
+				_ = s.systemLogSvc.Create(CreateSystemLogInput{
+					Level:          "info",
+					Category:       "rag",
+					Event:          "faq_direct_hit",
+					Source:         "backend",
+					ConversationID: &convID,
+					UserID:         &uID,
+					Message:        "FAQ 命中,直接返回",
+					Meta: map[string]interface{}{
+						"elapsed_ms": time.Since(ragStartedAt).Milliseconds(),
+					},
+				})
+			}
+			return &GenerateAIResponseResult{
+				Content:     ragContext,
+				SourcesUsed: "knowledge_base",
+			}, nil
+		}
 		if s.systemLogSvc != nil {
 			hit := strings.TrimSpace(ragContext) != ""
 			convID := conversationID
@@ -567,39 +594,65 @@ func (s *AIService) buildConversationHistory(conversationID uint) ([]MessageHist
 	return history, nil
 }
 
-// retrieveRAGContext 从知识库中检索相关文档内容
-// query: 用户查询文本
-// conversation: 对话信息(可能包含知识库 ID)
-// 返回: 检索到的文档内容(格式化后的字符串)
-func (s *AIService) retrieveRAGContext(ctx context.Context, query string, conversation *models.Conversation) (string, error) {
+// retrieveRAGContext 从知识库中检索相关文档内容。
+// 优先匹配 FAQ(关键词/问题精确匹配),命中后直接返回 FAQ 答案并标记 isFAQ=true,由调用方跳过 LLM。
+// 返回: (检索到的文档内容, 是否来自FAQ, 错误)
+func (s *AIService) retrieveRAGContext(ctx context.Context, query string, conversation *models.Conversation) (string, bool, error) {
+	// FAQ 优先匹配:命中直接返回答案,跳过向量检索和 LLM
+	if s.faqRepo != nil {
+		if answer, hit := s.matchFAQ(query); hit {
+			return answer, true, nil
+		}
+	}
+
 	// 确定知识库 ID(可以从对话中获取,或为空表示搜索所有知识库)
 	// TODO: 后续在 Conversation 模型增加 KnowledgeBaseID 字段
 	var knowledgeBaseID *uint
 	// knowledgeBaseID = conversation.KnowledgeBaseID // 暂时注释,等模型字段添加后启用
 
 	// 执行 RAG 检索(Top-K = 5,返回最相关的 5 个文档片段)
-	// 使用重排序优化检索结果
 	topK := 5
 	results, err := s.retrievalService.RetrieveWithRerank(ctx, query, topK, knowledgeBaseID)
 	if err != nil {
-		return "", fmt.Errorf("RAG 检索失败: %w", err)
+		return "", false, fmt.Errorf("RAG 检索失败: %w", err)
 	}
 
 	if len(results) == 0 {
-		// 没有检索到相关文档
-		return "", nil
+		return "", false, nil
 	}
 
-	// 格式化检索结果
+	// 格式化检索结果(已由 RetrievalService 做 score 阈值过滤)
 	var contextParts []string
 	for i, result := range results {
-		// 只使用相似度较高的结果(Score 越小表示相似度越高)
-		// 如果使用余弦相似度,通常阈值在 0.7-0.9 之间
-		// 这里我们暂时不过滤,让所有结果都参与
 		contextParts = append(contextParts, fmt.Sprintf("文档片段 %d:\n%s", i+1, result.Content))
 	}
 
-	return strings.Join(contextParts, "\n\n"), nil
+	return strings.Join(contextParts, "\n\n"), false, nil
+}
+
+// matchFAQ 尝试将用户查询与 FAQ 条目做关键词/子串匹配。
+// 返回 FAQ 答案和是否命中。命中时跳过 LLM,直接返回标准答案。
+func (s *AIService) matchFAQ(query string) (answer string, hit bool) {
+	faqs, err := s.faqRepo.List(nil)
+	if err != nil || len(faqs) == 0 {
+		return "", false
+	}
+	queryLower := strings.ToLower(strings.TrimSpace(query))
+	for _, faq := range faqs {
+		faqQuestion := strings.ToLower(strings.TrimSpace(faq.Question))
+		if strings.Contains(queryLower, faqQuestion) || strings.Contains(faqQuestion, queryLower) {
+			return faq.Answer, true
+		}
+		if faq.Keywords != "" {
+			for _, kw := range strings.Split(faq.Keywords, ",") {
+				kw = strings.ToLower(strings.TrimSpace(kw))
+				if kw != "" && strings.Contains(queryLower, kw) {
+					return faq.Answer, true
+				}
+			}
+		}
+	}
+	return "", false
 }
 
 // buildRAGPrompt 构建包含 RAG 上下文的 Prompt

+ 279 - 0
backend/service/chunk_service.go

@@ -0,0 +1,279 @@
+package service
+
+import (
+	"context"
+	"errors"
+	"fmt"
+	"log"
+	"strings"
+	"unicode/utf8"
+
+	"github.com/2930134478/AI-CS/backend/models"
+	"github.com/2930134478/AI-CS/backend/repository"
+	"github.com/2930134478/AI-CS/backend/service/rag"
+)
+
+// ChunkService 文档分段服务
+type ChunkService struct {
+	docRepo      *repository.DocumentRepository
+	kbRepo       *repository.KnowledgeBaseRepository
+	chunkRepo    *repository.DocumentChunkRepository
+	embeddingSvc *rag.DocumentEmbeddingService
+	vectorStore  *rag.VectorStoreService
+}
+
+// NewChunkService 创建分段服务实例
+func NewChunkService(
+	docRepo *repository.DocumentRepository,
+	kbRepo *repository.KnowledgeBaseRepository,
+	chunkRepo *repository.DocumentChunkRepository,
+	embeddingSvc *rag.DocumentEmbeddingService,
+	vectorStore *rag.VectorStoreService,
+) *ChunkService {
+	return &ChunkService{
+		docRepo:      docRepo,
+		kbRepo:       kbRepo,
+		chunkRepo:    chunkRepo,
+		embeddingSvc: embeddingSvc,
+		vectorStore:  vectorStore,
+	}
+}
+
+// ChunkRequest 分段请求参数
+type ChunkRequest struct {
+	Method    string `json:"method"`               // "char_count" | "separator"
+	ChunkSize int    `json:"chunk_size,omitempty"` // 按字数时的每段字数
+	Separator string `json:"separator,omitempty"`  // 按分隔符时的分隔符
+}
+
+// ChunkByCharCount 按字数分段(不重叠)
+func ChunkByCharCount(text string, chunkSize int) []string {
+	if chunkSize <= 0 {
+		chunkSize = 500
+	}
+
+	runes := []rune(text)
+	if len(runes) <= chunkSize {
+		return []string{text}
+	}
+
+	var chunks []string
+	for i := 0; i < len(runes); i += chunkSize {
+		end := i + chunkSize
+		if end > len(runes) {
+			end = len(runes)
+		}
+		chunks = append(chunks, string(runes[i:end]))
+	}
+	return chunks
+}
+
+// ChunkBySeparator 按分隔符分段
+func ChunkBySeparator(text string, sep string) []string {
+	if sep == "" {
+		return []string{text}
+	}
+
+	parts := strings.Split(text, sep)
+	var chunks []string
+	for _, part := range parts {
+		trimmed := strings.TrimSpace(part)
+		if trimmed != "" {
+			chunks = append(chunks, trimmed)
+		}
+	}
+	if len(chunks) == 0 {
+		return []string{text}
+	}
+	return chunks
+}
+
+// ExecuteChunking 执行分段:切分文本 → 写入 MySQL → 删除旧 Milvus 向量 → 逐段向量化写入 Milvus
+func (s *ChunkService) ExecuteChunking(ctx context.Context, documentID uint, req ChunkRequest) ([]models.DocumentChunk, error) {
+	doc, err := s.docRepo.GetByID(documentID)
+	if err != nil {
+		return nil, fmt.Errorf("文档不存在: %w", err)
+	}
+
+	if strings.TrimSpace(doc.Content) == "" {
+		return nil, errors.New("文档内容为空,无法分段")
+	}
+
+	var chunkTexts []string
+	switch req.Method {
+	case "char_count":
+		size := req.ChunkSize
+		if size <= 0 {
+			size = 500
+		}
+		chunkTexts = ChunkByCharCount(doc.Content, size)
+	case "separator":
+		if req.Separator == "" {
+			return nil, errors.New("分隔符不能为空")
+		}
+		chunkTexts = ChunkBySeparator(doc.Content, req.Separator)
+	default:
+		return nil, fmt.Errorf("不支持的分段方式: %s(支持 char_count 或 separator)", req.Method)
+	}
+
+	if len(chunkTexts) == 0 {
+		return nil, errors.New("分段结果为空")
+	}
+
+	if err := s.deleteExistingChunks(ctx, documentID); err != nil {
+		return nil, fmt.Errorf("删除旧分段失败: %w", err)
+	}
+
+	chunks := make([]*models.DocumentChunk, len(chunkTexts))
+	for i, text := range chunkTexts {
+		chunks[i] = &models.DocumentChunk{
+			DocumentID:      documentID,
+			KnowledgeBaseID: doc.KnowledgeBaseID,
+			ChunkIndex:      i,
+			Content:         text,
+			EmbeddingStatus: "pending",
+		}
+	}
+	if err := s.chunkRepo.BatchCreate(chunks); err != nil {
+		return nil, fmt.Errorf("保存分段失败: %w", err)
+	}
+
+	go s.embedChunks(documentID, doc.KnowledgeBaseID, chunks)
+
+	result := make([]models.DocumentChunk, len(chunks))
+	for i, c := range chunks {
+		result[i] = *c
+	}
+	return result, nil
+}
+
+// GetChunks 获取文档的分段列表
+func (s *ChunkService) GetChunks(documentID uint, page, pageSize int) ([]models.DocumentChunk, int64, error) {
+	if _, err := s.docRepo.GetByID(documentID); err != nil {
+		return nil, 0, fmt.Errorf("文档不存在: %w", err)
+	}
+	if page < 1 {
+		page = 1
+	}
+	if pageSize < 1 || pageSize > 100 {
+		pageSize = 20
+	}
+	offset := (page - 1) * pageSize
+	return s.chunkRepo.GetByDocumentIDPaginated(documentID, offset, pageSize)
+}
+
+// UpdateChunk 更新单个分段内容,仅重新向量化该段
+func (s *ChunkService) UpdateChunk(ctx context.Context, chunkID uint, content string) (*models.DocumentChunk, error) {
+	chunk, err := s.chunkRepo.GetByID(chunkID)
+	if err != nil {
+		return nil, fmt.Errorf("分段不存在: %w", err)
+	}
+
+	if strings.TrimSpace(content) == "" {
+		return nil, errors.New("分段内容不能为空")
+	}
+
+	chunk.Content = content
+	chunk.EmbeddingStatus = "pending"
+	if err := s.chunkRepo.Update(chunk); err != nil {
+		return nil, fmt.Errorf("更新分段失败: %w", err)
+	}
+
+	go s.reEmbedSingleChunk(chunk)
+
+	return chunk, nil
+}
+
+// reEmbedSingleChunk 仅重新向量化单个分段
+func (s *ChunkService) reEmbedSingleChunk(chunk *models.DocumentChunk) {
+	ctx := context.Background()
+
+	svc, err := s.embeddingSvc.GetEmbeddingService(ctx)
+	if err != nil {
+		log.Printf("[分段] 获取嵌入服务失败 (chunk=%d): %v", chunk.ID, err)
+		_ = s.chunkRepo.UpdateEmbeddingStatus(chunk.ID, "failed")
+		return
+	}
+
+	vectors, err := svc.EmbedTexts(ctx, []string{chunk.Content})
+	if err != nil || len(vectors) == 0 {
+		log.Printf("[分段] 单段向量化失败 (chunk=%d): %v", chunk.ID, err)
+		_ = s.chunkRepo.UpdateEmbeddingStatus(chunk.ID, "failed")
+		return
+	}
+
+	chunkIDStr := rag.ConvertDocumentID(chunk.ID)
+	_ = s.vectorStore.DeleteVectorByChunkID(ctx, chunkIDStr)
+
+	docIDStr := rag.ConvertDocumentID(chunk.DocumentID)
+	kbIDStr := rag.ConvertKnowledgeBaseID(chunk.KnowledgeBaseID)
+	if err := s.vectorStore.UpsertVector(ctx, docIDStr, kbIDStr, chunk.Content, chunkIDStr, vectors[0]); err != nil {
+		log.Printf("[分段] 单段向量写入失败 (chunk=%d): %v", chunk.ID, err)
+		_ = s.chunkRepo.UpdateEmbeddingStatus(chunk.ID, "failed")
+		return
+	}
+
+	_ = s.chunkRepo.UpdateEmbeddingStatus(chunk.ID, "completed")
+	log.Printf("[分段] 单段向量化完成 (chunk=%d, 长度=%d)", chunk.ID, len([]rune(chunk.Content)))
+}
+
+// DeleteChunks 删除文档的所有分段(MySQL + Milvus)
+func (s *ChunkService) DeleteChunks(ctx context.Context, documentID uint) error {
+	return s.deleteExistingChunks(ctx, documentID)
+}
+
+// deleteExistingChunks 删除文档的旧分段(MySQL + Milvus)
+func (s *ChunkService) deleteExistingChunks(ctx context.Context, documentID uint) error {
+	if err := s.chunkRepo.DeleteByDocumentID(documentID); err != nil {
+		return err
+	}
+
+	docIDStr := rag.ConvertDocumentID(documentID)
+	if err := s.vectorStore.DeleteVector(ctx, docIDStr); err != nil {
+		log.Printf("[分段] 删除旧向量失败(可能本就没有向量): %v", err)
+	}
+	return nil
+}
+
+// embedChunks 批量向量化所有分段并写入 Milvus(异步执行)
+func (s *ChunkService) embedChunks(documentID uint, knowledgeBaseID uint, chunks []*models.DocumentChunk) {
+	ctx := context.Background()
+	s.embedChunkList(ctx, documentID, knowledgeBaseID, chunks)
+}
+
+// embedChunkList 批量向量化分段列表
+func (s *ChunkService) embedChunkList(ctx context.Context, documentID uint, knowledgeBaseID uint, chunks []*models.DocumentChunk) {
+	for _, c := range chunks {
+		_ = s.chunkRepo.UpdateEmbeddingStatus(c.ID, "processing")
+	}
+
+	contents := make([]string, len(chunks))
+	for i, c := range chunks {
+		contents[i] = c.Content
+	}
+
+	docIDs := make([]uint, len(chunks))
+	kbIDs := make([]uint, len(chunks))
+	chunkIDs := make([]string, len(chunks))
+	for i, c := range chunks {
+		docIDs[i] = documentID
+		kbIDs[i] = knowledgeBaseID
+		chunkIDs[i] = rag.ConvertDocumentID(c.ID)
+	}
+
+	if err := s.embeddingSvc.EmbedDocuments(ctx, docIDs, kbIDs, contents, chunkIDs); err != nil {
+		log.Printf("[分段] 批量向量化失败 (doc=%d): %v", documentID, err)
+		for _, c := range chunks {
+			_ = s.chunkRepo.UpdateEmbeddingStatus(c.ID, "failed")
+		}
+		return
+	}
+
+	for _, c := range chunks {
+		_ = s.chunkRepo.UpdateEmbeddingStatus(c.ID, "completed")
+	}
+	log.Printf("[分段] 批量向量化完成 (doc=%d, chunks=%d)", documentID, len(chunks))
+}
+
+// ensure utf8 package is used
+var _ = utf8.RuneCountInString

+ 59 - 0
backend/service/conversation_access.go

@@ -0,0 +1,59 @@
+package service
+
+import (
+	"crypto/subtle"
+	"errors"
+
+	"github.com/2930134478/AI-CS/backend/models"
+	"github.com/2930134478/AI-CS/backend/utils"
+	"gorm.io/gorm"
+)
+
+// ErrConversationAccessDenied 表示无权访问该会话(如缺少或错误的 access_token)。
+var ErrConversationAccessDenied = errors.New("无权限访问该会话")
+
+// EnsureConversationAccessToken 若会话尚无令牌则生成并持久化(兼容历史数据)。
+func (s *ConversationService) EnsureConversationAccessToken(conv *models.Conversation) (*models.Conversation, error) {
+	if conv == nil {
+		return nil, ErrConversationNotFound
+	}
+	if conv.AccessToken != "" {
+		return conv, nil
+	}
+	token, err := utils.GenerateConversationAccessToken()
+	if err != nil {
+		return nil, err
+	}
+	if err := s.conversations.UpdateFields(conv.ID, map[string]interface{}{
+		"access_token": token,
+	}); err != nil {
+		return nil, err
+	}
+	conv.AccessToken = token
+	return conv, nil
+}
+
+// ValidateVisitorAccessToken 校验访客持有的会话 access_token。
+func (s *ConversationService) ValidateVisitorAccessToken(conversationID uint, accessToken string) error {
+	if conversationID == 0 || accessToken == "" {
+		return ErrConversationAccessDenied
+	}
+	conv, err := s.conversations.GetByID(conversationID)
+	if err != nil {
+		if errors.Is(err, gorm.ErrRecordNotFound) {
+			return ErrConversationNotFound
+		}
+		return err
+	}
+	if conv.ConversationType != "visitor" {
+		return ErrConversationAccessDenied
+	}
+	conv, err = s.EnsureConversationAccessToken(conv)
+	if err != nil {
+		return err
+	}
+	if subtle.ConstantTimeCompare([]byte(accessToken), []byte(conv.AccessToken)) != 1 {
+		return ErrConversationAccessDenied
+	}
+	return nil
+}

+ 282 - 0
backend/service/conversation_list_batch.go

@@ -0,0 +1,282 @@
+package service
+
+import (
+	"errors"
+	"log"
+	"os"
+	"strconv"
+	"strings"
+	"time"
+
+	"github.com/2930134478/AI-CS/backend/models"
+)
+
+const (
+	defaultConversationPageSize = 50
+	maxConversationPageSize       = 100
+)
+
+func parseConversationListPagination(page, pageSize int) (int, int) {
+	if page < 1 {
+		page = 1
+	}
+	if pageSize <= 0 {
+		pageSize = defaultConversationPageSize
+	}
+	if pageSize > maxConversationPageSize {
+		pageSize = maxConversationPageSize
+	}
+	return page, pageSize
+}
+
+func (s *ConversationService) buildSummariesBatch(conversations []models.Conversation, userID uint) ([]ConversationSummary, error) {
+	if len(conversations) == 0 {
+		return []ConversationSummary{}, nil
+	}
+
+	ids := make([]uint, len(conversations))
+	for i, conv := range conversations {
+		ids[i] = conv.ID
+	}
+
+	latestMap, err := s.messages.BatchLatestByConversationIDs(ids)
+	if err != nil {
+		return nil, err
+	}
+	unreadMap, err := s.messages.BatchCountUnreadBySender(ids, false)
+	if err != nil {
+		return nil, err
+	}
+	participatedMap := map[uint]bool{}
+	if userID > 0 {
+		participatedMap, err = s.messages.BatchHasAgentParticipated(ids, userID)
+		if err != nil {
+			return nil, err
+		}
+	}
+
+	result := make([]ConversationSummary, 0, len(conversations))
+	for _, conv := range conversations {
+		var lastSeen *time.Time
+		if conv.LastSeenAt != nil {
+			lastSeen = conv.LastSeenAt
+		}
+
+		summary := ConversationSummary{
+			ID:               conv.ID,
+			ConversationType: conv.ConversationType,
+			VisitorID:        conv.VisitorID,
+			AgentID:          conv.AgentID,
+			Status:           conv.Status,
+			ChatMode:         conv.ChatMode,
+			CreatedAt:        conv.CreatedAt,
+			UpdatedAt:        conv.UpdatedAt,
+			LastSeenAt:       lastSeen,
+			UnreadCount:      unreadMap[conv.ID],
+			HasParticipated:  participatedMap[conv.ID],
+		}
+
+		if message := latestMap[conv.ID]; message != nil {
+			var readAt *time.Time
+			if message.ReadAt != nil {
+				readAt = message.ReadAt
+			}
+			summary.LastMessage = &LastMessageSummary{
+				ID:            message.ID,
+				Content:       message.Content,
+				SenderIsAgent: message.SenderIsAgent,
+				MessageType:   message.MessageType,
+				IsRead:        message.IsRead,
+				ReadAt:        readAt,
+				CreatedAt:     message.CreatedAt,
+			}
+		}
+
+		result = append(result, summary)
+	}
+	return result, nil
+}
+
+// ListConversationsPaginated 分页返回访客会话列表(批量 SQL,无 N+1)。
+func (s *ConversationService) ListConversationsPaginated(userID uint, status string, page, pageSize int) (*ConversationListResult, error) {
+	if status == "" {
+		status = "open"
+	}
+	page, pageSize = parseConversationListPagination(page, pageSize)
+	offset := (page - 1) * pageSize
+
+	conversations, total, err := s.conversations.ListVisitorForAgentList(status, offset, pageSize)
+	if err != nil {
+		return nil, err
+	}
+
+	items, err := s.buildSummariesBatch(conversations, userID)
+	if err != nil {
+		return nil, err
+	}
+
+	totalUnread, err := s.messages.CountTotalUnreadVisitorForAgentList(status)
+	if err != nil {
+		totalUnread = 0
+	}
+
+	return &ConversationListResult{
+		Items:       items,
+		Total:       total,
+		Page:        page,
+		PageSize:    pageSize,
+		HasMore:     int64(offset+len(items)) < total,
+		TotalUnread: totalUnread,
+	}, nil
+}
+
+// ListInternalConversationsPaginated 分页返回内部对话列表。
+func (s *ConversationService) ListInternalConversationsPaginated(agentID uint, status string, page, pageSize int) (*ConversationListResult, error) {
+	if agentID == 0 {
+		return &ConversationListResult{Items: []ConversationSummary{}, Page: 1, PageSize: defaultConversationPageSize}, nil
+	}
+	if status == "" {
+		status = "open"
+	}
+	page, pageSize = parseConversationListPagination(page, pageSize)
+
+	conversations, err := s.conversations.ListInternalByAgentIDAndStatus(agentID, status)
+	if err != nil {
+		return nil, err
+	}
+
+	total := int64(len(conversations))
+	offset := (page - 1) * pageSize
+	if offset > len(conversations) {
+		offset = len(conversations)
+	}
+	end := offset + pageSize
+	if end > len(conversations) {
+		end = len(conversations)
+	}
+	pageItems := conversations[offset:end]
+
+	items, err := s.buildSummariesBatch(pageItems, agentID)
+	if err != nil {
+		return nil, err
+	}
+
+	return &ConversationListResult{
+		Items:   items,
+		Total:   total,
+		Page:    page,
+		PageSize: pageSize,
+		HasMore: int64(offset+len(items)) < total,
+	}, nil
+}
+
+// AutoCloseConversationDaysPolicy 自动关闭 stale 会话的策略(数据库覆盖优先于 .env)。
+type AutoCloseConversationDaysPolicy struct {
+	EffectiveDays       int  `json:"effective_days"`
+	EnvDays             int  `json:"env_days"`
+	PersistedInDatabase bool `json:"persisted_in_database"`
+}
+
+func autoCloseConversationDaysFromEnv() int {
+	days := 7
+	if v := os.Getenv("AUTO_CLOSE_CONVERSATION_DAYS"); v != "" {
+		if parsed, err := strconv.Atoi(v); err == nil {
+			days = parsed
+		}
+	}
+	return days
+}
+
+// EffectiveAutoCloseConversationDays 返回当前生效的自动关闭天数(0=禁用)。
+func (s *ConversationService) EffectiveAutoCloseConversationDays() int {
+	if s.appSettings != nil {
+		if row, err := s.appSettings.Get(models.AppSettingKeyAutoCloseConversationDays); err == nil && row != nil {
+			if v := strings.TrimSpace(row.Value); v != "" {
+				if parsed, err := strconv.Atoi(v); err == nil && parsed >= 0 {
+					return parsed
+				}
+			}
+		}
+	}
+	return autoCloseConversationDaysFromEnv()
+}
+
+// GetAutoCloseConversationDaysPolicy 读取自动关闭 stale 会话策略。
+func (s *ConversationService) GetAutoCloseConversationDaysPolicy() AutoCloseConversationDaysPolicy {
+	envDays := autoCloseConversationDaysFromEnv()
+	policy := AutoCloseConversationDaysPolicy{
+		EffectiveDays: envDays,
+		EnvDays:       envDays,
+	}
+	if s.appSettings != nil {
+		if row, err := s.appSettings.Get(models.AppSettingKeyAutoCloseConversationDays); err == nil && row != nil {
+			if v := strings.TrimSpace(row.Value); v != "" {
+				policy.PersistedInDatabase = true
+				if parsed, err := strconv.Atoi(v); err == nil && parsed >= 0 {
+					policy.EffectiveDays = parsed
+				}
+			}
+		}
+	}
+	return policy
+}
+
+// SetAutoCloseConversationDaysPolicy 写入自动关闭天数(0=禁用)。
+func (s *ConversationService) SetAutoCloseConversationDaysPolicy(inactiveDays int) error {
+	if inactiveDays < 0 {
+		return errors.New("inactive_days 不能为负数")
+	}
+	if s.appSettings == nil {
+		return errors.New("配置存储不可用")
+	}
+	return s.appSettings.SetValue(models.AppSettingKeyAutoCloseConversationDays, strconv.Itoa(inactiveDays))
+}
+
+// ClearAutoCloseConversationDaysPolicy 删除数据库覆盖,恢复为 .env 默认值。
+func (s *ConversationService) ClearAutoCloseConversationDaysPolicy() error {
+	if s.appSettings == nil {
+		return errors.New("配置存储不可用")
+	}
+	return s.appSettings.Delete(models.AppSettingKeyAutoCloseConversationDays)
+}
+
+// CloseStaleOpenVisitorConversations 关闭超过指定天数未更新的 open 访客会话(仅改 status,不删记录)。
+func (s *ConversationService) CloseStaleOpenVisitorConversations(inactiveDays int) (int64, error) {
+	if inactiveDays <= 0 {
+		return 0, nil
+	}
+	cutoff := time.Now().AddDate(0, 0, -inactiveDays)
+	return s.conversations.CloseStaleOpenVisitorConversations(cutoff)
+}
+
+// StartStaleConversationCleanup 启动后台任务:定期关闭长期未活跃的 open 访客会话。
+func (s *ConversationService) StartStaleConversationCleanup() {
+	run := func() {
+		inactiveDays := s.EffectiveAutoCloseConversationDays()
+		if inactiveDays <= 0 {
+			return
+		}
+		n, err := s.CloseStaleOpenVisitorConversations(inactiveDays)
+		if err != nil {
+			log.Printf("[会话维护] 自动关闭 stale 会话失败: %v", err)
+			return
+		}
+		if n > 0 {
+			log.Printf("[会话维护] 已自动关闭 %d 条超过 %d 天未更新的 open 访客会话", n, inactiveDays)
+		}
+	}
+
+	if days := s.EffectiveAutoCloseConversationDays(); days <= 0 {
+		log.Println("[会话维护] 自动关闭 stale 会话已禁用(effective_days=0)")
+	} else {
+		run()
+	}
+
+	go func() {
+		ticker := time.NewTicker(24 * time.Hour)
+		defer ticker.Stop()
+		for range ticker.C {
+			run()
+		}
+	}()
+}

+ 32 - 65
backend/service/conversation_service.go

@@ -8,6 +8,7 @@ import (
 	"github.com/2930134478/AI-CS/backend/infra/geoip"
 	"github.com/2930134478/AI-CS/backend/models"
 	"github.com/2930134478/AI-CS/backend/repository"
+	"github.com/2930134478/AI-CS/backend/utils"
 	"gorm.io/gorm"
 )
 
@@ -18,6 +19,7 @@ type ConversationService struct {
 	aiConfigRepo  *repository.AIConfigRepository // 用于验证 AI 配置
 	userRepo      *repository.UserRepository     // 用于查询用户设置
 	systemLogSvc  *SystemLogService              // 可选,结构化日志
+	appSettings   *repository.AppSettingRepository // 平台级会话维护等配置
 }
 
 // CloseConversation 客服主动关闭会话(visitor/internal 通用)。
@@ -51,6 +53,7 @@ func NewConversationService(
 	aiConfigRepo *repository.AIConfigRepository,
 	userRepo *repository.UserRepository,
 	systemLogSvc *SystemLogService,
+	appSettings *repository.AppSettingRepository,
 ) *ConversationService {
 	return &ConversationService{
 		conversations: conversations,
@@ -58,6 +61,7 @@ func NewConversationService(
 		aiConfigRepo:  aiConfigRepo,
 		userRepo:      userRepo,
 		systemLogSvc:  systemLogSvc,
+		appSettings:   appSettings,
 	}
 }
 
@@ -99,10 +103,16 @@ func (s *ConversationService) InitConversation(input InitConversationInput) (*In
 				aiConfigID = input.AIConfigID
 			}
 
+			accessToken, err := utils.GenerateConversationAccessToken()
+			if err != nil {
+				return nil, err
+			}
+
 			conv = &models.Conversation{
 				ConversationType: "visitor",
 				VisitorID:        input.VisitorID,
 				Status:           "open",
+				AccessToken:      accessToken,
 				Website:          input.Website,
 				Referrer:         input.Referrer,
 				Browser:          input.Browser,
@@ -248,6 +258,11 @@ func (s *ConversationService) InitConversation(input InitConversationInput) (*In
 		}
 	}
 
+	conv, err = s.EnsureConversationAccessToken(conv)
+	if err != nil {
+		return nil, err
+	}
+
 	if isNewConversation {
 		now := time.Now()
 		chatMode := input.ChatMode
@@ -296,6 +311,7 @@ func (s *ConversationService) InitConversation(input InitConversationInput) (*In
 	return &InitConversationResult{
 		ConversationID: conv.ID,
 		Status:         conv.Status,
+		AccessToken:    conv.AccessToken,
 	}, nil
 }
 
@@ -379,52 +395,13 @@ func (s *ConversationService) buildSummary(conv models.Conversation, userID uint
 	return summary, nil
 }
 
-// ListConversations 返回当前活跃会话的摘要信息。
-// userID: 当前登录的客服ID(可选,如果为0则使用默认过滤规则)
-// 过滤规则:
-// 1. 默认不显示 ChatMode == "ai" 的对话
-// 2. 如果 userID > 0 且该用户的 ReceiveAIConversations == false,则不显示 AI 对话
-// 3. 只显示 ChatMode == "human" 且存在访客消息的对话(访客切换到人工并发送消息后)
+// ListConversations 返回当前活跃会话的摘要信息(兼容旧调用,等价于第一页分页)。
 func (s *ConversationService) ListConversations(userID uint, status string) ([]ConversationSummary, error) {
-	// 默认展示进行中(open);历史使用 status=closed
-	if status == "" {
-		status = "open"
-	}
-	conversations, err := s.conversations.ListByTypeAndStatus("visitor", status)
+	result, err := s.ListConversationsPaginated(userID, status, 1, maxConversationPageSize)
 	if err != nil {
 		return nil, err
 	}
-
-	result := make([]ConversationSummary, 0, len(conversations))
-	for _, conv := range conversations {
-		// 过滤规则 1: 默认不显示 AI 对话
-		// 只有在会话页面手动开启"显示 AI 对话"时才显示
-		if conv.ChatMode == "ai" {
-			continue
-		}
-
-		// 过滤规则 2: 如果是人工对话,检查是否有访客发送的消息
-		// 只有当访客切换到人工并发送消息后,才显示在列表中
-		if conv.ChatMode == "human" {
-			hasVisitorMessage, err := s.messages.HasVisitorMessageInHumanMode(conv.ID)
-			if err != nil {
-				// 如果查询失败,为了安全起见,不显示该对话
-				continue
-			}
-			if !hasVisitorMessage {
-				// 没有访客消息,不显示(访客只是切换了模式,但还没发送消息)
-				continue
-			}
-		}
-
-		// 通过过滤,添加到结果列表
-		summary, err := s.buildSummary(conv, userID)
-		if err != nil {
-			continue // 如果构建摘要失败,跳过该对话
-		}
-		result = append(result, summary)
-	}
-	return result, nil
+	return result.Items, nil
 }
 
 // GetConversationDetail 获取指定会话的详细信息。内部对话仅创建者(agent_id)可查看。
@@ -472,7 +449,8 @@ func (s *ConversationService) GetConversationDetail(id uint, userID uint) (*Conv
 
 // SearchConversations 根据关键字检索会话摘要。
 // userID: 当前登录的客服ID(可选,用于检查参与状态)
-func (s *ConversationService) SearchConversations(query string, userID uint, status string) ([]ConversationSummary, error) {
+// conversationType: visitor | internal,空表示不过滤
+func (s *ConversationService) SearchConversations(query string, userID uint, status string, conversationType string) ([]ConversationSummary, error) {
 	pattern := "%" + query + "%"
 
 	idSet := map[uint]struct{}{}
@@ -508,13 +486,16 @@ func (s *ConversationService) SearchConversations(query string, userID uint, sta
 	}
 
 	result := make([]ConversationSummary, 0, len(conversations))
-	for _, conv := range conversations {
-		if status != "" && status != "all" && conv.Status != status {
+	summaries, err := s.buildSummariesBatch(conversations, userID)
+	if err != nil {
+		return nil, err
+	}
+	for _, summary := range summaries {
+		if conversationType != "" && summary.ConversationType != conversationType {
 			continue
 		}
-		summary, err := s.buildSummary(conv, userID)
-		if err != nil {
-			return nil, err
+		if status != "" && status != "all" && summary.Status != status {
+			continue
 		}
 		result = append(result, summary)
 	}
@@ -574,25 +555,11 @@ func (s *ConversationService) InitInternalConversation(agentID uint) (*InitConve
 	}, nil
 }
 
-// ListInternalConversations 返回当前客服的全部内部对话(知识库测试用)。
+// ListInternalConversations 返回当前客服的内部对话(第一页,兼容旧调用)。
 func (s *ConversationService) ListInternalConversations(agentID uint, status string) ([]ConversationSummary, error) {
-	if agentID == 0 {
-		return []ConversationSummary{}, nil
-	}
-	if status == "" {
-		status = "open"
-	}
-	conversations, err := s.conversations.ListInternalByAgentIDAndStatus(agentID, status)
+	result, err := s.ListInternalConversationsPaginated(agentID, status, 1, maxConversationPageSize)
 	if err != nil {
 		return nil, err
 	}
-	result := make([]ConversationSummary, 0, len(conversations))
-	for _, conv := range conversations {
-		summary, err := s.buildSummary(conv, agentID)
-		if err != nil {
-			continue
-		}
-		result = append(result, summary)
-	}
-	return result, nil
+	return result.Items, nil
 }

+ 276 - 0
backend/service/email_notification_config_service.go

@@ -0,0 +1,276 @@
+package service
+
+import (
+	"errors"
+	"fmt"
+	"os"
+	"strconv"
+	"strings"
+	"time"
+
+	"github.com/2930134478/AI-CS/backend/models"
+	"github.com/2930134478/AI-CS/backend/repository"
+	"github.com/2930134478/AI-CS/backend/utils"
+)
+
+// EmailNotificationConfigResult 返回给前端的配置(密码脱敏)
+type EmailNotificationConfigResult struct {
+	ID                    uint      `json:"id"`
+	Enabled               bool      `json:"enabled"`
+	SMTPHost              string    `json:"smtp_host"`
+	SMTPPort              int       `json:"smtp_port"`
+	SMTPUser              string    `json:"smtp_user"`
+	SMTPPasswordMasked    string    `json:"smtp_password_masked"`
+	FromEmail             string    `json:"from_email"`
+	FromName              string    `json:"from_name"`
+	OfflineDelaySeconds   int       `json:"offline_delay_seconds"`
+	EffectiveEnabled      bool      `json:"effective_enabled"`
+	EffectiveDelaySeconds int       `json:"effective_delay_seconds"`
+	PersistedInDatabase   bool      `json:"persisted_in_database"`
+	EnvEnabled            bool      `json:"env_enabled"`
+	EnvDelaySeconds       int       `json:"env_delay_seconds"`
+	UpdatedAt             time.Time `json:"updated_at,omitempty"`
+}
+
+// UpdateEmailNotificationConfigInput 更新离线邮件配置
+type UpdateEmailNotificationConfigInput struct {
+	Enabled             *bool
+	SMTPHost            *string
+	SMTPPort            *int
+	SMTPUser            *string
+	SMTPPassword        *string
+	FromEmail           *string
+	FromName            *string
+	OfflineDelaySeconds *int
+}
+
+// ResolvedSMTPConfig 实际发信用的 SMTP 配置
+type ResolvedSMTPConfig struct {
+	Enabled             bool
+	SMTPHost            string
+	SMTPPort            int
+	SMTPUser            string
+	SMTPPassword        string
+	FromEmail           string
+	FromName            string
+	OfflineDelaySeconds int
+}
+
+// EmailNotificationConfigService 离线邮件配置服务
+type EmailNotificationConfigService struct {
+	repo     *repository.EmailNotificationConfigRepository
+	userRepo *repository.UserRepository
+}
+
+// NewEmailNotificationConfigService 创建服务实例
+func NewEmailNotificationConfigService(
+	repo *repository.EmailNotificationConfigRepository,
+	userRepo *repository.UserRepository,
+) *EmailNotificationConfigService {
+	return &EmailNotificationConfigService{repo: repo, userRepo: userRepo}
+}
+
+func emailNotificationEnabledFromEnv() bool {
+	v := strings.ToLower(strings.TrimSpace(os.Getenv("OFFLINE_EMAIL_ENABLED")))
+	return v == "1" || v == "true" || v == "yes"
+}
+
+func offlineEmailDelaySecondsFromEnv() int {
+	sec := 60
+	if v := os.Getenv("OFFLINE_EMAIL_DELAY_SECONDS"); v != "" {
+		if parsed, err := strconv.Atoi(v); err == nil && parsed >= 0 {
+			sec = parsed
+		}
+	}
+	return sec
+}
+
+func smtpConfigFromEnv() (host, user, password, fromEmail, fromName string, port int) {
+	host = strings.TrimSpace(os.Getenv("SMTP_HOST"))
+	user = strings.TrimSpace(os.Getenv("SMTP_USER"))
+	password = os.Getenv("SMTP_PASSWORD")
+	fromEmail = strings.TrimSpace(os.Getenv("SMTP_FROM_EMAIL"))
+	fromName = strings.TrimSpace(os.Getenv("SMTP_FROM_NAME"))
+	port = 465
+	if v := os.Getenv("SMTP_PORT"); v != "" {
+		if parsed, err := strconv.Atoi(v); err == nil && parsed > 0 {
+			port = parsed
+		}
+	}
+	return
+}
+
+// ResolveEffective 合并 DB 与 .env,DB 优先
+func (s *EmailNotificationConfigService) ResolveEffective() (ResolvedSMTPConfig, error) {
+	envEnabled := emailNotificationEnabledFromEnv()
+	envDelay := offlineEmailDelaySecondsFromEnv()
+	envHost, envUser, envPass, envFrom, envFromName, envPort := smtpConfigFromEnv()
+
+	resolved := ResolvedSMTPConfig{
+		Enabled:             envEnabled,
+		SMTPHost:            envHost,
+		SMTPPort:            envPort,
+		SMTPUser:            envUser,
+		SMTPPassword:        envPass,
+		FromEmail:           envFrom,
+		FromName:            envFromName,
+		OfflineDelaySeconds: envDelay,
+	}
+
+	row, err := s.repo.Get()
+	if err != nil {
+		return resolved, err
+	}
+	if row == nil {
+		return resolved, nil
+	}
+
+	resolved.Enabled = row.Enabled
+	if row.SMTPHost != "" {
+		resolved.SMTPHost = row.SMTPHost
+	}
+	if row.SMTPPort > 0 {
+		resolved.SMTPPort = row.SMTPPort
+	}
+	if row.SMTPUser != "" {
+		resolved.SMTPUser = row.SMTPUser
+	}
+	if row.FromEmail != "" {
+		resolved.FromEmail = row.FromEmail
+	}
+	if row.FromName != "" {
+		resolved.FromName = row.FromName
+	}
+	if row.OfflineDelaySeconds >= 0 {
+		resolved.OfflineDelaySeconds = row.OfflineDelaySeconds
+	}
+	if row.SMTPPassword != "" {
+		decrypted, err := utils.DecryptAPIKey(row.SMTPPassword)
+		if err != nil {
+			return resolved, fmt.Errorf("解密 SMTP 密码失败: %w", err)
+		}
+		resolved.SMTPPassword = decrypted
+	}
+	return resolved, nil
+}
+
+// GetForAPI 返回给前端的配置
+func (s *EmailNotificationConfigService) GetForAPI() (*EmailNotificationConfigResult, error) {
+	envEnabled := emailNotificationEnabledFromEnv()
+	envDelay := offlineEmailDelaySecondsFromEnv()
+	effective, err := s.ResolveEffective()
+	if err != nil {
+		return nil, err
+	}
+
+	envHost, envUser, _, envFrom, envFromName, envPort := smtpConfigFromEnv()
+
+	result := &EmailNotificationConfigResult{
+		Enabled:               envEnabled,
+		SMTPPort:              envPort,
+		OfflineDelaySeconds:   envDelay,
+		EffectiveEnabled:      effective.Enabled,
+		EffectiveDelaySeconds: effective.OfflineDelaySeconds,
+		EnvEnabled:            envEnabled,
+		EnvDelaySeconds:       envDelay,
+		SMTPHost:              envHost,
+		SMTPUser:              envUser,
+		FromEmail:             envFrom,
+		FromName:              envFromName,
+	}
+
+	row, err := s.repo.Get()
+	if err != nil {
+		return nil, err
+	}
+	if row != nil {
+		result.PersistedInDatabase = true
+		result.ID = row.ID
+		result.Enabled = row.Enabled
+		result.SMTPHost = row.SMTPHost
+		result.SMTPPort = row.SMTPPort
+		if result.SMTPPort <= 0 {
+			result.SMTPPort = 465
+		}
+		result.SMTPUser = row.SMTPUser
+		result.FromEmail = row.FromEmail
+		result.FromName = row.FromName
+		result.OfflineDelaySeconds = row.OfflineDelaySeconds
+		result.UpdatedAt = row.UpdatedAt
+		if row.SMTPPassword != "" {
+			result.SMTPPasswordMasked = "******"
+		}
+	}
+	return result, nil
+}
+
+// Update 更新配置(仅管理员)
+func (s *EmailNotificationConfigService) Update(userID uint, input UpdateEmailNotificationConfigInput) (*EmailNotificationConfigResult, error) {
+	user, err := s.userRepo.GetByID(userID)
+	if err != nil || user == nil {
+		return nil, errors.New("用户不存在")
+	}
+	if user.Role != "admin" {
+		return nil, errors.New("仅管理员可修改离线邮件配置")
+	}
+
+	c, err := s.repo.Get()
+	if err != nil {
+		return nil, err
+	}
+	if c == nil {
+		c = &models.EmailNotificationConfig{ID: 1, SMTPPort: 465, OfflineDelaySeconds: 60}
+	}
+
+	if input.Enabled != nil {
+		c.Enabled = *input.Enabled
+	}
+	if input.SMTPHost != nil {
+		c.SMTPHost = strings.TrimSpace(*input.SMTPHost)
+	}
+	if input.SMTPPort != nil && *input.SMTPPort > 0 {
+		c.SMTPPort = *input.SMTPPort
+	}
+	if input.SMTPUser != nil {
+		c.SMTPUser = strings.TrimSpace(*input.SMTPUser)
+	}
+	if input.SMTPPassword != nil && strings.TrimSpace(*input.SMTPPassword) != "" {
+		encrypted, err := utils.EncryptAPIKey(strings.TrimSpace(*input.SMTPPassword))
+		if err != nil {
+			return nil, fmt.Errorf("加密 SMTP 密码失败: %w", err)
+		}
+		c.SMTPPassword = encrypted
+	}
+	if input.FromEmail != nil {
+		c.FromEmail = strings.TrimSpace(*input.FromEmail)
+	}
+	if input.FromName != nil {
+		c.FromName = strings.TrimSpace(*input.FromName)
+	}
+	if input.OfflineDelaySeconds != nil {
+		if *input.OfflineDelaySeconds < 0 {
+			return nil, errors.New("离线延迟秒数不能为负数")
+		}
+		c.OfflineDelaySeconds = *input.OfflineDelaySeconds
+	}
+
+	if err := s.repo.Save(c); err != nil {
+		return nil, err
+	}
+	return s.GetForAPI()
+}
+
+// ResetToEnv 删除数据库覆盖,恢复 .env
+func (s *EmailNotificationConfigService) ResetToEnv(userID uint) (*EmailNotificationConfigResult, error) {
+	user, err := s.userRepo.GetByID(userID)
+	if err != nil || user == nil {
+		return nil, errors.New("用户不存在")
+	}
+	if user.Role != "admin" {
+		return nil, errors.New("仅管理员可修改离线邮件配置")
+	}
+	if err := s.repo.Delete(); err != nil {
+		return nil, err
+	}
+	return s.GetForAPI()
+}

+ 16 - 0
backend/service/faq_service.go

@@ -278,6 +278,22 @@ func (s *FAQService) DeleteFAQ(id uint) error {
 	return s.faqs.Delete(id)
 }
 
+// QuickSearch 快速搜索 FAQ(用于聊天输入框),OR 宽松匹配。
+func (s *FAQService) QuickSearch(q string, limit int) ([]FAQSummary, error) {
+	if strings.TrimSpace(q) == "" {
+		return []FAQSummary{}, nil
+	}
+	faqs, err := s.faqs.QuickSearch(q, limit)
+	if err != nil {
+		return nil, err
+	}
+	summaries := make([]FAQSummary, 0, len(faqs))
+	for _, faq := range faqs {
+		summaries = append(summaries, *s.toSummary(&faq))
+	}
+	return summaries, nil
+}
+
 // parseKeywords 解析关键词查询字符串。
 // 输入格式:关键词之间用 % 分隔,例如 "openai%api%调用"
 // 返回:关键词数组

+ 49 - 6
backend/service/import/pdf_parser.go

@@ -1,11 +1,14 @@
 package import_service
 
 import (
-	"errors"
+	"fmt"
+	"path/filepath"
 	"strings"
+
+	"github.com/ledongthuc/pdf"
 )
 
-// PDFParser PDF 解析器
+// PDFParser PDF 解析器(纯 Go,使用 ledongthuc/pdf GetTextByRow 避免竖排)
 type PDFParser struct{}
 
 // NewPDFParser 创建 PDF 解析器
@@ -18,9 +21,49 @@ func (p *PDFParser) Supports(filePath string) bool {
 	return strings.HasSuffix(strings.ToLower(filePath), ".pdf")
 }
 
-// Parse 解析 PDF 文件
-// TODO: 需要集成专业库(如 pdfcpu/pdfcpu 或 gen2brain/go-fitz)
+// Parse 解析 PDF 文件(使用 GetTextByRow 按行分组,避免字符竖排)
 func (p *PDFParser) Parse(filePath string) (*ParsedDocument, error) {
-	// TODO: 实现 PDF 解析逻辑
-	return nil, errors.New("PDF 解析功能待实现,需要集成专业库")
+	f, reader, err := pdf.Open(filePath)
+	if err != nil {
+		return nil, fmt.Errorf("打开 PDF 失败: %w", err)
+	}
+	defer f.Close()
+
+	var allLines []string
+
+	for pageNum := 1; pageNum <= reader.NumPage(); pageNum++ {
+		page := reader.Page(pageNum)
+		if page.V.IsNull() {
+			continue
+		}
+
+		rows, err := page.GetTextByRow()
+		if err != nil {
+			continue
+		}
+
+		for _, row := range rows {
+			var sb strings.Builder
+			for _, t := range row.Content {
+				sb.WriteString(t.S)
+			}
+			line := strings.TrimSpace(sb.String())
+			if line != "" {
+				allLines = append(allLines, line)
+			}
+		}
+	}
+
+	text := strings.Join(allLines, "\n")
+	if strings.TrimSpace(text) == "" {
+		return nil, fmt.Errorf("PDF 文件中未提取到文本内容")
+	}
+
+	title := strings.TrimSuffix(filepath.Base(filePath), filepath.Ext(filePath))
+
+	return &ParsedDocument{
+		Title:    title,
+		Content:  text,
+		Metadata: map[string]interface{}{"source": "pdf"},
+	}, nil
 }

+ 85 - 6
backend/service/import/word_parser.go

@@ -1,11 +1,15 @@
 package import_service
 
 import (
-	"errors"
+	"archive/zip"
+	"encoding/xml"
+	"fmt"
+	"io"
+	"path/filepath"
 	"strings"
 )
 
-// WordParser Word 解析器
+// WordParser Word 解析器(直接解析 .docx ZIP/XML)
 type WordParser struct{}
 
 // NewWordParser 创建 Word 解析器
@@ -19,9 +23,84 @@ func (p *WordParser) Supports(filePath string) bool {
 		strings.HasSuffix(strings.ToLower(filePath), ".doc")
 }
 
-// Parse 解析 Word 文件
-// TODO: 需要集成专业库(如 unidoc/unioffice 或 lukasjarosch/go-docx)
+// Parse 解析 Word 文件(.docx 是 ZIP 压缩的 XML)
 func (p *WordParser) Parse(filePath string) (*ParsedDocument, error) {
-	// TODO: 实现 Word 解析逻辑
-	return nil, errors.New("Word 解析功能待实现,需要集成专业库")
+	if strings.HasSuffix(strings.ToLower(filePath), ".doc") {
+		return nil, fmt.Errorf("暂不支持旧版 .doc 格式,请转换为 .docx 后导入")
+	}
+
+	r, err := zip.OpenReader(filePath)
+	if err != nil {
+		return nil, fmt.Errorf("打开 Word 文档失败: %w", err)
+	}
+	defer r.Close()
+
+	var docFile *zip.File
+	for _, f := range r.File {
+		if f.Name == "word/document.xml" {
+			docFile = f
+			break
+		}
+	}
+	if docFile == nil {
+		return nil, fmt.Errorf("Word 文档格式异常:找不到 word/document.xml")
+	}
+
+	rc, err := docFile.Open()
+	if err != nil {
+		return nil, fmt.Errorf("读取文档内容失败: %w", err)
+	}
+	defer rc.Close()
+
+	data, err := io.ReadAll(rc)
+	if err != nil {
+		return nil, fmt.Errorf("读取文档内容失败: %w", err)
+	}
+
+	var doc struct {
+		Body struct {
+			Paragraphs []struct {
+				Runs []struct {
+					Text string `xml:"t"`
+				} `xml:"r"`
+			} `xml:"p"`
+		} `xml:"body"`
+	}
+	if err := xml.Unmarshal(data, &doc); err != nil {
+		return nil, fmt.Errorf("解析文档 XML 失败: %w", err)
+	}
+
+	var paragraphs []string
+	for _, p := range doc.Body.Paragraphs {
+		var texts []string
+		for _, run := range p.Runs {
+			if strings.TrimSpace(run.Text) != "" {
+				texts = append(texts, run.Text)
+			}
+		}
+		line := strings.TrimSpace(strings.Join(texts, ""))
+		if line != "" {
+			paragraphs = append(paragraphs, line)
+		}
+	}
+
+	if len(paragraphs) == 0 {
+		return nil, fmt.Errorf("Word 文档中未提取到文本内容")
+	}
+
+	content := strings.Join(paragraphs, "\n\n")
+
+	title := paragraphs[0]
+	if len([]rune(title)) > 100 {
+		title = string([]rune(title)[:100])
+	}
+	if len([]rune(paragraphs[0])) > 100 {
+		title = strings.TrimSuffix(filepath.Base(filePath), filepath.Ext(filePath))
+	}
+
+	return &ParsedDocument{
+		Title:    title,
+		Content:  content,
+		Metadata: map[string]interface{}{"source": "word"},
+	}, nil
 }

+ 16 - 5
backend/service/message_service.go

@@ -19,11 +19,17 @@ var (
 
 // MessageService 负责消息领域的业务处理。
 type MessageService struct {
-	db            *gorm.DB
-	conversations *repository.ConversationRepository
-	messages      *repository.MessageRepository
-	hub           BroadcastHub
-	aiService     *AIService // AI 服务(用于 AI 自动回复)
+	db               *gorm.DB
+	conversations    *repository.ConversationRepository
+	messages         *repository.MessageRepository
+	hub              BroadcastHub
+	aiService        *AIService // AI 服务(用于 AI 自动回复)
+	offlineEmailSvc  *OfflineEmailService
+}
+
+// SetOfflineEmailService 注入离线邮件服务(Hub 创建后调用)
+func (s *MessageService) SetOfflineEmailService(svc *OfflineEmailService) {
+	s.offlineEmailSvc = svc
 }
 
 // NewMessageService 创建 MessageService 实例。
@@ -132,6 +138,11 @@ func (s *MessageService) CreateMessage(input CreateMessageInput) (*models.Messag
 		log.Printf("⚠️ WebSocket Hub 为空,无法广播消息: 消息ID=%d, 对话ID=%d", message.ID, message.ConversationID)
 	}
 
+	// 人工访客会话:客服发消息且访客离线时,调度离线邮件
+	if input.SenderIsAgent && conv.ConversationType == "visitor" && conv.ChatMode == "human" && s.offlineEmailSvc != nil {
+		s.offlineEmailSvc.OnAgentMessage(message.ConversationID, message.ID)
+	}
+
 	// 3. 触发 AI 回复(文本/识图或生图,具体由 AI 配置的 model_type 决定)
 	needAIReply := s.aiService != nil && conv.ChatMode == "ai" && (
 		(!input.SenderIsAgent) || (conv.ConversationType == "internal" && input.SenderIsAgent))

+ 285 - 0
backend/service/offline_email_service.go

@@ -0,0 +1,285 @@
+package service
+
+import (
+	"context"
+	"fmt"
+	"log"
+	"strconv"
+	"strings"
+	"time"
+
+	"github.com/2930134478/AI-CS/backend/infra"
+	"github.com/2930134478/AI-CS/backend/models"
+	"github.com/2930134478/AI-CS/backend/repository"
+)
+
+// VisitorPresenceHub 访客在线状态探测
+type VisitorPresenceHub interface {
+	BroadcastHub
+	VisitorConnectionCount(conversationID uint) int
+}
+
+// OfflineEmailService 离线邮件延迟推送
+type OfflineEmailService struct {
+	configSvc     *EmailNotificationConfigService
+	jobRepo       *repository.OfflineEmailJobRepository
+	convRepo      *repository.ConversationRepository
+	messageRepo   *repository.MessageRepository
+	hub           VisitorPresenceHub
+}
+
+// NewOfflineEmailService 创建离线邮件服务
+func NewOfflineEmailService(
+	configSvc *EmailNotificationConfigService,
+	jobRepo *repository.OfflineEmailJobRepository,
+	convRepo *repository.ConversationRepository,
+	messageRepo *repository.MessageRepository,
+	hub VisitorPresenceHub,
+) *OfflineEmailService {
+	return &OfflineEmailService{
+		configSvc:   configSvc,
+		jobRepo:     jobRepo,
+		convRepo:    convRepo,
+		messageRepo: messageRepo,
+		hub:         hub,
+	}
+}
+
+// OnAgentMessage 客服在人工访客会话发消息后,若访客离线则调度邮件
+func (s *OfflineEmailService) OnAgentMessage(conversationID, messageID uint) {
+	if s == nil {
+		return
+	}
+	go func() {
+		if err := s.scheduleAgentMessage(conversationID, messageID); err != nil {
+			log.Printf("[离线邮件] 调度失败 conv=%d msg=%d: %v", conversationID, messageID, err)
+		}
+	}()
+}
+
+// CancelPending 访客上线时取消待发送任务
+func (s *OfflineEmailService) CancelPending(conversationID uint) {
+	if s == nil {
+		return
+	}
+	job, err := s.jobRepo.GetPendingByConversationID(conversationID)
+	if err != nil || job == nil {
+		return
+	}
+	job.Status = "cancelled"
+	_ = s.jobRepo.Save(job)
+}
+
+func (s *OfflineEmailService) scheduleAgentMessage(conversationID, messageID uint) error {
+	cfg, err := s.configSvc.ResolveEffective()
+	if err != nil || !cfg.Enabled {
+		return err
+	}
+	if cfg.SMTPHost == "" || cfg.FromEmail == "" {
+		return fmt.Errorf("SMTP 未配置")
+	}
+
+	conv, err := s.convRepo.GetByID(conversationID)
+	if err != nil {
+		return err
+	}
+	if conv.ConversationType != "visitor" || conv.ChatMode != "human" {
+		return nil
+	}
+	if strings.TrimSpace(conv.Email) == "" {
+		return nil
+	}
+	if s.hub != nil && s.hub.VisitorConnectionCount(conversationID) > 0 {
+		return nil
+	}
+
+	delay := cfg.OfflineDelaySeconds
+	if delay < 0 {
+		delay = 0
+	}
+	scheduledAt := time.Now().Add(time.Duration(delay) * time.Second)
+
+	job, err := s.jobRepo.GetPendingByConversationID(conversationID)
+	if err != nil {
+		return err
+	}
+	if job != nil {
+		ids := parseMessageIDs(job.MessageIDs)
+		if !containsUint(ids, messageID) {
+			ids = append(ids, messageID)
+		}
+		job.MessageIDs = joinMessageIDs(ids)
+		job.ScheduledAt = scheduledAt
+		return s.jobRepo.Save(job)
+	}
+
+	return s.jobRepo.Create(&models.OfflineEmailJob{
+		ConversationID: conversationID,
+		MessageIDs:     strconv.FormatUint(uint64(messageID), 10),
+		ScheduledAt:    scheduledAt,
+		Status:         "pending",
+	})
+}
+
+// StartWorker 后台轮询到期任务
+func (s *OfflineEmailService) StartWorker(ctx context.Context) {
+	if s == nil {
+		return
+	}
+	ticker := time.NewTicker(15 * time.Second)
+	defer ticker.Stop()
+	for {
+		select {
+		case <-ctx.Done():
+			return
+		case <-ticker.C:
+			s.processDueJobs()
+		}
+	}
+}
+
+func (s *OfflineEmailService) processDueJobs() {
+	jobs, err := s.jobRepo.ListDuePending(time.Now(), 20)
+	if err != nil {
+		log.Printf("[离线邮件] 查询待发送任务失败: %v", err)
+		return
+	}
+	for i := range jobs {
+		if err := s.processJob(&jobs[i]); err != nil {
+			log.Printf("[离线邮件] 处理任务 #%d 失败: %v", jobs[i].ID, err)
+		}
+	}
+}
+
+func (s *OfflineEmailService) processJob(job *models.OfflineEmailJob) error {
+	cfg, err := s.configSvc.ResolveEffective()
+	if err != nil {
+		return err
+	}
+	if !cfg.Enabled {
+		job.Status = "cancelled"
+		return s.jobRepo.Save(job)
+	}
+
+	if s.hub != nil && s.hub.VisitorConnectionCount(job.ConversationID) > 0 {
+		job.Status = "cancelled"
+		return s.jobRepo.Save(job)
+	}
+
+	conv, err := s.convRepo.GetByID(job.ConversationID)
+	if err != nil {
+		job.Status = "failed"
+		job.LastError = "会话不存在"
+		return s.jobRepo.Save(job)
+	}
+	to := strings.TrimSpace(conv.Email)
+	if to == "" {
+		job.Status = "cancelled"
+		job.LastError = "访客未留邮箱"
+		return s.jobRepo.Save(job)
+	}
+
+	ids := parseMessageIDs(job.MessageIDs)
+	var parts []string
+	for _, id := range ids {
+		msg, err := s.messageRepo.GetByID(id)
+		if err != nil || msg == nil {
+			continue
+		}
+		if !msg.SenderIsAgent || msg.MessageType == "system_message" {
+			continue
+		}
+		content := strings.TrimSpace(msg.Content)
+		if content == "" && msg.FileURL != nil {
+			content = "[附件消息]"
+		}
+		if content != "" {
+			parts = append(parts, content)
+		}
+	}
+	if len(parts) == 0 {
+		job.Status = "cancelled"
+		return s.jobRepo.Save(job)
+	}
+
+	subject := "您有一条新的客服回复"
+	body := "您好,\n\n客服在您离线时给您留了消息:\n\n"
+	body += strings.Join(parts, "\n\n---\n\n")
+	body += "\n\n"
+	if conv.Website != "" {
+		body += "您可返回原页面继续对话:\n" + conv.Website + "\n"
+	}
+	body += "\n—— AI-CS 智能客服"
+
+	mailCfg := infra.SMTPMailConfig{
+		Host:      cfg.SMTPHost,
+		Port:      cfg.SMTPPort,
+		User:      cfg.SMTPUser,
+		Password:  cfg.SMTPPassword,
+		FromEmail: cfg.FromEmail,
+		FromName:  cfg.FromName,
+	}
+	if err := infra.SendSMTPMail(mailCfg, to, subject, body); err != nil {
+		job.Status = "failed"
+		job.LastError = err.Error()
+		return s.jobRepo.Save(job)
+	}
+
+	job.Status = "sent"
+	job.LastError = ""
+	log.Printf("[离线邮件] 已发送 conv=%d to=%s messages=%d", job.ConversationID, to, len(parts))
+	return s.jobRepo.Save(job)
+}
+
+// SendTestEmail 发送测试邮件(设置页验证 SMTP)
+func (s *OfflineEmailService) SendTestEmail(to string) error {
+	cfg, err := s.configSvc.ResolveEffective()
+	if err != nil {
+		return err
+	}
+	if cfg.SMTPHost == "" || cfg.FromEmail == "" {
+		return fmt.Errorf("请先配置 SMTP 服务器与发件邮箱")
+	}
+	mailCfg := infra.SMTPMailConfig{
+		Host:      cfg.SMTPHost,
+		Port:      cfg.SMTPPort,
+		User:      cfg.SMTPUser,
+		Password:  cfg.SMTPPassword,
+		FromEmail: cfg.FromEmail,
+		FromName:  cfg.FromName,
+	}
+	body := "这是一封来自 AI-CS 的测试邮件。若您收到此邮件,说明 SMTP 配置正确。"
+	return infra.SendSMTPMail(mailCfg, to, "AI-CS SMTP 测试", body)
+}
+
+func parseMessageIDs(raw string) []uint {
+	parts := strings.Split(raw, ",")
+	var ids []uint
+	for _, p := range parts {
+		p = strings.TrimSpace(p)
+		if p == "" {
+			continue
+		}
+		if n, err := strconv.ParseUint(p, 10, 32); err == nil {
+			ids = append(ids, uint(n))
+		}
+	}
+	return ids
+}
+
+func joinMessageIDs(ids []uint) string {
+	strs := make([]string, len(ids))
+	for i, id := range ids {
+		strs[i] = strconv.FormatUint(uint64(id), 10)
+	}
+	return strings.Join(strs, ",")
+}
+
+func containsUint(ids []uint, target uint) bool {
+	for _, id := range ids {
+		if id == target {
+			return true
+		}
+	}
+	return false
+}

+ 18 - 10
backend/service/rag/embedding.go

@@ -22,13 +22,17 @@ func NewDocumentEmbeddingService(vectorStoreService *VectorStoreService, embeddi
 	}
 }
 
+// GetEmbeddingService 获取当前的嵌入服务实例
+func (s *DocumentEmbeddingService) GetEmbeddingService(ctx context.Context) (embedding.EmbeddingService, error) {
+	return s.embeddingProvider.Get(ctx)
+}
+
 // EmbedDocument 向量化单个文档并存储
-func (s *DocumentEmbeddingService) EmbedDocument(ctx context.Context, documentID uint, knowledgeBaseID uint, content string) error {
+func (s *DocumentEmbeddingService) EmbedDocument(ctx context.Context, documentID uint, knowledgeBaseID uint, content string, chunkDBID ...string) error {
 	svc, err := s.embeddingProvider.Get(ctx)
 	if err != nil {
 		return fmt.Errorf("获取嵌入服务失败: %w", err)
 	}
-	// 向量化
 	vectors, err := svc.EmbedTexts(ctx, []string{content})
 	if err != nil {
 		return fmt.Errorf("文档向量化失败: %w", err)
@@ -37,10 +41,13 @@ func (s *DocumentEmbeddingService) EmbedDocument(ctx context.Context, documentID
 		return fmt.Errorf("未返回向量")
 	}
 
-	// 存储向量
 	docIDStr := ConvertDocumentID(documentID)
 	kbIDStr := ConvertKnowledgeBaseID(knowledgeBaseID)
-	if err := s.vectorStoreService.UpsertVector(ctx, docIDStr, kbIDStr, content, vectors[0]); err != nil {
+	cid := ""
+	if len(chunkDBID) > 0 {
+		cid = chunkDBID[0]
+	}
+	if err := s.vectorStoreService.UpsertVector(ctx, docIDStr, kbIDStr, content, cid, vectors[0]); err != nil {
 		return fmt.Errorf("存储向量失败: %w", err)
 	}
 
@@ -48,7 +55,7 @@ func (s *DocumentEmbeddingService) EmbedDocument(ctx context.Context, documentID
 }
 
 // EmbedDocuments 批量向量化文档并存储
-func (s *DocumentEmbeddingService) EmbedDocuments(ctx context.Context, documentIDs []uint, knowledgeBaseIDs []uint, contents []string) error {
+func (s *DocumentEmbeddingService) EmbedDocuments(ctx context.Context, documentIDs []uint, knowledgeBaseIDs []uint, contents []string, chunkDBIDs ...[]string) error {
 	if len(documentIDs) != len(knowledgeBaseIDs) || len(documentIDs) != len(contents) {
 		return fmt.Errorf("参数长度不匹配")
 	}
@@ -56,9 +63,7 @@ func (s *DocumentEmbeddingService) EmbedDocuments(ctx context.Context, documentI
 	if err != nil {
 		return fmt.Errorf("获取嵌入服务失败: %w", err)
 	}
-	// 诊断日志:批量向量化前,我们传给 EmbedTexts 的文档/内容条数
 	log.Printf("[嵌入] EmbedDocuments 调用前: len(documentIDs)=%d, len(contents)=%d(若 contents 已是多条,说明上游在发请求前做了分块)", len(documentIDs), len(contents))
-	// 批量向量化
 	vectors, err := svc.EmbedTexts(ctx, contents)
 	if err != nil {
 		return fmt.Errorf("批量向量化失败: %w", err)
@@ -69,7 +74,6 @@ func (s *DocumentEmbeddingService) EmbedDocuments(ctx context.Context, documentI
 		return fmt.Errorf("向量数量不匹配")
 	}
 
-	// 转换 ID
 	docIDStrs := make([]string, len(documentIDs))
 	kbIDStrs := make([]string, len(knowledgeBaseIDs))
 	for i, id := range documentIDs {
@@ -79,8 +83,12 @@ func (s *DocumentEmbeddingService) EmbedDocuments(ctx context.Context, documentI
 		kbIDStrs[i] = ConvertKnowledgeBaseID(id)
 	}
 
-	// 批量存储向量
-	if err := s.vectorStoreService.UpsertVectors(ctx, docIDStrs, kbIDStrs, contents, vectors); err != nil {
+	cIDs := make([]string, len(contents))
+	if len(chunkDBIDs) > 0 && len(chunkDBIDs[0]) == len(contents) {
+		cIDs = chunkDBIDs[0]
+	}
+
+	if err := s.vectorStoreService.UpsertVectors(ctx, docIDStrs, kbIDStrs, contents, vectors, cIDs); err != nil {
 		return fmt.Errorf("批量存储向量失败: %w", err)
 	}
 

+ 29 - 2
backend/service/rag/retrieval.go

@@ -19,6 +19,7 @@ type RetrievalService struct {
 	cache              *Cache
 	reranker           *SimpleReranker
 	metrics            *Metrics
+	minScore           float32 // 相似度阈值,默认 0.22(分段检索分数通常低于整篇文档)
 }
 
 // NewRetrievalService 创建 RAG 检索服务实例(仅已发布文档且所属知识库已开启 RAG 的参与检索)
@@ -31,6 +32,14 @@ func NewRetrievalService(vectorStoreService *VectorStoreService, embeddingProvid
 		cache:              NewCache(),
 		reranker:           NewSimpleReranker(),
 		metrics:            NewMetrics(),
+		minScore:           0.22,
+	}
+}
+
+// SetMinScore 设置 RAG 相似度阈值(IP/余弦,分段场景建议 0.2~0.35)
+func (s *RetrievalService) SetMinScore(score float32) {
+	if score >= 0 && score <= 1 {
+		s.minScore = score
 	}
 }
 
@@ -93,8 +102,11 @@ func (s *RetrievalService) Retrieve(ctx context.Context, query string, topK int,
 		// 仅保留「已发布」的文档参与 RAG;未在 documents 表中的条目(如 FAQ)视为可展示
 		results = s.filterByPublished(ctx, results, topK)
 
-		// 缓存过滤后的结果
-		if s.cache != nil {
+		// 相似度阈值过滤:Milvus 使用 IP(归一化嵌入时等同余弦相似度)
+		results = s.filterByScore(results, s.minScore)
+
+		// 缓存过滤后的结果(空结果不缓存,避免误伤后续查询)
+		if s.cache != nil && len(results) > 0 {
 			s.cache.Set(query, topK, knowledgeBaseID, results)
 		}
 	}
@@ -202,6 +214,21 @@ func (s *RetrievalService) filterByPublished(ctx context.Context, results []Sear
 	return filtered
 }
 
+// filterByScore 按相似度阈值过滤结果。
+// Milvus 使用 IP 度量;归一化嵌入时分数等同余弦相似度。分段后 chunk 分数普遍低于整篇文档,阈值不宜过高。
+func (s *RetrievalService) filterByScore(results []SearchResult, minScore float32) []SearchResult {
+	if len(results) == 0 {
+		return results
+	}
+	filtered := make([]SearchResult, 0, len(results))
+	for _, r := range results {
+		if r.Score >= minScore {
+			filtered = append(filtered, r)
+		}
+	}
+	return filtered
+}
+
 // GetMetrics 获取性能指标
 func (s *RetrievalService) GetMetrics() map[string]interface{} {
 	return s.metrics.GetStats()

+ 12 - 5
backend/service/rag/vector_store.go

@@ -30,19 +30,19 @@ func (s *VectorStoreService) IsAvailable() bool {
 }
 
 // UpsertVector 插入或更新单个向量
-func (s *VectorStoreService) UpsertVector(ctx context.Context, documentID string, knowledgeBaseID string, content string, vector []float32) error {
+func (s *VectorStoreService) UpsertVector(ctx context.Context, documentID string, knowledgeBaseID string, content string, chunkDBID string, vector []float32) error {
 	if s.vectorStore == nil {
 		return ErrVectorStoreUnavailable
 	}
-	return s.vectorStore.UpsertVector(ctx, documentID, knowledgeBaseID, content, vector)
+	return s.vectorStore.UpsertVector(ctx, documentID, knowledgeBaseID, content, chunkDBID, vector)
 }
 
 // UpsertVectors 批量插入或更新向量
-func (s *VectorStoreService) UpsertVectors(ctx context.Context, documentIDs []string, knowledgeBaseIDs []string, contents []string, vectors [][]float32) error {
+func (s *VectorStoreService) UpsertVectors(ctx context.Context, documentIDs []string, knowledgeBaseIDs []string, contents []string, vectors [][]float32, chunkDBIDs []string) error {
 	if s.vectorStore == nil {
 		return ErrVectorStoreUnavailable
 	}
-	return s.vectorStore.UpsertVectors(ctx, documentIDs, knowledgeBaseIDs, contents, vectors)
+	return s.vectorStore.UpsertVectors(ctx, documentIDs, knowledgeBaseIDs, contents, vectors, chunkDBIDs)
 }
 
 // SearchVectors 搜索相似向量
@@ -55,7 +55,6 @@ func (s *VectorStoreService) SearchVectors(ctx context.Context, queryVector []fl
 		return nil, fmt.Errorf("向量检索失败: %w", err)
 	}
 
-	// 转换结果
 	searchResults := make([]SearchResult, len(results))
 	for i, r := range results {
 		searchResults[i] = SearchResult{
@@ -85,6 +84,14 @@ func (s *VectorStoreService) DeleteVectors(ctx context.Context, documentIDs []st
 	return s.vectorStore.DeleteVectors(ctx, documentIDs)
 }
 
+// DeleteVectorByChunkID 按 chunk_db_id 删除单条向量
+func (s *VectorStoreService) DeleteVectorByChunkID(ctx context.Context, chunkDBID string) error {
+	if s.vectorStore == nil {
+		return nil
+	}
+	return s.vectorStore.DeleteVectorByChunkID(ctx, chunkDBID)
+}
+
 // ConvertDocumentID 将 uint 转换为 string
 func ConvertDocumentID(id uint) string {
 	return strconv.FormatUint(uint64(id), 10)

+ 11 - 1
backend/service/types.go

@@ -25,6 +25,7 @@ type InitConversationInput struct {
 type InitConversationResult struct {
 	ConversationID uint
 	Status         string
+	AccessToken    string
 }
 
 // UpdateConversationContactInput 更新访客联系信息时需要的参数。
@@ -35,7 +36,16 @@ type UpdateConversationContactInput struct {
 	Notes          *string
 }
 
-// ConversationSummary 用于会话列表展示的概要信息。
+// ConversationListResult 分页会话列表。
+type ConversationListResult struct {
+	Items       []ConversationSummary
+	Total       int64
+	Page        int
+	PageSize    int
+	HasMore     bool
+	TotalUnread int64
+}
+
 type ConversationSummary struct {
 	ID               uint
 	ConversationType string // visitor | internal

+ 16 - 0
backend/utils/conversation_token.go

@@ -0,0 +1,16 @@
+package utils
+
+import (
+	"crypto/rand"
+	"encoding/hex"
+	"fmt"
+)
+
+// GenerateConversationAccessToken 生成访客会话访问令牌(64 字符 hex,256 bit 熵)。
+func GenerateConversationAccessToken() (string, error) {
+	b := make([]byte, 32)
+	if _, err := rand.Read(b); err != nil {
+		return "", fmt.Errorf("generate conversation access token: %w", err)
+	}
+	return hex.EncodeToString(b), nil
+}

+ 18 - 1
backend/websocket/handler.go

@@ -1,14 +1,18 @@
 package websocket
 
 import (
+	"errors"
 	"log"
 	"net/http"
 	"strconv"
+	"strings"
 
 	"github.com/2930134478/AI-CS/backend/repository"
+	"github.com/2930134478/AI-CS/backend/service"
 	"github.com/2930134478/AI-CS/backend/utils"
 	"github.com/gin-gonic/gin"
 	"github.com/gorilla/websocket"
+	"gorm.io/gorm"
 )
 
 var upgrader = websocket.Upgrader{
@@ -21,7 +25,7 @@ var upgrader = websocket.Upgrader{
 }
 
 // HandleWebSocket 处理 WebSocket 连接
-func HandleWebSocket(hub *Hub, userRepo *repository.UserRepository) gin.HandlerFunc {
+func HandleWebSocket(hub *Hub, userRepo *repository.UserRepository, conversationService *service.ConversationService) gin.HandlerFunc {
 	return func(c *gin.Context) {
 		// 从查询参数获取对话ID
 		conversationIDStr := c.Query("conversation_id")
@@ -70,6 +74,19 @@ func HandleWebSocket(hub *Hub, userRepo *repository.UserRepository) gin.HandlerF
 					return
 				}
 			}
+		} else if conversationService != nil {
+			accessToken := strings.TrimSpace(c.Query("access_token"))
+			if accessToken == "" {
+				accessToken = strings.TrimSpace(c.GetHeader("X-Conversation-Token"))
+			}
+			if err := conversationService.ValidateVisitorAccessToken(uint(conversationID), accessToken); err != nil {
+				if errors.Is(err, gorm.ErrRecordNotFound) {
+					c.JSON(http.StatusNotFound, gin.H{"error": "会话不存在"})
+				} else {
+					c.JSON(http.StatusUnauthorized, gin.H{"error": "access_token 无效或缺失"})
+				}
+				return
+			}
 		}
 
 		// 升级 HTTP 连接为 WebSocket 连接

+ 17 - 0
backend/websocket/hub.go

@@ -203,6 +203,23 @@ func (h *Hub) BroadcastToAllAgents(messageType string, data interface{}) {
 	}
 }
 
+// VisitorConnectionCount 返回指定对话当前访客 WebSocket 连接数
+func (h *Hub) VisitorConnectionCount(conversationID uint) int {
+	h.mu.RLock()
+	defer h.mu.RUnlock()
+	clients, ok := h.conversations[conversationID]
+	if !ok {
+		return 0
+	}
+	count := 0
+	for client := range clients {
+		if client.isVisitor {
+			count++
+		}
+	}
+	return count
+}
+
 // GetOnlineAgentIDs 获取所有在线客服的用户ID列表(去重)
 // 返回一个 map,key 是 agentID,value 是 true(用于快速查找)
 func (h *Hub) GetOnlineAgentIDs() map[uint]bool {

+ 510 - 0
frontend/app/agent/knowledge/[docId]/page.tsx

@@ -0,0 +1,510 @@
+"use client";
+
+import { useEffect, useState, useCallback } from "react";
+import { useParams, useRouter } from "next/navigation";
+import { useI18n } from "@/lib/i18n/provider";
+import { ResponsiveLayout } from "@/components/layout";
+import { Button } from "@/components/ui/button";
+import { Card } from "@/components/ui/card";
+import { Input } from "@/components/ui/input";
+import { Textarea } from "@/components/ui/textarea";
+import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription } from "@/components/ui/dialog";
+import { Badge } from "@/components/ui/badge";
+import { toast } from "@/hooks/useToast";
+import {
+  fetchDocument,
+  type Document,
+} from "@/features/agent/services/documentApi";
+import {
+  fetchChunks,
+  executeChunking,
+  updateChunk,
+  deleteChunks,
+  type DocumentChunk,
+} from "@/features/agent/services/chunkApi";
+import {
+  ChevronLeft,
+  ChevronRight,
+  Scissors,
+  Pencil,
+  Trash2,
+  Loader2,
+  FileText,
+  CheckCircle2,
+  Clock,
+  XCircle,
+} from "lucide-react";
+
+type ChunkMethod = "char_count" | "separator";
+
+interface DocumentDetailPageProps {
+  embedded?: boolean;
+  docId?: number;
+  onBack?: () => void;
+}
+
+export default function DocumentDetailPage(props: DocumentDetailPageProps = {}) {
+  const { embedded = false, docId: propDocId, onBack } = props;
+  const params = useParams<{ docId?: string }>();
+  const router = useRouter();
+  useI18n();
+  const docIdNum = propDocId ?? Number(params.docId ?? 0);
+
+  const [doc, setDoc] = useState<Document | null>(null);
+  const [loadingDoc, setLoadingDoc] = useState(true);
+  const [docError, setDocError] = useState("");
+
+  const [chunks, setChunks] = useState<DocumentChunk[]>([]);
+  const [loadingChunks, setLoadingChunks] = useState(false);
+  const [chunkPage, setChunkPage] = useState(1);
+  const [chunkTotalPage, setChunkTotalPage] = useState(1);
+  const [chunkTotal, setChunkTotal] = useState(0);
+  const pageSize = 10;
+
+  const [method, setMethod] = useState<ChunkMethod>("char_count");
+  const [chunkSize, setChunkSize] = useState(500);
+  const [separator, setSeparator] = useState("");
+  const [executing, setExecuting] = useState(false);
+
+  const [editChunk, setEditChunk] = useState<DocumentChunk | null>(null);
+  const [editContent, setEditContent] = useState("");
+  const [saving, setSaving] = useState(false);
+
+  const [showConfirm, setShowConfirm] = useState(false);
+
+  const loadDoc = useCallback(async () => {
+    if (!docIdNum || isNaN(docIdNum)) {
+      setDocError("文档 ID 无效");
+      setLoadingDoc(false);
+      return;
+    }
+    try {
+      setLoadingDoc(true);
+      const d = await fetchDocument(docIdNum);
+      setDoc(d);
+      setDocError("");
+    } catch (e: unknown) {
+      const msg = e instanceof Error ? e.message : "加载文档失败";
+      setDocError(msg);
+    } finally {
+      setLoadingDoc(false);
+    }
+  }, [docIdNum]);
+
+  const loadChunks = useCallback(async (page: number = 1) => {
+    if (!docIdNum || isNaN(docIdNum)) return;
+    try {
+      setLoadingChunks(true);
+      const res = await fetchChunks(docIdNum, page, pageSize);
+      setChunks(res.chunks || []);
+      setChunkTotalPage(res.total_page || 1);
+      setChunkTotal(res.total || 0);
+    } catch {
+      setChunks([]);
+    } finally {
+      setLoadingChunks(false);
+    }
+  }, [docIdNum, pageSize]);
+
+  useEffect(() => {
+    loadDoc();
+    loadChunks(chunkPage);
+  }, [loadDoc, loadChunks, chunkPage]);
+
+  useEffect(() => {
+    const hasPending = chunks.some(
+      (c) => c.embedding_status === "pending" || c.embedding_status === "processing"
+    );
+    if (!hasPending) return;
+    const timer = setInterval(() => loadChunks(chunkPage), 2500);
+    return () => clearInterval(timer);
+  }, [chunks, loadChunks, chunkPage]);
+
+  const handleExecute = async () => {
+    if (method === "separator" && !separator) {
+      toast.error("请输入分隔符");
+      return;
+    }
+    try {
+      setExecuting(true);
+      const res = await executeChunking(docIdNum, {
+        method,
+        chunk_size: method === "char_count" ? chunkSize : undefined,
+        separator: method === "separator" ? separator : undefined,
+      });
+      setChunks(res.chunks || []);
+      setChunkPage(1);
+      toast.success(`分段完成,共 ${res.chunk_count} 段`);
+    } catch (e: unknown) {
+      const msg = e instanceof Error ? e.message : "分段失败";
+      toast.error(msg);
+    } finally {
+      setExecuting(false);
+    }
+  };
+
+  const handleDelete = async () => {
+    try {
+      await deleteChunks(docIdNum);
+      setChunks([]);
+      setChunkPage(1);
+      setChunkTotalPage(1);
+      setChunkTotal(0);
+      setShowConfirm(false);
+      toast.success("分段已删除");
+    } catch (e: unknown) {
+      const msg = e instanceof Error ? e.message : "删除失败";
+      toast.error(msg);
+    }
+  };
+
+  const openEdit = (chunk: DocumentChunk) => {
+    setEditChunk(chunk);
+    setEditContent(chunk.content);
+  };
+
+  const handleSaveEdit = async () => {
+    if (!editChunk || !editContent.trim()) return;
+    try {
+      setSaving(true);
+      await updateChunk(docIdNum, editChunk.id, editContent);
+      toast.success("分段已更新,正在重新向量化");
+      setEditChunk(null);
+      loadChunks();
+    } catch (e: unknown) {
+      const msg = e instanceof Error ? e.message : "保存失败";
+      toast.error(msg);
+    } finally {
+      setSaving(false);
+    }
+  };
+
+  const statusBadge = (status: string) => {
+    switch (status) {
+      case "completed":
+        return (
+          <Badge className="bg-green-100 text-green-700 border-green-200">
+            <CheckCircle2 className="w-3 h-3 mr-1" />
+            已向量化
+          </Badge>
+        );
+      case "processing":
+        return (
+          <Badge className="bg-blue-100 text-blue-700 border-blue-200">
+            <Loader2 className="w-3 h-3 mr-1 animate-spin" />
+            向量化中
+          </Badge>
+        );
+      case "pending":
+        return (
+          <Badge className="bg-yellow-100 text-yellow-700 border-yellow-200">
+            <Clock className="w-3 h-3 mr-1" />
+            待向量化
+          </Badge>
+        );
+      case "failed":
+        return (
+          <Badge className="bg-red-100 text-red-700 border-red-200">
+            <XCircle className="w-3 h-3 mr-1" />
+            向量化失败
+          </Badge>
+        );
+      default:
+        return <Badge>{status}</Badge>;
+    }
+  };
+
+  const truncate = (text: string, maxLen: number) => {
+    if (text.length <= maxLen) return text;
+    return text.slice(0, maxLen) + "...";
+  };
+
+  const headerContent = (
+    <div className="flex items-center gap-3">
+      <Button
+        variant="ghost"
+        size="icon"
+        onClick={() => (onBack ? onBack() : router.back())}
+        className="shrink-0"
+      >
+        <ChevronLeft className="w-5 h-5" />
+      </Button>
+      <div>
+        <h1 className="text-lg font-semibold">
+          {loadingDoc ? "加载中..." : doc?.title || "文档详情"}
+        </h1>
+        {doc && (
+          <div className="flex items-center gap-2 mt-1 text-sm text-muted-foreground">
+            <Badge variant="secondary">{doc.type}</Badge>
+            <Badge
+              className={
+                doc.status === "published"
+                  ? "bg-green-100 text-green-700"
+                  : "bg-gray-100 text-gray-700"
+              }
+            >
+              {doc.status === "published" ? "已发布" : "草稿"}
+            </Badge>
+            <span>·</span>
+            <span>
+              {chunkTotal > 0 ? `${chunkTotal} 个分段` : "未分段"}
+            </span>
+          </div>
+        )}
+      </div>
+    </div>
+  );
+
+  const chunkControls = (
+    <Card className="p-4">
+      <h2 className="font-medium mb-3 flex items-center gap-2">
+        <Scissors className="w-4 h-4" />
+        分段操作
+      </h2>
+
+      <div className="flex gap-2 mb-3">
+        <Button
+          variant={method === "char_count" ? "default" : "outline"}
+          size="sm"
+          onClick={() => setMethod("char_count")}
+        >
+          按字数
+        </Button>
+        <Button
+          variant={method === "separator" ? "default" : "outline"}
+          size="sm"
+          onClick={() => setMethod("separator")}
+        >
+          按分隔符
+        </Button>
+      </div>
+
+      <div className="flex items-end gap-3">
+        {method === "char_count" ? (
+          <div className="flex-1 max-w-[200px]">
+            <label className="text-sm text-muted-foreground mb-1 block">
+              每段字数
+            </label>
+            <Input
+              type="number"
+              min={100}
+              max={10000}
+              value={chunkSize}
+              onChange={(e) => setChunkSize(Number(e.target.value) || 500)}
+            />
+          </div>
+        ) : (
+          <div className="flex-1 max-w-[300px]">
+            <label className="text-sm text-muted-foreground mb-1 block">
+              分隔字符
+            </label>
+            <Input
+              placeholder='如:## 或 \n\n'
+              value={separator}
+              onChange={(e) => setSeparator(e.target.value)}
+            />
+          </div>
+        )}
+        <Button onClick={handleExecute} disabled={executing}>
+          {executing && <Loader2 className="w-4 h-4 mr-1 animate-spin" />}
+          执行分段
+        </Button>
+        {chunks.length > 0 && (
+          <Button
+            variant="outline"
+            className="text-red-600"
+            onClick={() => setShowConfirm(true)}
+          >
+            <Trash2 className="w-4 h-4 mr-1" />
+            清除分段
+          </Button>
+        )}
+      </div>
+      {chunks.length > 0 && (
+        <p className="text-xs text-muted-foreground mt-2">
+          执行新分段将自动替换旧分段并重新向量化
+        </p>
+      )}
+    </Card>
+  );
+
+  const chunkList = (
+    <div className="space-y-3">
+      <h2 className="font-medium flex items-center gap-2">
+        <FileText className="w-4 h-4" />
+        分段列表
+        {loadingChunks && <Loader2 className="w-3 h-3 animate-spin" />}
+      </h2>
+
+      {chunks.length === 0 && !loadingChunks ? (
+        <Card className="p-8 text-center text-muted-foreground">
+          <Scissors className="w-8 h-8 mx-auto mb-2 opacity-50" />
+          <p>尚未分段</p>
+          <p className="text-sm mt-1">
+            选择分段方式并执行,系统将自动为每段生成向量索引
+          </p>
+        </Card>
+      ) : (
+        <>
+          {chunks.map((chunk) => (
+            <Card key={chunk.id} className="p-4">
+              <div className="flex items-start justify-between gap-3">
+                <div className="flex-1 min-w-0">
+                  <div className="flex items-center gap-2 mb-2">
+                    <span className="text-sm font-medium text-muted-foreground">
+                      第 {chunk.chunk_index + 1} 段
+                    </span>
+                    {statusBadge(chunk.embedding_status)}
+                    <span className="text-xs text-muted-foreground">
+                      {chunk.content.length} 字
+                    </span>
+                  </div>
+                  <p className="text-sm whitespace-pre-wrap line-clamp-3">
+                    {truncate(chunk.content, 200)}
+                  </p>
+                </div>
+                <Button
+                  variant="ghost"
+                  size="icon"
+                  onClick={() => openEdit(chunk)}
+                  className="shrink-0"
+                >
+                  <Pencil className="w-4 h-4" />
+                </Button>
+              </div>
+            </Card>
+          ))}
+          {chunkTotalPage > 1 && (
+            <div className="flex items-center justify-center gap-2 pt-2">
+              <Button
+                variant="outline"
+                size="sm"
+                onClick={() => setChunkPage((p) => Math.max(1, p - 1))}
+                disabled={chunkPage <= 1}
+              >
+                <ChevronLeft className="w-4 h-4" />
+              </Button>
+              <span className="text-sm text-muted-foreground">
+                第 {chunkPage}/{chunkTotalPage} 页,共 {chunkTotal} 条
+              </span>
+              <Button
+                variant="outline"
+                size="sm"
+                onClick={() => setChunkPage((p) => Math.min(chunkTotalPage, p + 1))}
+                disabled={chunkPage >= chunkTotalPage}
+              >
+                <ChevronRight className="w-4 h-4" />
+              </Button>
+            </div>
+          )}
+        </>
+      )}
+    </div>
+  );
+
+  const mainContent = (
+    <div className="max-w-3xl mx-auto space-y-6 p-4">
+      {docError ? (
+        <Card className="p-8 text-center text-red-500">
+          <p>{docError}</p>
+          <Button variant="outline" className="mt-4" onClick={() => (onBack ? onBack() : router.back())}>
+            返回
+          </Button>
+        </Card>
+      ) : loadingDoc ? (
+        <div className="flex justify-center py-16">
+          <Loader2 className="w-6 h-6 animate-spin" />
+        </div>
+      ) : doc ? (
+        <>
+          {chunkControls}
+          {chunkList}
+        </>
+      ) : null}
+    </div>
+  );
+
+  const editDialog = (
+    <Dialog
+      open={!!editChunk}
+      onOpenChange={(open) => {
+        if (!open) setEditChunk(null);
+      }}
+    >
+      <DialogContent className="max-w-2xl">
+        <DialogHeader>
+          <DialogTitle>
+            编辑第 {(editChunk?.chunk_index ?? 0) + 1} 段
+          </DialogTitle>
+          <DialogDescription>
+            修改后会自动重新向量化该分段
+          </DialogDescription>
+        </DialogHeader>
+        <Textarea
+          value={editContent}
+          onChange={(e) => setEditContent(e.target.value)}
+          rows={12}
+          className="min-h-[200px]"
+        />
+        <div className="flex justify-end gap-2">
+          <Button variant="outline" onClick={() => setEditChunk(null)}>
+            取消
+          </Button>
+          <Button onClick={handleSaveEdit} disabled={saving}>
+            {saving && <Loader2 className="w-4 h-4 mr-1 animate-spin" />}
+            保存
+          </Button>
+        </div>
+      </DialogContent>
+    </Dialog>
+  );
+
+  const confirmDialog = (
+    <Dialog open={showConfirm} onOpenChange={setShowConfirm}>
+      <DialogContent>
+        <DialogHeader>
+          <DialogTitle>确认清除分段</DialogTitle>
+          <DialogDescription>
+            将删除该文档的所有分段及对应向量索引。此操作不可撤销。
+          </DialogDescription>
+        </DialogHeader>
+        <div className="flex justify-end gap-2">
+          <Button variant="outline" onClick={() => setShowConfirm(false)}>
+            取消
+          </Button>
+          <Button variant="destructive" onClick={handleDelete}>
+            确认清除
+          </Button>
+        </div>
+      </DialogContent>
+    </Dialog>
+  );
+
+  if (embedded) {
+    return (
+      <>
+        <div className="flex-1 flex flex-col min-h-0 overflow-hidden">
+          <div className="px-4 pt-2 pb-1 border-b bg-background">
+            {headerContent}
+          </div>
+          <div className="flex-1 overflow-y-auto">
+            {mainContent}
+          </div>
+        </div>
+        {editDialog}
+        {confirmDialog}
+      </>
+    );
+  }
+
+  return (
+    <>
+      <ResponsiveLayout
+        main={mainContent}
+        header={headerContent}
+      />
+      {editDialog}
+      {confirmDialog}
+    </>
+  );
+}

+ 42 - 0
frontend/app/agent/knowledge/page.tsx

@@ -60,9 +60,11 @@ import {
   Loader2,
   ChevronLeft,
   ChevronRight,
+  Scissors,
 } from "lucide-react";
 import { Textarea } from "@/components/ui/textarea";
 import { toast } from "@/hooks/useToast";
+import DocumentDetailPage from "./[docId]/page";
 
 export default function KnowledgePage(props: any = {}) {
   const { embedded = false } = props;
@@ -70,6 +72,8 @@ export default function KnowledgePage(props: any = {}) {
   const { agent } = useAuth();
   const { t, lang } = useI18n();
 
+  const [chunkingDocId, setChunkingDocId] = useState<number | null>(null);
+
   const tr = (key: I18nKey, vars?: Record<string, string>) => {
     let s = t(key);
     if (!vars) return s;
@@ -825,6 +829,15 @@ export default function KnowledgePage(props: any = {}) {
                               <Edit className="mr-1 h-4 w-4 shrink-0" />
                               {t("agent.common.edit")}
                             </Button>
+                            <Button
+                              variant="outline"
+                              size="sm"
+                              className="shrink-0"
+                              onClick={() => setChunkingDocId(doc.id)}
+                            >
+                              <Scissors className="mr-1 h-4 w-4 shrink-0" />
+                              分段
+                            </Button>
                             <Button
                               variant="destructive"
                               size="sm"
@@ -1264,6 +1277,18 @@ export default function KnowledgePage(props: any = {}) {
   );
 
   if (embedded) {
+    if (chunkingDocId) {
+      return (
+        <>
+          <DocumentDetailPage
+            embedded
+            docId={chunkingDocId}
+            onBack={() => setChunkingDocId(null)}
+          />
+          {dialogs}
+        </>
+      );
+    }
     return (
       <>
         <div className="flex-1 flex flex-col min-h-0 overflow-hidden">
@@ -1275,6 +1300,23 @@ export default function KnowledgePage(props: any = {}) {
     );
   }
 
+  if (chunkingDocId) {
+    return (
+      <>
+        <ResponsiveLayout
+          main={
+            <DocumentDetailPage
+              embedded
+              docId={chunkingDocId}
+              onBack={() => setChunkingDocId(null)}
+            />
+          }
+        />
+        {dialogs}
+      </>
+    );
+  }
+
   return (
     <>
       <ResponsiveLayout main={mainContent} header={headerContent} />

+ 456 - 0
frontend/app/agent/settings/page.tsx

@@ -21,6 +21,19 @@ import {
   type EmbeddingConfig,
   type UpdateEmbeddingConfigRequest,
 } from "@/features/agent/services/embeddingConfigApi";
+import {
+  deleteAutoCloseConversationDaysPolicy,
+  fetchAutoCloseConversationDaysPolicy,
+  putAutoCloseConversationDaysPolicy,
+  type AutoCloseConversationDaysPolicy,
+} from "@/features/agent/services/conversationApi";
+import {
+  fetchEmailNotificationConfig,
+  resetEmailNotificationConfig,
+  sendEmailNotificationTest,
+  updateEmailNotificationConfig,
+  type EmailNotificationConfig,
+} from "@/features/agent/services/emailNotificationApi";
 import { useProfile } from "@/features/agent/hooks/useProfile";
 import { apiUrl } from "@/lib/config";
 import { Checkbox } from "@/components/ui/checkbox";
@@ -76,6 +89,32 @@ export default function SettingsPage(props: any = {}) {
   const [embeddingSubmitting, setEmbeddingSubmitting] = useState(false);
   const [embeddingError, setEmbeddingError] = useState("");
 
+  // 会话维护:自动关闭长期未活跃 open 访客会话(平台级)
+  const [autoClosePolicy, setAutoClosePolicy] = useState<AutoCloseConversationDaysPolicy | null>(null);
+  const [autoCloseDaysDraft, setAutoCloseDaysDraft] = useState("7");
+  const [autoCloseLoading, setAutoCloseLoading] = useState(false);
+  const [autoCloseSubmitting, setAutoCloseSubmitting] = useState(false);
+  const [autoCloseError, setAutoCloseError] = useState("");
+
+  // 离线邮件通知(平台级,仅管理员可修改)
+  const [isAdmin, setIsAdmin] = useState(false);
+  const [emailConfig, setEmailConfig] = useState<EmailNotificationConfig | null>(null);
+  const [emailForm, setEmailForm] = useState({
+    enabled: false,
+    smtp_host: "",
+    smtp_port: "465",
+    smtp_user: "",
+    smtp_password: "",
+    from_email: "",
+    from_name: "",
+    offline_delay_seconds: "60",
+  });
+  const [emailTestTo, setEmailTestTo] = useState("");
+  const [emailLoading, setEmailLoading] = useState(false);
+  const [emailSubmitting, setEmailSubmitting] = useState(false);
+  const [emailTesting, setEmailTesting] = useState(false);
+  const [emailError, setEmailError] = useState("");
+
   // 检查登录状态
   useEffect(() => {
     const storedUserId = localStorage.getItem("agent_user_id");
@@ -84,6 +123,7 @@ export default function SettingsPage(props: any = {}) {
       return;
     }
     setUserId(Number.parseInt(storedUserId, 10));
+    setIsAdmin(localStorage.getItem("agent_role") === "admin");
   }, [router]);
 
   // 加载个人资料(用于获取和更新 AI 对话接收设置)
@@ -147,6 +187,157 @@ export default function SettingsPage(props: any = {}) {
     }
   }, [userId]);
 
+  const loadAutoClosePolicy = async () => {
+    if (!userId) return;
+    try {
+      setAutoCloseLoading(true);
+      setAutoCloseError("");
+      const policy = await fetchAutoCloseConversationDaysPolicy();
+      setAutoClosePolicy(policy);
+      setAutoCloseDaysDraft(String(policy.effective_days));
+    } catch (e) {
+      console.error("加载会话维护配置失败:", e);
+      setAutoCloseError(t("agent.settings.autoClose.errorLoad"));
+    } finally {
+      setAutoCloseLoading(false);
+    }
+  };
+
+  useEffect(() => {
+    if (userId) {
+      void loadAutoClosePolicy();
+    }
+  }, [userId]);
+
+  const loadEmailConfig = async () => {
+    if (!userId) return;
+    try {
+      setEmailLoading(true);
+      setEmailError("");
+      const data = await fetchEmailNotificationConfig(userId);
+      setEmailConfig(data);
+      setEmailForm({
+        enabled: data.enabled,
+        smtp_host: data.smtp_host || "",
+        smtp_port: String(data.smtp_port || 465),
+        smtp_user: data.smtp_user || "",
+        smtp_password: "",
+        from_email: data.from_email || "",
+        from_name: data.from_name || "",
+        offline_delay_seconds: String(data.offline_delay_seconds ?? 60),
+      });
+    } catch (e) {
+      console.error("加载离线邮件配置失败:", e);
+      setEmailError(t("agent.settings.offlineEmail.errorLoad"));
+    } finally {
+      setEmailLoading(false);
+    }
+  };
+
+  useEffect(() => {
+    if (userId) {
+      void loadEmailConfig();
+    }
+  }, [userId]);
+
+  const handleSaveEmailConfig = async (e: React.FormEvent) => {
+    e.preventDefault();
+    if (!userId || !isAdmin) return;
+    const delay = Number.parseInt(emailForm.offline_delay_seconds, 10);
+    const port = Number.parseInt(emailForm.smtp_port, 10);
+    if (Number.isNaN(delay) || delay < 0) {
+      setEmailError(t("agent.settings.offlineEmail.errorInvalidDelay"));
+      return;
+    }
+    setEmailSubmitting(true);
+    setEmailError("");
+    try {
+      await updateEmailNotificationConfig(userId, {
+        enabled: emailForm.enabled,
+        smtp_host: emailForm.smtp_host || undefined,
+        smtp_port: Number.isNaN(port) ? undefined : port,
+        smtp_user: emailForm.smtp_user || undefined,
+        from_email: emailForm.from_email || undefined,
+        from_name: emailForm.from_name || undefined,
+        offline_delay_seconds: delay,
+        ...(emailForm.smtp_password ? { smtp_password: emailForm.smtp_password } : {}),
+      });
+      await loadEmailConfig();
+      toast.success(t("agent.settings.offlineEmail.toastSaved"));
+    } catch (err) {
+      setEmailError((err as Error).message);
+    } finally {
+      setEmailSubmitting(false);
+    }
+  };
+
+  const handleResetEmailConfig = async () => {
+    if (!userId || !isAdmin) return;
+    setEmailSubmitting(true);
+    setEmailError("");
+    try {
+      await resetEmailNotificationConfig(userId);
+      await loadEmailConfig();
+      toast.success(t("agent.settings.offlineEmail.toastReset"));
+    } catch (err) {
+      setEmailError((err as Error).message);
+    } finally {
+      setEmailSubmitting(false);
+    }
+  };
+
+  const handleSendEmailTest = async () => {
+    if (!userId || !isAdmin) return;
+    const to = emailTestTo.trim();
+    if (!to) return;
+    setEmailTesting(true);
+    setEmailError("");
+    try {
+      await sendEmailNotificationTest(userId, to);
+      toast.success(t("agent.settings.offlineEmail.toastTestSent"));
+    } catch (err) {
+      setEmailError((err as Error).message);
+    } finally {
+      setEmailTesting(false);
+    }
+  };
+
+  const handleSaveAutoClosePolicy = async (e: React.FormEvent) => {
+    e.preventDefault();
+    if (!userId) return;
+    const parsed = Number.parseInt(autoCloseDaysDraft, 10);
+    if (Number.isNaN(parsed) || parsed < 0) {
+      setAutoCloseError(t("agent.settings.autoClose.errorInvalid"));
+      return;
+    }
+    setAutoCloseSubmitting(true);
+    setAutoCloseError("");
+    try {
+      await putAutoCloseConversationDaysPolicy(parsed);
+      await loadAutoClosePolicy();
+      toast.success(t("agent.settings.autoClose.toastSaved"));
+    } catch (err) {
+      setAutoCloseError((err as Error).message);
+    } finally {
+      setAutoCloseSubmitting(false);
+    }
+  };
+
+  const handleResetAutoClosePolicy = async () => {
+    if (!userId) return;
+    setAutoCloseSubmitting(true);
+    setAutoCloseError("");
+    try {
+      await deleteAutoCloseConversationDaysPolicy();
+      await loadAutoClosePolicy();
+      toast.success(t("agent.settings.autoClose.toastReset"));
+    } catch (err) {
+      setAutoCloseError((err as Error).message);
+    } finally {
+      setAutoCloseSubmitting(false);
+    }
+  };
+
   // 保存知识库向量配置(仅管理员;保存后立即生效,无需重启)
   const handleSaveEmbeddingConfig = async (e: React.FormEvent) => {
     e.preventDefault();
@@ -348,6 +539,271 @@ export default function SettingsPage(props: any = {}) {
             </CardContent>
           </Card>
 
+          {/* 会话维护:自动关闭长期未活跃 open 访客会话 */}
+          <Card>
+            <CardHeader>
+              <CardTitle>{t("agent.settings.autoClose.title")}</CardTitle>
+              <p className="text-sm text-muted-foreground mt-1">
+                {t("agent.settings.autoClose.lead")}
+              </p>
+            </CardHeader>
+            <CardContent>
+              {autoCloseLoading ? (
+                <div className="text-center py-6 text-muted-foreground">{t("common.loading")}</div>
+              ) : (
+                <form onSubmit={handleSaveAutoClosePolicy} className="space-y-4">
+                  {autoCloseError && (
+                    <div className="p-3 bg-red-50 border border-red-200 rounded-md text-red-600 text-sm">
+                      {autoCloseError}
+                    </div>
+                  )}
+                  <div>
+                    <Label className="block text-sm font-medium mb-1">
+                      {t("agent.settings.autoClose.daysLabel")}
+                    </Label>
+                    <Input
+                      type="number"
+                      min={0}
+                      value={autoCloseDaysDraft}
+                      onChange={(e) => setAutoCloseDaysDraft(e.target.value)}
+                      className="max-w-xs"
+                    />
+                    <p className="text-xs text-muted-foreground mt-2">
+                      {t("agent.settings.autoClose.daysHint")}
+                    </p>
+                  </div>
+                  {autoClosePolicy ? (
+                    <p className="text-xs text-muted-foreground">
+                      {t("agent.settings.autoClose.statusEffective")}: {autoClosePolicy.effective_days}
+                      {" · "}
+                      {t("agent.settings.autoClose.statusEnv")}: {autoClosePolicy.env_days}
+                      {" · "}
+                      {autoClosePolicy.persisted_in_database
+                        ? t("agent.settings.autoClose.statusDb")
+                        : t("agent.settings.autoClose.statusEnvOnly")}
+                    </p>
+                  ) : null}
+                  <div className="flex flex-wrap gap-2">
+                    <Button type="submit" disabled={autoCloseSubmitting}>
+                      {autoCloseSubmitting ? t("common.saving") : t("agent.settings.autoClose.save")}
+                    </Button>
+                    {autoClosePolicy?.persisted_in_database ? (
+                      <Button
+                        type="button"
+                        variant="outline"
+                        disabled={autoCloseSubmitting}
+                        onClick={() => void handleResetAutoClosePolicy()}
+                      >
+                        {t("agent.settings.autoClose.resetEnv")}
+                      </Button>
+                    ) : null}
+                  </div>
+                </form>
+              )}
+            </CardContent>
+          </Card>
+
+          {/* 离线邮件通知 */}
+          <Card>
+            <CardHeader>
+              <CardTitle>{t("agent.settings.offlineEmail.title")}</CardTitle>
+              <p className="text-sm text-muted-foreground mt-1">
+                {t("agent.settings.offlineEmail.lead")}
+              </p>
+              {!isAdmin ? (
+                <p className="text-xs text-amber-600 mt-2">
+                  {t("agent.settings.offlineEmail.adminOnly")}
+                </p>
+              ) : null}
+            </CardHeader>
+            <CardContent>
+              {emailLoading ? (
+                <div className="text-center py-6 text-muted-foreground">{t("common.loading")}</div>
+              ) : (
+                <form onSubmit={handleSaveEmailConfig} className="space-y-4">
+                  {emailError && (
+                    <div className="p-3 bg-red-50 border border-red-200 rounded-md text-red-600 text-sm">
+                      {emailError}
+                    </div>
+                  )}
+                  <div className="flex items-center gap-2">
+                    <Checkbox
+                      id="offline_email_enabled"
+                      checked={emailForm.enabled}
+                      disabled={!isAdmin}
+                      onCheckedChange={(checked) =>
+                        setEmailForm({ ...emailForm, enabled: checked === true })
+                      }
+                    />
+                    <Label htmlFor="offline_email_enabled" className="text-sm cursor-pointer">
+                      {t("agent.settings.offlineEmail.enabled")}
+                    </Label>
+                  </div>
+                  <div>
+                    <Label className="block text-sm font-medium mb-1">
+                      {t("agent.settings.offlineEmail.delayLabel")}
+                    </Label>
+                    <Input
+                      type="number"
+                      min={0}
+                      value={emailForm.offline_delay_seconds}
+                      disabled={!isAdmin}
+                      onChange={(e) =>
+                        setEmailForm({ ...emailForm, offline_delay_seconds: e.target.value })
+                      }
+                      className="max-w-xs"
+                    />
+                    <p className="text-xs text-muted-foreground mt-2">
+                      {t("agent.settings.offlineEmail.delayHint")}
+                    </p>
+                  </div>
+                  <div className="grid grid-cols-1 md:grid-cols-2 gap-4">
+                    <div>
+                      <Label className="block text-sm font-medium mb-1">
+                        {t("agent.settings.offlineEmail.smtpHost")}
+                      </Label>
+                      <Input
+                        value={emailForm.smtp_host}
+                        disabled={!isAdmin}
+                        onChange={(e) =>
+                          setEmailForm({ ...emailForm, smtp_host: e.target.value })
+                        }
+                        placeholder="smtp.example.com"
+                      />
+                    </div>
+                    <div>
+                      <Label className="block text-sm font-medium mb-1">
+                        {t("agent.settings.offlineEmail.smtpPort")}
+                      </Label>
+                      <Input
+                        type="number"
+                        min={1}
+                        value={emailForm.smtp_port}
+                        disabled={!isAdmin}
+                        onChange={(e) =>
+                          setEmailForm({ ...emailForm, smtp_port: e.target.value })
+                        }
+                        placeholder="465"
+                      />
+                    </div>
+                    <div>
+                      <Label className="block text-sm font-medium mb-1">
+                        {t("agent.settings.offlineEmail.smtpUser")}
+                      </Label>
+                      <Input
+                        value={emailForm.smtp_user}
+                        disabled={!isAdmin}
+                        onChange={(e) =>
+                          setEmailForm({ ...emailForm, smtp_user: e.target.value })
+                        }
+                      />
+                    </div>
+                    <div>
+                      <Label className="block text-sm font-medium mb-1">
+                        {t("agent.settings.offlineEmail.smtpPassword")}
+                      </Label>
+                      <Input
+                        type="password"
+                        value={emailForm.smtp_password}
+                        disabled={!isAdmin}
+                        onChange={(e) =>
+                          setEmailForm({ ...emailForm, smtp_password: e.target.value })
+                        }
+                        placeholder={
+                          emailConfig?.smtp_password_masked
+                            ? t("agent.settings.offlineEmail.smtpPasswordKeepEmpty")
+                            : undefined
+                        }
+                      />
+                    </div>
+                    <div>
+                      <Label className="block text-sm font-medium mb-1">
+                        {t("agent.settings.offlineEmail.fromEmail")}
+                      </Label>
+                      <Input
+                        type="email"
+                        value={emailForm.from_email}
+                        disabled={!isAdmin}
+                        onChange={(e) =>
+                          setEmailForm({ ...emailForm, from_email: e.target.value })
+                        }
+                      />
+                    </div>
+                    <div>
+                      <Label className="block text-sm font-medium mb-1">
+                        {t("agent.settings.offlineEmail.fromName")}
+                      </Label>
+                      <Input
+                        value={emailForm.from_name}
+                        disabled={!isAdmin}
+                        onChange={(e) =>
+                          setEmailForm({ ...emailForm, from_name: e.target.value })
+                        }
+                      />
+                    </div>
+                  </div>
+                  {emailConfig ? (
+                    <p className="text-xs text-muted-foreground">
+                      {t("agent.settings.offlineEmail.statusEffective")}:{" "}
+                      {emailConfig.effective_enabled
+                        ? t("agent.settings.offlineEmail.statusOn")
+                        : t("agent.settings.offlineEmail.statusOff")}
+                      {" · "}
+                      {t("agent.settings.offlineEmail.delayLabel")}:{" "}
+                      {emailConfig.effective_delay_seconds}s
+                      {" · "}
+                      {t("agent.settings.offlineEmail.statusEnv")}:{" "}
+                      {emailConfig.env_enabled ? "on" : "off"},{" "}
+                      {emailConfig.env_delay_seconds}s
+                      {" · "}
+                      {emailConfig.persisted_in_database
+                        ? t("agent.settings.offlineEmail.statusDb")
+                        : t("agent.settings.offlineEmail.statusEnvOnly")}
+                    </p>
+                  ) : null}
+                  <div className="flex flex-wrap gap-2">
+                    <Button type="submit" disabled={!isAdmin || emailSubmitting}>
+                      {emailSubmitting
+                        ? t("common.saving")
+                        : t("agent.settings.offlineEmail.save")}
+                    </Button>
+                    {isAdmin && emailConfig?.persisted_in_database ? (
+                      <Button
+                        type="button"
+                        variant="outline"
+                        disabled={emailSubmitting}
+                        onClick={() => void handleResetEmailConfig()}
+                      >
+                        {t("agent.settings.offlineEmail.resetEnv")}
+                      </Button>
+                    ) : null}
+                  </div>
+                  {isAdmin ? (
+                    <div className="flex flex-col sm:flex-row gap-2 pt-2 border-t">
+                      <Input
+                        type="email"
+                        value={emailTestTo}
+                        onChange={(e) => setEmailTestTo(e.target.value)}
+                        placeholder={t("agent.settings.offlineEmail.testTo")}
+                        className="sm:max-w-xs"
+                      />
+                      <Button
+                        type="button"
+                        variant="outline"
+                        disabled={emailTesting || !emailTestTo.trim()}
+                        onClick={() => void handleSendEmailTest()}
+                      >
+                        {emailTesting
+                          ? t("common.saving")
+                          : t("agent.settings.offlineEmail.testSend")}
+                      </Button>
+                    </div>
+                  ) : null}
+                </form>
+              )}
+            </CardContent>
+          </Card>
+
           {/* 知识库向量模型(平台级,仅管理员可修改;保存后立即生效) */}
           <Card>
             <CardHeader>

+ 56 - 2
frontend/components/dashboard/ConversationList.tsx

@@ -1,13 +1,20 @@
 "use client";
 
+import { useCallback, useEffect, useRef } from "react";
+import { Loader2 } from "lucide-react";
+
 import { ConversationSummary } from "@/features/agent/types";
 import { ConversationListItem } from "./ConversationListItem";
+import { Button } from "@/components/ui/button";
 
 interface ConversationListProps {
   conversations: ConversationSummary[];
   selectedConversationId: number | null;
   onSelect: (id: number) => void;
   searchQuery: string;
+  hasMore?: boolean;
+  loadingMore?: boolean;
+  onLoadMore?: () => void;
 }
 
 export function ConversationList({
@@ -15,7 +22,40 @@ export function ConversationList({
   selectedConversationId,
   onSelect,
   searchQuery,
+  hasMore = false,
+  loadingMore = false,
+  onLoadMore,
 }: ConversationListProps) {
+  const scrollRootRef = useRef<HTMLDivElement>(null);
+  const sentinelRef = useRef<HTMLDivElement>(null);
+
+  const handleLoadMore = useCallback(() => {
+    if (!hasMore || loadingMore || searchQuery.trim() || !onLoadMore) {
+      return;
+    }
+    onLoadMore();
+  }, [hasMore, loadingMore, onLoadMore, searchQuery]);
+
+  useEffect(() => {
+    const root = scrollRootRef.current;
+    const sentinel = sentinelRef.current;
+    if (!root || !sentinel || !onLoadMore || searchQuery.trim()) {
+      return;
+    }
+
+    const observer = new IntersectionObserver(
+      (entries) => {
+        if (entries.some((entry) => entry.isIntersecting)) {
+          handleLoadMore();
+        }
+      },
+      { root, rootMargin: "120px", threshold: 0 }
+    );
+
+    observer.observe(sentinel);
+    return () => observer.disconnect();
+  }, [handleLoadMore, onLoadMore, searchQuery, conversations.length]);
+
   if (conversations.length === 0) {
     return (
       <div className="flex-1 overflow-y-auto scrollbar-auto">
@@ -27,7 +67,10 @@ export function ConversationList({
   }
 
   return (
-    <div className="flex-1 overflow-y-auto px-2 py-2 scrollbar-auto">
+    <div
+      ref={scrollRootRef}
+      className="flex-1 overflow-y-auto px-2 py-2 scrollbar-auto min-h-0"
+    >
       {conversations.map((conversation) => (
         <ConversationListItem
           key={conversation.id}
@@ -36,7 +79,18 @@ export function ConversationList({
           onSelect={onSelect}
         />
       ))}
+
+      {!searchQuery.trim() && hasMore ? (
+        <div ref={sentinelRef} className="py-3 flex justify-center">
+          {loadingMore ? (
+            <Loader2 className="w-5 h-5 animate-spin text-muted-foreground" />
+          ) : (
+            <Button variant="ghost" size="sm" onClick={handleLoadMore}>
+              加载更多
+            </Button>
+          )}
+        </div>
+      ) : null}
     </div>
   );
 }
-

+ 9 - 0
frontend/components/dashboard/ConversationSidebar.tsx

@@ -23,6 +23,9 @@ interface ConversationSidebarProps {
   /** 内部对话(知识库测试)模式:显示「新建内部对话」按钮,隐藏筛选 */
   mode?: "visitor" | "internal";
   onNewClick?: () => void;
+  hasMore?: boolean;
+  loadingMore?: boolean;
+  onLoadMore?: () => void;
 }
 
 export function ConversationSidebar({
@@ -37,6 +40,9 @@ export function ConversationSidebar({
   onStatusChange,
   mode = "visitor",
   onNewClick,
+  hasMore,
+  loadingMore,
+  onLoadMore,
 }: ConversationSidebarProps) {
   const { t } = useI18n();
   return (
@@ -67,6 +73,9 @@ export function ConversationSidebar({
         selectedConversationId={selectedConversationId}
         onSelect={onSelectConversation}
         searchQuery={searchQuery}
+        hasMore={hasMore}
+        loadingMore={loadingMore}
+        onLoadMore={onLoadMore}
       />
     </div>
   );

+ 45 - 7
frontend/components/dashboard/DashboardShell.tsx

@@ -6,8 +6,11 @@ import { usePathname, useRouter, useSearchParams } from "next/navigation";
 import { useAuth } from "@/features/agent/hooks/useAuth";
 import { useConversations } from "@/features/agent/hooks/useConversations";
 import { useMessages } from "@/features/agent/hooks/useMessages";
-import { initInternalConversation } from "@/features/agent/services/conversationApi";
-import { closeConversation } from "@/features/agent/services/conversationApi";
+import {
+  initInternalConversation,
+  closeConversation,
+  fetchConversations,
+} from "@/features/agent/services/conversationApi";
 import { toast } from "@/hooks/useToast";
 import { useProfile } from "@/features/agent/hooks/useProfile";
 import { Profile } from "@/features/agent/types";
@@ -104,23 +107,55 @@ export function DashboardShell() {
     selectedConversationId,
     searchQuery,
     loading,
+    loadingMore,
     isInitialLoad,
+    hasMore,
+    totalUnread,
     setSearchQuery,
     selectConversation,
     updateConversation,
     refresh: refreshConversations,
+    loadMore: loadMoreConversations,
     hasConversation,
   } = useConversations({
     agentId: agent?.id ?? null,
     filter: conversationFilter,
     listType: isInternalChat ? "internal" : "visitor",
     status: conversationStatus,
+    enabled: isChatPage,
+    pollIntervalMs: isChatPage ? 15000 : 0,
   });
 
-  // 计算总未读消息数
-  const totalUnreadCount = useMemo(() => {
-    return conversations.reduce((sum, conv) => sum + (conv.unread_count ?? 0), 0);
-  }, [conversations]);
+  // 非会话页:轻量拉取未读数(不阻塞页面、不全量列表)
+  const [navUnreadCount, setNavUnreadCount] = useState(0);
+  useEffect(() => {
+    if (isChatPage || !agent?.id) {
+      return;
+    }
+    let cancelled = false;
+    const pullUnread = async () => {
+      try {
+        const result = await fetchConversations(agent.id, {
+          status: "open",
+          page: 1,
+          page_size: 1,
+        });
+        if (!cancelled) {
+          setNavUnreadCount(result.total_unread);
+        }
+      } catch {
+        /* 角标失败不影响设置页 */
+      }
+    };
+    void pullUnread();
+    const timer = setInterval(pullUnread, 60000);
+    return () => {
+      cancelled = true;
+      clearInterval(timer);
+    };
+  }, [isChatPage, agent?.id]);
+
+  const totalUnreadCount = isChatPage ? totalUnread : navUnreadCount;
 
   // 更新页面标题显示未读消息数
   usePageTitle(totalUnreadCount, "AI-CS");
@@ -286,7 +321,7 @@ export function DashboardShell() {
     }
   }, [agent?.id, refreshConversations, selectConversation]);
 
-  if (authLoading || (loading && isInitialLoad)) {
+  if (authLoading || (isChatPage && loading && isInitialLoad)) {
     return (
       <div className="flex justify-center items-center min-h-screen bg-background">
         <div className="text-lg text-muted-foreground">加载中...</div>
@@ -324,6 +359,9 @@ export function DashboardShell() {
         }}
         mode={isInternalChat ? "internal" : "visitor"}
         onNewClick={isInternalChat ? handleNewInternalConversation : undefined}
+        hasMore={hasMore}
+        loadingMore={loadingMore}
+        onLoadMore={loadMoreConversations}
       />
     </div>
   ) : (

+ 210 - 0
frontend/components/dashboard/FAQSearchDropdown.tsx

@@ -0,0 +1,210 @@
+"use client";
+
+import { useCallback, useEffect, useRef, useState } from "react";
+import type { FAQQuickResult } from "@/features/agent/services/faqApi";
+import { quickSearchFAQs } from "@/features/agent/services/faqApi";
+import { Loader2, Search } from "lucide-react";
+import { useI18n } from "@/lib/i18n/provider";
+
+interface FAQSearchDropdownProps {
+  open: boolean;
+  onClose: () => void;
+  onSelect: (faq: FAQQuickResult) => void;
+}
+
+export function FAQSearchDropdown({
+  open,
+  onClose,
+  onSelect,
+}: FAQSearchDropdownProps) {
+  const { t } = useI18n();
+  const inputRef = useRef<HTMLInputElement>(null);
+  const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);
+  const containerRef = useRef<HTMLDivElement>(null);
+
+  const [query, setQuery] = useState("");
+  const [results, setResults] = useState<FAQQuickResult[]>([]);
+  const [loading, setLoading] = useState(false);
+  const [error, setError] = useState<string | null>(null);
+  const [selectedIndex, setSelectedIndex] = useState(-1);
+
+  useEffect(() => {
+    if (open) {
+      setQuery("");
+      setResults([]);
+      setError(null);
+      setSelectedIndex(-1);
+      setTimeout(() => inputRef.current?.focus(), 30);
+    }
+  }, [open]);
+
+  useEffect(() => {
+    return () => {
+      if (debounceRef.current) clearTimeout(debounceRef.current);
+    };
+  }, []);
+
+  const doSearch = useCallback(async (q: string) => {
+    if (!q.trim()) {
+      setResults([]);
+      setError(null);
+      setSelectedIndex(-1);
+      return;
+    }
+    setLoading(true);
+    setError(null);
+    try {
+      const data = await quickSearchFAQs(q.trim(), 10);
+      setResults(data);
+      setSelectedIndex(data.length > 0 ? 0 : -1);
+    } catch (err) {
+      setError((err as Error).message || "搜索失败");
+      setResults([]);
+      setSelectedIndex(-1);
+    } finally {
+      setLoading(false);
+    }
+  }, []);
+
+  const handleInputChange = useCallback(
+    (value: string) => {
+      setQuery(value);
+      if (debounceRef.current) clearTimeout(debounceRef.current);
+      debounceRef.current = setTimeout(() => doSearch(value), 300);
+    },
+    [doSearch]
+  );
+
+  const handleSelect = useCallback(
+    (faq: FAQQuickResult) => {
+      onSelect(faq);
+    },
+    [onSelect]
+  );
+
+  const handleKeyDown = useCallback(
+    (event: React.KeyboardEvent<HTMLInputElement>) => {
+      switch (event.key) {
+        case "ArrowDown":
+          event.preventDefault();
+          setSelectedIndex((prev) =>
+            results.length === 0 ? -1 : Math.min(results.length - 1, prev + 1)
+          );
+          break;
+        case "ArrowUp":
+          event.preventDefault();
+          setSelectedIndex((prev) => Math.max(0, prev - 1));
+          break;
+        case "Enter":
+          if (selectedIndex >= 0 && selectedIndex < results.length) {
+            event.preventDefault();
+            handleSelect(results[selectedIndex]);
+          }
+          break;
+        case "Escape":
+          event.preventDefault();
+          onClose();
+          break;
+      }
+    },
+    [results, selectedIndex, handleSelect, onClose]
+  );
+
+  useEffect(() => {
+    if (!open) return;
+    const handleClickOutside = (event: MouseEvent) => {
+      if (
+        containerRef.current &&
+        !containerRef.current.contains(event.target as Node)
+      ) {
+        onClose();
+      }
+    };
+    const timer = setTimeout(() => {
+      document.addEventListener("mousedown", handleClickOutside);
+    }, 100);
+    return () => {
+      clearTimeout(timer);
+      document.removeEventListener("mousedown", handleClickOutside);
+    };
+  }, [open, onClose]);
+
+  if (!open) return null;
+
+  return (
+    <div
+      ref={containerRef}
+      className="absolute bottom-full left-4 right-4 mb-2 bg-popover border border-border rounded-xl shadow-xl z-50 max-h-[380px] flex flex-col overflow-hidden"
+    >
+      <div className="flex items-center gap-2 px-3 py-3 border-b border-border/50 flex-shrink-0">
+        <Search className="w-4 h-4 text-muted-foreground flex-shrink-0" />
+        <input
+          ref={inputRef}
+          type="text"
+          value={query}
+          onChange={(e) => handleInputChange(e.target.value)}
+          onKeyDown={handleKeyDown}
+          placeholder={t("agent.faqs.quickSearch.placeholder")}
+          className="flex-1 bg-transparent border-none outline-none text-sm placeholder:text-muted-foreground"
+        />
+        <kbd
+          className="text-[10px] px-1.5 py-0.5 rounded bg-muted border border-border text-muted-foreground cursor-pointer flex-shrink-0"
+          onClick={onClose}
+        >
+          Esc
+        </kbd>
+      </div>
+
+      <div className="overflow-y-auto flex-1 min-h-[60px]">
+        {loading && (
+          <div className="flex items-center justify-center gap-2 py-8 text-sm text-muted-foreground">
+            <Loader2 className="w-4 h-4 animate-spin" />
+            {t("agent.faqs.quickSearch.searching")}
+          </div>
+        )}
+
+        {error && (
+          <div className="text-center py-6 text-sm text-destructive px-4">
+            {error}
+          </div>
+        )}
+
+        {!loading && !error && query.trim() && results.length === 0 && (
+          <div className="text-center py-6 text-sm text-muted-foreground">
+            {t("agent.faqs.quickSearch.noResults")}
+          </div>
+        )}
+
+        {!loading && results.length > 0 && (
+          <div className="py-1">
+            {results.map((faq, index) => (
+              <button
+                key={faq.id}
+                type="button"
+                onClick={() => handleSelect(faq)}
+                className={`w-full text-left px-4 py-2.5 transition-colors cursor-pointer ${
+                  index === selectedIndex
+                    ? "bg-accent text-accent-foreground"
+                    : "hover:bg-accent/60"
+                }`}
+              >
+                <div className="font-medium text-sm line-clamp-1">
+                  {faq.question}
+                </div>
+                <div className="text-xs text-muted-foreground mt-0.5 line-clamp-2">
+                  {faq.answer}
+                </div>
+              </button>
+            ))}
+          </div>
+        )}
+
+        {!loading && !query.trim() && (
+          <div className="text-center py-6 text-sm text-muted-foreground">
+            {t("agent.faqs.quickSearch.startTyping")}
+          </div>
+        )}
+      </div>
+    </div>
+  );
+}

+ 43 - 2
frontend/components/dashboard/MessageInput.tsx

@@ -7,6 +7,8 @@ import { uploadFile, UploadFileResult } from "@/features/agent/services/messageA
 import { X, Paperclip, Image as ImageIcon } from "lucide-react";
 import { toast } from "@/hooks/useToast";
 import { useI18n } from "@/lib/i18n/provider";
+import type { FAQQuickResult } from "@/features/agent/services/faqApi";
+import { FAQSearchDropdown } from "./FAQSearchDropdown";
 
 interface MessageInputProps {
   value: string;
@@ -39,6 +41,39 @@ export function MessageInput({
   const [filePreview, setFilePreview] = useState<FilePreview | null>(null);
   // 上传中状态
   const [uploading, setUploading] = useState(false);
+  const [faqOpen, setFaqOpen] = useState(false);
+
+  const closeFAQ = useCallback(() => {
+    setFaqOpen(false);
+  }, []);
+
+  const handleFAQSelect = useCallback(
+    (faq: FAQQuickResult) => {
+      onChange(faq.answer);
+      setFaqOpen(false);
+      setTimeout(() => inputRef.current?.focus(), 0);
+    },
+    [onChange]
+  );
+
+  const handleInputChange = useCallback(
+    (newValue: string) => {
+      if (newValue === "/" && value === "") {
+        setFaqOpen(true);
+        return;
+      }
+      if (faqOpen && newValue === "") {
+        setFaqOpen(false);
+      }
+      onChange(newValue);
+    },
+    [value, onChange, faqOpen]
+  );
+
+  const handleFAQClose = useCallback(() => {
+    setFaqOpen(false);
+    setTimeout(() => inputRef.current?.focus(), 0);
+  }, []);
 
   // 当发送状态从 true 变为 false 时(发送完成),自动聚焦到输入框
   useEffect(() => {
@@ -209,10 +244,16 @@ export function MessageInput({
 
   return (
     <div
-      className="bg-gradient-to-t from-background to-muted/30 flex-shrink-0 border-t border-border/50"
+      className="bg-gradient-to-t from-background to-muted/30 flex-shrink-0 border-t border-border/50 relative"
       onDragOver={handleDragOver}
       onDrop={handleDrop}
     >
+      <FAQSearchDropdown
+        open={faqOpen}
+        onClose={handleFAQClose}
+        onSelect={handleFAQSelect}
+      />
+
       {/* 文件预览区域 */}
       {filePreview && (
         <div className="px-4 pt-3 pb-2 flex items-start gap-2">
@@ -287,7 +328,7 @@ export function MessageInput({
               : t("agent.input.placeholder")
           }
           value={value}
-          onChange={(event) => onChange(event.target.value)}
+          onChange={(event) => handleInputChange(event.target.value)}
           className="flex-1 border-border/50 focus:border-primary/50 focus:ring-primary/20"
           disabled={sending || uploading}
         />

+ 33 - 8
frontend/components/visitor/ChatWidget.tsx

@@ -21,6 +21,7 @@ import {
   UploadFileResult,
 } from "@/features/agent/services/messageApi";
 import { initVisitorConversation } from "@/features/visitor/services/conversationApi";
+import { getVisitorAccessToken } from "@/lib/visitor-session";
 import { postWidgetOpen } from "@/features/visitor/services/analyticsApi";
 import { fetchOnlineAgents } from "@/features/visitor/services/visitorApi";
 import {
@@ -119,6 +120,7 @@ export function ChatWidget({
 
   // ===== 状态管理 =====
   const [conversationId, setConversationId] = useState<number | null>(null);
+  const [accessToken, setAccessToken] = useState<string | null>(null);
   const [conversationStatus, setConversationStatus] = useState<string>("open");
   const [messages, setMessages] = useState<MessageItem[]>([]);
   const [loadingMessages, setLoadingMessages] = useState(true);
@@ -144,6 +146,7 @@ export function ChatWidget({
   const [widgetConfig, setWidgetConfig] = useState<VisitorWidgetConfig | null>(null);
   const typingSeqRef = useRef(0);
   const typingTimerRef = useRef<NodeJS.Timeout | null>(null);
+  const initRef = useRef(false);
 
   // 声音通知开关(访客端)
   const { enabled: soundEnabled, toggle: toggleSound } = useSoundNotification(true);
@@ -278,6 +281,7 @@ export function ChatWidget({
         if (result.conversation_id) {
           setConversationId(result.conversation_id);
           setConversationStatus(result.status);
+          setAccessToken(result.access_token || null);
           setChatMode(mode);
         }
       } catch (error) {
@@ -290,13 +294,29 @@ export function ChatWidget({
     []
   );
 
-  // 初始化默认对话(人工模式)
+  // 初始化默认对话(人工模式);initRef 防止 Strict Mode 双次 mount 创建两个会话
   useEffect(() => {
-    if (visitorId !== null && !conversationId && !initializing && isOpen) {
+    if (
+      visitorId !== null &&
+      !conversationId &&
+      !initializing &&
+      isOpen &&
+      !initRef.current
+    ) {
+      initRef.current = true;
       initializeConversation(visitorId, "human");
     }
   }, [visitorId, conversationId, initializing, isOpen, initializeConversation]);
 
+  useEffect(() => {
+    if (conversationId && !accessToken) {
+      const stored = getVisitorAccessToken(conversationId);
+      if (stored) {
+        setAccessToken(stored);
+      }
+    }
+  }, [conversationId, accessToken]);
+
   // 处理模式切换
   const handleModeSwitch = useCallback(
     (mode: "human" | "ai") => {
@@ -329,7 +349,8 @@ export function ChatWidget({
       }
       const result = await markMessagesRead(
         targetConversationId,
-        targetReaderIsAgent
+        targetReaderIsAgent,
+        accessToken ?? undefined
       );
       if (!result || result.message_ids.length === 0) {
         return;
@@ -347,7 +368,7 @@ export function ChatWidget({
         )
       );
     },
-    [conversationId]
+    [conversationId, accessToken]
   );
 
   // 拉取历史消息(AI 模式时包含 AI 对话记录,人工模式时仅人工消息)
@@ -358,7 +379,7 @@ export function ChatWidget({
     setLoadingMessages(true);
     try {
       const includeAIMessages = chatMode === "ai";
-      const data = await fetchMessages(conversationId, includeAIMessages);
+      const data = await fetchMessages(conversationId, includeAIMessages, accessToken ?? undefined);
       const normalizedMessages = data.map((msg) => ({
         ...msg,
         is_read: msg.is_read ?? false,
@@ -370,7 +391,7 @@ export function ChatWidget({
     } finally {
       setLoadingMessages(false);
     }
-  }, [conversationId, chatMode, shouldHideForVisitor, isMessageInCurrentMode]);
+  }, [conversationId, accessToken, chatMode, shouldHideForVisitor, isMessageInCurrentMode]);
 
   useEffect(() => {
     if (isOpen && conversationId) {
@@ -578,8 +599,9 @@ export function ChatWidget({
 
   const { send: sendWebSocketEvent } = useWebSocket<ChatWebSocketPayload>({
     conversationId,
-    enabled: Boolean(conversationId) && isOpen,
+    enabled: Boolean(conversationId && accessToken) && isOpen,
     isVisitor: true,
+    accessToken: accessToken ?? undefined,
     onMessage: handleWebSocketMessage,
     onError: (error) => {
       console.error("WebSocket 连接错误(访客端):", error);
@@ -685,6 +707,7 @@ export function ChatWidget({
           conversationId,
           content: messageContent,
           senderIsAgent: false,
+          accessToken: accessToken ?? undefined,
           fileUrl: fileInfo?.file_url,
           fileType: fileInfo?.file_type as "image" | "document" | undefined,
           fileName: fileInfo?.file_name,
@@ -709,7 +732,7 @@ export function ChatWidget({
         setSending(false);
       }
     },
-    [conversationId, input, sending, visitorId, chatMode, needWebSearch, sendTypingStop, widgetConfig]
+    [conversationId, accessToken, input, sending, visitorId, chatMode, needWebSearch, sendTypingStop, widgetConfig]
   );
 
   // 如果不打开,不渲染内容
@@ -940,6 +963,8 @@ export function ChatWidget({
           onSubmit={handleSendMessage}
           sending={sending}
           conversationId={conversationId ?? undefined}
+          accessToken={accessToken ?? undefined}
+          visitorId={visitorId}
           toolsSlot={
             chatMode === "ai" && (widgetConfig?.web_search_enabled ?? false) ? (
               <button

+ 146 - 6
frontend/components/visitor/VisitorMessageInput.tsx

@@ -2,6 +2,15 @@
 
 import { FormEvent, ReactNode, useCallback, useEffect, useRef, useState } from "react";
 import { uploadFile, UploadFileResult } from "@/features/agent/services/messageApi";
+import { updateVisitorContactEmail } from "@/features/visitor/services/conversationApi";
+import {
+  getVisitorContactEmail,
+  saveVisitorContactEmail,
+  saveVisitorEmailPromptDone,
+  shouldShowVisitorEmailPrompt,
+} from "@/lib/visitor-session";
+import { useI18n } from "@/lib/i18n/provider";
+import { cn } from "@/lib/utils";
 import { Paperclip, ArrowUp, X } from "lucide-react";
 import { toast } from "@/hooks/useToast";
 
@@ -11,6 +20,8 @@ interface VisitorMessageInputProps {
   onSubmit: (fileInfo?: UploadFileResult) => Promise<void> | void;
   sending: boolean;
   conversationId?: number;
+  accessToken?: string;
+  visitorId?: number;
   toolsSlot?: ReactNode;
   submitLeftSlot?: ReactNode;
 }
@@ -20,20 +31,49 @@ interface FilePreview {
   preview?: string;
 }
 
+const EMAIL_PATTERN = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
+
 export function VisitorMessageInput({
   value,
   onChange,
   onSubmit,
   sending,
   conversationId,
+  accessToken,
+  visitorId,
   toolsSlot,
   submitLeftSlot,
 }: VisitorMessageInputProps) {
+  const { t } = useI18n();
   const inputRef = useRef<HTMLInputElement>(null);
   const fileInputRef = useRef<HTMLInputElement>(null);
   const prevSendingRef = useRef<boolean>(false);
+  const emailSaveTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
+  const lastSavedEmailRef = useRef<string>("");
+
   const [filePreview, setFilePreview] = useState<FilePreview | null>(null);
   const [uploading, setUploading] = useState(false);
+  const [contactExpanded, setContactExpanded] = useState(false);
+  const [email, setEmail] = useState("");
+  const [emailPromptActive, setEmailPromptActive] = useState(true);
+
+  useEffect(() => {
+    if (!visitorId) return;
+    const saved = getVisitorContactEmail(visitorId);
+    if (saved) {
+      setEmail(saved);
+      lastSavedEmailRef.current = saved;
+    }
+    setEmailPromptActive(shouldShowVisitorEmailPrompt(visitorId));
+  }, [visitorId]);
+
+  const dismissEmailPrompt = useCallback(() => {
+    setContactExpanded(false);
+    setEmailPromptActive(false);
+    if (visitorId) {
+      saveVisitorEmailPromptDone(visitorId);
+    }
+  }, [visitorId]);
 
   useEffect(() => {
     if (prevSendingRef.current && !sending && inputRef.current) {
@@ -42,6 +82,59 @@ export function VisitorMessageInput({
     prevSendingRef.current = sending;
   }, [sending]);
 
+  const persistEmail = useCallback(
+    async (raw: string) => {
+      const trimmed = raw.trim();
+      if (!trimmed || !EMAIL_PATTERN.test(trimmed)) return;
+      if (!conversationId) return;
+      if (trimmed === lastSavedEmailRef.current) return;
+
+      try {
+        await updateVisitorContactEmail(conversationId, trimmed, accessToken);
+        lastSavedEmailRef.current = trimmed;
+        if (visitorId) {
+          saveVisitorContactEmail(visitorId, trimmed);
+        }
+        dismissEmailPrompt();
+      } catch (error) {
+        console.warn("保存访客邮箱失败:", error);
+      }
+    },
+    [accessToken, conversationId, visitorId, dismissEmailPrompt]
+  );
+
+  const scheduleEmailSave = useCallback(
+    (raw: string) => {
+      if (emailSaveTimerRef.current) {
+        clearTimeout(emailSaveTimerRef.current);
+      }
+      emailSaveTimerRef.current = setTimeout(() => {
+        void persistEmail(raw);
+      }, 700);
+    },
+    [persistEmail]
+  );
+
+  useEffect(() => {
+    if (!conversationId || !email.trim()) return;
+    if (!EMAIL_PATTERN.test(email.trim())) return;
+    scheduleEmailSave(email);
+    return () => {
+      if (emailSaveTimerRef.current) clearTimeout(emailSaveTimerRef.current);
+    };
+  }, [email, conversationId, scheduleEmailSave]);
+
+  useEffect(() => {
+    if (!conversationId || !email.trim()) return;
+    if (!EMAIL_PATTERN.test(email.trim())) return;
+    void persistEmail(email);
+  }, [conversationId, email, persistEmail]);
+
+  const handleFirstInteraction = useCallback(() => {
+    if (!emailPromptActive) return;
+    setContactExpanded((prev) => (prev ? prev : true));
+  }, [emailPromptActive]);
+
   const handleFileSelect = useCallback(async (file: File) => {
     const MAX_FILE_SIZE = 10 * 1024 * 1024;
     if (file.size > MAX_FILE_SIZE) {
@@ -88,12 +181,16 @@ export function VisitorMessageInput({
     if (sending || uploading) return;
     if (!value.trim() && !filePreview) return;
 
+    if (email.trim() && EMAIL_PATTERN.test(email.trim())) {
+      await persistEmail(email);
+    }
+
     try {
       let fileInfo: UploadFileResult | undefined;
       if (filePreview) {
         setUploading(true);
         try {
-          fileInfo = await uploadFile(filePreview.file, conversationId);
+          fileInfo = await uploadFile(filePreview.file, conversationId, accessToken);
         } catch (error) {
           toast.error((error as Error).message || "文件上传失败");
           setUploading(false);
@@ -105,7 +202,8 @@ export function VisitorMessageInput({
       await onSubmit(fileInfo);
       onChange("");
       handleRemoveFile();
-    } catch (error) {
+      dismissEmailPrompt();
+    } catch {
       // 发送异常由上层统一处理
     }
   };
@@ -135,17 +233,25 @@ export function VisitorMessageInput({
   useEffect(() => {
     return () => {
       if (filePreview?.preview) URL.revokeObjectURL(filePreview.preview);
+      if (emailSaveTimerRef.current) clearTimeout(emailSaveTimerRef.current);
     };
   }, [filePreview]);
 
   return (
-    <form onSubmit={handleSubmit} className="rounded-2xl border border-slate-200/90 bg-white shadow-[0_8px_24px_-20px_rgba(15,23,42,0.35)] px-3 py-2">
+    <form
+      onSubmit={handleSubmit}
+      className="rounded-2xl border border-slate-200/90 bg-white shadow-[0_8px_24px_-20px_rgba(15,23,42,0.35)] px-3 py-2"
+    >
       {filePreview && (
         <div className="mb-2 rounded-xl border border-slate-200 bg-slate-50 p-2 flex items-start gap-2">
           <div className="flex-1 min-w-0">
             {filePreview.preview ? (
               <div className="inline-block">
-                <img src={filePreview.preview} alt="预览" className="max-w-[180px] max-h-[140px] rounded-lg object-cover border border-slate-200" />
+                <img
+                  src={filePreview.preview}
+                  alt="预览"
+                  className="max-w-[180px] max-h-[140px] rounded-lg object-cover border border-slate-200"
+                />
                 <div className="mt-1 text-xs text-slate-500">
                   {filePreview.file.name} ({formatFileSize(filePreview.file.size)})
                 </div>
@@ -167,12 +273,47 @@ export function VisitorMessageInput({
         </div>
       )}
 
+      {emailPromptActive && (
+        <div
+          className={cn(
+            "grid transition-[grid-template-rows,opacity,margin] duration-200 ease-out",
+            contactExpanded ? "grid-rows-[1fr] opacity-100 mb-2" : "grid-rows-[0fr] opacity-0 mb-0"
+          )}
+        >
+          <div className="overflow-hidden">
+            <div className="pb-2 border-b border-slate-100">
+              <div className="flex items-center gap-2">
+                <input
+                  type="email"
+                  inputMode="email"
+                  autoComplete="email"
+                  placeholder={t("chat.email.placeholder")}
+                  value={email}
+                  onChange={(e) => setEmail(e.target.value)}
+                  onBlur={() => void persistEmail(email)}
+                  className="flex-1 min-w-0 bg-transparent text-sm text-slate-800 placeholder:text-slate-400 outline-none border-none px-1 py-0.5"
+                  disabled={sending || uploading}
+                />
+                <span className="shrink-0 text-[10px] text-slate-400 tracking-wide">
+                  {t("chat.email.optional")}
+                </span>
+              </div>
+              <p className="mt-1 px-1 text-[10px] leading-snug text-slate-400">
+                {t("chat.email.privacy")}
+              </p>
+            </div>
+          </div>
+        </div>
+      )}
+
       <input
         ref={inputRef}
         type="text"
-        placeholder={filePreview ? "添加消息(可选)..." : "输入消息"}
+        placeholder={filePreview ? "添加消息(可选)..." : t("chat.input.placeholder")}
         value={value}
         onChange={(event) => onChange(event.target.value)}
+        onKeyDown={handleFirstInteraction}
+        onPaste={handleFirstInteraction}
         className="w-full bg-transparent text-sm text-slate-800 placeholder:text-slate-400 outline-none border-none px-1"
         disabled={sending || uploading}
       />
@@ -212,4 +353,3 @@ export function VisitorMessageInput({
     </form>
   );
 }
-

+ 187 - 84
frontend/features/agent/hooks/useConversations.ts

@@ -27,6 +27,9 @@ const sortByUpdatedAtDesc = (list: ConversationSummary[]) =>
 
 import type { ConversationFilter } from "@/components/dashboard/ConversationHeader";
 
+const PAGE_SIZE = 50;
+const POLL_INTERVAL_MS = 15000;
+
 interface UseConversationsOptions {
   agentId?: number | null;
   filter?: ConversationFilter;
@@ -34,10 +37,21 @@ interface UseConversationsOptions {
   listType?: ConversationListType;
   /** 会话状态:open(进行中)/ closed(历史) */
   status?: ConversationStatus;
+  /** 为 false 时不加载列表、不轮询(非会话页使用) */
+  enabled?: boolean;
+  /** 轮询间隔毫秒;0 表示不轮询 */
+  pollIntervalMs?: number;
 }
 
 export function useConversations(options?: UseConversationsOptions) {
-  const { agentId, filter = "all", listType = "visitor", status = "open" } = options || {};
+  const {
+    agentId,
+    filter = "all",
+    listType = "visitor",
+    status = "open",
+    enabled = true,
+    pollIntervalMs = POLL_INTERVAL_MS,
+  } = options || {};
   const [conversations, setConversations] = useState<ConversationSummary[]>([]);
   const [filteredConversations, setFilteredConversations] = useState<
     ConversationSummary[]
@@ -46,85 +60,166 @@ export function useConversations(options?: UseConversationsOptions) {
     number | null
   >(null);
   const [searchQuery, setSearchQuery] = useState("");
-  const [loading, setLoading] = useState(true);
-  const [isInitialLoad, setIsInitialLoad] = useState(true);
+  const [loading, setLoading] = useState(enabled);
+  const [loadingMore, setLoadingMore] = useState(false);
+  const [isInitialLoad, setIsInitialLoad] = useState(enabled);
+  const [hasMore, setHasMore] = useState(false);
+  const [totalUnread, setTotalUnread] = useState(0);
+  const pageRef = useRef(1);
+  const prevListTypeRef = useRef(listType);
 
   const searchRef = useRef("");
   const refreshTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
   const wsToken = getAgentWSToken() ?? undefined;
 
-  // 根据 filter 过滤会话
   const applyFilter = useCallback(
-    (conversations: ConversationSummary[]): ConversationSummary[] => {
+    (list: ConversationSummary[]): ConversationSummary[] => {
       if (!agentId) {
-        return conversations;
+        return list;
       }
 
       switch (filter) {
         case "mine":
-          // 只显示当前用户参与过的会话(has_participated === true)
-          // 即当前用户在该会话中发送过消息的会话
-          return conversations.filter((conv) => conv.has_participated === true);
+          return list.filter((conv) => conv.has_participated === true);
         case "others":
-          // 显示除了当前用户参与过的其他人的会话(has_participated !== true)
-          return conversations.filter((conv) => conv.has_participated !== true);
+          return list.filter((conv) => conv.has_participated !== true);
         case "all":
         default:
-          return conversations;
+          return list;
       }
     },
     [agentId, filter]
   );
 
-  const loadConversations = useCallback(async () => {
-    setLoading(true);
-    try {
-      // 内部对话(知识库测试)必须带 user_id,后端否则返回 400;未登录或 agentId 未就绪时不请求
-      if (listType === "internal" && !agentId) {
-        setConversations([]);
-        setFilteredConversations([]);
-        setSelectedConversationId(null);
+  const mergeConversationPages = useCallback(
+    (
+      prev: ConversationSummary[],
+      nextItems: ConversationSummary[],
+      replace: boolean
+    ) => {
+      if (replace) {
+        return nextItems;
+      }
+      const map = new Map<number, ConversationSummary>();
+      for (const item of prev) {
+        map.set(item.id, item);
+      }
+      for (const item of nextItems) {
+        map.set(item.id, item);
+      }
+      return sortByUpdatedAtDesc(Array.from(map.values()));
+    },
+    []
+  );
+
+  const loadConversations = useCallback(
+    async (opts?: { append?: boolean }) => {
+      if (!enabled) {
         return;
       }
-      const data = await fetchConversations(
-        agentId ?? undefined,
-        listType === "internal" ? { type: "internal", status } : { status }
-      );
-      setConversations(data);
-      const filtered = listType === "internal" ? data : applyFilter(data);
-      if (!searchRef.current.trim()) {
-        setFilteredConversations(filtered);
+      const append = opts?.append ?? false;
+      if (append) {
+        setLoadingMore(true);
+      } else {
+        setLoading(true);
+        pageRef.current = 1;
       }
-      setSelectedConversationId((prev) => {
-        if (prev) {
-          return prev;
+
+      try {
+        if (listType === "internal" && !agentId) {
+          setConversations([]);
+          setFilteredConversations([]);
+          setSelectedConversationId(null);
+          setHasMore(false);
+          setTotalUnread(0);
+          return;
         }
-        return filtered.length > 0 ? filtered[0].id : null;
-      });
-    } catch (error) {
-      console.error(error);
-    } finally {
+
+        const page = append ? pageRef.current + 1 : 1;
+        const result = await fetchConversations(
+          agentId ?? undefined,
+          listType === "internal"
+            ? { type: "internal", status, page, page_size: PAGE_SIZE }
+            : { status, page, page_size: PAGE_SIZE }
+        );
+
+        pageRef.current = page;
+        setHasMore(result.has_more);
+        setTotalUnread(result.total_unread);
+
+        setConversations((prev) => {
+          const merged = mergeConversationPages(prev, result.items, !append);
+          if (!searchRef.current.trim()) {
+            const filtered =
+              listType === "internal" ? merged : applyFilter(merged);
+            const sorted = sortByUpdatedAtDesc(filtered);
+            setFilteredConversations(sorted);
+            if (!append) {
+              setSelectedConversationId((prevSelected) => {
+                if (prevSelected && sorted.some((c) => c.id === prevSelected)) {
+                  return prevSelected;
+                }
+                return sorted.length > 0 ? sorted[0].id : null;
+              });
+            }
+          }
+          return merged;
+        });
+      } catch (error) {
+        console.error(error);
+      } finally {
+        if (append) {
+          setLoadingMore(false);
+        } else {
+          setLoading(false);
+          setIsInitialLoad(false);
+        }
+      }
+    },
+    [enabled, applyFilter, agentId, listType, status, mergeConversationPages]
+  );
+
+  const loadMoreConversations = useCallback(async () => {
+    if (!enabled || loading || loadingMore || !hasMore || searchRef.current.trim()) {
+      return;
+    }
+    await loadConversations({ append: true });
+  }, [enabled, loading, loadingMore, hasMore, loadConversations]);
+
+  useEffect(() => {
+    if (!enabled) {
       setLoading(false);
       setIsInitialLoad(false);
+      return;
     }
-  }, [applyFilter, agentId, filter, listType, status]);
+    void loadConversations();
+  }, [enabled, loadConversations]);
 
+  // 切换 listType(访客对话 ↔ 知识库测试)时立即清空,避免串台
   useEffect(() => {
-    loadConversations();
-  }, [loadConversations]);
+    if (prevListTypeRef.current !== listType) {
+      prevListTypeRef.current = listType;
+      setConversations([]);
+      setFilteredConversations([]);
+      setSelectedConversationId(null);
+      setHasMore(false);
+      setTotalUnread(0);
+      pageRef.current = 1;
+      searchRef.current = "";
+      setSearchQuery("");
+    }
+  }, [listType]);
 
-  // 兜底定时刷新:防止 WebSocket 漏事件/无会话时无法建立全局 WS 导致列表长期不更新。
   useEffect(() => {
-    if (!agentId) {
+    if (!enabled || !agentId || pollIntervalMs <= 0) {
       return;
     }
     const interval = setInterval(() => {
       void loadConversations();
-    }, 15000);
+    }, pollIntervalMs);
     return () => clearInterval(interval);
-  }, [agentId, loadConversations]);
+  }, [enabled, agentId, pollIntervalMs, loadConversations]);
 
-  // 当 filter / listType 改变时,重新应用过滤(不重新加载数据)
   useEffect(() => {
     if (isInitialLoad) {
       return;
@@ -134,7 +229,7 @@ export function useConversations(options?: UseConversationsOptions) {
   }, [filter, listType, conversations, isInitialLoad, applyFilter]);
 
   useEffect(() => {
-    if (isInitialLoad) {
+    if (!enabled || isInitialLoad) {
       return;
     }
     const handler = setTimeout(async () => {
@@ -146,13 +241,22 @@ export function useConversations(options?: UseConversationsOptions) {
         return;
       }
       if (listType === "internal") {
-        setFilteredConversations(sortByUpdatedAtDesc(conversations.filter((c) => (c.last_message?.content ?? "").toLowerCase().includes(query.toLowerCase()))));
+        setFilteredConversations(
+          sortByUpdatedAtDesc(
+            conversations.filter((c) =>
+              (c.last_message?.content ?? "").toLowerCase().includes(query.toLowerCase())
+            )
+          )
+        );
         setLoading(false);
         return;
       }
       try {
         setLoading(true);
-        const data = await searchConversations(query, agentId ?? undefined, { status });
+        const data = await searchConversations(query, agentId ?? undefined, {
+          status,
+          type: listType,
+        });
         const filtered = applyFilter(data);
         setFilteredConversations(sortByUpdatedAtDesc(filtered));
       } catch (error) {
@@ -164,7 +268,7 @@ export function useConversations(options?: UseConversationsOptions) {
     }, 300);
 
     return () => clearTimeout(handler);
-  }, [searchQuery, conversations, isInitialLoad, applyFilter, agentId, listType, status]);
+  }, [enabled, searchQuery, conversations, isInitialLoad, applyFilter, agentId, listType, status]);
 
   const selectConversation = useCallback((conversationId: number | null) => {
     setSelectedConversationId((prev) =>
@@ -207,13 +311,16 @@ export function useConversations(options?: UseConversationsOptions) {
     []
   );
 
-  const setAllConversations = useCallback((data: ConversationSummary[]) => {
-    setConversations(data);
-    if (!searchRef.current.trim()) {
-      const filtered = applyFilter(data);
-      setFilteredConversations(filtered);
-    }
-  }, [applyFilter]);
+  const setAllConversations = useCallback(
+    (data: ConversationSummary[]) => {
+      setConversations(data);
+      if (!searchRef.current.trim()) {
+        const filtered = applyFilter(data);
+        setFilteredConversations(filtered);
+      }
+    },
+    [applyFilter]
+  );
 
   const hasConversation = useCallback(
     (conversationId: number) => {
@@ -223,33 +330,28 @@ export function useConversations(options?: UseConversationsOptions) {
   );
 
   const scheduleRefreshConversations = useCallback(() => {
+    if (!enabled) {
+      return;
+    }
     if (refreshTimerRef.current) {
       clearTimeout(refreshTimerRef.current);
     }
     refreshTimerRef.current = setTimeout(() => {
       void loadConversations();
     }, 500);
-  }, [loadConversations]);
+  }, [enabled, loadConversations]);
 
-  // 建立全局 WebSocket 连接以接收 visitor_status_update 等全局事件
-  // 使用第一个对话的 ID(如果存在),否则不建立连接
   const globalConversationId = conversations.length > 0 ? conversations[0].id : null;
 
-  // 处理全局 WebSocket 事件:访客在线状态 + 新消息摘要
   const handleGlobalWebSocketMessage = useCallback(
     (event: WSMessage<ChatWebSocketPayload>) => {
       if (event.type === "visitor_status_update" && event.data) {
         const payload = event.data as VisitorStatusUpdatePayload;
-        if (payload?.conversation_id) {
-          if (payload.is_online === true) {
-            // 在线:更新为当前时间(实时更新在线状态)
-            updateConversation(payload.conversation_id, (conv) => ({
-              ...conv,
-              last_seen_at: new Date().toISOString(),
-            }));
-          }
-          // 离线时,last_seen_at 会在后端更新,这里不需要特殊处理
-          // 因为对话列表会定期刷新,或者通过其他方式更新
+        if (payload?.conversation_id && payload.is_online === true) {
+          updateConversation(payload.conversation_id, (conv) => ({
+            ...conv,
+            last_seen_at: new Date().toISOString(),
+          }));
         }
       } else if (event.type === "new_message" && event.data) {
         const message = event.data as MessageItem;
@@ -258,7 +360,6 @@ export function useConversations(options?: UseConversationsOptions) {
         }
         const isConversationExists = hasConversation(message.conversation_id);
         if (!isConversationExists) {
-          // 新会话(当前列表里还没有)时,延迟刷新把它拉进来
           scheduleRefreshConversations();
           return;
         }
@@ -287,6 +388,9 @@ export function useConversations(options?: UseConversationsOptions) {
             created_at: message.created_at,
           },
         }));
+        if (isVisitorMessage && message.conversation_id !== selectedConversationId) {
+          setTotalUnread((n) => n + 1);
+        }
       }
     },
     [
@@ -305,33 +409,32 @@ export function useConversations(options?: UseConversationsOptions) {
     };
   }, []);
 
-  // 建立全局 WebSocket 连接(用于接收全局事件)
   useWebSocket<ChatWebSocketPayload>({
     conversationId: globalConversationId,
-    enabled: Boolean(globalConversationId && agentId),
+    enabled: Boolean(enabled && globalConversationId && agentId),
     isVisitor: false,
     agentId: agentId ?? undefined,
     wsToken,
     onMessage: handleGlobalWebSocketMessage,
-    onError: (error) => {
-      // 静默处理错误,避免影响用户体验
-    },
-    onClose: () => {
-      // 静默处理关闭,避免影响用户体验
-    },
+    onError: () => {},
+    onClose: () => {},
   });
 
-  const contextValue = useMemo(
+  return useMemo(
     () => ({
       conversations,
       filteredConversations,
       selectedConversationId,
       searchQuery,
       loading,
+      loadingMore,
       isInitialLoad,
+      hasMore,
+      totalUnread,
       setSearchQuery,
       selectConversation,
-      refresh: loadConversations,
+      refresh: () => loadConversations(),
+      loadMore: loadMoreConversations,
       updateConversation,
       setAllConversations,
       hasConversation,
@@ -342,16 +445,16 @@ export function useConversations(options?: UseConversationsOptions) {
       selectedConversationId,
       searchQuery,
       loading,
+      loadingMore,
       isInitialLoad,
+      hasMore,
+      totalUnread,
       selectConversation,
       loadConversations,
+      loadMoreConversations,
       updateConversation,
       setAllConversations,
-      setSearchQuery,
       hasConversation,
     ]
   );
-
-  return contextValue;
 }
-

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

@@ -9,6 +9,7 @@ interface UseWebSocketOptions<T> {
   isVisitor?: boolean; // 是否是访客(默认为 true)
   agentId?: number; // 客服ID(如果是客服连接,需要传递)
   wsToken?: string; // 客服 WS 令牌(登录后下发)
+  accessToken?: string; // 访客会话令牌
   onMessage: (payload: WSMessage<T>) => void;
   onError?: (error: Event) => void;
   onClose?: () => void;
@@ -20,6 +21,7 @@ export function useWebSocket<T>({
   isVisitor = true, // 默认是访客
   agentId,
   wsToken,
+  accessToken,
   onMessage,
   onError,
   onClose,
@@ -49,12 +51,18 @@ export function useWebSocket<T>({
       clientRef.current = null;
       return;
     }
+    if (isVisitor && !accessToken) {
+      clientRef.current?.disconnect();
+      clientRef.current = null;
+      return;
+    }
 
     const client = new WSClient<T>({
       conversationId,
       isVisitor,
       agentId,
       wsToken,
+      accessToken,
       // 使用 ref 的 current 值,这样即使回调函数变化也不会导致重新连接
       onMessage: (payload) => onMessageRef.current(payload),
       onError: onErrorRef.current
@@ -74,7 +82,7 @@ export function useWebSocket<T>({
     };
     // 只依赖 conversationId、enabled、isVisitor 和 agentId,不依赖回调函数
     // 回调函数通过 useRef 存储,不会导致重新连接
-  }, [conversationId, enabled, isVisitor, agentId, wsToken]);
+  }, [conversationId, enabled, isVisitor, agentId, wsToken, accessToken]);
 
   const send = useCallback((type: string, data?: unknown): boolean => {
     return clientRef.current?.send(type, data) ?? false;

+ 117 - 0
frontend/features/agent/services/chunkApi.ts

@@ -0,0 +1,117 @@
+import { apiUrl, getAgentHeaders } from "@/lib/config";
+
+// 文档分段
+export interface DocumentChunk {
+  id: number;
+  document_id: number;
+  knowledge_base_id: number;
+  chunk_index: number;
+  content: string;
+  embedding_status: string;
+  created_at: string;
+  updated_at: string;
+}
+
+// 分段请求参数
+export interface ChunkRequest {
+  method: "char_count" | "separator";
+  chunk_size?: number;
+  separator?: string;
+}
+
+// 分段列表响应
+export interface ChunkListResponse {
+  chunks: DocumentChunk[];
+  total: number;
+  page: number;
+  page_size: number;
+  total_page: number;
+}
+
+// 分段执行响应
+export interface ChunkExecuteResponse {
+  message: string;
+  chunk_count: number;
+  chunks: DocumentChunk[];
+}
+
+// 执行分段
+export async function executeChunking(
+  documentId: number,
+  req: ChunkRequest
+): Promise<ChunkExecuteResponse> {
+  const res = await fetch(apiUrl(`/documents/${documentId}/chunks`), {
+    method: "POST",
+    headers: { "Content-Type": "application/json", ...getAgentHeaders() },
+    body: JSON.stringify(req),
+  });
+
+  if (!res.ok) {
+    const error = await res.json().catch(() => ({}));
+    throw new Error(error.error || "执行分段失败");
+  }
+
+  return res.json();
+}
+
+// 获取分段列表
+export async function fetchChunks(
+  documentId: number,
+  page: number = 1,
+  pageSize: number = 10
+): Promise<ChunkListResponse> {
+  const params = new URLSearchParams({
+    page: String(page),
+    page_size: String(pageSize),
+  });
+  const res = await fetch(
+    apiUrl(`/documents/${documentId}/chunks?${params}`),
+    {
+      cache: "no-store",
+      headers: getAgentHeaders(),
+    }
+  );
+
+  if (!res.ok) {
+    const error = await res.json().catch(() => ({}));
+    throw new Error(error.error || "获取分段列表失败");
+  }
+
+  return res.json();
+}
+
+// 更新单个分段
+export async function updateChunk(
+  documentId: number,
+  chunkId: number,
+  content: string
+): Promise<DocumentChunk> {
+  const res = await fetch(
+    apiUrl(`/documents/${documentId}/chunks/${chunkId}`),
+    {
+      method: "PUT",
+      headers: { "Content-Type": "application/json", ...getAgentHeaders() },
+      body: JSON.stringify({ content }),
+    }
+  );
+
+  if (!res.ok) {
+    const error = await res.json().catch(() => ({}));
+    throw new Error(error.error || "更新分段失败");
+  }
+
+  return res.json();
+}
+
+// 删除所有分段
+export async function deleteChunks(documentId: number): Promise<void> {
+  const res = await fetch(apiUrl(`/documents/${documentId}/chunks`), {
+    method: "DELETE",
+    headers: getAgentHeaders(),
+  });
+
+  if (!res.ok) {
+    const error = await res.json().catch(() => ({}));
+    throw new Error(error.error || "删除分段失败");
+  }
+}

+ 124 - 14
frontend/features/agent/services/conversationApi.ts

@@ -7,28 +7,87 @@ import {
 export type ConversationListType = "visitor" | "internal";
 export type ConversationStatus = "open" | "closed";
 
+export interface ConversationListResponse {
+  items: ConversationSummary[];
+  total: number;
+  page: number;
+  page_size: number;
+  has_more: boolean;
+  total_unread: number;
+}
+
+const DEFAULT_PAGE_SIZE = 50;
+
+function normalizeConversationItem(item: ConversationSummary): ConversationSummary {
+  return {
+    ...item,
+    unread_count: item.unread_count ?? 0,
+    has_participated: item.has_participated ?? false,
+  };
+}
+
+function parseConversationListPayload(data: unknown): ConversationListResponse {
+  if (Array.isArray(data)) {
+    const items = data.map((item) =>
+      normalizeConversationItem(item as ConversationSummary)
+    );
+    return {
+      items,
+      total: items.length,
+      page: 1,
+      page_size: items.length,
+      has_more: false,
+      total_unread: items.reduce((sum, c) => sum + (c.unread_count ?? 0), 0),
+    };
+  }
+  if (data && typeof data === "object") {
+    const raw = data as Record<string, unknown>;
+    const items = Array.isArray(raw.items)
+      ? raw.items.map((item) =>
+          normalizeConversationItem(item as ConversationSummary)
+        )
+      : [];
+    return {
+      items,
+      total: Number(raw.total ?? items.length),
+      page: Number(raw.page ?? 1),
+      page_size: Number(raw.page_size ?? items.length),
+      has_more: Boolean(raw.has_more),
+      total_unread: Number(raw.total_unread ?? 0),
+    };
+  }
+  return {
+    items: [],
+    total: 0,
+    page: 1,
+    page_size: DEFAULT_PAGE_SIZE,
+    has_more: false,
+    total_unread: 0,
+  };
+}
+
 export async function fetchConversations(
   userId?: number,
-  opts?: { type?: ConversationListType; status?: ConversationStatus }
-): Promise<ConversationSummary[]> {
+  opts?: {
+    type?: ConversationListType;
+    status?: ConversationStatus;
+    page?: number;
+    page_size?: number;
+  }
+): Promise<ConversationListResponse> {
   const params = new URLSearchParams();
   if (userId) params.set("user_id", String(userId));
   if (opts?.type) params.set("type", opts.type);
   if (opts?.status) params.set("status", opts.status);
+  params.set("page", String(opts?.page ?? 1));
+  params.set("page_size", String(opts?.page_size ?? DEFAULT_PAGE_SIZE));
   const url = `${apiUrl("/conversations")}?${params.toString()}`;
   const res = await fetch(url, { cache: "no-store", headers: getAgentHeaders() });
   if (!res.ok) {
     throw new Error("获取对话列表失败");
   }
   const data = await res.json();
-  if (!Array.isArray(data)) {
-    return [];
-  }
-  return data.map((item) => ({
-    ...item,
-    unread_count: item.unread_count ?? 0,
-    has_participated: item.has_participated ?? false,
-  }));
+  return parseConversationListPayload(data);
 }
 
 /** 创建一条内部对话(知识库测试),返回新对话 ID */
@@ -48,12 +107,19 @@ export async function initInternalConversation(userId: number): Promise<{ conver
 export async function searchConversations(
   query: string,
   userId?: number,
-  opts?: { status?: ConversationStatus }
+  opts?: { status?: ConversationStatus; type?: ConversationListType }
 ): Promise<ConversationSummary[]> {
   const status = opts?.status ?? "open";
-  const url = userId
-    ? `${apiUrl("/conversations/search")}?q=${encodeURIComponent(query)}&user_id=${userId}&status=${status}`
-    : `${apiUrl("/conversations/search")}?q=${encodeURIComponent(query)}&status=${status}`;
+  const listType = opts?.type ?? "visitor";
+  const params = new URLSearchParams({
+    q: query,
+    status,
+    type: listType,
+  });
+  if (userId) {
+    params.set("user_id", String(userId));
+  }
+  const url = `${apiUrl("/conversations/search")}?${params.toString()}`;
   const res = await fetch(url, {
     cache: "no-store",
     headers: getAgentHeaders(),
@@ -139,3 +205,47 @@ export async function updateConversationContact(
   };
 }
 
+export interface AutoCloseConversationDaysPolicy {
+  effective_days: number;
+  env_days: number;
+  persisted_in_database: boolean;
+}
+
+export async function fetchAutoCloseConversationDaysPolicy(): Promise<AutoCloseConversationDaysPolicy> {
+  const res = await fetch(apiUrl("/conversations/maintenance/auto-close-days"), {
+    cache: "no-store",
+    headers: getAgentHeaders(),
+  });
+  if (!res.ok) {
+    throw new Error("获取会话维护配置失败");
+  }
+  return res.json();
+}
+
+export async function putAutoCloseConversationDaysPolicy(
+  inactiveDays: number
+): Promise<{ effective_days: number }> {
+  const res = await fetch(apiUrl("/conversations/maintenance/auto-close-days"), {
+    method: "PUT",
+    headers: { "Content-Type": "application/json", ...getAgentHeaders() },
+    body: JSON.stringify({ inactive_days: inactiveDays }),
+  });
+  if (!res.ok) {
+    const err = await res.json().catch(() => ({}));
+    throw new Error((err as { error?: string }).error || "保存会话维护配置失败");
+  }
+  return res.json();
+}
+
+export async function deleteAutoCloseConversationDaysPolicy(): Promise<{ effective_days: number }> {
+  const res = await fetch(apiUrl("/conversations/maintenance/auto-close-days"), {
+    method: "DELETE",
+    headers: getAgentHeaders(),
+  });
+  if (!res.ok) {
+    const err = await res.json().catch(() => ({}));
+    throw new Error((err as { error?: string }).error || "恢复默认配置失败");
+  }
+  return res.json();
+}
+

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

@@ -94,7 +94,7 @@ export async function fetchDocument(id: number): Promise<Document> {
 export async function createDocument(data: CreateDocumentRequest): Promise<Document> {
   const res = await fetch(apiUrl("/documents"), {
     method: "POST",
-    headers: { "Content-Type": "application/json" },
+    headers: { "Content-Type": "application/json", ...getAgentHeaders() },
     body: JSON.stringify(data),
   });
 

+ 88 - 0
frontend/features/agent/services/emailNotificationApi.ts

@@ -0,0 +1,88 @@
+import { apiUrl, getAgentHeaders } from "@/lib/config";
+
+export interface EmailNotificationConfig {
+  id?: number;
+  enabled: boolean;
+  smtp_host: string;
+  smtp_port: number;
+  smtp_user: string;
+  smtp_password_masked?: string;
+  from_email: string;
+  from_name: string;
+  offline_delay_seconds: number;
+  effective_enabled: boolean;
+  effective_delay_seconds: number;
+  persisted_in_database: boolean;
+  env_enabled: boolean;
+  env_delay_seconds: number;
+  updated_at?: string;
+}
+
+export interface UpdateEmailNotificationConfigRequest {
+  enabled?: boolean;
+  smtp_host?: string;
+  smtp_port?: number;
+  smtp_user?: string;
+  smtp_password?: string;
+  from_email?: string;
+  from_name?: string;
+  offline_delay_seconds?: number;
+}
+
+export async function fetchEmailNotificationConfig(
+  userId: number
+): Promise<EmailNotificationConfig> {
+  const res = await fetch(
+    `${apiUrl("/agent/email-notification-config")}?user_id=${userId}`,
+    { cache: "no-store", headers: getAgentHeaders() }
+  );
+  if (!res.ok) {
+    throw new Error("获取离线邮件配置失败");
+  }
+  return res.json();
+}
+
+export async function updateEmailNotificationConfig(
+  userId: number,
+  data: UpdateEmailNotificationConfigRequest
+): Promise<EmailNotificationConfig> {
+  const res = await fetch(apiUrl("/agent/email-notification-config"), {
+    method: "PUT",
+    headers: { "Content-Type": "application/json", ...getAgentHeaders() },
+    body: JSON.stringify({ user_id: userId, ...data }),
+  });
+  if (!res.ok) {
+    const err = await res.json();
+    throw new Error(err.error || "更新离线邮件配置失败");
+  }
+  return res.json();
+}
+
+export async function resetEmailNotificationConfig(
+  userId: number
+): Promise<EmailNotificationConfig> {
+  const res = await fetch(
+    `${apiUrl("/agent/email-notification-config")}?user_id=${userId}`,
+    { method: "DELETE", headers: getAgentHeaders() }
+  );
+  if (!res.ok) {
+    const err = await res.json();
+    throw new Error(err.error || "恢复离线邮件配置失败");
+  }
+  return res.json();
+}
+
+export async function sendEmailNotificationTest(
+  userId: number,
+  to: string
+): Promise<void> {
+  const res = await fetch(apiUrl("/agent/email-notification-config/test"), {
+    method: "POST",
+    headers: { "Content-Type": "application/json", ...getAgentHeaders() },
+    body: JSON.stringify({ user_id: userId, to }),
+  });
+  if (!res.ok) {
+    const err = await res.json();
+    throw new Error(err.error || "发送测试邮件失败");
+  }
+}

+ 29 - 0
frontend/features/agent/services/faqApi.ts

@@ -102,6 +102,35 @@ export async function updateFAQ(
   return res.json();
 }
 
+export interface FAQQuickResult {
+  id: number;
+  question: string;
+  answer: string;
+  keywords: string;
+}
+
+/** FAQ 快速搜索(聊天输入框 `/` 触发) */
+export async function quickSearchFAQs(
+  q: string,
+  limit: number = 10
+): Promise<FAQQuickResult[]> {
+  const url = apiUrl(
+    `/faqs-search?q=${encodeURIComponent(q)}&limit=${limit}`
+  );
+  const res = await fetch(url, {
+    cache: "no-store",
+    headers: getAgentHeaders(),
+  });
+  if (!res.ok) {
+    const error = await res.json().catch(() => ({}));
+    throw new Error(
+      (error as { error?: string }).error || "FAQ 搜索失败"
+    );
+  }
+  const data = await res.json();
+  return data.faqs || [];
+}
+
 // 删除 FAQ
 export async function deleteFAQ(id: number): Promise<void> {
   const res = await fetch(apiUrl(`/faqs/${id}`), {

+ 31 - 5
frontend/features/agent/services/messageApi.ts

@@ -1,4 +1,5 @@
 import { apiUrl, getAgentHeaders } from "@/lib/config";
+import { getVisitorConversationHeaders } from "@/lib/visitor-session";
 import { MessageItem } from "../types";
 import { reportFrontendLog } from "./systemLogApi";
 
@@ -51,6 +52,7 @@ interface SendMessagePayload {
   content: string;
   senderId?: number;
   senderIsAgent?: boolean;
+  accessToken?: string;
   fileUrl?: string;
   fileType?: "image" | "document";
   fileName?: string;
@@ -73,12 +75,17 @@ export interface UploadFileResult {
 
 export async function fetchMessages(
   conversationId: number,
-  includeAIMessages: boolean = false
+  includeAIMessages: boolean = false,
+  accessToken?: string
 ): Promise<MessageItem[]> {
   const res = await fetch(
     `${apiUrl("/messages")}?conversation_id=${conversationId}&include_ai_messages=${includeAIMessages}`,
     {
       cache: "no-store",
+      headers: {
+        ...getAgentHeaders(),
+        ...getVisitorConversationHeaders(conversationId, accessToken),
+      },
     }
   );
   if (!res.ok) {
@@ -102,7 +109,8 @@ export async function fetchMessages(
 // 上传文件
 export async function uploadFile(
   file: File,
-  conversationId?: number
+  conversationId?: number,
+  accessToken?: string
 ): Promise<UploadFileResult> {
   const formData = new FormData();
   formData.append("file", file);
@@ -112,6 +120,10 @@ export async function uploadFile(
 
   const res = await fetch(apiUrl("/messages/upload"), {
     method: "POST",
+    headers:
+      conversationId != null
+        ? getVisitorConversationHeaders(conversationId, accessToken)
+        : getAgentHeaders(),
     body: formData,
   });
 
@@ -141,6 +153,7 @@ export async function sendMessage({
   content,
   senderId,
   senderIsAgent = true,
+  accessToken,
   fileUrl,
   fileType,
   fileName,
@@ -172,7 +185,13 @@ export async function sendMessage({
 
   const res = await fetch(apiUrl("/messages"), {
     method: "POST",
-    headers: { "Content-Type": "application/json", ...getAgentHeaders() },
+    headers: {
+      "Content-Type": "application/json",
+      ...getAgentHeaders(),
+      ...(senderIsAgent
+        ? {}
+        : getVisitorConversationHeaders(conversationId, accessToken)),
+    },
     body: JSON.stringify(payload),
   });
   if (!res.ok) {
@@ -207,11 +226,18 @@ export interface MarkMessagesReadResult {
 
 export async function markMessagesRead(
   conversationId: number,
-  readerIsAgent: boolean
+  readerIsAgent: boolean,
+  accessToken?: string
 ): Promise<MarkMessagesReadResult | null> {
   const res = await fetch(apiUrl("/messages/read"), {
     method: "PUT",
-    headers: { "Content-Type": "application/json", ...getAgentHeaders() },
+    headers: {
+      "Content-Type": "application/json",
+      ...getAgentHeaders(),
+      ...(readerIsAgent
+        ? {}
+        : getVisitorConversationHeaders(conversationId, accessToken)),
+    },
     body: JSON.stringify({
       conversation_id: conversationId,
       reader_is_agent: readerIsAgent,

+ 36 - 1
frontend/features/visitor/services/conversationApi.ts

@@ -1,4 +1,8 @@
 import { apiUrl } from "@/lib/config";
+import {
+  getVisitorConversationHeaders,
+  saveVisitorAccessToken,
+} from "@/lib/visitor-session";
 import { reportFrontendLog } from "@/features/agent/services/systemLogApi";
 
 export interface InitVisitorConversationPayload {
@@ -16,6 +20,7 @@ export interface InitVisitorConversationPayload {
 export interface InitVisitorConversationResult {
   conversation_id: number;
   status: string;
+  access_token: string;
 }
 
 export async function initVisitorConversation(
@@ -50,9 +55,39 @@ export async function initVisitorConversation(
   }
 
   const data = await res.json();
+  const conversationId = data.conversation_id ?? 0;
+  const accessToken = typeof data.access_token === "string" ? data.access_token : "";
+  if (conversationId && accessToken) {
+    saveVisitorAccessToken(conversationId, accessToken);
+  }
   return {
-    conversation_id: data.conversation_id ?? 0,
+    conversation_id: conversationId,
     status: data.status ?? "open",
+    access_token: accessToken,
   };
 }
 
+/** 访客更新本会话的联系邮箱(可选,用于离线收消息) */
+export async function updateVisitorContactEmail(
+  conversationId: number,
+  email: string,
+  accessToken?: string | null
+): Promise<{ email: string }> {
+  const res = await fetch(apiUrl(`/conversations/${conversationId}/contact`), {
+    method: "PUT",
+    headers: {
+      "Content-Type": "application/json",
+      ...getVisitorConversationHeaders(conversationId, accessToken),
+    },
+    body: JSON.stringify({ email: email.trim() }),
+  });
+
+  if (!res.ok) {
+    const err = await res.json().catch(() => ({}));
+    throw new Error(err.error || "保存邮箱失败");
+  }
+
+  const data = await res.json();
+  return { email: data.email ?? email.trim() };
+}
+

+ 157 - 3
frontend/lib/i18n/dict.ts

@@ -243,6 +243,10 @@ export type I18nKey =
   | "agent.faqs.toast.createSuccess"
   | "agent.faqs.toast.updateSuccess"
   | "agent.faqs.toast.deleteSuccess"
+  | "agent.faqs.quickSearch.placeholder"
+  | "agent.faqs.quickSearch.searching"
+  | "agent.faqs.quickSearch.noResults"
+  | "agent.faqs.quickSearch.startTyping"
   | "agent.faqs.toast.emptyRequired"
   | "agent.faqs.card.keywords"
   | "agent.faqs.card.createdAt"
@@ -318,6 +322,48 @@ export type I18nKey =
   | "agent.settings.modelType.text"
   | "agent.settings.modelType.video"
   | "agent.settings.section.global"
+  | "agent.settings.autoClose.title"
+  | "agent.settings.autoClose.lead"
+  | "agent.settings.autoClose.daysLabel"
+  | "agent.settings.autoClose.daysHint"
+  | "agent.settings.autoClose.statusEffective"
+  | "agent.settings.autoClose.statusEnv"
+  | "agent.settings.autoClose.statusDb"
+  | "agent.settings.autoClose.statusEnvOnly"
+  | "agent.settings.autoClose.save"
+  | "agent.settings.autoClose.resetEnv"
+  | "agent.settings.autoClose.errorLoad"
+  | "agent.settings.autoClose.errorInvalid"
+  | "agent.settings.autoClose.toastSaved"
+  | "agent.settings.autoClose.toastReset"
+  | "agent.settings.offlineEmail.title"
+  | "agent.settings.offlineEmail.lead"
+  | "agent.settings.offlineEmail.enabled"
+  | "agent.settings.offlineEmail.delayLabel"
+  | "agent.settings.offlineEmail.delayHint"
+  | "agent.settings.offlineEmail.smtpHost"
+  | "agent.settings.offlineEmail.smtpPort"
+  | "agent.settings.offlineEmail.smtpUser"
+  | "agent.settings.offlineEmail.smtpPassword"
+  | "agent.settings.offlineEmail.smtpPasswordKeepEmpty"
+  | "agent.settings.offlineEmail.fromEmail"
+  | "agent.settings.offlineEmail.fromName"
+  | "agent.settings.offlineEmail.statusEffective"
+  | "agent.settings.offlineEmail.statusEnv"
+  | "agent.settings.offlineEmail.statusDb"
+  | "agent.settings.offlineEmail.statusEnvOnly"
+  | "agent.settings.offlineEmail.save"
+  | "agent.settings.offlineEmail.resetEnv"
+  | "agent.settings.offlineEmail.testTo"
+  | "agent.settings.offlineEmail.testSend"
+  | "agent.settings.offlineEmail.adminOnly"
+  | "agent.settings.offlineEmail.errorLoad"
+  | "agent.settings.offlineEmail.errorInvalidDelay"
+  | "agent.settings.offlineEmail.toastSaved"
+  | "agent.settings.offlineEmail.toastReset"
+  | "agent.settings.offlineEmail.toastTestSent"
+  | "agent.settings.offlineEmail.statusOn"
+  | "agent.settings.offlineEmail.statusOff"
   | "agent.settings.subtitle"
   | "agent.settings.title"
   | "agent.settings.toast.embeddingSaved"
@@ -532,7 +578,11 @@ export type I18nKey =
   | "common.irreversibleHint"
   | "chat.title"
   | "chat.mode.human"
-  | "chat.mode.ai";
+  | "chat.mode.ai"
+  | "chat.email.placeholder"
+  | "chat.email.optional"
+  | "chat.email.privacy"
+  | "chat.input.placeholder";
 
 export const DEFAULT_LANG: Lang = "zh-CN";
 export const LANG_STORAGE_KEY = "aics_lang";
@@ -791,6 +841,10 @@ export const DICT: Record<Lang, Record<I18nKey, string>> = {
     "agent.faqs.toast.createSuccess": "创建成功",
     "agent.faqs.toast.updateSuccess": "更新成功",
     "agent.faqs.toast.deleteSuccess": "删除成功",
+    "agent.faqs.quickSearch.placeholder": "输入关键词搜索 FAQ...",
+    "agent.faqs.quickSearch.searching": "搜索中...",
+    "agent.faqs.quickSearch.noResults": "未找到匹配的 FAQ",
+    "agent.faqs.quickSearch.startTyping": "输入关键词开始搜索",
     "agent.faqs.toast.emptyRequired": "问题和答案不能为空",
     "agent.faqs.card.keywords": "关键词",
     "agent.faqs.card.createdAt": "创建时间",
@@ -870,6 +924,50 @@ export const DICT: Record<Lang, Record<I18nKey, string>> = {
     "agent.settings.modelType.text": "文本",
     "agent.settings.modelType.video": "视频",
     "agent.settings.section.global": "全局设置",
+    "agent.settings.autoClose.title": "会话维护",
+    "agent.settings.autoClose.lead":
+      "超过指定天数未更新的「进行中」访客会话将自动标记为「已关闭」。仅改状态,不删除记录;可在会话列表「历史」中查看。填 0 表示禁用。",
+    "agent.settings.autoClose.daysLabel": "自动关闭天数",
+    "agent.settings.autoClose.daysHint": "按会话最后更新时间(updated_at)计算。默认 7 天;0 = 不自动关闭。",
+    "agent.settings.autoClose.statusEffective": "当前生效",
+    "agent.settings.autoClose.statusEnv": "环境变量默认",
+    "agent.settings.autoClose.statusDb": "已保存到数据库(覆盖 .env)",
+    "agent.settings.autoClose.statusEnvOnly": "使用 .env 默认值",
+    "agent.settings.autoClose.save": "保存会话维护设置",
+    "agent.settings.autoClose.resetEnv": "恢复为 .env 默认",
+    "agent.settings.autoClose.errorLoad": "加载会话维护配置失败",
+    "agent.settings.autoClose.errorInvalid": "请输入 0 或正整数",
+    "agent.settings.autoClose.toastSaved": "会话维护设置已保存,下次定时任务起生效",
+    "agent.settings.autoClose.toastReset": "已恢复为 .env 默认配置",
+    "agent.settings.offlineEmail.title": "离线邮件通知",
+    "agent.settings.offlineEmail.lead":
+      "访客离线且已留邮箱时,客服发送人工消息后延迟 N 秒推送邮件;访客重新上线则取消待发邮件。支持阿里云等云厂商 SMTP,也可在 .env 中配置默认值。",
+    "agent.settings.offlineEmail.enabled": "启用离线邮件通知",
+    "agent.settings.offlineEmail.delayLabel": "离线延迟(秒)",
+    "agent.settings.offlineEmail.delayHint": "客服发消息后等待的秒数,默认 60;访客在此期间上线则不发邮件。",
+    "agent.settings.offlineEmail.smtpHost": "SMTP 服务器",
+    "agent.settings.offlineEmail.smtpPort": "SMTP 端口",
+    "agent.settings.offlineEmail.smtpUser": "SMTP 用户名",
+    "agent.settings.offlineEmail.smtpPassword": "SMTP 密码 / 授权码",
+    "agent.settings.offlineEmail.smtpPasswordKeepEmpty": "留空则保留已保存的密码",
+    "agent.settings.offlineEmail.fromEmail": "发件人邮箱",
+    "agent.settings.offlineEmail.fromName": "发件人名称",
+    "agent.settings.offlineEmail.statusEffective": "当前生效",
+    "agent.settings.offlineEmail.statusEnv": "环境变量默认",
+    "agent.settings.offlineEmail.statusDb": "已保存到数据库(覆盖 .env)",
+    "agent.settings.offlineEmail.statusEnvOnly": "使用 .env 默认值",
+    "agent.settings.offlineEmail.save": "保存离线邮件设置",
+    "agent.settings.offlineEmail.resetEnv": "恢复为 .env 默认",
+    "agent.settings.offlineEmail.testTo": "测试收件邮箱",
+    "agent.settings.offlineEmail.testSend": "发送测试邮件",
+    "agent.settings.offlineEmail.adminOnly": "仅管理员可修改与测试",
+    "agent.settings.offlineEmail.errorLoad": "加载离线邮件配置失败",
+    "agent.settings.offlineEmail.errorInvalidDelay": "延迟秒数不能为负数",
+    "agent.settings.offlineEmail.toastSaved": "离线邮件设置已保存,立即生效",
+    "agent.settings.offlineEmail.toastReset": "已恢复为 .env 默认配置",
+    "agent.settings.offlineEmail.toastTestSent": "测试邮件已发送",
+    "agent.settings.offlineEmail.statusOn": "已启用",
+    "agent.settings.offlineEmail.statusOff": "未启用",
     "agent.settings.subtitle": "管理 AI 服务商配置",
     "agent.settings.title": "AI 配置管理",
     "agent.settings.toast.embeddingSaved": "保存成功,配置已立即生效。",
@@ -989,7 +1087,7 @@ export const DICT: Record<Lang, Record<I18nKey, string>> = {
     "agent.knowledge.dialog.docDeleteConfirm": "确定要删除文档 \"{{title}}\" 吗?",
     "agent.knowledge.dialog.importTitle": "导入文档",
     "agent.knowledge.dialog.importDesc":
-      "选择文件上传或输入 URL 批量导入。当前支持的文件格式:Markdown(.md、.markdown);PDF、Word 解析功能开发中。",
+      "选择文件上传或输入 URL 批量导入。当前支持的文件格式:Markdown(.md、.markdown)、PDF(.pdf)、Word(.docx);旧版 .doc 请先转为 .docx。",
     "agent.knowledge.field.name": "名称",
     "agent.knowledge.field.descOptional": "描述(可选)",
     "agent.knowledge.field.title": "标题",
@@ -1101,6 +1199,10 @@ export const DICT: Record<Lang, Record<I18nKey, string>> = {
     "chat.title": "客服聊天",
     "chat.mode.human": "人工客服",
     "chat.mode.ai": "AI 客服",
+    "chat.email.placeholder": "留下邮箱,离线也能收消息",
+    "chat.email.optional": "选填",
+    "chat.email.privacy": "邮箱仅用于接收客服回复,不会用于营销",
+    "chat.input.placeholder": "输入消息",
   },
   en: {
     "nav.features": "Features",
@@ -1356,6 +1458,10 @@ export const DICT: Record<Lang, Record<I18nKey, string>> = {
     "agent.faqs.toast.createSuccess": "Created",
     "agent.faqs.toast.updateSuccess": "Updated",
     "agent.faqs.toast.deleteSuccess": "Deleted",
+    "agent.faqs.quickSearch.placeholder": "Type to search FAQs...",
+    "agent.faqs.quickSearch.searching": "Searching...",
+    "agent.faqs.quickSearch.noResults": "No matching FAQs found",
+    "agent.faqs.quickSearch.startTyping": "Type keywords to search",
     "agent.faqs.toast.emptyRequired": "Question and answer are required",
     "agent.faqs.card.keywords": "Keywords",
     "agent.faqs.card.createdAt": "Created",
@@ -1436,6 +1542,50 @@ export const DICT: Record<Lang, Record<I18nKey, string>> = {
     "agent.settings.modelType.text": "Text",
     "agent.settings.modelType.video": "Video",
     "agent.settings.section.global": "Global",
+    "agent.settings.autoClose.title": "Conversation maintenance",
+    "agent.settings.autoClose.lead":
+      "Open visitor conversations with no activity for N days are marked closed (not deleted). Find them under History in the chat list. Set 0 to disable.",
+    "agent.settings.autoClose.daysLabel": "Auto-close after (days)",
+    "agent.settings.autoClose.daysHint": "Based on conversation updated_at. Default 7; 0 = disabled.",
+    "agent.settings.autoClose.statusEffective": "Effective",
+    "agent.settings.autoClose.statusEnv": "Env default",
+    "agent.settings.autoClose.statusDb": "Saved in database (overrides .env)",
+    "agent.settings.autoClose.statusEnvOnly": "Using .env default",
+    "agent.settings.autoClose.save": "Save maintenance settings",
+    "agent.settings.autoClose.resetEnv": "Reset to .env default",
+    "agent.settings.autoClose.errorLoad": "Failed to load maintenance settings",
+    "agent.settings.autoClose.errorInvalid": "Enter 0 or a positive integer",
+    "agent.settings.autoClose.toastSaved": "Saved. Takes effect on the next scheduled run.",
+    "agent.settings.autoClose.toastReset": "Reset to .env default",
+    "agent.settings.offlineEmail.title": "Offline email notifications",
+    "agent.settings.offlineEmail.lead":
+      "When a visitor is offline but left an email, agent messages are emailed after a delay. Cancelled if the visitor comes back online. Use cloud SMTP (e.g. Aliyun) or .env defaults.",
+    "agent.settings.offlineEmail.enabled": "Enable offline email",
+    "agent.settings.offlineEmail.delayLabel": "Offline delay (seconds)",
+    "agent.settings.offlineEmail.delayHint": "Wait time after an agent message before sending email. Default 60. Skipped if visitor reconnects.",
+    "agent.settings.offlineEmail.smtpHost": "SMTP host",
+    "agent.settings.offlineEmail.smtpPort": "SMTP port",
+    "agent.settings.offlineEmail.smtpUser": "SMTP username",
+    "agent.settings.offlineEmail.smtpPassword": "SMTP password / app password",
+    "agent.settings.offlineEmail.smtpPasswordKeepEmpty": "Leave blank to keep saved password",
+    "agent.settings.offlineEmail.fromEmail": "From email",
+    "agent.settings.offlineEmail.fromName": "From name",
+    "agent.settings.offlineEmail.statusEffective": "Effective",
+    "agent.settings.offlineEmail.statusEnv": "Env default",
+    "agent.settings.offlineEmail.statusDb": "Saved in database (overrides .env)",
+    "agent.settings.offlineEmail.statusEnvOnly": "Using .env default",
+    "agent.settings.offlineEmail.save": "Save offline email settings",
+    "agent.settings.offlineEmail.resetEnv": "Reset to .env default",
+    "agent.settings.offlineEmail.testTo": "Test recipient",
+    "agent.settings.offlineEmail.testSend": "Send test email",
+    "agent.settings.offlineEmail.adminOnly": "Admin only: edit and test",
+    "agent.settings.offlineEmail.errorLoad": "Failed to load offline email settings",
+    "agent.settings.offlineEmail.errorInvalidDelay": "Delay cannot be negative",
+    "agent.settings.offlineEmail.toastSaved": "Offline email settings saved",
+    "agent.settings.offlineEmail.toastReset": "Reset to .env default",
+    "agent.settings.offlineEmail.toastTestSent": "Test email sent",
+    "agent.settings.offlineEmail.statusOn": "Enabled",
+    "agent.settings.offlineEmail.statusOff": "Disabled",
     "agent.settings.subtitle": "Manage AI provider settings",
     "agent.settings.title": "AI configuration",
     "agent.settings.toast.embeddingSaved": "Saved. Changes are live.",
@@ -1559,7 +1709,7 @@ export const DICT: Record<Lang, Record<I18nKey, string>> = {
     "agent.knowledge.dialog.docDeleteConfirm": "Delete doc \"{{title}}\"?",
     "agent.knowledge.dialog.importTitle": "Import docs",
     "agent.knowledge.dialog.importDesc":
-      "Upload files or import by URL. Supported: Markdown (.md, .markdown). PDF/Word parsing is in progress.",
+      "Upload files or import by URL. Supported: Markdown (.md, .markdown), PDF (.pdf), Word (.docx). Legacy .doc files must be converted to .docx first.",
     "agent.knowledge.field.name": "Name",
     "agent.knowledge.field.descOptional": "Description (optional)",
     "agent.knowledge.field.title": "Title",
@@ -1673,6 +1823,10 @@ export const DICT: Record<Lang, Record<I18nKey, string>> = {
     "chat.title": "Chat",
     "chat.mode.human": "Human",
     "chat.mode.ai": "AI",
+    "chat.email.placeholder": "Leave your email to get replies when offline",
+    "chat.email.optional": "Optional",
+    "chat.email.privacy": "Email is only used for support replies, not marketing",
+    "chat.input.placeholder": "Type a message",
   },
 };
 

+ 69 - 0
frontend/lib/visitor-session.ts

@@ -0,0 +1,69 @@
+const STORAGE_PREFIX = "visitor_access_token_";
+const EMAIL_PREFIX = "visitor_contact_email_";
+const EMAIL_PROMPT_DONE_PREFIX = "visitor_email_prompt_done_";
+
+export function saveVisitorContactEmail(visitorId: number, email: string): void {
+  if (typeof window === "undefined" || !visitorId || !email.trim()) {
+    return;
+  }
+  window.localStorage.setItem(`${EMAIL_PREFIX}${visitorId}`, email.trim());
+}
+
+export function getVisitorContactEmail(visitorId: number): string | null {
+  if (typeof window === "undefined" || !visitorId) {
+    return null;
+  }
+  return window.localStorage.getItem(`${EMAIL_PREFIX}${visitorId}`);
+}
+
+/** 标记邮箱采集提示已完成(已填邮箱或已发过首条消息),清除浏览器缓存前不再展示 */
+export function saveVisitorEmailPromptDone(visitorId: number): void {
+  if (typeof window === "undefined" || !visitorId) return;
+  window.localStorage.setItem(`${EMAIL_PROMPT_DONE_PREFIX}${visitorId}`, "1");
+}
+
+export function isVisitorEmailPromptDone(visitorId: number): boolean {
+  if (typeof window === "undefined" || !visitorId) return false;
+  return window.localStorage.getItem(`${EMAIL_PROMPT_DONE_PREFIX}${visitorId}`) === "1";
+}
+
+/** 是否还需要展示邮箱采集行(无已存邮箱且未完成采集流程) */
+export function shouldShowVisitorEmailPrompt(visitorId: number): boolean {
+  if (!visitorId) return true;
+  if (getVisitorContactEmail(visitorId)) return false;
+  if (isVisitorEmailPromptDone(visitorId)) return false;
+  return true;
+}
+
+export function saveVisitorAccessToken(conversationId: number, token: string): void {
+  if (typeof window === "undefined" || !conversationId || !token) {
+    return;
+  }
+  window.localStorage.setItem(`${STORAGE_PREFIX}${conversationId}`, token);
+}
+
+export function getVisitorAccessToken(conversationId: number): string | null {
+  if (typeof window === "undefined" || !conversationId) {
+    return null;
+  }
+  return window.localStorage.getItem(`${STORAGE_PREFIX}${conversationId}`);
+}
+
+export function clearVisitorAccessToken(conversationId: number): void {
+  if (typeof window === "undefined" || !conversationId) {
+    return;
+  }
+  window.localStorage.removeItem(`${STORAGE_PREFIX}${conversationId}`);
+}
+
+/** 访客会话 API 请求头(携带 access_token) */
+export function getVisitorConversationHeaders(
+  conversationId: number,
+  accessToken?: string | null
+): Record<string, string> {
+  const token = accessToken ?? getVisitorAccessToken(conversationId);
+  if (!token) {
+    return {};
+  }
+  return { "X-Conversation-Token": token };
+}

+ 6 - 0
frontend/lib/websocket.ts

@@ -14,6 +14,7 @@ export interface WSOptions<T = unknown> {
   isVisitor?: boolean; // 是否是访客(默认为 true)
   agentId?: number; // 客服ID(如果是客服连接,需要传递)
   wsToken?: string; // 客服 WS 令牌(登录后下发)
+  accessToken?: string; // 访客 WebSocket 令牌
   onMessage?: (message: WSMessage<T>) => void; // 收到消息时的回调
   onError?: (error: Event) => void; // 连接错误时的回调
   onClose?: () => void; // 连接关闭时的回调
@@ -26,6 +27,7 @@ export class WSClient<T = unknown> {
   private isVisitor: boolean;
   private agentId?: number; // 客服ID
   private wsToken?: string; // 客服 WS 令牌
+  private accessToken?: string; // 访客会话令牌
   private onMessage?: (message: WSMessage<T>) => void;
   private onError?: (error: Event) => void;
   private onClose?: () => void;
@@ -41,6 +43,7 @@ export class WSClient<T = unknown> {
     this.isVisitor = options.isVisitor !== undefined ? options.isVisitor : true;
     this.agentId = options.agentId;
     this.wsToken = options.wsToken;
+    this.accessToken = options.accessToken;
     this.onMessage = options.onMessage;
     this.onError = options.onError;
     this.onClose = options.onClose;
@@ -60,6 +63,9 @@ export class WSClient<T = unknown> {
     const protocol = typeof window !== 'undefined' && window.location.protocol === 'https:' ? 'wss:' : 'ws:';
     const host = typeof window !== 'undefined' ? window.location.host : '';
     let wsUrl = `${protocol}//${host}/ws?conversation_id=${this.conversationId}&is_visitor=${this.isVisitor}`;
+    if (this.isVisitor && this.accessToken) {
+      wsUrl += `&access_token=${encodeURIComponent(this.accessToken)}`;
+    }
     // 如果是客服连接,添加 agent_id 参数
     if (!this.isVisitor && this.agentId) {
       wsUrl += `&agent_id=${this.agentId}`;