commit efe801b8bfc638c07898fb0017b0046d942fc3df
Author: mlogclub
Web Widget / Open API] --> B[创建或匹配会话]
+ B --> C[进入 AI Agent]
+ C --> D{是否命中 Skill / 工具流程?}
+ D -- 是 --> E[执行 Skill / MCP Tool]
+ D -- 否 --> F[进入知识库检索]
+ F --> G[召回相关知识片段]
+ G --> H[LLM 生成回答]
+ E --> H
+ H --> I{回答是否可信且可直接返回?}
+ I -- 是 --> J[返回给用户]
+ J --> K{问题是否结束?}
+ K -- 否 --> C
+ K -- 是 --> L[结束会话或沉淀数据]
+ I -- 否 --> M[触发转人工策略]
+ M --> N[进入待接入池 / 指定客服组]
+ N --> O[客服工作台接管]
+ O --> P{是否需要工单跟踪?}
+ P -- 是 --> Q[创建或关联工单]
+ P -- 否 --> R[人工继续处理]
+ Q --> R
+ R --> S[问题解决并关闭]
+```
+
+## 适用场景
+
+- 官网在线客服
+- SaaS 产品支持
+- AI + 人工混合接待
+- 企业内部服务台或运营支持台
+- 需要知识库问答与人工协同的客服团队
+
+## 技术栈
+
+- Backend: Golang
+- Frontend: Next.js 16 + React 19 + shadcn/ui + Tailwind CSS
+- Database: SQLite / MySQL
+- Vector DB: Qdrant
+- AI: OpenAI-compatible LLM / Embedding + RAG + SKILLS + MCP
+
+## 项目结构
+
+```text
+.
+├── cmd/ # server / migration / generator
+├── internal/
+│ ├── controllers/ # API controllers
+│ ├── services/ # business services
+│ ├── repositories/ # data access
+│ ├── models/ # GORM models
+│ ├── migration/ # data migrations
+│ └── ai/ # LLM / RAG / MCP related logic
+├── web/ # admin console (Next.js)
+├── widget/ # embeddable customer chat widget
+├── config/ # config files
+└── docs/ # project docs
+```
+
+## 快速开始
+
+### 1. 环境要求
+
+- Go `1.26+`
+- Node.js `20+`
+- `pnpm`
+- Qdrant
+
+### 2. 准备配置
+
+复制示例配置:
+
+```bash
+cp config/config.example.yaml config/config.yaml
+```
+
+默认配置使用:
+
+- SQLite:`data/app.db`
+- Backend:`http://127.0.0.1:8083`
+- Qdrant gRPC:`127.0.0.1:6334`
+
+### 3. 启动 Qdrant
+
+如果你本地还没有 Qdrant,可以用 Docker 快速启动:
+
+```bash
+docker run -p 6333:6333 -p 6334:6334 qdrant/qdrant
+```
+
+### 4. 安装前端依赖
+
+```bash
+cd web
+pnpm install
+cd ..
+```
+
+### 5. 启动项目
+
+同时启动后端和前端:
+
+```bash
+make run
+```
+
+或分别启动:
+
+```bash
+make run-server
+make run-web
+```
+
+## 常用命令
+
+```bash
+make run # 同时启动后端和前端
+make run-server # 启动后端
+make run-web # 启动前端
+make build # 构建后端二进制
+make test # 运行 Go 测试
+make tidy # go mod tidy
+make generator # 执行代码生成
+make enums # 生成前端枚举
+make migration # 执行 migration
+```
+
+## 系统视角
+
+- 管理后台:负责 AI Agent、知识库、客服组、工单与运营配置
+- 客服工作台:负责接管会话、处理消息与人工服务
+- Widget:负责承接用户侧咨询入口
+
+这使得`贝壳AI客服`可以同时覆盖:
+
+- AI 接待
+- 人工协同
+- 知识驱动回答
+- 工单追踪闭环
+
+## 开源定位
+
+`贝壳AI客服`适合作为以下方向的开源基础项目:
+
+- AI 客服系统
+- AI Helpdesk / AI Support Platform
+- RAG + Human Handoff 的落地样板
+- 面向企业场景的 AI Agent 应用框架
+
+如果你在寻找一个 **以 AI Agent 为中心,而不是仅仅把 LLM 嵌进聊天框** 的客服系统,这个项目就是为此设计的。
diff --git a/cmd/enums/generator.go b/cmd/enums/generator.go
new file mode 100644
index 0000000..7cf1cb7
--- /dev/null
+++ b/cmd/enums/generator.go
@@ -0,0 +1,546 @@
+package main
+
+import (
+ "bytes"
+ "fmt"
+ "go/ast"
+ "go/token"
+ "os"
+ "path/filepath"
+ "sort"
+ "strconv"
+ "strings"
+
+ "golang.org/x/tools/go/packages"
+)
+
+const (
+ enumsPkgName = "enums"
+ enumsDir = "internal/pkg/enums"
+ outputPath = "web/lib/generated/enums.ts"
+)
+
+type enumValueType string
+
+const (
+ enumValueTypeInt enumValueType = "int"
+ enumValueTypeString enumValueType = "string"
+)
+
+type constDef struct {
+ Name string
+ ValueType enumValueType
+ Value any
+}
+
+type enumItem struct {
+ Name string
+ Value any
+ Label string
+ Order int
+}
+
+type enumDef struct {
+ Name string
+ ValueType enumValueType
+ Items []enumItem
+}
+
+func main() {
+ defs, err := parseEnums(enumsDir)
+ if err != nil {
+ panic(err)
+ }
+ content := buildTSFile(defs)
+ if err := os.MkdirAll(filepath.Dir(outputPath), 0o755); err != nil {
+ panic(err)
+ }
+ if err := os.WriteFile(outputPath, []byte(content), 0o644); err != nil {
+ panic(err)
+ }
+}
+
+func parseEnums(dir string) ([]enumDef, error) {
+ pkgs, err := packages.Load(&packages.Config{
+ Mode: packages.NeedName | packages.NeedFiles | packages.NeedSyntax,
+ Dir: ".",
+ }, "./"+filepath.ToSlash(dir))
+ if err != nil {
+ return nil, err
+ }
+ if packages.PrintErrors(pkgs) > 0 {
+ return nil, fmt.Errorf("failed to load package %s", dir)
+ }
+
+ var pkg *packages.Package
+ for _, candidate := range pkgs {
+ if candidate.Name == enumsPkgName {
+ pkg = candidate
+ break
+ }
+ }
+ if pkg == nil {
+ return nil, fmt.Errorf("package %s not found in %s", enumsPkgName, dir)
+ }
+
+ typeMap := make(map[string]enumValueType)
+ constMap := make(map[string]constDef)
+ orderMap := make(map[string]int)
+ order := 0
+
+ files := sortedPackageFiles(pkg)
+ for _, file := range files {
+ for _, decl := range file.Decls {
+ genDecl, ok := decl.(*ast.GenDecl)
+ if !ok {
+ continue
+ }
+ switch genDecl.Tok {
+ case token.TYPE:
+ readTypes(genDecl, typeMap)
+ case token.CONST:
+ readConsts(genDecl, typeMap, constMap, orderMap, &order)
+ }
+ }
+ }
+
+ var defs []enumDef
+ for _, file := range files {
+ for _, decl := range file.Decls {
+ genDecl, ok := decl.(*ast.GenDecl)
+ if !ok || genDecl.Tok != token.VAR {
+ continue
+ }
+ items := parseLabelMaps(genDecl, typeMap, constMap, orderMap)
+ defs = append(defs, items...)
+ }
+ }
+
+ sort.Slice(defs, func(i, j int) bool {
+ return defs[i].Name < defs[j].Name
+ })
+ return defs, nil
+}
+
+func sortedPackageFiles(pkg *packages.Package) []*ast.File {
+ type fileWithPath struct {
+ path string
+ file *ast.File
+ }
+
+ pairs := make([]fileWithPath, 0, len(pkg.Syntax))
+ for _, file := range pkg.Syntax {
+ pairs = append(pairs, fileWithPath{
+ path: pkg.Fset.Position(file.Package).Filename,
+ file: file,
+ })
+ }
+
+ sort.Slice(pairs, func(i, j int) bool {
+ return pairs[i].path < pairs[j].path
+ })
+
+ results := make([]*ast.File, 0, len(pairs))
+ for _, pair := range pairs {
+ results = append(results, pair.file)
+ }
+ return results
+}
+
+func readTypes(genDecl *ast.GenDecl, typeMap map[string]enumValueType) {
+ for _, spec := range genDecl.Specs {
+ typeSpec, ok := spec.(*ast.TypeSpec)
+ if !ok {
+ continue
+ }
+ ident, ok := typeSpec.Type.(*ast.Ident)
+ if !ok {
+ continue
+ }
+ valueType, ok := parseValueType(ident.Name)
+ if !ok {
+ continue
+ }
+ typeMap[typeSpec.Name.Name] = valueType
+ }
+}
+
+func readConsts(genDecl *ast.GenDecl, typeMap map[string]enumValueType, constMap map[string]constDef, orderMap map[string]int, order *int) {
+ for _, spec := range genDecl.Specs {
+ valueSpec, ok := spec.(*ast.ValueSpec)
+ if !ok {
+ continue
+ }
+
+ explicitTypeName := ""
+ explicitValueType := enumValueType("")
+ if valueSpec.Type != nil {
+ typeName := exprName(valueSpec.Type)
+ if valueType, ok := lookupValueType(typeName, typeMap); ok {
+ explicitTypeName = typeName
+ explicitValueType = valueType
+ }
+ }
+
+ for idx, name := range valueSpec.Names {
+ valueExpr := expressionAt(valueSpec.Values, idx)
+ if valueExpr == nil {
+ continue
+ }
+
+ valueType := explicitValueType
+ if valueType == "" {
+ valueType = inferValueType(valueExpr)
+ }
+ if valueType == "" {
+ continue
+ }
+
+ value, ok := parseLiteralValue(valueExpr, valueType)
+ if !ok {
+ continue
+ }
+
+ constMap[name.Name] = constDef{
+ Name: name.Name,
+ ValueType: valueType,
+ Value: value,
+ }
+ orderMap[name.Name] = *order
+ *order++
+ _ = explicitTypeName
+ }
+ }
+}
+
+func parseLabelMaps(genDecl *ast.GenDecl, typeMap map[string]enumValueType, constMap map[string]constDef, orderMap map[string]int) []enumDef {
+ var defs []enumDef
+ for _, spec := range genDecl.Specs {
+ valueSpec, ok := spec.(*ast.ValueSpec)
+ if !ok || len(valueSpec.Values) != 1 {
+ continue
+ }
+
+ compLit, ok := valueSpec.Values[0].(*ast.CompositeLit)
+ if !ok {
+ continue
+ }
+
+ mapType, ok := compLit.Type.(*ast.MapType)
+ if !ok {
+ continue
+ }
+
+ if exprName(mapType.Value) != "string" {
+ continue
+ }
+
+ keyTypeName := exprName(mapType.Key)
+ mapValueType, ok := lookupValueType(keyTypeName, typeMap)
+ if !ok {
+ continue
+ }
+
+ items := make([]enumItem, 0, len(compLit.Elts))
+ constNames := make([]string, 0, len(compLit.Elts))
+ for _, elt := range compLit.Elts {
+ kv, ok := elt.(*ast.KeyValueExpr)
+ if !ok {
+ continue
+ }
+
+ constName := exprName(kv.Key)
+ def, ok := constMap[constName]
+ if !ok || def.ValueType != mapValueType {
+ continue
+ }
+
+ label, ok := parseStringLiteral(kv.Value)
+ if !ok {
+ continue
+ }
+
+ items = append(items, enumItem{
+ Value: def.Value,
+ Label: label,
+ Order: orderMap[constName],
+ })
+ constNames = append(constNames, constName)
+ }
+
+ if len(items) == 0 {
+ continue
+ }
+
+ enumName := keyTypeName
+ if keyTypeName == "int" || keyTypeName == "string" {
+ enumName = exportedEnumName(valueSpec.Names[0].Name)
+ if enumName == "" {
+ enumName = commonIdentifierPrefix(constNames)
+ }
+ }
+ if enumName == "" {
+ continue
+ }
+
+ valid := true
+ for idx, constName := range constNames {
+ itemName := strings.TrimPrefix(constName, enumName)
+ if itemName == "" {
+ valid = false
+ break
+ }
+ items[idx].Name = itemName
+ }
+ if !valid {
+ continue
+ }
+
+ sort.Slice(items, func(i, j int) bool {
+ return items[i].Order < items[j].Order
+ })
+
+ defs = append(defs, enumDef{
+ Name: enumName,
+ ValueType: mapValueType,
+ Items: items,
+ })
+ }
+ return defs
+}
+
+func expressionAt(values []ast.Expr, idx int) ast.Expr {
+ if idx < len(values) {
+ return values[idx]
+ }
+ return nil
+}
+
+func exprName(expr ast.Expr) string {
+ switch v := expr.(type) {
+ case *ast.Ident:
+ return v.Name
+ default:
+ return ""
+ }
+}
+
+func parseValueType(name string) (enumValueType, bool) {
+ switch name {
+ case "int":
+ return enumValueTypeInt, true
+ case "string":
+ return enumValueTypeString, true
+ default:
+ return "", false
+ }
+}
+
+func lookupValueType(name string, typeMap map[string]enumValueType) (enumValueType, bool) {
+ if valueType, ok := parseValueType(name); ok {
+ return valueType, true
+ }
+ valueType, ok := typeMap[name]
+ return valueType, ok
+}
+
+func inferValueType(expr ast.Expr) enumValueType {
+ switch value := expr.(type) {
+ case *ast.BasicLit:
+ switch value.Kind {
+ case token.INT:
+ return enumValueTypeInt
+ case token.STRING:
+ return enumValueTypeString
+ }
+ case *ast.UnaryExpr:
+ if value.Op == token.SUB {
+ return inferValueType(value.X)
+ }
+ }
+ return ""
+}
+
+func parseLiteralValue(expr ast.Expr, valueType enumValueType) (any, bool) {
+ switch value := expr.(type) {
+ case *ast.BasicLit:
+ switch valueType {
+ case enumValueTypeInt:
+ parsed, err := strconv.Atoi(value.Value)
+ return parsed, err == nil
+ case enumValueTypeString:
+ parsed, err := strconv.Unquote(value.Value)
+ return parsed, err == nil
+ }
+ case *ast.UnaryExpr:
+ if valueType == enumValueTypeInt && value.Op == token.SUB {
+ parsed, ok := parseLiteralValue(value.X, valueType)
+ if !ok {
+ return nil, false
+ }
+ return -parsed.(int), true
+ }
+ }
+ return nil, false
+}
+
+func parseStringLiteral(expr ast.Expr) (string, bool) {
+ basicLit, ok := expr.(*ast.BasicLit)
+ if !ok || basicLit.Kind != token.STRING {
+ return "", false
+ }
+ value, err := strconv.Unquote(basicLit.Value)
+ return value, err == nil
+}
+
+func commonIdentifierPrefix(names []string) string {
+ if len(names) == 0 {
+ return ""
+ }
+
+ common := splitIdentifier(names[0])
+ for _, name := range names[1:] {
+ tokens := splitIdentifier(name)
+ limit := min(len(common), len(tokens))
+ idx := 0
+ for idx < limit && common[idx] == tokens[idx] {
+ idx++
+ }
+ common = common[:idx]
+ if len(common) == 0 {
+ return ""
+ }
+ }
+ return strings.Join(common, "")
+}
+
+func exportedEnumName(varName string) string {
+ base := strings.TrimSuffix(varName, "LabelMap")
+ if base == "" {
+ return ""
+ }
+
+ tokens := splitIdentifier(base)
+ if len(tokens) == 0 {
+ return ""
+ }
+
+ var result strings.Builder
+ for _, token := range tokens {
+ if token == "" {
+ continue
+ }
+ if isAllLower(token) && len(token) <= 3 {
+ result.WriteString(strings.ToUpper(token))
+ continue
+ }
+ result.WriteString(strings.ToUpper(token[:1]))
+ result.WriteString(token[1:])
+ }
+ return result.String()
+}
+
+func splitIdentifier(value string) []string {
+ if value == "" {
+ return nil
+ }
+
+ runes := []rune(value)
+ parts := make([]string, 0, 4)
+ start := 0
+ for idx := 1; idx < len(runes); idx++ {
+ prev := runes[idx-1]
+ curr := runes[idx]
+
+ if isBoundary(runes, idx, prev, curr) {
+ parts = append(parts, string(runes[start:idx]))
+ start = idx
+ }
+ }
+ parts = append(parts, string(runes[start:]))
+ return parts
+}
+
+func isBoundary(runes []rune, idx int, prev rune, curr rune) bool {
+ if isLower(prev) && isUpper(curr) {
+ return true
+ }
+ if isUpper(prev) && isUpper(curr) && idx+1 < len(runes) && isLower(runes[idx+1]) {
+ return true
+ }
+ if isDigit(prev) != isDigit(curr) {
+ return true
+ }
+ return false
+}
+
+func isLower(r rune) bool {
+ return r >= 'a' && r <= 'z'
+}
+
+func isUpper(r rune) bool {
+ return r >= 'A' && r <= 'Z'
+}
+
+func isDigit(r rune) bool {
+ return r >= '0' && r <= '9'
+}
+
+func isAllLower(value string) bool {
+ for _, r := range value {
+ if !isLower(r) {
+ return false
+ }
+ }
+ return true
+}
+
+func buildTSFile(defs []enumDef) string {
+ var buf bytes.Buffer
+ buf.WriteString("// Code generated by cmd/enums/generator.go. DO NOT EDIT.\n\n")
+ for _, def := range defs {
+ buf.WriteString(renderEnum(def))
+ buf.WriteString("\n")
+ buf.WriteString(renderLabels(def))
+ buf.WriteString("\n\n")
+ }
+ return strings.TrimRight(buf.String(), "\n") + "\n"
+}
+
+func renderEnum(def enumDef) string {
+ var lines []string
+ lines = append(lines, fmt.Sprintf("export enum %s {", def.Name))
+ for _, item := range def.Items {
+ lines = append(lines, fmt.Sprintf(" %s = %s,", item.Name, formatTSValue(item.Value, def.ValueType)))
+ }
+ lines = append(lines, "}")
+ return strings.Join(lines, "\n")
+}
+
+func renderLabels(def enumDef) string {
+ var lines []string
+ lines = append(lines, fmt.Sprintf("export const %sLabels: Record<%s, string> = {", def.Name, def.Name))
+ for _, item := range def.Items {
+ lines = append(lines, fmt.Sprintf(" [%s.%s]: %s,", def.Name, item.Name, strconv.Quote(item.Label)))
+ }
+ lines = append(lines, "}")
+ return strings.Join(lines, "\n")
+}
+
+func formatTSValue(value any, valueType enumValueType) string {
+ switch valueType {
+ case enumValueTypeString:
+ return strconv.Quote(value.(string))
+ case enumValueTypeInt:
+ return fmt.Sprint(value.(int))
+ default:
+ panic(fmt.Sprintf("unsupported enum value type: %s", valueType))
+ }
+}
+
+func min(a int, b int) int {
+ if a < b {
+ return a
+ }
+ return b
+}
diff --git a/cmd/generator/generator.go b/cmd/generator/generator.go
new file mode 100644
index 0000000..1c7b061
--- /dev/null
+++ b/cmd/generator/generator.go
@@ -0,0 +1,69 @@
+package main
+
+import (
+ "cs-agent/internal/models"
+
+ "github.com/mlogclub/codegen"
+)
+
+func main() {
+ codegen.GenerateWithOption(
+ codegen.Options{
+ BaseDir: "./",
+ PkgName: "cs-agent",
+ Version: 1,
+ Repository: true,
+ Service: true,
+ Controller: false,
+ WebIndex: false,
+ WebEdit: false,
+ },
+ codegen.GetGenerateStruct(&models.Migration{}),
+ codegen.GetGenerateStruct(&models.User{}),
+ codegen.GetGenerateStruct(&models.UserIdentity{}),
+ codegen.GetGenerateStruct(&models.Company{}),
+ codegen.GetGenerateStruct(&models.Customer{}),
+ codegen.GetGenerateStruct(&models.CustomerIdentity{}),
+ codegen.GetGenerateStruct(&models.CustomerContact{}),
+ codegen.GetGenerateStruct(&models.Role{}),
+ codegen.GetGenerateStruct(&models.Permission{}),
+ codegen.GetGenerateStruct(&models.UserRole{}),
+ codegen.GetGenerateStruct(&models.RolePermission{}),
+ codegen.GetGenerateStruct(&models.UserPermission{}),
+ codegen.GetGenerateStruct(&models.LoginSession{}),
+ codegen.GetGenerateStruct(&models.LoginCredentialLog{}),
+ codegen.GetGenerateStruct(&models.Asset{}),
+ codegen.GetGenerateStruct(&models.Tag{}),
+ codegen.GetGenerateStruct(&models.Conversation{}),
+ codegen.GetGenerateStruct(&models.ConversationParticipant{}),
+ codegen.GetGenerateStruct(&models.Message{}),
+ codegen.GetGenerateStruct(&models.WxWorkKFSyncState{}),
+ codegen.GetGenerateStruct(&models.WxWorkKFConversation{}),
+ codegen.GetGenerateStruct(&models.WxWorkKFMessageRef{}),
+ codegen.GetGenerateStruct(&models.ChannelMessageOutbox{}),
+ codegen.GetGenerateStruct(&models.ConversationAssignment{}),
+ codegen.GetGenerateStruct(&models.ConversationTag{}),
+ codegen.GetGenerateStruct(&models.QuickReply{}),
+ codegen.GetGenerateStruct(&models.AIAgent{}),
+ codegen.GetGenerateStruct(&models.Channel{}),
+ codegen.GetGenerateStruct(&models.ConversationEventLog{}),
+ codegen.GetGenerateStruct(&models.Ticket{}),
+ codegen.GetGenerateStruct(&models.TicketTag{}),
+ codegen.GetGenerateStruct(&models.TicketResolutionCode{}),
+ codegen.GetGenerateStruct(&models.TicketPriorityConfig{}),
+ codegen.GetGenerateStruct(&models.TicketComment{}),
+ codegen.GetGenerateStruct(&models.TicketWatcher{}),
+ codegen.GetGenerateStruct(&models.TicketEventLog{}),
+ codegen.GetGenerateStruct(&models.TicketSLARecord{}),
+ codegen.GetGenerateStruct(&models.TicketRelation{}),
+ codegen.GetGenerateStruct(&models.AgentProfile{}),
+ codegen.GetGenerateStruct(&models.AgentTeam{}),
+ codegen.GetGenerateStruct(&models.AgentTeamSchedule{}),
+ codegen.GetGenerateStruct(&models.AIConfig{}),
+ codegen.GetGenerateStruct(&models.SkillDefinition{}),
+ codegen.GetGenerateStruct(&models.SkillRunLog{}),
+ codegen.GetGenerateStruct(&models.AgentRunLog{}),
+ codegen.GetGenerateStruct(&models.SystemConfig{}),
+ )
+
+}
diff --git a/cmd/migration/main.go b/cmd/migration/main.go
new file mode 100644
index 0000000..7ce43c2
--- /dev/null
+++ b/cmd/migration/main.go
@@ -0,0 +1,32 @@
+package main
+
+import (
+ "log/slog"
+
+ "cs-agent/internal/bootstrap"
+ "cs-agent/internal/pkg/config"
+ "cs-agent/internal/pkg/logx"
+)
+
+func main() {
+ cfg, err := config.Load("config/config.yaml")
+ if err != nil {
+ slog.Error("load config failed", "error", err)
+ return
+ }
+ logx.Init(logx.Config{
+ Level: cfg.Logger.Level,
+ Format: cfg.Logger.Format,
+ AddSource: cfg.Logger.AddSource,
+ })
+
+ if _, err = bootstrap.InitDB(cfg.DB); err != nil {
+ slog.Error("init db failed", "error", err)
+ return
+ }
+ if err = bootstrap.InitMigrations(); err != nil {
+ slog.Error("run migrations failed", "error", err)
+ return
+ }
+ slog.Info("migrations completed")
+}
diff --git a/cmd/testdata/.gitignore b/cmd/testdata/.gitignore
new file mode 100644
index 0000000..c5f6ce3
--- /dev/null
+++ b/cmd/testdata/.gitignore
@@ -0,0 +1 @@
+aiconfig/ai_config.yaml
diff --git a/cmd/testdata/README_AGENTTEAM.md b/cmd/testdata/README_AGENTTEAM.md
new file mode 100644
index 0000000..55c5199
--- /dev/null
+++ b/cmd/testdata/README_AGENTTEAM.md
@@ -0,0 +1,169 @@
+# Agent Team (客服组) 初始化文档
+
+## 概述
+
+Agent Team 初始化模块在 `cmd/testdata/agentteam/init.go` 中,用于创建客服组和客服人员的测试数据。
+
+## 初始化过程
+
+该模块在 AI Agent 初始化**之前**调用,按照以下顺序初始化:
+
+1. **客服组创建**
+ - 名称:`默认客服组`
+ - 组长:Bootstrap 管理员用户(username: `admin`)
+ - 状态:启用
+ - 描述:`Local testdata seed - default service team`
+
+2. **客服用户创建**
+ - **客服A**:
+ - 用户名:`agent_a`
+ - 昵称:`客服A`
+ - 工号:`AGENT_A`
+ - **客服B**:
+ - 用户名:`agent_b`
+ - 昵称:`客服B`
+ - 工号:`AGENT_B`
+ - 默认密码:`ChangeMe123!`(与管理员相同)
+
+3. **客服档案创建**
+ - 为每个客服用户创建 `AgentProfile` 记录
+ - 关联到 `默认客服组`
+ - 初始设置:
+ - 服务状态:`空闲(ServiceStatusIdle)`
+ - 最大并发接待数:`5`
+ - 自动分配优先级:`10`
+ - 开启自动分配:`true`
+
+## 返回结果
+
+```go
+type InitResult struct {
+ TeamCreated bool // 是否新创建了客服组
+ UsersCreated int // 新创建的用户数
+ ProfilesCreated int // 新创建的客服档案数
+ UpdatesApplied int // 更新的档案数
+}
+```
+
+## 幂等性
+
+该初始化过程是幂等的:
+
+- **客服组**:通过 `name` 字段去重。若组已存在,则只更新组长信息
+- **客服用户**:通过 `username` 字段去重。若用户已存在,则只更新昵称和状态
+- **客服档案**:通过 `user_id` 字段去重。若档案已存在,则更新关键信息(组ID、工号、显示名等)
+
+## 事务支持
+
+所有数据库操作均使用事务包装:
+
+```go
+sqls.WithTransaction(func(ctx *sqls.TxContext) error { ... })
+```
+
+确保数据一致性,任何操作失败都会导致整个事务回滚。
+
+## 审计字段
+
+创建和更新的所有记录都包含标准审计字段:
+
+```go
+AuditFields{
+ CreatedAt: now,
+ CreateUserID: constants.SystemAuditUserID, // 0
+ CreateUserName: constants.SystemAuditUserName, // "System"
+ UpdatedAt: now,
+ UpdateUserID: constants.SystemAuditUserID, // 0
+ UpdateUserName: constants.SystemAuditUserName, // "System"
+}
+```
+
+## 调用顺序
+
+在 `cmd/testdata/main.go` 中的初始化顺序:
+
+```
+1. aiconfig.Init() // AI Configuration
+2. kb.Init() // Knowledge Base
+3. agentteam.Init() // Agent Team ← 客服组初始化
+4. aiagent.Init() // AI Agent (可使用 TeamIDs 字段)
+5. widgetsite.Init() // Widget Site
+```
+
+## 测试运行
+
+运行所有测试数据初始化:
+
+```bash
+cd cs-agent
+go run cmd/testdata/main.go -config config/config.yaml
+# 或使用 -yes 标志跳过确认
+go run cmd/testdata/main.go -config config/config.yaml -yes
+```
+
+初始化输出示例:
+
+```
+2026-03-25T10:30:45.123+08:00 INFO testdata initialization completed
+ droppedTables=45
+ aiConfigSkipped=false
+ aiConfigFile=cmd/testdata/aiconfig/ai_config.local.yaml
+ aiConfigCreated=3
+ aiConfigUpdated=0
+ knowledgeBaseID=1
+ kbChaptersTotal=120
+ kbDocumentsCreated=120
+ kbDocumentsUpdated=0
+ agentTeamCreated=true
+ agentTeamUsersCreated=2
+ agentTeamProfilesCreated=2
+ agentTeamUpdatesApplied=0
+ aiAgentCreated=1
+ aiAgentUpdated=0
+ widgetSiteCreated=2
+ widgetSiteUpdated=0
+```
+
+## 扩展说明
+
+### 添加更多客服
+
+如需在初始化中添加更多客服,修改 `initUsersAndProfiles()` 函数中的 `agentUsers` 切片:
+
+```go
+agentUsers := []struct {
+ username string
+ nickname string
+ code string
+}{
+ { username: "agent_a", nickname: "客服A", code: "AGENT_A" },
+ { username: "agent_b", nickname: "客服B", code: "AGENT_B" },
+ { username: "agent_c", nickname: "客服C", code: "AGENT_C" }, // 新增
+}
+```
+
+### 自定义客服组名称和组长
+
+修改 `initTeam()` 函数:
+
+```go
+func initTeam(leaderUserID int64) (bool, error) {
+ teamName := "你的自定义组名" // 修改这里
+ // ...
+}
+```
+
+## 相关模型
+
+- `models.AgentTeam` - 客服组
+- `models.User` - 用户账号
+- `models.AgentProfile` - 客服档案
+- `models.AuditFields` - 审计字段
+- `enums.ServiceStatus` - 服务状态(空闲/忙碌)
+- `enums.Status` - 通用状态
+
+## 相关仓库
+
+- `repositories.AgentTeamRepository`
+- `repositories.UserRepository`
+- `repositories.AgentProfileRepository`
diff --git a/cmd/testdata/README_AI_AGENT.md b/cmd/testdata/README_AI_AGENT.md
new file mode 100644
index 0000000..f11a6de
--- /dev/null
+++ b/cmd/testdata/README_AI_AGENT.md
@@ -0,0 +1,145 @@
+# AI Agent 初始化说明
+
+## 初始化流程
+
+`cmd/testdata/main.go` 中的数据初始化按以下顺序执行:
+
+1. **删除所有现有表** - 清空数据库
+2. **运行 migrations** - 创建表结构
+3. **初始化 AI 配置** (`aiconfig.Init()`)
+ - 种子文件:`cmd/testdata/aiconfig/ai_config.local.yaml`
+ - 包含 LLM、Embedding、Rerank 三种模型配置
+4. **初始化知识库** (`kb.Init()`)
+ - 从 HTML 文件读取知识库数据
+ - 创建知识库和文档
+5. **初始化 AI Agent** (`aiagent.Init()`)
+ - 依赖于 AI Config(需要启用的 LLM 配置)
+ - 依赖于 Knowledge Base(需要已创建的知识库)
+ - 创建测试 AI 凯服 Agent
+
+## AI Agent 配置
+
+### 种子数据字段说明
+
+| 字段 | 说明 | 示例值 |
+|-----|------|-------|
+| `name` | Agent 唯一标识 | `测试AI客服` |
+| `description` | Agent 描述 | `本地测试 AI 客服 Agent` |
+| `status` | 启用状态 | `0`(启用) / `1`(禁用) |
+| `aiConfigID` | 绑定的 AI 配置 ID | 自动获取启用的 LLM 配置 |
+| `serviceMode` | 服务模式 | `3`(AI 优先)/ `1`(仅 AI)/ `2`(仅人工) |
+| `systemPrompt` | 系统提示词 | 详见下方 |
+| `welcomeMessage` | 欢迎语 | `您好,欢迎咨询!` |
+| `teamIDs` | 可转接的客服组 ID | 多个 ID 逗号分隔(可为空) |
+| `handoffMode` | 转人工模式 | `1`(待接入池)/ `2`(客服组池)/ `3`(AI托底) |
+| `maxAIReplyRounds` | 单会话最大 AI 回复次数 | `5`(超过后强制转人工) |
+| `fallbackMode` | 无答案兜底模式 | `1`(直接声明无答案)/ `2`(引导补充信息)/ `3`(转人工) |
+| `knowledgeIDs` | 绑定的知识库 ID | 自动获取启用的知识库 |
+| `sortNo` | 排序号 | `10`(用于后台展示排序) |
+| `remark` | 备注 | `Local testdata seed` |
+
+### 默认测试 Agent 配置
+
+```yaml
+名称: 测试AI客服
+AI 配置: 最新启用的 LLM 配置
+知识库: 最新启用的知识库(水浒传)
+服务模式: AI 优先(AI 先回答,客户满意后关闭,不满意转人工)
+系统提示词: 你是一个友好的客服助手,请用中文回答用户的问题。
+最大回复次数: 5 轮(超过后强制转人工)
+转人工模式: 进入待接入池
+无答案处理: 引导补充信息或换个问法
+```
+
+## 使用步骤
+
+### 1. 初始化测试数据
+
+```bash
+# 创建 AI Config 种子文件
+cp cmd/testdata/aiconfig/ai_config.local.example.yaml cmd/testdata/aiconfig/ai_config.local.yaml
+
+# 编辑,填入真实的 API 密钥
+vim cmd/testdata/aiconfig/ai_config.local.yaml
+
+# 执行初始化(自动模式)
+go run cmd/testdata/main.go -yes
+
+# 或交互模式,输入 INIT 确认
+go run cmd/testdata/main.go
+```
+
+### 2. 验证初始化结果
+
+```bash
+# 查看数据库中的 AI Agent
+mysql> SELECT id, name, status, service_mode FROM t_ai_agent;
+
+# 查看日志输出
+# 输出示例:
+# {"level":"info","msg":"testdata initialization completed","droppedTables":38,"aiConfigSkipped":false,"aiConfigCreated":3,"aiConfigUpdated":0,"aiConfigFile":"cmd/testdata/aiconfig/ai_config.local.yaml","chapters":120,"createdDocuments":120,"updatedDocuments":0,"knowledgeBaseID":1,"aiAgentCreated":1,"aiAgentUpdated":0}
+```
+
+## 扩展和自定义
+
+### 添加更多 AI Agent
+
+在 `cmd/testdata/aiagent/init.go` 的 `buildSeedItems()` 函数中添加更多 Agent 配置:
+
+```go
+func buildSeedItems(aiConfigID, knowledgeID int64) []models.AIAgent {
+ now := time.Now()
+ return []models.AIAgent{
+ {
+ Name: "测试AI客服",
+ // ... 现有配置
+ },
+ {
+ Name: "投诉处理专员",
+ Description: "专门处理投诉的 AI Agent",
+ ServiceMode: enums.IMConversationServiceModeHumanOnly, // 仅人工
+ SystemPrompt: "你是一个专业的投诉处理专员...",
+ // ... 其他配置
+ },
+ }
+}
+```
+
+### 修改默认 Agent 配置
+
+直接编辑 `buildSeedItems()` 函数中的 Agent 定义,重新执行 `go run cmd/testdata/main.go -yes` 即可更新。
+
+### 动态查询 AI 配置
+
+如需在初始化时获取特定配置的 ID,修改 `getDefaultAIConfigID()` 和 `getDefaultKnowledgeID()` 函数:
+
+```go
+// 示例:获取特定提供商的配置
+func getSpecificAIConfigID(modelType enums.AIModelType, provider enums.AIProvider) int64 {
+ aiConfig := repositories.AIConfigRepository.Take(
+ sqls.DB(),
+ "model_type = ? AND provider = ? AND status = ?",
+ string(modelType),
+ string(provider),
+ enums.StatusOk,
+ )
+ if aiConfig != nil {
+ return aiConfig.ID
+ }
+ return 0
+}
+```
+
+## 常见问题
+
+**Q: 为什么 AI Agent 初始化失败?**
+A: 检查以下几点:
+- AI Config 是否初始化成功(需要至少一个启用的 LLM 配置)
+- Knowledge Base 是否初始化成功(需要至少一个启用的知识库)
+- 查看错误日志中的具体错误信息
+
+**Q: 如何更改 Agent 的系统提示词?**
+A: 编辑 `buildSeedItems()` 中的 `SystemPrompt` 字段,然后重新运行初始化。
+
+**Q: 初始化时如何指定特定的 AI 配置?**
+A: 修改 `getDefaultAIConfigID()` 函数,改为查询特定的配置名称或属性。
diff --git a/cmd/testdata/agentteam/init.go b/cmd/testdata/agentteam/init.go
new file mode 100644
index 0000000..e8389f8
--- /dev/null
+++ b/cmd/testdata/agentteam/init.go
@@ -0,0 +1,219 @@
+package agentteam
+
+import (
+ "cs-agent/internal/models"
+ "cs-agent/internal/pkg/constants"
+ "cs-agent/internal/pkg/enums"
+ "cs-agent/internal/repositories"
+ "fmt"
+ "time"
+
+ "github.com/mlogclub/simple/sqls"
+ "golang.org/x/crypto/bcrypt"
+)
+
+type InitResult struct {
+ TeamCreated bool
+ UsersCreated int
+ ProfilesCreated int
+ UpdatesApplied int
+}
+
+// Init 初始化客服组和客服用户
+// 创建:
+// 1. 客服组,组长为管理员用户
+// 2. 客服A 用户
+// 3. 客服B 用户
+// 4. 为客服A和客服B创建客服档案,关联到该客服组
+func Init() (*InitResult, error) {
+ result := &InitResult{}
+
+ // 获取管理员用户
+ adminUser := repositories.UserRepository.Take(
+ sqls.DB(),
+ "username = ?",
+ constants.BootstrapAdminUsername,
+ )
+ if adminUser == nil {
+ return result, fmt.Errorf("bootstrap admin user not found")
+ }
+
+ err := sqls.WithTransaction(func(ctx *sqls.TxContext) error {
+ return initTeamAndUsers(ctx, adminUser, result)
+ })
+ if err != nil {
+ return result, fmt.Errorf("init team and users failed: %w", err)
+ }
+
+ return result, nil
+}
+
+func initTeamAndUsers(ctx *sqls.TxContext, leaderUser *models.User, result *InitResult) error {
+ teamName := "默认客服组"
+ now := time.Now()
+
+ team := repositories.AgentTeamRepository.Take(ctx.Tx, "name = ?", teamName)
+ if team != nil {
+ if err := ctx.Tx.Model(team).Updates(map[string]any{
+ "leader_user_id": leaderUser.ID,
+ "update_user_id": constants.SystemAuditUserID,
+ "update_user_name": constants.SystemAuditUserName,
+ "updated_at": now,
+ }).Error; err != nil {
+ return err
+ }
+ } else {
+ team = &models.AgentTeam{
+ Name: teamName,
+ LeaderUserID: leaderUser.ID,
+ Status: enums.StatusOk,
+ Description: "Local testdata seed - default service team",
+ AuditFields: models.AuditFields{
+ CreatedAt: now,
+ CreateUserID: constants.SystemAuditUserID,
+ CreateUserName: constants.SystemAuditUserName,
+ UpdatedAt: now,
+ UpdateUserID: constants.SystemAuditUserID,
+ UpdateUserName: constants.SystemAuditUserName,
+ },
+ }
+ if err := ctx.Tx.Create(team).Error; err != nil {
+ return err
+ }
+ result.TeamCreated = true
+ }
+
+ agentUsers := []struct {
+ username string
+ nickname string
+ code string
+ }{
+ {
+ username: leaderUser.Username,
+ nickname: "客服组长",
+ code: "AGENT_LEADER_A",
+ },
+ {
+ username: "agent_a",
+ nickname: "客服A",
+ code: "AGENT_A",
+ },
+ {
+ username: "agent_b",
+ nickname: "客服B",
+ code: "AGENT_B",
+ },
+ }
+
+ for _, agentUser := range agentUsers {
+ userID, userCreated, err := createOrGetUser(ctx, agentUser.username, agentUser.nickname)
+ if err != nil {
+ return err
+ }
+ if userCreated {
+ result.UsersCreated++
+ }
+
+ // 创建或更新客服档案
+ profileCreated, err := createOrUpdateProfile(ctx, userID, team.ID, agentUser.code, agentUser.nickname)
+ if err != nil {
+ return err
+ }
+ if profileCreated {
+ result.ProfilesCreated++
+ } else {
+ result.UpdatesApplied++
+ }
+ }
+
+ return nil
+}
+
+func createOrGetUser(ctx *sqls.TxContext, username, nickname string) (int64, bool, error) {
+ user := repositories.UserRepository.Take(
+ ctx.Tx,
+ "username = ?",
+ username,
+ )
+
+ if user != nil {
+ return user.ID, false, nil
+ }
+
+ // 创建新用户
+ hashedPassword, err := bcrypt.GenerateFromPassword([]byte(constants.BootstrapAdminPassword), bcrypt.DefaultCost)
+ if err != nil {
+ return 0, false, err
+ }
+
+ now := time.Now()
+ newUser := &models.User{
+ Username: username,
+ Nickname: nickname,
+ Password: string(hashedPassword),
+ Status: enums.StatusOk,
+ Remark: "Local testdata seed",
+ AuditFields: models.AuditFields{
+ CreatedAt: now,
+ CreateUserID: constants.SystemAuditUserID,
+ CreateUserName: constants.SystemAuditUserName,
+ UpdatedAt: now,
+ UpdateUserID: constants.SystemAuditUserID,
+ UpdateUserName: constants.SystemAuditUserName,
+ },
+ }
+
+ if err := ctx.Tx.Create(newUser).Error; err != nil {
+ return 0, false, err
+ }
+
+ return newUser.ID, true, nil
+}
+
+func createOrUpdateProfile(ctx *sqls.TxContext, userID, teamID int64, agentCode, displayName string) (bool, error) {
+ profile := repositories.AgentProfileRepository.Take(
+ ctx.Tx,
+ "user_id = ?",
+ userID,
+ )
+
+ now := time.Now()
+
+ if profile != nil {
+ // 档案已存在,更新关键信息
+ return false, ctx.Tx.Model(profile).Updates(map[string]any{
+ "team_id": teamID,
+ "agent_code": agentCode,
+ "display_name": displayName,
+ "status": enums.StatusOk,
+ "update_user_id": constants.SystemAuditUserID,
+ "update_user_name": constants.SystemAuditUserName,
+ "updated_at": now,
+ }).Error
+ }
+
+ // 创建新档案
+ newProfile := &models.AgentProfile{
+ UserID: userID,
+ TeamID: teamID,
+ AgentCode: agentCode,
+ DisplayName: displayName,
+ Avatar: "",
+ ServiceStatus: enums.ServiceStatusIdle,
+ MaxConcurrentCount: 5,
+ PriorityLevel: 10,
+ AutoAssignEnabled: true,
+ Status: enums.StatusOk,
+ Remark: "Local testdata seed",
+ AuditFields: models.AuditFields{
+ CreatedAt: now,
+ CreateUserID: constants.SystemAuditUserID,
+ CreateUserName: constants.SystemAuditUserName,
+ UpdatedAt: now,
+ UpdateUserID: constants.SystemAuditUserID,
+ UpdateUserName: constants.SystemAuditUserName,
+ },
+ }
+
+ return true, ctx.Tx.Create(newProfile).Error
+}
diff --git a/cmd/testdata/aiagent/init.go b/cmd/testdata/aiagent/init.go
new file mode 100644
index 0000000..98c000f
--- /dev/null
+++ b/cmd/testdata/aiagent/init.go
@@ -0,0 +1,152 @@
+package aiagent
+
+import (
+ "cs-agent/cmd/testdata/skill"
+ "cs-agent/internal/models"
+ "cs-agent/internal/pkg/enums"
+ "cs-agent/internal/pkg/utils"
+ "cs-agent/internal/repositories"
+ "fmt"
+ "time"
+
+ "github.com/mlogclub/simple/sqls"
+)
+
+type InitResult struct {
+ Created int
+ Updated int
+}
+
+// Init 初始化 AI Agent 测试数据
+// 依赖于 AI Config 和 Knowledge Base 已初始化
+func Init() (*InitResult, error) {
+ result := &InitResult{}
+
+ aiConfigID, err := getDefaultAIConfigID()
+ if err != nil {
+ return result, fmt.Errorf("get default ai config id failed: %w", err)
+ }
+ if aiConfigID == 0 {
+ return result, fmt.Errorf("no default ai config found, please init ai config first")
+ }
+
+ knowledgeIDs, err := getDefaultKnowledgeIDs()
+ if err != nil {
+ return result, fmt.Errorf("get default knowledge ids failed: %w", err)
+ }
+
+ defaultTeamIDs := getDefaultTeamIDs()
+ defaultSkillIDs, err := getDefaultSkillIDs()
+ if err != nil {
+ return result, fmt.Errorf("get default skill ids failed: %w", err)
+ }
+
+ seedItems := buildSeedItems(aiConfigID, knowledgeIDs, defaultTeamIDs, defaultSkillIDs)
+ for _, item := range seedItems {
+ itemCopy := item
+ if err := sqls.WithTransaction(func(ctx *sqls.TxContext) error {
+ existing := repositories.AIAgentRepository.Take(ctx.Tx, "name = ?", itemCopy.Name)
+ if existing != nil {
+ // 更新
+ if err := ctx.Tx.Model(existing).Updates(&itemCopy).Error; err != nil {
+ return err
+ }
+ result.Updated++
+ } else {
+ // 创建
+ if err := ctx.Tx.Create(&itemCopy).Error; err != nil {
+ return err
+ }
+ result.Created++
+ }
+ return nil
+ }); err != nil {
+ return nil, fmt.Errorf("upsert ai agent failed: %w", err)
+ }
+ }
+
+ return result, nil
+}
+
+func buildSeedItems(aiConfigID int64, knowledgeIDs []int64, defaultTeamIDs string, defaultSkillIDs string) []models.AIAgent {
+ now := time.Now()
+ return []models.AIAgent{
+ {
+ Name: "测试AI客服",
+ Description: "本地测试 AI 客服 Agent",
+ Status: enums.StatusOk,
+ AIConfigID: aiConfigID,
+ ServiceMode: enums.IMConversationServiceModeAIFirst,
+ SystemPrompt: "你是一个友好的客服助手,请用中文回答用户的问题。",
+ WelcomeMessage: "您好,欢迎咨询!有什么可以帮助您的?",
+ ReplyTimeoutSeconds: 180,
+ TeamIDs: defaultTeamIDs,
+ HandoffMode: enums.AIAgentHandoffModeWaitPool,
+ MaxAIReplyRounds: 0,
+ FallbackMode: enums.AIAgentFallbackModeGuideRephrase,
+ FallbackMessage: "我暂时没有找到足够准确的信息。你可以补充订单号、产品名或更具体的问题,我再继续帮你查。",
+ KnowledgeIDs: utils.JoinInt64s(knowledgeIDs),
+ SkillIDs: defaultSkillIDs,
+ SortNo: 10,
+ Remark: "Local testdata seed",
+ AuditFields: models.AuditFields{
+ CreatedAt: now,
+ CreateUserID: 0,
+ CreateUserName: "System",
+ UpdatedAt: now,
+ UpdateUserID: 0,
+ UpdateUserName: "System",
+ },
+ },
+ }
+}
+
+func getDefaultAIConfigID() (int64, error) {
+ aiConfig := repositories.AIConfigRepository.Take(
+ sqls.DB(),
+ "model_type = ? AND status = ?",
+ string(enums.AIModelTypeLLM),
+ enums.StatusOk,
+ )
+ if aiConfig == nil {
+ return 0, nil
+ }
+ return aiConfig.ID, nil
+}
+
+func getDefaultKnowledgeIDs() ([]int64, error) {
+ knowledges := repositories.KnowledgeBaseRepository.Find(
+ sqls.DB(),
+ sqls.NewCnd().Where("status = ?", enums.StatusOk),
+ )
+ ids := make([]int64, 0, len(knowledges))
+ for _, knowledge := range knowledges {
+ ids = append(ids, knowledge.ID)
+ }
+ return ids, nil
+}
+
+func getDefaultTeamIDs() string {
+ teams := repositories.AgentTeamRepository.Find(
+ sqls.DB(),
+ sqls.NewCnd().Where("status = ?", enums.StatusOk),
+ )
+ teamIDs := make([]int64, 0, len(teams))
+ for _, team := range teams {
+ teamIDs = append(teamIDs, team.ID)
+ }
+ return utils.JoinInt64s(teamIDs)
+}
+
+func getDefaultSkillIDs() (string, error) {
+ skillItem := repositories.SkillDefinitionRepository.Take(
+ sqls.DB(),
+ "code = ? AND status = ?",
+ skill.TestGreetingSkillCode,
+ enums.StatusOk,
+ )
+ if skillItem == nil {
+ return "", fmt.Errorf("default test skill not found: %s", skill.TestGreetingSkillCode)
+ }
+ return utils.JoinInt64s([]int64{skillItem.ID}), nil
+}
diff --git a/cmd/testdata/aiconfig/ai_config.example.yaml b/cmd/testdata/aiconfig/ai_config.example.yaml
new file mode 100644
index 0000000..eea0fe8
--- /dev/null
+++ b/cmd/testdata/aiconfig/ai_config.example.yaml
@@ -0,0 +1,76 @@
+# 使用的时候,将文件复制到 cmd/testdata/aiconfig/ 目录下,并重命名为 ai_config.yaml
+#
+# 配置规则说明:
+# 1. name: 配置的唯一标识,系统通过此字段进行去重和更新
+# 2. provider: 模型提供商,固定写:openai,系统目前ai接口调用都走的openai协议
+# 3. baseUrl: API 服务地址
+# 4. apiKey: API 密钥
+# 5. modelType: 模型类型,必须为以下之一:
+# - llm: 大语言模型,用于对话和推理
+# - embedding: 文本向量化模型,用于生成文本嵌入向量
+# - rerank: 排序模型,用于重排序搜索结果
+# 6. modelName: 具体的模型名称,由提供商指定
+# 7. dimension: 向量维度
+# - llm 和 rerank: 通常设为 0
+# - embedding: 向量维度数,如 OpenAI text-embedding-3-small 为 1536
+# 8. maxContextTokens: 最大上下文令牌数,0 表示无限制
+# - llm 和 embedding: 分别表示模型支持的最大输入令牌数
+# - rerank: 通常设为 0
+# 9. maxOutputTokens: 最大输出令牌数
+# - llm: 模型支持的最大输出令牌数
+# - embedding 和 rerank: 通常设为 0
+# 10. timeoutMs: 请求超时时间(毫秒)
+# 11. maxRetryCount: 失败重试次数
+# 12. rpmLimit: 每分钟请求限制,0 表示无限制
+# 13. tpmLimit: 每分钟令牌限制,0 表示无限制
+# 15. sortNo: 排序号,用于同类型模型的优先级排序(数字越小优先级越高)
+# 16. remark: 备注信息,用于描述此配置的用途或特殊说明
+#
+items:
+ - name: LLM模型
+ provider: openai
+ baseUrl: https://dashscope.aliyuncs.com/compatible-mode/v1
+ apiKey:
诗曰:
绛帻鸡人报晓筹,尚衣方进翠云裘。九天阊阖开宫殿,万国衣冠拜冕旒。
日色才临仙掌动,香烟欲傍衮龙浮。朝罢须裁五色诏,佩声归到凤池头。
话说大宋仁宗天子在位,嘉祐三年三月三日五更三点,天子驾坐紫宸殿,受百官朝贺。但见:
祥云迷凤阁,瑞气罩龙楼。含烟御柳拂旌旗,带露宫花迎剑戟。天香影里,玉簪珠履聚丹墀;仙乐声中,绣袄锦衣扶御驾。珍珠帘卷,黄金殿上现金舆;凤尾扇开,白玉阶前停宝辇。隐隐净鞭三下响,层层文武两班齐。
当有殿头官喝道:“有事出班早奏,无事卷帘退朝。”只见班部丛中,宰相赵哲、参政文彦博出班奏曰:“目今京师瘟疫盛行,民不聊生,伤损军民多矣。伏望陛下释罪宽恩,省刑薄税,以禳天灾,救济万民。”天子听奏,急敕翰林院随即草诏:一面降赦天下罪囚,应有民间税赋悉皆赦免;一面命在京宫观寺院,修设好事禳灾。不料其年瘟疫转盛。仁宗天子闻知,龙体不安。复会百官,众皆计议。向那班部中,有一大臣越班启奏。天子看时,乃是参知政事范仲淹。拜罢起居,奏曰:“目今天灾盛行,军民涂炭,日夕不能聊生,人遭缧绁之厄。以臣愚意,要禳此灾,可宣嗣汉天师星夜临朝,就京师禁院修设三千六百分罗天大醮,奏闻上帝,可以禳保民间瘟疫。”仁宗天子准奏。急令翰林学士草诏一道,天子御笔亲书,并降御香一炷,钦差内外提点殿前太尉洪信为天使,前往江西信州龙虎山,宣请嗣汉天师张真人星夜临朝,祈禳瘟疫。就金殿上焚起御香,亲将丹诏付与洪太尉为使,即便登程前去。
洪信领了圣敕,辞别天子,不敢久停。从人背了诏书,金盒子盛了御香,带了数十人,上了铺马,一行部从,离了东京,取路径投信州贵溪县来。于路上但见:
遥山叠翠,远水澄清。奇花绽锦绣铺林,嫩柳舞金丝拂地。风和日暖,时过野店山村;路直沙平,夜宿邮亭驿馆。罗衣荡漾红尘内,骏马驱驰紫陌中。
且说太尉洪信赍擎御书丹诏,一行人从上了路途,夜宿邮亭,朝行驿站,远程近接,渴饮饥餐,不止一日,来到江西信州。大小官员出郭迎接,随即差人报知龙虎山上清宫住持道众,准备接诏。次日,众位官同送太尉到于龙虎山下。只见上清宫许多道众,鸣钟击鼓,香花灯烛,幢幡宝盖,一派仙乐,都下山来迎接丹诏,直至上清宫前下马。太尉看那宫殿时,端的是好座上清宫。但见:
青松屈曲,翠柏阴森。门悬敕额金书,户列灵符玉篆。虚皇坛畔,依稀垂柳名花;炼药炉边,掩映苍松老桧。左壁厢天丁力士,参随着太乙真君;右势下玉女金童,簇捧定紫微大帝。披发仗剑,北方真武踏龟蛇;靸履顶冠,南极老人伏龙虎。前排二十八宿星君,后列三十二帝天子。阶砌下流水潺湲,墙院后好山环绕。鹤生丹顶,龟长绿毛。树梢头献果苍猿,莎草内衔芝白鹿。三清殿上鸣金钟,道士步虚;四圣堂前敲玉磬,真人礼斗。献香台砌,彩霞光射碧琉璃;召将瑶坛,赤日影摇红玛瑙。早来门外祥云现,疑是天师送老君。
当下上至住持真人,下及道童侍从,前迎后引,接至三清殿上,请将诏书,居中供养着。洪太尉便问监宫真人道:“天师今在何处?”住持真人向前禀道:“好教太尉得知:这代祖师号曰‘虚靖天师’,性好清高,倦于迎送,自向龙虎山顶,结一茅庵,修真养性。因此不住本宫。”太尉道:“目今天子宣诏,如何得见?”真人答道:“容禀:诏敕权供在殿上,贫道等亦不敢开读。且请太尉到方丈献茶,再烦计议。”当时将丹诏供养在三清殿上,与众官都到方丈。太尉居中坐下,执事人等献茶,就进斋供,水陆俱备。斋罢,太尉再问真人道:“既然天师在山顶庵中,何不着人请将下来相见,开宣丹诏?”真人禀道:“太尉,这代祖师虽在山顶,其实道行非常,清高自在,倦惹凡尘。能驾雾兴云,踪迹不定,未尝下山。贫道等如常亦难得见,怎生教人请得下来!”太尉道:“似此如何得见!目今京师瘟疫盛行,今上天子特遣下官为使,赍捧御书丹诏,亲奉龙香,来请天师,要做三千六百分罗天大醮,以禳天灾,救济万民。似此怎生奈何?”真人禀道:“朝廷天子要救万民,只除是太尉办一点志诚心,斋戒沐浴,更换布衣,休带从人,自背诏书,焚烧御香,步行上山礼拜,叩请天师,方许得见。如若心不志诚,空走一遭,亦难得见。”太尉听说便道:“俺从京师食素到此,如何心不志诚!既然恁地,依着你说,明日绝早上山。”当晚各自权歇。
次日五更时分,众道士起来,备下香汤斋供。请太尉起来,香汤沐浴,换了一身新鲜布衣,脚下穿上麻鞋草履,吃了素斋,取过丹诏,用黄罗包袱背在脊梁上,手里提着银手炉,降降地烧着御香。许多道众人等,送到后山,指与路径。真人又禀道:“太尉要救万民,休生退悔之心,只顾志诚上去。”太尉别了众人,口诵天尊宝号,纵步上山来。将至半山,望见大顶直侵霄汉,果然好座大山。正是:
根盘地角,顶接天心。远观磨断乱云痕,近看平吞明月魄。高低不等谓之山,侧石通道谓之岫,孤岭崎岖谓之路,上面极平谓之顶,头圆下壮谓之峦,隐虎藏豹谓之穴,隐风隐云谓之岩,高人隐居谓之洞,有境有界谓之府,樵人出没谓之径,能通车马谓之道,流水有声谓之涧,古渡源头谓之溪,岩崖滴水谓之泉。左壁为掩,右壁为映。出的是云,纳的是雾。锥尖象小,崎峻似峭,悬空似险,削如平。千峰竞秀,万壑争流。瀑布斜飞,藤萝倒挂。虎啸时风生谷口,猿啼时月坠山腰。恰似青黛染成千块玉,碧纱笼罩万堆烟。
这洪太尉独自一个,行了一回,盘坡转径,揽葛攀藤。约莫走过了数个山头,三二里多路,看看脚酸腿软,正走不动,口里不说,肚里踌躇,心中想道:“我是朝廷贵官公子,在京师时重茵而卧,列鼎而食,尚兀自倦怠;何曾穿草鞋,走这般山路!知他天师在那里,却教下官受这般苦!”又行不到三五十步,掇着肩气喘。只见山凹里起一阵风,风过处,向那松树背后奔雷也似吼一声,扑地跳出一个吊睛白额锦毛大虫来。洪太尉吃了一惊,叫声:“阿呀!”扑地望后便倒。偷眼看那大虫时,但见:
毛披一带黄金色,爪露银钩十八只。睛如闪电尾如鞭,口似血盆牙似戟。
伸腰展臂势狰狞,摆尾摇头声霹雳。山中狐兔尽潜藏,涧下獐狍皆敛迹。
那大虫望着洪太尉,左盘右旋,咆哮了一回,托地望后山坡下跳了去。洪太尉倒在树根底下,唬的三十六个牙齿捉对儿厮打,那心头一似十五个吊桶,七上八落的响,浑身却如重风麻木,两腿一似斗败公鸡,口里连声叫苦。大虫去了一盏茶时,方才爬将起来,再收拾地上香炉,还把龙香烧着,再上山来,务要寻见天师。又行过三五十步,口里叹了数口气,怨道:皇帝御限,差俺来这里,教我受这场惊恐。”说犹未了,只觉得那里又一阵风,吹得毒气直冲将来。太尉定睛看时,山边竹藤里簌簌地响,抢出一条吊桶大小、雪花也似蛇来。太尉见了,又吃一惊,撇了手炉,叫一声:“我今番死也!”望后便倒在盘砣石边。微闪开眼来看那蛇时,但见:
昂首惊飙起,掣目电光生。动荡则折峡倒冈,呼吸则吹云吐雾。鳞甲乱分千片玉,尾梢斜卷一堆银。
那条大蛇径抢到盘砣石边,朝着洪太尉盘做一堆,两只眼迸出金光,张开巨口,吐出舌头,喷那毒气在洪太尉脸上。惊得太尉三魂荡荡,七魄悠悠。那蛇看了洪太尉一回,望山下一溜,却早不见了。太尉方才爬得起来,说道:“惭愧!惊杀下官!”看身上时,寒粟子比馉饳儿大小。口里骂那道士:“叵耐无礼,戏弄下官,教俺受这般惊恐!若山上寻不见天师,下去和他别有话说。”再拿了银提炉,整顿身上诏敕并衣服巾帻,却待再要上山去。正欲移步,只听得松树背后隐隐地笛声吹响,渐渐近来。太尉定睛看时,只见那一个道童,倒骑着一头黄牛,横吹着一管铁笛,转出山凹来。太尉看那道童时,但见:
头绾两枚丫髻,身穿一领青衣;腰间绦结草来编,脚下芒鞋麻间隔。明眸皓齿,飘飘并不染尘埃;绿鬓朱颜,耿耿全然无俗态。
昔日吕洞宾有首牧童诗道得好:
草铺横野六七里,笛弄晚风三四声。归来饱饭黄昏后,不脱蓑衣卧月明。
只见那个道童,笑吟吟地骑着黄牛,横吹着那管铁笛,正过山来。洪太尉见了,便唤那个道童:“你从那里来?认得我么?”道童不采,只顾吹笛。太尉连问数声,道童呵呵大笑,拿着铁笛,指着洪太尉说道:“你来此间,莫非要见天师么?”太尉大惊,便道:“你是牧童,如何得知?”道童笑说:“我早间在草庵中伏侍天师,听得天师说道:‘朝中今上仁宗天子,差个洪太尉赍擎丹诏御香,到来山中,宣我往东京做三千六百分罗天大醮,祈禳天下瘟疫。我如今乘鹤驾云去也。’这早晚想是去了,不在庵中。你休上去,山内毒虫猛兽极多,恐伤害了你性命。”太尉再问道:“你不要说谎?”道童笑了一声,也不回应,又吹着铁笛转过山坡去了。太尉寻思道:“这小的如何尽知此事?想是天师分付他,已定是了。”欲待再上山去,方才惊唬的苦,争些儿送了性命,不如下山去罢。
太尉拿着提炉,再寻旧路,奔下山来。众道士接着,请至方丈坐下。真人便问太尉道:“曾见天师么?”太尉说道:“我是朝廷中贵官,如何教俺走得山路,吃了这般辛苦,争些儿送了性命!为头上至半山里,跳出一只吊睛白额大虫,惊得下官魂魄都没了。又行不过一个山嘴,竹藤里抢出一条雪花大蛇来,盘做一堆,拦住去路。若不是俺福分大,如何得性命回京。尽是你这道众,戏弄下官!”真人复道:“贫道等怎敢轻慢大臣,这是祖师试探太尉之心。本山虽有蛇虎,并不伤人。”太尉又道:“我正走不动,方欲再上山坡,只见松树傍边转出一个道童,骑着一头黄牛,吹着管铁笛,正过山来。我便问他:‘那里来识得俺么?’他道:‘已都知了。’说天师分付,早晨乘鹤驾云望东京去了。下官因此回来。”真人道:“太尉可惜错过,这个牧童正是天师。”太尉道:“他既是天师,如何这等猥獕?”真人答道:“这代天师非同小可,虽然年幼,其实道行非常。他是额外之人,四方显化,极是灵验。世人皆称为道通祖师。”洪太尉道:“我直如此有眼不识真师,当面错过!”真人道:“太尉但请放心,既然祖师法旨道是去了,比及太尉回京之日,这场醮事祖师已都完了。”太尉见说,方才放心。真人一面教安排筵宴,管待太尉;请将丹诏收藏于御书匣内放了,留在上清宫中,龙香就三清殿上烧了。当日方丈内大排斋供,设宴饮酌。至晚席罢,止宿到晓。
次日早膳已后,真人道众并提点执事人等请太尉游山。太尉大喜。许多人从跟随着,步行出方丈,前面两个道童引路,行至宫前宫后,看玩许多景致。三清殿上,富贵不可尽言。左廊下,九天殿、紫微殿、北极殿;右廊下,太乙殿、三官殿、驱邪殿。诸宫看遍,行到右廊后一所去处。洪太尉看时,另外一所殿宇:一遭都是捣椒红泥墙;正面两扇朱红槅子;门上使着胳膊大锁锁着,交叉上面贴着十数道封皮,封皮上又是重重叠叠使着朱印;檐前一面朱红漆金字牌额,上书四个金字,写道:“伏魔之殿”。太尉尉指着门道:“此殿是甚么去处?”真人答道:“此乃是前代老祖天师锁镇魔王之殿。”太尉又问道:“如何上面重重叠叠贴着许多封皮?”真人答道:“此是祖老大唐洞玄国师封锁魔王在此。但是经传一代天师,亲手便添一道封皮,使其子子孙孙不敢妄开。走了魔君,非常利害。今经八九代祖师,誓不敢开。锁用铜汁灌铸,谁知里面的事。小道自来住持本宫三十余年,也只听闻。”洪太尉听了,心中惊怪,想道:“我且试看魔王一看。”便对真人说道:“你且开门来,我看魔王甚么模样。”
真人告道:“太尉,此殿决不敢开。先祖天师叮咛告戒:今后诸人不许擅开。”太尉笑道:“胡说!你等要妄生怪事,煽惑百姓良民,故意安排这等去处,假称锁镇魔王,显耀你们道术。我读一鉴之书,何曾见锁魔之法。神鬼之道,处隔幽冥,我不信有魔王在内。快疾与我打开,我看魔王如何。”真人三回五次禀说:“此殿开不得,恐惹利害,有伤于人。”太尉大怒,指着道众说道:“你等不开与我看,回到朝廷,先奏你们众道士阻当宣诏,违别圣旨,不令我见天师的罪犯;后奏你等私设此殿,假称锁镇魔王,煽惑军民百姓。把你都追了度牒,刺配远恶军州受苦。”真人等惧怕太尉权势,只得唤几个火工道人来,先把封皮揭了,将铁锤打开大锁。众人把门推开,看里面时,黑洞洞地,但见:
昏昏默默,查查冥冥。数百年不见太阳光,亿万载难瞻明月影。不分南北,怎辨东西。黑烟霭霭扑人寒,冷气阴阴侵体颤。人迹不到之处,妖精往来之乡。闪开双目有如盲,伸出两手不见掌。常如三十夜,却似五更时。
众人一齐都到殿内,黑暗暗不见一物。太尉教从人取十数个火把点着,将来打一照时,四边并无别物,只中央一个石碑,约高五六尺,下面石龟趺坐,太半陷在泥里。照那碑碣上时,前面都是龙章凤篆,天书符箓,人皆不识。照那碑后时,却有回个真字大书,凿着“遇洪而开”。却不是一来天罡星合当出世,二来宋朝必显忠良,三来凑巧遇着洪信。岂不是天数!洪太尉看了这四个字,大喜,便对真人说道:“你等阻当我,却怎地数百年前已注我姓字在此?‘遇洪而开’,分明是教我开看,却何妨!我想这个魔王,都只在石碑底下。汝等从人与我多唤几个火工人等,将锄头铁锹来掘开。”真人慌忙谏道:“太尉,不可掘动!恐有利害,伤犯于人,不当稳便。”太尉大怒,喝道:“你等道众,省得甚么!碑上分明凿着遇我教开,你如何阻当!快与我唤人来开。”真人又三回五次禀道:“恐有不好。”太尉那里肯听。只得聚集众人,先把石碑放倒,一齐并力掘那石龟,半日方才掘得起。又掘下去,约有三四尺深,见一片大青石板,可方丈围。洪太尉叫再掘起来。真人又苦禀道:“不可掘动!”太尉那里肯听。众人只得把石板一齐扛起,看时,石板底下却是一个万丈深浅地穴。只见穴内刮剌剌一声响亮,那响非同小可,恰似:
天摧地塌,岳撼山崩。钱塘江上,潮头浪拥出海门来;泰华山头,巨灵神一劈山峰碎。共工奋怒,去盔撞倒了不周山;力士施威,飞锤击碎了始皇辇。一风撼折千竿竹,十万军中半夜雷。
那一声响亮过处,只见一道黑气,从穴里滚将起来,掀塌了半个殿角。那道黑气直冲上半天里,空中散作百十道金光,望四面八方去了。众人吃了一惊,发声喊,都走了,撇下锄头铁锹,尽从殿内奔将出来,推倒攧翻无数。惊得洪太尉目睁痴呆,罔知所措,面色如土。奔到廊下,只见真人向前叫苦不迭。太尉问道:“走了的却是甚么妖魔?”那真人言不过数句,话不过一席,说出这个缘由。有分教:
一朝皇帝,夜眠不稳,昼食忘餐。直使宛子城中藏猛虎,蓼儿洼内聚飞龙。
毕竟龙虎山真人说出甚言语来,且听下回分解。
` 前即可。若你们站点启用了 CSP,需要把平台域名加入允许列表,否则组件可能加载失败。", SimilarQuestions: []string{"官网怎么挂咨询入口", "Web Widget 怎么接", "网站客服按钮嵌入"}, Remark: "渠道接入"}, + {Question: "Web Widget 的颜色和文案可以自定义吗?", Answer: "可以。你可以在渠道配置里修改主色、标题、欢迎语、按钮文案、是否展示头像和工作时间提示。保存后前端会在几分钟内刷新缓存,若你希望立即生效,可手动清理页面缓存。", SimilarQuestions: []string{"咨询浮窗能改样式吗", "按钮文案怎么改", "Widget 主题色设置"}, Remark: "渠道接入"}, + {Question: "支持把客服入口嵌到微信 H5 页面吗?", Answer: "支持,但需要使用适配移动端的 H5 咨询页或自定义嵌入页。若在微信内打开,建议同时开启微信浏览器兼容模式,并测试键盘弹起、页面滚动和文件上传权限是否正常。", SimilarQuestions: []string{"H5 页面能接客服吗", "微信里能打开咨询页吗", "移动端客服入口"}, Remark: "渠道接入"}, + {Question: "客户消息提醒可以推送到企业微信吗?", Answer: "支持把新会话、超时未回复、工单升级等提醒推送到企业微信机器人或应用消息。建议只推送关键事件,避免通知过载影响值班人员判断。", SimilarQuestions: []string{"消息提醒发企业微信", "能推送到企微吗", "新会话通知怎么接"}, Remark: "渠道接入"}, + {Question: "访客进入咨询前能先收集手机号吗?", Answer: "可以。你可以在欢迎页开启预采集表单,要求客户填写手机号、订单号、邮箱等信息后再进入会话。这样有助于后续识别身份和分配对应业务团队。", SimilarQuestions: []string{"咨询前收集手机号", "先填表再聊天", "访客信息预采集"}, Remark: "渠道接入"}, + {Question: "支持接入 WhatsApp 或 Telegram 吗?", Answer: "平台可以通过开放接口或第三方集成中间层接入海外渠道,但具体能力取决于你们当前套餐和所选服务商。若是正式商用,建议先确认消息模板、号码资质和当地合规要求。", SimilarQuestions: []string{"能接 WhatsApp 吗", "支持 Telegram 吗", "海外渠道接入"}, Remark: "渠道接入"}, + {Question: "为什么网站上看不到客服浮窗?", Answer: "先检查脚本是否成功加载、站点域名是否在渠道白名单内,以及浏览器是否拦截了第三方脚本。若开启了广告拦截插件或严格 CSP,也可能导致组件被屏蔽。", SimilarQuestions: []string{"网页不显示咨询按钮", "Widget 没出来", "客服浮窗不见了"}, Remark: "渠道接入"}, + {Question: "不同站点可以共用一个客服渠道吗?", Answer: "可以共用,但更建议按站点或品牌拆分渠道,这样可以分别配置欢迎语、机器人、工作时间和报表来源。若多个站点业务差异较大,共用一个渠道会影响会话分流和数据分析。", SimilarQuestions: []string{"多个官网能共用渠道吗", "不同域名用一个 Widget", "站点渠道怎么规划"}, Remark: "渠道接入"}, + {Question: "工单和实时会话有什么关系?", Answer: "实时会话适合即时咨询,工单适合需要跨班次跟进、跨部门协作或需要留痕审批的问题。会话中如果发现问题无法当场解决,可以一键转为工单,并保留原始聊天记录作为上下文。", SimilarQuestions: []string{"为什么还需要工单", "会话和工单区别", "聊天怎么转工单"}, Remark: "工单"}, + {Question: "如何把会话升级成工单?", Answer: "在会话详情页点击“创建工单”,系统会自动带出客户信息、会话摘要和最近消息。你只需补充工单类型、优先级、负责人和期望完成时间即可。", SimilarQuestions: []string{"聊天转工单在哪里", "会话升级工单", "怎么建售后单"}, Remark: "工单"}, + {Question: "工单支持 SLA 超时提醒吗?", Answer: "支持。你可以为不同工单类型配置首次响应时限、处理时限和升级规则,临近超时时会给负责人和主管发送提醒,超时后也可自动升级到上级处理。", SimilarQuestions: []string{"工单超时提醒", "SLA 怎么配置", "工单逾期通知"}, Remark: "工单"}, + {Question: "工单能分配给外部协作人吗?", Answer: "目前标准成员体系主要面向内部账号。如果需要外部协作,可为供应商或合作方单独开受限角色账号,并限制其仅查看被指派工单,避免访问其他客户数据。", SimilarQuestions: []string{"工单给外包处理", "外部人员能看工单吗", "供应商协作权限"}, Remark: "工单"}, + {Question: "工单状态有哪些推荐用法?", Answer: "常见做法是设置为“待受理、处理中、待客户反馈、已解决、已关闭”。其中“待客户反馈”适合需要客户补充材料的场景,“已解决”表示业务已处理完成但仍保留回访窗口。", SimilarQuestions: []string{"工单状态怎么设计", "售后单流程建议", "工单字段如何规划"}, Remark: "工单"}, + {Question: "能否查看工单处理的完整操作记录?", Answer: "可以。每张工单都保留状态变更、指派变更、备注、附件上传和评论记录,方便审计和复盘。管理员还可以导出操作日志做质检或合规留存。", SimilarQuestions: []string{"工单处理日志", "谁改过工单怎么查", "工单历史记录"}, Remark: "工单"}, + {Question: "工单附件支持哪些格式?", Answer: "常见图片、PDF、Excel、Word 和压缩包都支持,单文件大小上限由你们当前存储配置决定。若附件包含客户证件或敏感资料,建议同步开启下载权限控制和水印。", SimilarQuestions: []string{"工单能上传什么文件", "附件格式限制", "售后凭证支持哪些类型"}, Remark: "工单"}, + {Question: "重复提交的工单可以自动合并吗?", Answer: "可以通过规则按手机号、订单号、邮箱或自定义字段检测重复,并提示坐席合并处理。是否自动合并建议谨慎开启,避免把不同问题错误归并到同一张工单。", SimilarQuestions: []string{"重复工单怎么处理", "能自动识别重复吗", "相同订单重复建单"}, Remark: "工单"}, + {Question: "客户信息可以从 CRM 自动同步过来吗?", Answer: "支持通过开放 API、Webhook 或中间件同步客户主数据,例如姓名、手机号、会员等级、所属销售和最近订单。同步后这些字段可以直接在会话侧边栏展示,减少客服来回切系统查询。", SimilarQuestions: []string{"CRM 能同步到客服吗", "客户资料自动带入", "怎么对接用户信息"}, Remark: "集成"}, + {Question: "平台提供开放 API 吗?", Answer: "提供。你可以通过 API 创建会话、发送消息、查询客户、同步工单和拉取报表。正式对接前建议先在测试环境验证签名、限流和错误码处理,再切换到生产。", SimilarQuestions: []string{"有没有开放接口", "客服系统 API 文档", "能程序化调用吗"}, Remark: "集成"}, + {Question: "Webhook 可以推送哪些事件?", Answer: "常见事件包括新会话创建、会话关闭、客户留言、工单创建、工单状态变更、机器人转人工和客户满意度回收等。你可以按需订阅,避免把所有事件都推到业务系统。", SimilarQuestions: []string{"Webhook 支持什么事件", "事件推送列表", "回调通知有哪些"}, Remark: "集成"}, + {Question: "API 调用频率有限制吗?", Answer: "有。默认按应用和接口维度做限流,避免高峰期影响平台稳定。若你们需要批量同步历史数据,建议走离线导入或提前联系技术支持申请更高配额。", SimilarQuestions: []string{"接口限流是多少", "API 有 QPS 限制吗", "批量同步会不会被限流"}, Remark: "集成"}, + {Question: "如何验证开放 API 的签名是否正确?", Answer: "请先确认时间戳、随机串、请求体摘要和签名算法与文档一致。排查时建议先用平台提供的示例请求对比,再检查服务端是否在参与签名的原始字符串里改动了空格、换行或字段顺序。", SimilarQuestions: []string{"API 签名不通过", "签名校验失败怎么办", "接口鉴权报错"}, Remark: "集成"}, + {Question: "可以把会话记录同步到内部 BI 系统吗?", Answer: "可以。你可以通过报表导出、API 增量拉取或消息回调三种方式同步。若是 BI 场景,建议每天离线拉取聚合数据,避免用高频实时接口增加系统压力。", SimilarQuestions: []string{"会话数据怎么同步 BI", "报表能对接数仓吗", "聊天记录导入分析系统"}, Remark: "集成"}, + {Question: "支持和订单系统做查单联动吗?", Answer: "支持。接入后坐席在会话侧边栏即可查看订单状态、支付情况、发货时间和物流单号。若你们希望机器人直接回答订单问题,还需要额外配置工具调用权限。", SimilarQuestions: []string{"能和订单系统打通吗", "聊天页直接查订单", "查单联动怎么做"}, Remark: "集成"}, + {Question: "为什么 API 请求返回 401 或 403?", Answer: "401 一般表示签名错误、时间戳过期或访问令牌无效;403 多数是应用没有该接口权限或 IP 白名单未放通。建议先检查应用状态、签名配置和请求来源 IP。", SimilarQuestions: []string{"接口返回 401 怎么办", "API 403 原因", "鉴权失败怎么排查"}, Remark: "集成"}, + {Question: "聊天记录会保存多久?", Answer: "默认保存期限取决于你们的套餐和合规配置,常见为 180 天到长期保存可选。若业务涉及金融、教育或跨境场景,建议结合行业监管要求单独设置归档周期。", SimilarQuestions: []string{"会话记录保留多久", "聊天消息存多久", "历史记录保存周期"}, Remark: "数据安全"}, + {Question: "导出聊天记录时会包含客户手机号吗?", Answer: "默认按权限控制。普通坐席导出的明细通常会对手机号、邮箱等敏感字段做脱敏,只有拥有高级数据权限的管理员才能导出完整信息。", SimilarQuestions: []string{"导出会脱敏吗", "手机号会不会直接导出", "聊天记录敏感信息处理"}, Remark: "数据安全"}, + {Question: "平台支持数据脱敏吗?", Answer: "支持。你可以对手机号、身份证号、银行卡号、邮箱和地址启用显示脱敏、日志脱敏以及导出脱敏。对于高敏字段,建议同时配置按角色可见范围。", SimilarQuestions: []string{"客户信息能脱敏吗", "隐私字段隐藏", "敏感数据保护"}, Remark: "数据安全"}, + {Question: "是否支持按角色限制查看聊天记录?", Answer: "支持。你可以限制普通坐席只能查看自己接待过的会话,主管查看本团队,管理员查看全局。对于投诉、法务等敏感会话,也可以单独设置更严格的访问范围。", SimilarQuestions: []string{"聊天记录权限隔离", "谁能看全部会话", "会话查看范围"}, Remark: "数据安全"}, + {Question: "客户要求删除个人数据时怎么处理?", Answer: "管理员可在客户资料页发起“数据删除”或“匿名化处理”。系统会按配置清空或打码可识别字段,同时保留必要的审计记录,以满足合规要求和内部追溯。", SimilarQuestions: []string{"用户要求删数据", "隐私删除怎么做", "客户信息匿名化"}, Remark: "数据安全"}, + {Question: "系统有操作日志吗?", Answer: "有。成员登录、权限变更、知识库编辑、工单操作、导出报表等关键动作都会进入审计日志。管理员可以按时间、成员、对象类型筛选并导出。", SimilarQuestions: []string{"后台操作有记录吗", "谁改了配置怎么查", "审计日志在哪里"}, Remark: "数据安全"}, + {Question: "支持设置 IP 白名单吗?", Answer: "支持。你可以在安全设置里为后台登录和开放 API 分别配置 IP 白名单。若你们办公网络经常变动,建议至少给高权限账号启用 MFA,避免完全依赖固定 IP。", SimilarQuestions: []string{"后台能限制 IP 吗", "接口白名单怎么配", "登录来源限制"}, Remark: "数据安全"}, + {Question: "聊天内容会不会被平台拿去训练公共模型?", Answer: "默认不会。客户数据仅用于你们自身的业务处理和已授权的产品功能,不会擅自用于公共模型训练。若你们开通了定制优化服务,也会以合同和配置项约定的数据范围为准。", SimilarQuestions: []string{"聊天数据会训练模型吗", "数据会不会外泄", "平台会拿客户数据训练吗"}, Remark: "数据安全"}, + {Question: "如何给客户打标签?", Answer: "可以在客户详情页手动添加标签,也可以通过规则根据来源渠道、访问页面、下单次数、会员等级或对话关键词自动打标签。标签通常用于分流、营销和服务分层。", SimilarQuestions: []string{"客户标签怎么加", "支持自动标签吗", "用户标签规则"}, Remark: "客户管理"}, + {Question: "客户历史会话在哪里看?", Answer: "打开客户资料页即可看到该客户的历史会话、工单、满意度评价和最近访问记录。若同一个客户用多个渠道接入,建议先配置身份合并规则,避免历史被拆散。", SimilarQuestions: []string{"怎么查客户历史咨询", "用户轨迹在哪里", "以前的聊天记录怎么看"}, Remark: "客户管理"}, + {Question: "一个客户在多个渠道咨询,会被识别成同一个人吗?", Answer: "可以,但需要提前配置统一身份标识,例如手机号、会员 ID、邮箱或外部用户 ID。若不同渠道没有共同标识,系统会默认视为不同访客。", SimilarQuestions: []string{"多渠道客户合并", "同一个人跨渠道识别", "用户身份统一"}, Remark: "客户管理"}, + {Question: "如何筛选高价值客户并优先接待?", Answer: "你可以结合会员等级、近 90 天消费金额、订单频次或 VIP 标签建立高价值客户规则,并在路由策略里设置优先分配到专属团队或高级坐席。", SimilarQuestions: []string{"VIP 客户优先接待", "高价值用户怎么识别", "客户分层服务"}, Remark: "客户管理"}, + {Question: "客户昵称乱码或显示异常怎么办?", Answer: "优先确认上游渠道返回的编码是否为 UTF-8,以及是否包含平台不支持的特殊字符。若只是个别历史数据异常,可通过客户资料页手动更正;若批量异常,建议检查同步接口。", SimilarQuestions: []string{"昵称显示乱码", "客户名称异常", "中文昵称不正常"}, Remark: "客户管理"}, + {Question: "客户满意度评价怎么配置?", Answer: "在“服务评价”里可设置评价时机、问题文案、星级或满意/不满意选项,以及低分自动预警规则。通常建议在会话关闭后立即触发,回收率会更高。", SimilarQuestions: []string{"满意度怎么开", "服务评价配置", "会话结束后评分"}, Remark: "客户管理"}, + {Question: "可以屏蔽骚扰客户吗?", Answer: "支持。你可以把手机号、邮箱、Cookie 或访客 ID 加入黑名单,命中后系统可直接拒绝接入、静默排队,或只允许其提交留言不进入人工接待。", SimilarQuestions: []string{"骚扰用户怎么封禁", "黑名单在哪里设置", "恶意访客屏蔽"}, Remark: "客户管理"}, + {Question: "客户资料支持自定义字段吗?", Answer: "支持。常见如订单等级、合同编号、所属城市、设备型号等都可以配置为自定义字段,并在导入、API 同步和页面展示中统一使用。", SimilarQuestions: []string{"客户字段能自定义吗", "自定义用户属性", "资料页增加业务字段"}, Remark: "客户管理"}, + {Question: "如何设置自动欢迎语?", Answer: "在渠道或机器人配置中启用欢迎语后,可按工作时间、渠道、客户标签或访问页面展示不同文案。建议文案简洁,并明确告知当前是机器人还是人工服务。", SimilarQuestions: []string{"自动回复欢迎语", "开场白在哪里配", "客户进入先发一句"}, Remark: "自动化"}, + {Question: "支持非工作时间自动回复吗?", Answer: "支持。你可以为夜间、节假日或团队休息时段配置离线回复,告知客户预计处理时间、紧急联系电话或留言入口。必要时也可在离线时继续让机器人先接待。", SimilarQuestions: []string{"下班后自动回复", "非工作时间留言", "夜间自动应答"}, Remark: "自动化"}, + {Question: "如何设置关键词自动转人工?", Answer: "在机器人策略里新增转人工规则,输入关键词或短语即可,例如“投诉”“退款”“人工客服”“发票重开”等。建议同时加入同义词和常见口语表达,减少漏判。", SimilarQuestions: []string{"关键词触发人工", "哪些词会转接客服", "自动转人工规则"}, Remark: "自动化"}, + {Question: "会话超时未回复能自动提醒坐席吗?", Answer: "可以。你可以按首响超时、处理中超时和即将 SLA 超时三个阶段配置提醒,支持站内提醒、邮件和企业微信通知。", SimilarQuestions: []string{"超时提醒怎么配", "客服久未回复提醒", "消息超时通知"}, Remark: "自动化"}, + {Question: "能按客户标签分配不同的机器人吗?", Answer: "支持。你可以在路由规则里按客户标签、渠道来源或页面入口命中不同机器人,例如新客走导购机器人,老客走售后机器人。", SimilarQuestions: []string{"不同用户进不同 AI", "按标签分机器人", "机器人路由规则"}, Remark: "自动化"}, + {Question: "能自动给会话生成摘要吗?", Answer: "支持。在开启 AI 摘要后,系统会在会话结束时生成问题摘要、处理结果和待跟进事项,便于转工单、交班和质检。", SimilarQuestions: []string{"聊天自动总结", "会话摘要功能", "交班摘要怎么生成"}, Remark: "自动化"}, + {Question: "自动化规则执行顺序是怎样的?", Answer: "通常按“接入识别 -> 路由分配 -> 机器人应答 -> 转人工/升级 -> 会后自动化”的顺序执行。若多条规则都命中,系统会按优先级和创建顺序决定实际结果。", SimilarQuestions: []string{"规则先后顺序", "自动化命中顺序", "多个规则冲突怎么办"}, Remark: "自动化"}, + {Question: "支持根据访问页面触发不同欢迎语吗?", Answer: "支持。你可以在 Web 渠道里按 URL 路径或页面分组配置欢迎语,比如商品页引导咨询库存,支付页引导咨询优惠和支付问题。", SimilarQuestions: []string{"不同页面不同文案", "页面维度欢迎语", "按 URL 展示话术"}, Remark: "自动化"}, + {Question: "如何查看会话量趋势和高峰时段?", Answer: "在“数据报表-流量分析”里可按小时、日期和渠道查看会话量、访客量、排队峰值和人工接待率。高峰时段建议结合排班数据一起分析。", SimilarQuestions: []string{"会话高峰怎么看", "流量趋势报表", "哪个时间段最忙"}, Remark: "数据报表"}, + {Question: "报表里的首响应时长为什么和人工感觉不一致?", Answer: "因为报表一般按系统时间自动计算,且会区分机器人首响、人工首响和工作时间内首响。建议先确认你看的指标口径,再核对是否排除了离线时段和转接等待时间。", SimilarQuestions: []string{"首响数据不对", "报表口径怎么看", "为什么和实际感受不同"}, Remark: "数据报表"}, + {Question: "机器人命中率和解决率分别是什么意思?", Answer: "命中率指机器人对进入其服务范围的消息中,成功检索并给出答案的比例;解决率指客户在机器人处理后未再转人工且会话完成的比例。命中率高不代表解决率一定高,需要结合看。", SimilarQuestions: []string{"命中率是什么意思", "AI 解决率怎么理解", "机器人指标解释"}, Remark: "数据报表"}, + {Question: "支持按团队导出日报吗?", Answer: "支持。大部分报表都支持按团队、坐席和渠道维度筛选后导出,也可通过定时报表每天自动发送到指定邮箱。", SimilarQuestions: []string{"团队日报导出", "报表自动发邮件", "每天定时发送数据"}, Remark: "数据报表"}, + {Question: "如何看哪些 FAQ 最常被命中?", Answer: "进入知识检索日志或 FAQ 报表,可查看各 FAQ 的命中次数、平均分数和最近命中时间。命中高但满意度低的条目通常最值得优先优化。", SimilarQuestions: []string{"FAQ 命中排行", "哪些知识用得最多", "热门 FAQ 在哪看"}, Remark: "数据报表"}, + {Question: "坐席质检结果可以纳入绩效吗?", Answer: "可以。平台支持按会话抽检结果、服务评价、超时率和处理时长生成坐席评分,但是否直接纳入绩效建议由你们内部制度决定。", SimilarQuestions: []string{"质检分能做绩效吗", "客服评分怎么计算", "质检报表用途"}, Remark: "数据报表"}, + {Question: "导出的报表时间是按哪个时区算的?", Answer: "默认按当前企业设置的时区计算,并以服务器完成汇总的时间窗口为准。若你们团队跨国协作,建议统一企业时区并在报表标题中注明。", SimilarQuestions: []string{"报表时区怎么定", "导出时间不一致", "跨时区数据口径"}, Remark: "数据报表"}, + {Question: "历史报表可以保留多久?", Answer: "报表明细和汇总保留周期取决于套餐和数据保留策略,核心运营报表一般支持长期查看,超大体量的明细日志可能会按月归档。", SimilarQuestions: []string{"报表数据保存多久", "能看多久之前的数据", "历史统计保留"}, Remark: "数据报表"}, + {Question: "如何升级套餐或增加坐席数?", Answer: "联系客户成功经理或在“账单中心-套餐管理”发起升级申请即可。新增坐席一般按剩余计费周期折算费用,升级后权限和资源配额会即时扩容。", SimilarQuestions: []string{"怎么加购买席位", "套餐升级在哪里", "新增坐席费用"}, Remark: "计费发票"}, + {Question: "试用版和正式版有什么主要区别?", Answer: "试用版通常限制坐席数、历史数据保留、开放 API、品牌自定义和高级 AI 能力。正式版支持更完整的权限体系、报表、集成和 SLA 服务。", SimilarQuestions: []string{"试用版限制有哪些", "正式版多了什么", "免费版和付费版区别"}, Remark: "计费发票"}, + {Question: "账单是按自然月还是合同周期结算?", Answer: "标准订阅一般按合同周期结算,可选月付或年付;增购席位和资源通常会按当前合同剩余天数做比例折算。具体以合同和商务报价单为准。", SimilarQuestions: []string{"怎么结算费用", "按月还是按年收费", "增购怎么计费"}, Remark: "计费发票"}, + {Question: "可以开增值税专用发票吗?", Answer: "可以。请在“账单中心-发票信息”中维护完整的开票资料,包括公司名称、税号、开户地址、电话和开户行信息。审核通过后,发票会按账单周期开具。", SimilarQuestions: []string{"能开发票吗", "专票怎么申请", "开票资料在哪里填"}, Remark: "计费发票"}, + {Question: "发票抬头填错了可以重开吗?", Answer: "如发票尚未寄出或尚未作废,可联系商务或财务协助修改;若纸质票已开具且不符合重开条件,需要按财税流程先退回原票再重开。电子发票是否可重开以当前税控规则为准。", SimilarQuestions: []string{"发票信息错了怎么办", "开错票能重开吗", "发票抬头修改"}, Remark: "计费发票"}, + {Question: "欠费后会立刻停服务吗?", Answer: "通常不会立即停服,平台会先进入宽限期并多次提醒管理员续费。宽限期结束后,部分高级能力可能先受限,继续欠费才会影响登录或接待功能。", SimilarQuestions: []string{"欠费多久停用", "没续费会怎样", "账单逾期影响"}, Remark: "计费发票"}, + {Question: "AI 调用量超额后怎么收费?", Answer: "若套餐包含固定 AI 配额,超出部分一般按实际调用量或令牌量计费。建议在账单中心开启用量预警,避免月底集中超额。", SimilarQuestions: []string{"AI 超额怎么计费", "模型调用量收费", "超出配额怎么办"}, Remark: "计费发票"}, + {Question: "如何查看本月已经使用了多少 AI 配额?", Answer: "在“账单中心-用量明细”里可以看到按天汇总的模型调用次数、Token 用量和按机器人/知识库拆分的消耗情况。", SimilarQuestions: []string{"AI 配额怎么看", "本月用了多少 token", "模型用量报表"}, Remark: "计费发票"}, + {Question: "聊天消息发出去但客户收不到怎么办?", Answer: "先确认消息是否发送成功、客户渠道是否在线,以及第三方通道是否有回执失败。若只在某个渠道异常,通常是渠道侧限流、模板审核或网络问题。", SimilarQuestions: []string{"消息发不出去", "客户收不到回复", "发送成功但对方没收到"}, Remark: "故障排查"}, + {Question: "上传文件时报“格式不支持”怎么处理?", Answer: "先核对文件扩展名和实际内容是否一致,例如部分截图工具导出的文件后缀和真实格式不匹配。若格式本身受支持,建议检查文件是否损坏或超出大小限制。", SimilarQuestions: []string{"附件上传失败", "文件格式不支持", "为什么不能传图片"}, Remark: "故障排查"}, + {Question: "页面很卡或者消息列表加载很慢是什么原因?", Answer: "常见原因包括浏览器缓存过多、同时打开过多会话标签、网络延迟较高或页面中存在超大附件。建议先刷新页面、关闭不必要标签页,并确认本地网络和浏览器版本。", SimilarQuestions: []string{"后台卡顿怎么办", "消息加载慢", "聊天页很慢"}, Remark: "故障排查"}, + {Question: "为什么刚配置好的机器人没有生效?", Answer: "通常需要确认机器人是否绑定到了正确渠道、服务时间是否覆盖当前时段,以及该渠道是否仍在走人工优先。若配置无误仍未生效,可查看调试日志确认是否命中了路由条件。", SimilarQuestions: []string{"机器人不工作", "AI 没有接管会话", "配置完没效果"}, Remark: "故障排查"}, + {Question: "知识库明明有答案,机器人还是说不知道,怎么办?", Answer: "先看该问题是否命中了正确知识库,其次检查 FAQ 问法和用户真实提问是否差距过大,再检查阈值是否过高导致被过滤。必要时补充相似问,或降低最低相似度阈值后重新验证。", SimilarQuestions: []string{"知识命不中怎么办", "FAQ 有内容却答不出", "AI 说查不到答案"}, Remark: "故障排查"}, + {Question: "浏览器提示证书风险,平台打不开怎么办?", Answer: "请先确认访问的域名是否正确、企业网络是否做了 HTTPS 解密,以及本机时间是否准确。若是自定义域名场景,还要检查证书是否已过期或未正确绑定。", SimilarQuestions: []string{"证书错误怎么处理", "网站打不开提示不安全", "HTTPS 有问题"}, Remark: "故障排查"}, + {Question: "为什么导出的 CSV 打开后中文乱码?", Answer: "通常是因为本地表格软件默认不是 UTF-8 编码。建议优先用支持 UTF-8 的方式导入,或在导出后用支持编码选择的工具打开。", SimilarQuestions: []string{"CSV 中文乱码", "导出文件打不开中文", "报表编码问题"}, Remark: "故障排查"}, + {Question: "系统升级会影响线上接待吗?", Answer: "常规小版本升级一般采用滚动发布,不会中断在线接待;涉及数据库结构或底层组件的大版本升级,平台会提前通知维护窗口,并建议你们避开业务高峰。", SimilarQuestions: []string{"升级会停机吗", "发版影响线上吗", "系统维护通知"}, Remark: "故障排查"}, + {Question: "支持自定义品牌 Logo 和登录页吗?", Answer: "支持自定义企业 Logo、登录页欢迎语和部分品牌色,高级套餐还支持自定义域名和白标展示。具体可配置项取决于你们的套餐级别。", SimilarQuestions: []string{"能换成自己品牌吗", "登录页能自定义吗", "支持白标吗"}, Remark: "品牌配置"}, + {Question: "可以按品牌线配置不同客服团队吗?", Answer: "可以。很多企业会按品牌、区域或产品线拆分团队和渠道,再为每个团队配置独立机器人、工作时间和报表视图。", SimilarQuestions: []string{"多品牌怎么管理", "不同业务线分团队", "品牌独立配置"}, Remark: "品牌配置"}, + {Question: "自定义域名需要准备什么?", Answer: "通常需要你们提供已备案域名、DNS 管理权限以及用于回源或 CNAME 的配置能力。若开启 HTTPS,还需要按指引完成证书校验或授权托管证书。", SimilarQuestions: []string{"绑定自定义域名", "专属域名怎么配", "客服系统独立域名"}, Remark: "品牌配置"}, + {Question: "Logo 替换后前端多久刷新?", Answer: "通常几分钟内会生效,具体取决于 CDN 缓存时间。若你在后台已经看到新 Logo,但前台仍未更新,建议清空浏览器缓存或稍后再试。", SimilarQuestions: []string{"换 Logo 后没生效", "品牌图标多久更新", "前端缓存多久"}, Remark: "品牌配置"}, + {Question: "支持多组织或多租户统一管理吗?", Answer: "支持企业下管理多个组织,但权限和数据隔离方式需按实际业务设计。若是完全独立运营的品牌或国家站点,通常建议拆成独立组织。", SimilarQuestions: []string{"多租户支持吗", "多个子公司统一管", "多组织架构"}, Remark: "品牌配置"}, + {Question: "如何申请产品培训或上线辅导?", Answer: "你可以联系客户成功经理预约标准培训、管理员培训或机器人调优辅导。首次上线建议安排一次管理员培训和一次一线坐席培训,能明显减少上线初期问题。", SimilarQuestions: []string{"有没有培训服务", "上线辅导怎么预约", "员工使用培训"}, Remark: "客户成功"}, + {Question: "遇到紧急故障,最快如何联系支持团队?", Answer: "若购买了企业服务,可通过专属工单通道、服务群或紧急支持电话联系。提交时请尽量附上问题时间、组织 ID、影响范围、截图和复现步骤,便于快速定位。", SimilarQuestions: []string{"紧急问题联系谁", "系统故障怎么报", "售后支持入口"}, Remark: "客户成功"}, + {Question: "产品更新公告在哪里看?", Answer: "你可以在后台首页公告栏、帮助中心更新日志或服务群中查看版本发布说明。涉及影响配置或操作习惯的变更,平台一般会提前通知。", SimilarQuestions: []string{"版本更新在哪里看", "发布说明入口", "新功能公告"}, Remark: "客户成功"}, + {Question: "能提供上线前的最佳实践建议吗?", Answer: "可以。标准建议包括先梳理高频问题 FAQ、配置清晰的转人工策略、按业务拆分知识库、先从一个渠道灰度上线,再逐步扩展到全部渠道。", SimilarQuestions: []string{"上线前准备什么", "机器人落地建议", "客服系统实施建议"}, Remark: "客户成功"}, + {Question: "平台支持数据迁移服务吗?", Answer: "支持按项目评估。常见迁移内容包括历史客户资料、会话记录、FAQ、工单和成员账号。由于不同系统字段差异较大,迁移前通常需要做一次字段映射确认。", SimilarQuestions: []string{"从旧系统迁移数据", "历史消息能导入吗", "数据迁移服务"}, Remark: "客户成功"}, + {Question: "如何判断当前 FAQ 是否需要优化?", Answer: "可以优先看三类信号:命中高但转人工率高、命中高但满意度低、以及客户经常追问同一问题。出现这些情况时,通常说明答案不够完整、口径不一致,或相似问覆盖不够。", SimilarQuestions: []string{"FAQ 优化依据", "哪些问答该先改", "知识库效果怎么评估"}, Remark: "知识运营"}, + {Question: "FAQ 的答案建议写多长?", Answer: "建议先给出结论,再补充步骤和注意事项。大多数客服 FAQ 控制在 80 到 220 字效果较好,太短容易信息不全,太长又不利于机器人稳定引用和客户快速阅读。", SimilarQuestions: []string{"FAQ 答案长度建议", "回答写多长合适", "问答内容怎么控制"}, Remark: "知识运营"}, + {Question: "一个问题有多个业务口径,FAQ 应该怎么处理?", Answer: "不要把多个冲突口径塞进同一条 FAQ。更合理的做法是按前置条件拆分,比如“个人版如何退款”和“企业版如何退款”分别建条目,并在答案开头明确适用范围。", SimilarQuestions: []string{"FAQ 口径冲突怎么办", "同一问题多个答案", "知识条目怎么拆"}, Remark: "知识运营"}, + {Question: "FAQ 更新后如何通知坐席口径变化?", Answer: "建议结合版本说明、站内公告和班前会同步。对核心条目可以开启“重点知识变更提醒”,让坐席在登录后先看到更新摘要。", SimilarQuestions: []string{"知识更新怎么通知客服", "FAQ 改动如何同步", "口径变更提醒"}, Remark: "知识运营"}, + {Question: "谁适合负责 FAQ 日常维护?", Answer: "通常由业务负责人提供口径,知识运营或客服主管负责整理成标准 FAQ,再由一线坐席持续反馈命中和遗漏情况。最好明确唯一责任人,避免长期无人维护。", SimilarQuestions: []string{"FAQ 谁来维护", "知识库负责人怎么定", "问答内容归谁管"}, Remark: "知识运营"}, + {Question: "知识库应该多久复盘一次?", Answer: "高频业务建议每周看一次命中和转人工数据,每月至少做一次系统复盘。活动期、发版期和政策调整期需要更高频地更新知识。", SimilarQuestions: []string{"FAQ 多久更新一次", "知识运营复盘频率", "什么时候维护知识库"}, Remark: "知识运营"}, + {Question: "如何区分应该写 FAQ 还是快捷回复?", Answer: "固定且经常重复的问题,优先写 FAQ 供机器人和检索使用;只在人工接待时需要快速发送、但未必适合机器人直接回答的内容,更适合放快捷回复。", SimilarQuestions: []string{"FAQ 和快捷回复区别", "什么内容放快捷回复", "知识条目怎么分类"}, Remark: "知识运营"}, + {Question: "低频但高风险的问题也要录入 FAQ 吗?", Answer: "建议录入,但要在答案中明确风险提示和升级路径,比如退款争议、隐私投诉、法务取证等。即便命中率不高,这类问题也能显著降低误答风险。", SimilarQuestions: []string{"低频问题有必要建 FAQ 吗", "高风险问答怎么处理", "投诉类知识要不要建"}, Remark: "知识运营"}, + } +} + +func read(name string) (chapters []Chapter, err error) { + path := filepath.Join("cmd", "testdata", "kb", name+".html") + data, err := os.ReadFile(path) + if err != nil { + return nil, err + } + + doc, err := html.Parse(bytes.NewReader(data)) + if err != nil { + return nil, err + } + + baseDir := filepath.Dir(path) + seen := make(map[string]struct{}) + + var walk func(*html.Node) error + walk = func(n *html.Node) error { + if n.Type == html.ElementNode && n.Data == "a" { + parent := n.Parent + if parent != nil && parent.Type == html.ElementNode && parent.Data == "span" && parent.Parent != nil && hasClass(parent.Parent, "chapter") { + href := strings.TrimSpace(getAttr(n, "href")) + if href != "" { + resolved := filepath.Clean(filepath.Join(baseDir, href)) + if _, ok := seen[resolved]; !ok { + seen[resolved] = struct{}{} + + title := cleanText(nodeText(parent)) + title = strings.ReplaceAll(title, "(原文)", "") + title = strings.TrimSpace(title) + + content, readErr := readChapterContent(resolved) + if readErr != nil { + return readErr + } + + chapters = append(chapters, Chapter{ + Title: title, + Link: resolved, + Content: content, + }) + } + } + } + } + + for c := n.FirstChild; c != nil; c = c.NextSibling { + if err := walk(c); err != nil { + return err + } + } + return nil + } + + err = walk(doc) + if err != nil { + return nil, err + } + + return chapters, nil +} + +func readChapterContent(link string) (content string, err error) { + data, err := os.ReadFile(link) + if err != nil { + return "", err + } + + doc, err := html.Parse(bytes.NewReader(data)) + if err != nil { + return "", err + } + + lines := make([]string, 0, 128) + var walk func(*html.Node) + walk = func(n *html.Node) { + if n.Type == html.ElementNode { + if n.Data == "h1" { + title := cleanText(nodeText(n)) + title = strings.ReplaceAll(title, " 原文", "") + title = strings.TrimSpace(title) + if title != "" { + lines = append(lines, title) + } + } + + if n.Data == "p" { + if hasClass(n, "next") || getAttr(n, "id") == "home" || getAttr(n, "id") == "list" { + return + } + if isInsideClass(n, "pn") { + return + } + line := cleanText(nodeText(n)) + if line != "" { + lines = append(lines, line) + } + } + } + + for c := n.FirstChild; c != nil; c = c.NextSibling { + walk(c) + } + } + + walk(doc) + return strings.Join(lines, "\n"), nil +} + +func getAttr(n *html.Node, key string) string { + for _, a := range n.Attr { + if a.Key == key { + return a.Val + } + } + return "" +} + +func hasClass(n *html.Node, className string) bool { + classes := strings.Fields(getAttr(n, "class")) + for _, c := range classes { + if c == className { + return true + } + } + return false +} + +func isInsideClass(n *html.Node, className string) bool { + for p := n.Parent; p != nil; p = p.Parent { + if hasClass(p, className) { + return true + } + } + return false +} + +func nodeText(n *html.Node) string { + if n == nil { + return "" + } + + var b strings.Builder + var walk func(*html.Node) + walk = func(cur *html.Node) { + if cur.Type == html.TextNode { + b.WriteString(cur.Data) + } + for c := cur.FirstChild; c != nil; c = c.NextSibling { + walk(c) + } + } + + walk(n) + return b.String() +} + +func cleanText(s string) string { + s = strings.ReplaceAll(s, "\u00a0", " ") + s = strings.TrimSpace(s) + if s == "" { + return "" + } + return strings.Join(strings.Fields(s), " ") +} diff --git a/cmd/testdata/kb/原文版水浒传/第一回 张天师祈禳瘟疫 洪太尉误走妖魔-原文.html b/cmd/testdata/kb/原文版水浒传/第一回 张天师祈禳瘟疫 洪太尉误走妖魔-原文.html new file mode 100755 index 0000000..27d87b6 --- /dev/null +++ b/cmd/testdata/kb/原文版水浒传/第一回 张天师祈禳瘟疫 洪太尉误走妖魔-原文.html @@ -0,0 +1 @@ +