From efe801b8bfc638c07898fb0017b0046d942fc3df Mon Sep 17 00:00:00 2001
From: mlogclub
Date: Thu, 9 Apr 2026 10:01:23 +0800
Subject: [PATCH] Init
---
.gitignore | 14 +
.gitmodules | 6 +
.vscode/launch.json | 34 +
.vscode/tasks.json | 30 +
AGENTS.md | 510 +
LICENSE | 201 +
Makefile | 124 +
README.md | 176 +
cmd/enums/generator.go | 546 ++
cmd/generator/generator.go | 69 +
cmd/migration/main.go | 32 +
cmd/testdata/.gitignore | 1 +
cmd/testdata/README_AGENTTEAM.md | 169 +
cmd/testdata/README_AI_AGENT.md | 145 +
cmd/testdata/agentteam/init.go | 219 +
cmd/testdata/aiagent/init.go | 152 +
cmd/testdata/aiconfig/ai_config.example.yaml | 76 +
cmd/testdata/aiconfig/init.go | 237 +
cmd/testdata/channel/init.go | 89 +
cmd/testdata/kb/README.md | 4 +
cmd/testdata/kb/kb.go | 591 ++
.../第一回 张天师祈禳瘟疫 洪太尉误走妖魔-原文.html | 1 +
...百一十一回 张顺夜伏金山寺 宋江智取润州城-原文.html | 1 +
...十七回 睦州城箭射邓元觉 乌龙岭神助宋公明-原文.html | 47 +
...十三回 混江龙太湖小结义 宋公明苏州大会垓-原文.html | 48 +
...百一十九回 鲁智深浙江坐化宋公明衣锦还乡-原文.html | 1 +
...十二回 卢俊义分兵宣州道 宋公明大战毗陵郡-原文.html | 1 +
...百一十五回 张顺魂捉方天定 宋江智取宁海军-原文.html | 46 +
...十八回 卢俊义大战昱岭关 宋公明智取清溪洞-原文.html | 48 +
...十六回 卢俊义分兵歙州道 宋公明大战乌龙岭-原文.html | 47 +
...百一十四回 宁海军宋江吊孝 涌金门张顺归神-原文.html | 1 +
...一百一十回 燕青秋林渡射 宋江东京城献俘-原文.html | 1 +
...百二十回 宋公明神聚蓼儿 徽宗帝梦游梁山泊-原文.html | 1 +
...第一百回 张清琼英双建功 陈瓘宋江同奏捷-原文.html | 55 +
...百零一回 谋坟地阴险产逆 蹈春阳妖艳生奸-原文.html | 59 +
...百零七回 宋江大胜纪山军 朱武打破六花阵-原文.html | 51 +
...零三回 张管营因妾弟丧身 范节级为表兄医脸-原文.html | 58 +
...第一百零九回 王庆渡江被捉 宋江剿寇成功-原文.html | 1 +
...百零二回 王庆因奸吃官司 龚端被打师军犯-原文.html | 59 +
...零五回 宋公明避暑疗军兵 乔道清回风烧贼寇-原文.html | 54 +
...百零八回 乔道清兴雾取城 小旋风藏炮击贼-原文.html | 45 +
...百零六回 书生谈笑却强敌 水军汨没破坚城-原文.html | 54 +
...零四回 段家庄重招新女婿 房山寨双并旧强人-原文.html | 1 +
...十一回 忠义堂石碣受天文 梁山泊英雄排座次-原文.html | 1 +
...七十七回 梁山泊十面埋伏 宋公明两赢童贯-原文.html | 1 +
.../第七十三回 黑旋风乔捉鬼 梁山泊双献头-原文.html | 1 +
...七十九回 刘唐放火烧战船 宋江两败高太尉-原文.html | 59 +
...七十二回 柴进簪花入禁院 李逵元夜闹东京-原文.html | 1 +
...十五回 活阎罗倒船偷御酒 黑旋风扯诏谤徽宗-原文.html | 56 +
...十八回 十节度议取梁山泊 宋公明一败高太尉-原文.html | 61 +
...回 吴加亮布四斗五方旗 宋公明排九宫八卦阵-原文.html | 1 +
...七十四回 燕青智扑擎天柱 李逵寿张乔坐衙-原文.html | 1 +
...十回 没羽箭飞石打英雄 宋公明弃粮擒壮士-原文.html | 1 +
...七回 花和尚倒拔垂杨柳 豹子头误入白虎堂-原文.html | 60 +
...十一回 张都监血溅鸳鸯楼 武行者夜走蜈蚣岭-原文.html | 1 +
...十七回 没遮拦追赶及时雨 船火儿夜闹浔阳江-原文.html | 1 +
...三十三回 宋江夜看小鳌山 花荣大闹清风寨-原文.html | 1 +
...十九回 浔阳楼宋江吟反诗 梁山泊戴宗传假信-原文.html | 1 +
...三十二回 武行者醉打孔亮 锦毛虎义释宋江-原文.html | 1 +
...三十五回 石将军村店寄书 小李广梁山射雁-原文.html | 1 +
...十八回 及时雨会神行太保 黑旋风斗浪里白跳-原文.html | 1 +
...十六回 梁山泊吴用举戴宗 揭阳岭宋江逢李俊-原文.html | 59 +
...十四回 镇三山大闹青州道 霹雳火夜走瓦砾场-原文.html | 60 +
...第三十回 施恩三入死囚牢 武松大闹飞云浦-原文.html | 1 +
...三回 史大郎夜走华阴县 鲁提辖拳打镇关西-原文.html | 1 +
...九十一回 宋公明兵渡黄河 卢俊义赚城黑夜-原文.html | 54 +
...九十七回 陈瓘谏官升安抚 琼英处女做先锋-原文.html | 50 +
.../第九十三回 李逵梦闹天池 宋江兵分两路-原文.html | 57 +
...十九回 花和尚解脱缘缠井 混江龙水灌太原城-原文.html | 56 +
...十二回 振军威小李广神箭 打盖郡智多星密筹-原文.html | 57 +
...九十五回 宋公明忠感后土 乔道清术败宋兵-原文.html | 51 +
.../第九十八回 张清缘配琼英 吴用计鸩邬梨-原文.html | 1 +
...十六回 幻魔君术窘五龙山 入云龙兵围百谷岭-原文.html | 47 +
.../第九十四回 关胜义降三将 李逵莽陷众人-原文.html | 1 +
...第九十回 五台山宋江参禅 双林镇燕青遇故-原文.html | 54 +
.../第九回 柴进门招天下客 林冲棒打洪教头-原文.html | 61 +
...二十一回 虔婆醉打唐牛儿 宋江怒杀阎婆惜-原文.html | 1 +
...回 母夜叉孟州道卖人肉 武都头十字坡遇张青-原文.html | 51 +
...二十三回 横海郡柴进留宾 景阳冈武松打虎-原文.html | 1 +
...二十九回 施恩重霸孟州道 武松醉打蒋门神-原文.html | 56 +
...二十二回 阎婆大闹郓城县 朱仝义释宋公明-原文.html | 58 +
...二十五回 王婆计啜西门庆 淫妇药鸩武大郎-原文.html | 57 +
...二十八回 武松威镇安平寨 施恩义夺快活林-原文.html | 53 +
...二十六回 郓哥大闹授官厅 武松斗杀西门庆-原文.html | 1 +
...二十四回 王婆贪贿说风情 郓哥不忿闹茶肆-原文.html | 1 +
...十回 梁山泊义士尊晁盖 郓城县月夜走刘唐-原文.html | 1 +
...二回 王教头私走延安府 九纹龙大闹史家村-原文.html | 1 +
...十一回 插翅虎枷打白秀英 美髯公误失小衙内-原文.html | 1 +
...五十七回 徐宁教使钩镰枪 宋江大破连环马-原文.html | 1 +
...五十三回 戴宗智取公孙胜 李逵斧劈罗真人-原文.html | 1 +
...五十九回 吴用赚金铃吊挂 宋江闹西岳华山-原文.html | 1 +
...五十二回 李逵打死殷天锡 柴进失陷高唐州-原文.html | 1 +
...十五回 高太尉大兴三路兵 呼延灼摆布连环马-原文.html | 1 +
...五十八回 三山聚义打青州 众虎同心归水泊-原文.html | 1 +
...五十六回 吴用使时迁盗甲 汤隆赚徐宁上山-原文.html | 1 +
...十四回 入云龙斗法破高廉 黑旋风探穴救柴进-原文.html | 1 +
...十回 吴学究双用连环计 宋公明三打祝家庄-原文.html | 57 +
...五回 小霸王醉入销金帐 花和尚大闹桃花村-原文.html | 1 +
...八十一回 燕青月夜遇道君 戴宗定计赚萧让-原文.html | 1 +
...八十七回 宋公明大战幽州 呼延灼力擒番将-原文.html | 1 +
...十三回 宋公明奉诏破大辽 陈桥驿滴泪斩小卒-原文.html | 1 +
...八十九回 宋公明破阵成功 宿太尉颁恩降诏-原文.html | 1 +
...十二回 梁山泊分金大买市 宋公明全伙受招安-原文.html | 1 +
...十五回 宋公明夜度益津关 吴学究智取文安县-原文.html | 59 +
...十八回 颜统军阵列混天象 宋公明梦授玄女法-原文.html | 1 +
...十六回 宋公明大战独鹿山 卢俊义兵陷青石峪-原文.html | 1 +
...十四回 宋公明兵打蓟州城 卢俊义大战玉田县-原文.html | 1 +
...第八十回 张顺凿漏海鳅船 宋江三败高太尉-原文.html | 1 +
...八回 林教头刺配沧州道 鲁智深大闹野猪林-原文.html | 54 +
...六十一回 吴用智赚玉麒麟 张顺夜闹金沙渡-原文.html | 1 +
...六十七回 宋江赏马步三军 关胜降水火二将-原文.html | 1 +
...六十三回 宋江兵打北京城 关胜议取梁山泊-原文.html | 1 +
...十九回 东平府误陷九纹龙 宋公明义释双枪将-原文.html | 1 +
...六十二回 放冷箭燕青救主 劫法场石秀跳楼-原文.html | 1 +
...十五回 托塔天王梦中显圣 浪里白跳水上报冤-原文.html | 1 +
...十八回 宋公明夜打曾头市 卢俊义活捉史文恭-原文.html | 1 +
...六十六回 时迁火烧翠云楼 吴用智取大名府-原文.html | 1 +
...十四回 呼延灼夜月赚关胜 宋公明雪天擒索超-原文.html | 1 +
...十回 公孙胜芒砀山降魔 晁天王曾头市中箭-原文.html | 1 +
...六回 九纹龙剪径赤松林 鲁智深火烧瓦罐寺-原文.html | 1 +
...第十一回 朱贵水亭施号箭 林冲雪夜上梁山-原文.html | 1 +
...七回 花和尚单打二龙山 青面兽双夺宝珠寺-原文.html | 1 +
...第十三回 急先锋东郭争功 青面兽北京斗武-原文.html | 1 +
...第十九回 林冲水寨大并火 晁盖梁山小夺泊-原文.html | 1 +
...第十二回 梁山泊林冲落草 汴京城杨志卖刀-原文.html | 54 +
...五回 吴学究说三阮撞筹 公孙胜应七星聚义-原文.html | 1 +
...八回 美髯公智稳插翅虎 宋公明私放晃天王-原文.html | 60 +
...第十六回 杨志押送金银担 吴用智取生辰纲-原文.html | 1 +
...四回 赤发鬼醉卧灵官殿 晁天王认义东溪村-原文.html | 1 +
...十回 林教头风雪山神庙 陆虞候火烧草料场-原文.html | 58 +
...四十一回 宋江智取无为军 张顺活捉黄文炳-原文.html | 1 +
...十七回 扑天雕双修生死书 宋公明一打祝家庄-原文.html | 1 +
...十三回 假李逵剪径劫单人 黑旋风沂岭杀四虎-原文.html | 1 +
...四十九回 解珍解宝双越狱 孙立孙新大劫牢-原文.html | 1 +
...十二回 还道村受三卷天书 宋公明遇九天玄女-原文.html | 1 +
...四十五回 杨雄醉骂潘巧云 石秀智杀裴如海-原文.html | 1 +
...十八回 一丈青单捉王矮虎 宋公明两打祝家庄-原文.html | 1 +
...十六回 病关索大闹翠屏山 拚命三火烧祝家庄-原文.html | 1 +
...十四回 锦豹子小径逢戴宗 病关索长街遇石秀-原文.html | 1 +
...十回 梁山泊好汉劫法场 白龙庙英雄小聚义-原文.html | 1 +
...四回 赵员外重修文殊院 鲁智深大闹五台山-原文.html | 1 +
cmd/testdata/kb/水浒传.html | 272 +
cmd/testdata/main.go | 214 +
cmd/testdata/quickreply/init.go | 144 +
cmd/testdata/skill/init.go | 67 +
cmd/testdata/tag/init.go | 72 +
config/config.example.yaml | 84 +
docs | 1 +
go.mod | 138 +
go.sum | 531 +
internal/ai/embedding.go | 101 +
internal/ai/llm.go | 105 +
internal/ai/mcps/client.go | 198 +
.../mcps/providers/system_tools_provider.go | 69 +
internal/ai/mcps/providers/types.go | 10 +
internal/ai/mcps/registry.go | 23 +
internal/ai/mcps/runtime.go | 72 +
internal/ai/mcps/server.go | 32 +
internal/ai/mcps/types.go | 45 +
internal/ai/openai_client.go | 37 +
internal/ai/rag/answer.go | 432 +
internal/ai/rag/answer_test.go | 128 +
internal/ai/rag/chunk/fixed_provider.go | 44 +
internal/ai/rag/chunk/provider.go | 12 +
internal/ai/rag/chunk/registry.go | 59 +
internal/ai/rag/chunk/structured_provider.go | 261 +
internal/ai/rag/chunk/types.go | 32 +
internal/ai/rag/chunk/utils.go | 218 +
internal/ai/rag/index.go | 702 ++
internal/ai/rag/rerank.go | 140 +
internal/ai/rag/retrieve.go | 507 +
internal/ai/rag/retrieve_log.go | 300 +
internal/ai/rag/retrieve_test.go | 49 +
internal/ai/rag/types.go | 48 +
internal/ai/rag/utils.go | 112 +
internal/ai/rag/utils_test.go | 43 +
internal/ai/rag/vectordb/payload.go | 43 +
internal/ai/rag/vectordb/provider.go | 86 +
internal/ai/rag/vectordb/qdrant.go | 292 +
.../ai/runtime/internal/engine/service.go | 451 +
internal/ai/runtime/internal/engine/types.go | 52 +
.../impl/adapter/ai_config_adapter.go | 26 +
.../impl/adapter/conversation_adapter.go | 22 +
.../internal/impl/adapter/mcp_tool_adapter.go | 192 +
.../internal/impl/adapter/message_adapter.go | 69 +
.../impl/agents/customer_service_agent.go | 55 +
.../impl/callbacks/agent_trace_handler.go | 85 +
.../impl/callbacks/runlog_callback.go | 41 +
.../internal/impl/callbacks/trace_callback.go | 63 +
.../internal/impl/factory/agent_factory.go | 112 +
.../impl/factory/chat_model_factory.go | 64 +
.../internal/impl/factory/runner_factory.go | 27 +
.../internal/impl/factory/tool_factory.go | 100 +
.../impl/retrievers/knowledge_retriever.go | 32 +
.../internal/impl/store/checkpoint_store.go | 69 +
internal/ai/runtime/registry/registry.go | 44 +
internal/ai/runtime/registry/types.go | 26 +
internal/ai/runtime/reply_service.go | 563 ++
internal/ai/runtime/service.go | 177 +
.../tools/create_ticket_confirm_tool.go | 215 +
internal/ai/runtime/tools/helper.go | 63 +
internal/ai/runtime/types.go | 54 +
internal/ai/skills/execution.go | 248 +
internal/ai/skills/log.go | 56 +
internal/ai/skills/matcher.go | 183 +
internal/ai/skills/runtime.go | 94 +
internal/ai/skills/types.go | 70 +
internal/bootstrap/db.go | 107 +
internal/bootstrap/db_test.go | 66 +
internal/bootstrap/init.go | 44 +
internal/bootstrap/migration.go | 15 +
internal/bootstrap/server.go | 150 +
internal/builders/agent_profile_builder.go | 82 +
internal/builders/agent_run_log_builder.go | 30 +
internal/builders/asset_builder.go | 27 +
internal/builders/company_builder.go | 32 +
internal/builders/conversation_builder.go | 267 +
internal/builders/customer_builder.go | 39 +
internal/builders/customer_contact_builder.go | 36 +
internal/builders/knowledge_builder.go | 152 +
internal/builders/skill_builder.go | 35 +
internal/builders/tag_builder.go | 75 +
internal/builders/ticket_builder.go | 567 ++
internal/builders/ticket_config_builder.go | 61 +
internal/builders/user_builder.go | 68 +
internal/controllers/api/auth_controller.go | 120 +
.../controllers/console/agent_controller.go | 101 +
.../console/agent_run_log_controller.go | 52 +
.../console/agent_team_controller.go | 124 +
.../console/agent_team_schedule_controller.go | 103 +
.../console/ai_agent_controller.go | 183 +
.../console/ai_config_controller.go | 143 +
.../controllers/console/asset_controller.go | 92 +
.../controllers/console/channel_controller.go | 118 +
.../controllers/console/company_controller.go | 113 +
.../console/conversation_controller.go | 365 +
.../console/customer_contact_controller.go | 76 +
.../console/customer_controller.go | 129 +
.../console/dashboard_controller.go | 18 +
.../console/knowledge_base_controller.go | 164 +
.../console/knowledge_document_controller.go | 111 +
.../console/knowledge_faq_controller.go | 94 +
.../console/knowledge_retrieve_controller.go | 85 +
.../knowledge_retrieve_log_controller.go | 77 +
.../controllers/console/mcp_controller.go | 70 +
.../console/permission_controller.go | 71 +
.../console/quick_reply_controller.go | 118 +
.../controllers/console/role_controller.go | 187 +
.../controllers/console/session_controller.go | 83 +
.../console/skill_definition_controller.go | 280 +
.../controllers/console/tag_controller.go | 127 +
.../controllers/console/ticket_controller.go | 501 +
.../ticket_priority_config_controller.go | 102 +
.../ticket_resolution_code_controller.go | 85 +
.../controllers/console/user_controller.go | 199 +
.../open/im_conversation_controller.go | 77 +
.../controllers/open/im_message_controller.go | 173 +
.../controllers/open/im_widget_controller.go | 25 +
.../controllers/third/wechat_controller.go | 66 +
internal/middleware/auth_middleware.go | 24 +
internal/middleware/open_im_middleware.go | 46 +
internal/middleware/ws_middleware.go | 84 +
internal/migration/000001_init_schema.go | 7 +
internal/migration/000002_init_auth_data.go | 256 +
.../000003_init_ticket_priority_configs.go | 44 +
internal/migration/README.md | 3 +
internal/migration/migration.go | 101 +
internal/models/models.go | 981 ++
internal/pkg/config/config.go | 156 +
internal/pkg/config/runtime.go | 14 +
internal/pkg/constants/auth.go | 380 +
internal/pkg/constants/constants.go | 6 +
internal/pkg/dto/dto.go | 13 +
internal/pkg/dto/request/admin_request.go | 97 +
internal/pkg/dto/request/agent_request.go | 64 +
internal/pkg/dto/request/ai_request.go | 75 +
internal/pkg/dto/request/asset_request.go | 9 +
internal/pkg/dto/request/auth_request.go | 18 +
internal/pkg/dto/request/channel_request.go | 24 +
internal/pkg/dto/request/company_request.go | 21 +
.../pkg/dto/request/conversation_request.go | 61 +
.../dto/request/customer_contact_request.go | 27 +
internal/pkg/dto/request/customer_request.go | 72 +
internal/pkg/dto/request/knowledge_request.go | 82 +
internal/pkg/dto/request/mcp_request.go | 11 +
internal/pkg/dto/request/message_request.go | 21 +
.../pkg/dto/request/quick_reply_request.go | 26 +
internal/pkg/dto/request/skill_request.go | 40 +
internal/pkg/dto/request/tag_request.go | 27 +
.../pkg/dto/request/ticket_config_request.go | 37 +
internal/pkg/dto/request/ticket_request.go | 149 +
internal/pkg/dto/response/admin_response.go | 58 +
internal/pkg/dto/response/agent_response.go | 44 +
internal/pkg/dto/response/ai_response.go | 100 +
internal/pkg/dto/response/asset_response.go | 21 +
internal/pkg/dto/response/auth_response.go | 21 +
internal/pkg/dto/response/channel_response.go | 34 +
internal/pkg/dto/response/company_response.go | 14 +
.../pkg/dto/response/conversation_response.go | 53 +
.../dto/response/customer_contact_response.go | 18 +
.../pkg/dto/response/customer_response.go | 18 +
.../pkg/dto/response/dashboard_response.go | 83 +
.../pkg/dto/response/knowledge_response.go | 204 +
internal/pkg/dto/response/mcp_response.go | 101 +
internal/pkg/dto/response/message_response.go | 27 +
.../pkg/dto/response/quick_reply_response.go | 13 +
internal/pkg/dto/response/skill_response.go | 51 +
internal/pkg/dto/response/tag_response.go | 26 +
.../dto/response/ticket_config_response.go | 22 +
internal/pkg/dto/response/ticket_response.go | 151 +
internal/pkg/dto/response/widget_response.go | 5 +
internal/pkg/enums/agent.go | 31 +
internal/pkg/enums/ai.go | 33 +
internal/pkg/enums/asset.go | 37 +
internal/pkg/enums/customer.go | 40 +
internal/pkg/enums/enums.go | 86 +
internal/pkg/enums/external_identity.go | 33 +
internal/pkg/enums/im.go | 266 +
internal/pkg/enums/knowledge.go | 234 +
internal/pkg/enums/skill.go | 17 +
internal/pkg/enums/ticket.go | 156 +
internal/pkg/enums/wxwork_kf.go | 50 +
internal/pkg/errorsx/errors.go | 36 +
internal/pkg/irisx/context.go | 35 +
internal/pkg/logx/logger.go | 47 +
internal/pkg/openidentity/openidentity.go | 84 +
internal/pkg/utils/content_chunk.go | 115 +
internal/pkg/utils/content_chunk_test.go | 72 +
internal/pkg/utils/utils.go | 103 +
.../repositories/agent_profile_repository.go | 101 +
.../repositories/agent_run_log_repository.go | 90 +
.../repositories/agent_team_repository.go | 110 +
.../agent_team_schedule_repository.go | 102 +
internal/repositories/ai_agent_repository.go | 110 +
internal/repositories/ai_config_repository.go | 110 +
internal/repositories/asset_repository.go | 117 +
.../channel_message_outbox_repository.go | 102 +
internal/repositories/channel_repository.go | 102 +
internal/repositories/company_repository.go | 109 +
.../conversation_assignment_repository.go | 101 +
.../conversation_event_log_repository.go | 101 +
.../conversation_interrupt_repository.go | 88 +
.../conversation_participant_repository.go | 101 +
.../conversation_read_state_repository.go | 91 +
.../repositories/conversation_repository.go | 101 +
.../conversation_tag_repository.go | 101 +
.../customer_contact_repository.go | 102 +
.../customer_identity_repository.go | 113 +
internal/repositories/customer_repository.go | 126 +
internal/repositories/dashboard_repository.go | 108 +
.../repositories/knowledge_base_repository.go | 91 +
.../knowledge_chunk_repository.go | 140 +
.../knowledge_document_repository.go | 105 +
.../repositories/knowledge_faq_repository.go | 71 +
.../login_credential_log_repository.go | 101 +
.../repositories/login_session_repository.go | 101 +
internal/repositories/message_repository.go | 127 +
internal/repositories/migration_repository.go | 101 +
.../repositories/permission_repository.go | 101 +
.../repositories/quick_reply_repository.go | 101 +
.../role_permission_repository.go | 101 +
internal/repositories/role_repository.go | 101 +
.../skill_definition_repository.go | 105 +
.../repositories/skill_run_log_repository.go | 102 +
.../repositories/system_config_repository.go | 102 +
internal/repositories/tag_repository.go | 102 +
.../ticket_collaborator_repository.go | 83 +
.../repositories/ticket_comment_repository.go | 102 +
.../ticket_event_log_repository.go | 102 +
.../repositories/ticket_mention_repository.go | 74 +
.../ticket_no_sequence_repository.go | 38 +
.../ticket_priority_config_repository.go | 81 +
.../ticket_relation_repository.go | 106 +
internal/repositories/ticket_repository.go | 102 +
.../ticket_resolution_code_repository.go | 81 +
.../ticket_sla_record_repository.go | 109 +
.../repositories/ticket_tag_repository.go | 73 +
.../repositories/ticket_view_repository.go | 83 +
.../repositories/ticket_watcher_repository.go | 109 +
.../repositories/user_identity_repository.go | 110 +
.../user_permission_repository.go | 101 +
internal/repositories/user_repository.go | 135 +
internal/repositories/user_role_repository.go | 101 +
.../wx_work_kf_conversation_repository.go | 102 +
.../wx_work_kf_message_ref_repository.go | 102 +
.../wx_work_kf_sync_state_repository.go | 102 +
internal/services/agent_profile_service.go | 206 +
internal/services/agent_run_log_service.go | 65 +
.../services/agent_team_schedule_service.go | 208 +
internal/services/agent_team_service.go | 172 +
internal/services/ai_agent_service.go | 362 +
internal/services/ai_config_service.go | 264 +
internal/services/ai_reply_hook.go | 5 +
internal/services/asset_service.go | 201 +
internal/services/auth_service.go | 489 +
internal/services/auth_service_test.go | 15 +
.../channel_message_outbox_service.go | 137 +
internal/services/channel_service.go | 264 +
internal/services/company_service.go | 149 +
.../conversation_assignment_service.go | 75 +
.../services/conversation_dispatch_service.go | 458 +
.../conversation_event_log_service.go | 82 +
.../conversation_interrupt_service.go | 187 +
.../conversation_participant_service.go | 82 +
.../conversation_read_state_service.go | 275 +
internal/services/conversation_service.go | 734 ++
internal/services/conversation_tag_service.go | 94 +
internal/services/cronx/cron.go | 49 +
internal/services/customer_contact_service.go | 523 +
.../services/customer_identity_service.go | 67 +
internal/services/customer_service.go | 272 +
internal/services/dashboard_service.go | 408 +
internal/services/im_message_asset.go | 65 +
internal/services/knowledge_base_service.go | 236 +
.../services/knowledge_base_service_test.go | 17 +
.../services/knowledge_document_service.go | 203 +
internal/services/knowledge_faq_service.go | 167 +
.../knowledge_retrieve_log_service.go | 46 +
.../services/login_credential_log_service.go | 66 +
internal/services/login_session_service.go | 94 +
internal/services/mcp_debug_service.go | 155 +
internal/services/message_service.go | 584 ++
internal/services/migration_service.go | 66 +
internal/services/permission_service.go | 66 +
internal/services/quick_reply_service.go | 124 +
internal/services/role_permission_service.go | 66 +
internal/services/role_service.go | 199 +
internal/services/skill_definition_service.go | 88 +
internal/services/skill_run_log_service.go | 66 +
internal/services/skill_runtime_service.go | 77 +
internal/services/storage/dto.go | 23 +
internal/services/storage/local.go | 70 +
internal/services/storage/oss.go | 177 +
internal/services/storage/provider.go | 34 +
internal/services/storage/utils.go | 85 +
internal/services/system_config_service.go | 67 +
internal/services/tag_service.go | 268 +
.../services/ticket_collaborator_service.go | 54 +
internal/services/ticket_comment_service.go | 67 +
internal/services/ticket_event_log_service.go | 67 +
internal/services/ticket_mention_service.go | 46 +
internal/services/ticket_no_service.go | 84 +
.../ticket_priority_config_service.go | 162 +
internal/services/ticket_relation_service.go | 212 +
.../ticket_resolution_code_service.go | 134 +
internal/services/ticket_service.go | 1845 ++++
internal/services/ticket_service_test.go | 851 ++
.../services/ticket_sla_record_service.go | 67 +
internal/services/ticket_tag_service.go | 104 +
internal/services/ticket_view_service.go | 93 +
internal/services/ticket_watcher_service.go | 67 +
internal/services/user_identity_service.go | 66 +
internal/services/user_permission_service.go | 66 +
internal/services/user_role_service.go | 66 +
internal/services/user_service.go | 298 +
internal/services/ws_connection_manager.go | 126 +
internal/services/ws_realtime_types.go | 276 +
internal/services/ws_service.go | 575 ++
.../kf_msg_or_event_handler.go | 28 +
.../wx_work_kf_conversation_service.go | 67 +
.../wx_work_kf_message_ref_service.go | 70 +
.../services/wx_work_kf_sync_state_service.go | 71 +
.../services/wxwork_kf_inbound_service.go | 633 ++
.../services/wxwork_kf_outbound_service.go | 520 +
internal/services/wxwork_login_service.go | 245 +
internal/wxwork/callback_dispatcher.go | 117 +
internal/wxwork/login.go | 255 +
internal/wxwork/wxwork.go | 70 +
qdrant | 1 +
web/.env.example | 1 +
web/.gitignore | 42 +
web/README.md | 36 +
web/app/(console)/_components/alert-list.tsx | 71 +
.../(console)/_components/summary-cards.tsx | 105 +
.../(console)/_components/team-load-panel.tsx | 98 +
web/app/(console)/_components/trend-panel.tsx | 117 +
web/app/(console)/agent-run-logs/page.tsx | 458 +
.../agent-team-schedules/_components/edit.tsx | 283 +
.../(console)/agent-team-schedules/page.tsx | 284 +
web/app/(console)/agents/_components/edit.tsx | 527 +
.../agents/_components/team-edit.tsx | 371 +
.../agents/_components/team-sidebar.tsx | 287 +
web/app/(console)/agents/page.tsx | 474 +
.../(console)/ai-agents/_components/edit.tsx | 1197 +++
web/app/(console)/ai-agents/page.tsx | 563 ++
.../(console)/ai-configs/_components/edit.tsx | 461 +
.../_components/option-combobox.tsx | 90 +
web/app/(console)/ai-configs/page.tsx | 685 ++
.../(console)/channels/_components/edit.tsx | 295 +
web/app/(console)/channels/page.tsx | 357 +
.../(console)/companies/_components/edit.tsx | 223 +
web/app/(console)/companies/page.tsx | 359 +
.../_components/detail.tsx | 560 ++
.../(console)/conversation-monitor/page.tsx | 913 ++
.../conversations/_components/chat-panel.tsx | 677 ++
.../_components/conversation-info-panel.tsx | 757 ++
.../_components/conversation-list.tsx | 121 +
.../_components/conversation-tag-picker.tsx | 215 +
web/app/(console)/conversations/page.tsx | 405 +
.../(console)/customers/_components/edit.tsx | 4 +
web/app/(console)/customers/page.tsx | 468 +
web/app/(console)/help/page.tsx | 16 +
.../knowledge/_components/debug-panel.tsx | 225 +
.../knowledge/_components/document-edit.tsx | 239 +
.../knowledge/_components/document-list.tsx | 565 ++
.../knowledge/_components/faq-edit.tsx | 218 +
.../_components/faq-import-dialog.tsx | 402 +
.../knowledge/_components/faq-list.tsx | 453 +
.../_components/knowledge-base-edit.tsx | 536 ++
.../_components/knowledge-base-list.tsx | 527 +
.../_components/retrieve-log-detail.tsx | 262 +
.../_components/retrieve-log-list.tsx | 292 +
web/app/(console)/knowledge/page.tsx | 197 +
web/app/(console)/layout.tsx | 65 +
web/app/(console)/mcp/page.tsx | 392 +
web/app/(console)/page.tsx | 172 +
web/app/(console)/permissions/page.tsx | 229 +
.../quick-replies/_components/edit.tsx | 304 +
web/app/(console)/quick-replies/page.tsx | 380 +
.../roles/_components/assign-permissions.tsx | 284 +
web/app/(console)/roles/page.tsx | 358 +
web/app/(console)/settings/page.tsx | 16 +
.../skill-definition/_components/edit.tsx | 265 +
web/app/(console)/skill-definition/page.tsx | 523 +
web/app/(console)/tags/_components/edit.tsx | 292 +
web/app/(console)/tags/page.tsx | 635 ++
web/app/(console)/ticket-priorities/page.tsx | 616 ++
.../ticket-resolution-codes/page.tsx | 477 +
web/app/(console)/ticket-risk/page.tsx | 326 +
...create-ticket-from-conversation-dialog.tsx | 69 +
.../(console)/tickets/_components/edit.tsx | 546 ++
.../_components/ticket-assign-dialog.tsx | 243 +
.../ticket-collaborator-dialog.tsx | 149 +
.../_components/ticket-customer-panel.tsx | 529 +
.../_components/ticket-priority-badge.tsx | 43 +
.../_components/ticket-reason-dialog.tsx | 122 +
.../_components/ticket-relation-dialog.tsx | 229 +
.../_components/ticket-reply-dialog.tsx | 244 +
.../tickets/_components/ticket-sla-badge.tsx | 62 +
.../_components/ticket-status-badge.tsx | 35 +
.../_components/ticket-status-dialog.tsx | 279 +
web/app/(console)/tickets/detail/page.tsx | 1271 +++
web/app/(console)/tickets/page.tsx | 1243 +++
.../users/_components/assign-roles.tsx | 335 +
.../(console)/users/_components/create.tsx | 374 +
web/app/(console)/users/_components/edit.tsx | 270 +
.../_components/initial-password-dialog.tsx | 78 +
.../users/_components/reset-password.tsx | 104 +
web/app/(console)/users/page.tsx | 494 +
web/app/favicon.ico | Bin 0 -> 25931 bytes
web/app/globals.css | 129 +
web/app/layout.tsx | 55 +
web/app/login/page.tsx | 67 +
web/app/login/wxwork/callback/page.tsx | 65 +
web/components.json | 25 +
web/components/app-sidebar.tsx | 70 +
web/components/auth-provider.tsx | 106 +
web/components/change-password-dialog.tsx | 146 +
web/components/chart-area-interactive.tsx | 158 +
web/components/company-picker.tsx | 243 +
web/components/confirm-provider.tsx | 124 +
web/components/content-editor/convert.ts | 26 +
.../content-editor/editor-mode-switch.tsx | 49 +
web/components/content-editor/html-editor.tsx | 362 +
web/components/content-editor/index.tsx | 150 +
.../content-editor/markdown-editor.css | 48 +
.../content-editor/markdown-editor.tsx | 149 +
web/components/content-editor/toolbar.css | 12 +
web/components/content-editor/toolbar.tsx | 142 +
web/components/content-editor/types.ts | 42 +
.../conversation-actions/close-dialog.tsx | 163 +
.../conversation-actions/transfer-dialog.tsx | 230 +
web/components/customer-form-dialog.tsx | 88 +
web/components/customer-form.tsx | 498 +
.../customer-link-or-create-dialog.tsx | 271 +
web/components/dashboard-placeholder.tsx | 68 +
web/components/data-table.tsx | 144 +
web/components/editor/DESIGN.md | 235 +
web/components/editor/html.tsx | 148 +
web/components/editor/index.tsx | 49 +
web/components/editor/markdown.tsx | 171 +
web/components/editor/toolbar.tsx | 63 +
web/components/editor/types.ts | 21 +
web/components/im-message-editor.tsx | 412 +
web/components/im-message-html.tsx | 86 +
web/components/image-input.tsx | 139 +
web/components/image-lightbox.tsx | 372 +
web/components/json-code-editor.tsx | 75 +
web/components/json-viewer.tsx | 77 +
web/components/list-pagination.tsx | 88 +
web/components/login-form.tsx | 141 +
web/components/nav-documents.tsx | 95 +
web/components/nav-main.tsx | 56 +
web/components/nav-secondary.tsx | 50 +
web/components/nav-user.tsx | 127 +
web/components/option-combobox.tsx | 106 +
web/components/project-dialog.tsx | 175 +
web/components/rich-text-editor.tsx | 161 +
web/components/section-cards.tsx | 109 +
web/components/site-header.tsx | 72 +
web/components/theme-provider.tsx | 17 +
web/components/theme-toggle.tsx | 64 +
web/components/ui/avatar.tsx | 109 +
web/components/ui/badge.tsx | 52 +
web/components/ui/breadcrumb.tsx | 125 +
web/components/ui/button-group.tsx | 87 +
web/components/ui/button.tsx | 60 +
web/components/ui/calendar.tsx | 221 +
web/components/ui/card.tsx | 103 +
web/components/ui/chart.tsx | 356 +
web/components/ui/checkbox.tsx | 29 +
web/components/ui/combobox.tsx | 297 +
web/components/ui/command.tsx | 196 +
web/components/ui/context-menu.tsx | 271 +
web/components/ui/dialog.tsx | 157 +
web/components/ui/drawer.tsx | 131 +
web/components/ui/dropdown-menu.tsx | 268 +
web/components/ui/field.tsx | 238 +
web/components/ui/input-group.tsx | 158 +
web/components/ui/input.tsx | 20 +
web/components/ui/label.tsx | 20 +
web/components/ui/menubar.tsx | 280 +
web/components/ui/popover.tsx | 90 +
web/components/ui/resizable.tsx | 50 +
web/components/ui/scroll-area.tsx | 55 +
web/components/ui/select.tsx | 201 +
web/components/ui/separator.tsx | 25 +
web/components/ui/sheet.tsx | 135 +
web/components/ui/sidebar.tsx | 724 ++
web/components/ui/skeleton.tsx | 13 +
web/components/ui/sonner.tsx | 49 +
web/components/ui/switch.tsx | 32 +
web/components/ui/table.tsx | 116 +
web/components/ui/tabs.tsx | 82 +
web/components/ui/textarea.tsx | 18 +
web/components/ui/toggle-group.tsx | 89 +
web/components/ui/toggle.tsx | 45 +
web/components/ui/tooltip.tsx | 66 +
web/eslint.config.mjs | 18 +
web/hooks/use-agent-conversation-realtime.ts | 248 +
web/hooks/use-lg-media.ts | 25 +
web/hooks/use-mobile.ts | 19 +
web/lib/api/admin.ts | 1622 ++++
web/lib/api/agent.ts | 268 +
web/lib/api/auth.ts | 44 +
web/lib/api/client.ts | 104 +
web/lib/api/company.ts | 77 +
web/lib/api/customer-contact.ts | 77 +
web/lib/api/customer.ts | 108 +
web/lib/api/dashboard.ts | 81 +
web/lib/api/im.ts | 225 +
web/lib/api/ticket-config.ts | 139 +
web/lib/api/ticket.ts | 487 +
web/lib/auth.ts | 55 +
web/lib/enums.ts | 15 +
web/lib/generated/enums.ts | 420 +
web/lib/im-message.ts | 129 +
web/lib/navigation.tsx | 308 +
web/lib/services/notification.ts | 42 +
web/lib/stores/agent-conversations.ts | 599 ++
web/lib/ticket-priority.ts | 19 +
web/lib/utils.ts | 50 +
web/next.config.ts | 32 +
web/package.json | 64 +
web/pnpm-lock.yaml | 8562 +++++++++++++++++
web/pnpm-workspace.yaml | 6 +
web/postcss.config.mjs | 7 +
web/public/wxwork.svg | 1 +
web/styles/main.scss | 44 +
web/tsconfig.json | 34 +
widget/.env.example | 1 +
widget/.gitignore | 42 +
widget/app/frame/page.tsx | 5 +
widget/app/globals.css | 157 +
widget/app/layout.tsx | 24 +
widget/app/page.tsx | 339 +
widget/components.json | 25 +
widget/components/im/chat-shell.tsx | 322 +
widget/components/im/connection-status.tsx | 43 +
widget/components/im/message-editor.tsx | 339 +
widget/components/im/message-html.tsx | 82 +
widget/components/im/message-input.tsx | 123 +
widget/components/im/message-list.tsx | 348 +
widget/components/image-lightbox.tsx | 376 +
widget/eslint.config.mjs | 9 +
widget/lib/services/conversation.ts | 29 +
widget/lib/services/http.ts | 33 +
widget/lib/services/message-asset.ts | 133 +
widget/lib/services/message.ts | 127 +
widget/lib/services/notification.ts | 37 +
widget/lib/services/realtime.ts | 42 +
widget/lib/services/types.ts | 81 +
widget/lib/services/widget-config.ts | 13 +
widget/lib/store/chat-store.ts | 569 ++
widget/lib/utils.ts | 45 +
widget/lib/widget/config.ts | 52 +
widget/lib/widget/host-bridge.ts | 85 +
widget/lib/widget/sdk-template.js | 270 +
widget/lib/widget/visitor.ts | 16 +
widget/next.config.ts | 42 +
widget/package.json | 37 +
widget/pnpm-lock.yaml | 4795 +++++++++
widget/pnpm-workspace.yaml | 3 +
widget/postcss.config.mjs | 7 +
widget/public/sdk/cs-agent-widget.js | 270 +
widget/scripts/build-sdk.mjs | 14 +
widget/tsconfig.json | 34 +
707 files changed, 110595 insertions(+)
create mode 100644 .gitignore
create mode 100644 .gitmodules
create mode 100644 .vscode/launch.json
create mode 100644 .vscode/tasks.json
create mode 100644 AGENTS.md
create mode 100644 LICENSE
create mode 100644 Makefile
create mode 100644 README.md
create mode 100644 cmd/enums/generator.go
create mode 100644 cmd/generator/generator.go
create mode 100644 cmd/migration/main.go
create mode 100644 cmd/testdata/.gitignore
create mode 100644 cmd/testdata/README_AGENTTEAM.md
create mode 100644 cmd/testdata/README_AI_AGENT.md
create mode 100644 cmd/testdata/agentteam/init.go
create mode 100644 cmd/testdata/aiagent/init.go
create mode 100644 cmd/testdata/aiconfig/ai_config.example.yaml
create mode 100644 cmd/testdata/aiconfig/init.go
create mode 100644 cmd/testdata/channel/init.go
create mode 100644 cmd/testdata/kb/README.md
create mode 100644 cmd/testdata/kb/kb.go
create mode 100755 cmd/testdata/kb/原文版水浒传/第一回 张天师祈禳瘟疫 洪太尉误走妖魔-原文.html
create mode 100755 cmd/testdata/kb/原文版水浒传/第一百一十一回 张顺夜伏金山寺 宋江智取润州城-原文.html
create mode 100755 cmd/testdata/kb/原文版水浒传/第一百一十七回 睦州城箭射邓元觉 乌龙岭神助宋公明-原文.html
create mode 100755 cmd/testdata/kb/原文版水浒传/第一百一十三回 混江龙太湖小结义 宋公明苏州大会垓-原文.html
create mode 100755 cmd/testdata/kb/原文版水浒传/第一百一十九回 鲁智深浙江坐化宋公明衣锦还乡-原文.html
create mode 100755 cmd/testdata/kb/原文版水浒传/第一百一十二回 卢俊义分兵宣州道 宋公明大战毗陵郡-原文.html
create mode 100755 cmd/testdata/kb/原文版水浒传/第一百一十五回 张顺魂捉方天定 宋江智取宁海军-原文.html
create mode 100755 cmd/testdata/kb/原文版水浒传/第一百一十八回 卢俊义大战昱岭关 宋公明智取清溪洞-原文.html
create mode 100755 cmd/testdata/kb/原文版水浒传/第一百一十六回 卢俊义分兵歙州道 宋公明大战乌龙岭-原文.html
create mode 100755 cmd/testdata/kb/原文版水浒传/第一百一十四回 宁海军宋江吊孝 涌金门张顺归神-原文.html
create mode 100755 cmd/testdata/kb/原文版水浒传/第一百一十回 燕青秋林渡射 宋江东京城献俘-原文.html
create mode 100755 cmd/testdata/kb/原文版水浒传/第一百二十回 宋公明神聚蓼儿 徽宗帝梦游梁山泊-原文.html
create mode 100755 cmd/testdata/kb/原文版水浒传/第一百回 张清琼英双建功 陈瓘宋江同奏捷-原文.html
create mode 100755 cmd/testdata/kb/原文版水浒传/第一百零一回 谋坟地阴险产逆 蹈春阳妖艳生奸-原文.html
create mode 100755 cmd/testdata/kb/原文版水浒传/第一百零七回 宋江大胜纪山军 朱武打破六花阵-原文.html
create mode 100755 cmd/testdata/kb/原文版水浒传/第一百零三回 张管营因妾弟丧身 范节级为表兄医脸-原文.html
create mode 100755 cmd/testdata/kb/原文版水浒传/第一百零九回 王庆渡江被捉 宋江剿寇成功-原文.html
create mode 100755 cmd/testdata/kb/原文版水浒传/第一百零二回 王庆因奸吃官司 龚端被打师军犯-原文.html
create mode 100755 cmd/testdata/kb/原文版水浒传/第一百零五回 宋公明避暑疗军兵 乔道清回风烧贼寇-原文.html
create mode 100755 cmd/testdata/kb/原文版水浒传/第一百零八回 乔道清兴雾取城 小旋风藏炮击贼-原文.html
create mode 100755 cmd/testdata/kb/原文版水浒传/第一百零六回 书生谈笑却强敌 水军汨没破坚城-原文.html
create mode 100755 cmd/testdata/kb/原文版水浒传/第一百零四回 段家庄重招新女婿 房山寨双并旧强人-原文.html
create mode 100755 cmd/testdata/kb/原文版水浒传/第七十一回 忠义堂石碣受天文 梁山泊英雄排座次-原文.html
create mode 100755 cmd/testdata/kb/原文版水浒传/第七十七回 梁山泊十面埋伏 宋公明两赢童贯-原文.html
create mode 100755 cmd/testdata/kb/原文版水浒传/第七十三回 黑旋风乔捉鬼 梁山泊双献头-原文.html
create mode 100755 cmd/testdata/kb/原文版水浒传/第七十九回 刘唐放火烧战船 宋江两败高太尉-原文.html
create mode 100755 cmd/testdata/kb/原文版水浒传/第七十二回 柴进簪花入禁院 李逵元夜闹东京-原文.html
create mode 100755 cmd/testdata/kb/原文版水浒传/第七十五回 活阎罗倒船偷御酒 黑旋风扯诏谤徽宗-原文.html
create mode 100755 cmd/testdata/kb/原文版水浒传/第七十八回 十节度议取梁山泊 宋公明一败高太尉-原文.html
create mode 100755 cmd/testdata/kb/原文版水浒传/第七十六回 吴加亮布四斗五方旗 宋公明排九宫八卦阵-原文.html
create mode 100755 cmd/testdata/kb/原文版水浒传/第七十四回 燕青智扑擎天柱 李逵寿张乔坐衙-原文.html
create mode 100755 cmd/testdata/kb/原文版水浒传/第七十回 没羽箭飞石打英雄 宋公明弃粮擒壮士-原文.html
create mode 100755 cmd/testdata/kb/原文版水浒传/第七回 花和尚倒拔垂杨柳 豹子头误入白虎堂-原文.html
create mode 100755 cmd/testdata/kb/原文版水浒传/第三十一回 张都监血溅鸳鸯楼 武行者夜走蜈蚣岭-原文.html
create mode 100755 cmd/testdata/kb/原文版水浒传/第三十七回 没遮拦追赶及时雨 船火儿夜闹浔阳江-原文.html
create mode 100755 cmd/testdata/kb/原文版水浒传/第三十三回 宋江夜看小鳌山 花荣大闹清风寨-原文.html
create mode 100755 cmd/testdata/kb/原文版水浒传/第三十九回 浔阳楼宋江吟反诗 梁山泊戴宗传假信-原文.html
create mode 100755 cmd/testdata/kb/原文版水浒传/第三十二回 武行者醉打孔亮 锦毛虎义释宋江-原文.html
create mode 100755 cmd/testdata/kb/原文版水浒传/第三十五回 石将军村店寄书 小李广梁山射雁-原文.html
create mode 100755 cmd/testdata/kb/原文版水浒传/第三十八回 及时雨会神行太保 黑旋风斗浪里白跳-原文.html
create mode 100755 cmd/testdata/kb/原文版水浒传/第三十六回 梁山泊吴用举戴宗 揭阳岭宋江逢李俊-原文.html
create mode 100755 cmd/testdata/kb/原文版水浒传/第三十四回 镇三山大闹青州道 霹雳火夜走瓦砾场-原文.html
create mode 100755 cmd/testdata/kb/原文版水浒传/第三十回 施恩三入死囚牢 武松大闹飞云浦-原文.html
create mode 100755 cmd/testdata/kb/原文版水浒传/第三回 史大郎夜走华阴县 鲁提辖拳打镇关西-原文.html
create mode 100755 cmd/testdata/kb/原文版水浒传/第九十一回 宋公明兵渡黄河 卢俊义赚城黑夜-原文.html
create mode 100755 cmd/testdata/kb/原文版水浒传/第九十七回 陈瓘谏官升安抚 琼英处女做先锋-原文.html
create mode 100755 cmd/testdata/kb/原文版水浒传/第九十三回 李逵梦闹天池 宋江兵分两路-原文.html
create mode 100755 cmd/testdata/kb/原文版水浒传/第九十九回 花和尚解脱缘缠井 混江龙水灌太原城-原文.html
create mode 100755 cmd/testdata/kb/原文版水浒传/第九十二回 振军威小李广神箭 打盖郡智多星密筹-原文.html
create mode 100755 cmd/testdata/kb/原文版水浒传/第九十五回 宋公明忠感后土 乔道清术败宋兵-原文.html
create mode 100755 cmd/testdata/kb/原文版水浒传/第九十八回 张清缘配琼英 吴用计鸩邬梨-原文.html
create mode 100755 cmd/testdata/kb/原文版水浒传/第九十六回 幻魔君术窘五龙山 入云龙兵围百谷岭-原文.html
create mode 100755 cmd/testdata/kb/原文版水浒传/第九十四回 关胜义降三将 李逵莽陷众人-原文.html
create mode 100755 cmd/testdata/kb/原文版水浒传/第九十回 五台山宋江参禅 双林镇燕青遇故-原文.html
create mode 100755 cmd/testdata/kb/原文版水浒传/第九回 柴进门招天下客 林冲棒打洪教头-原文.html
create mode 100755 cmd/testdata/kb/原文版水浒传/第二十一回 虔婆醉打唐牛儿 宋江怒杀阎婆惜-原文.html
create mode 100755 cmd/testdata/kb/原文版水浒传/第二十七回 母夜叉孟州道卖人肉 武都头十字坡遇张青-原文.html
create mode 100755 cmd/testdata/kb/原文版水浒传/第二十三回 横海郡柴进留宾 景阳冈武松打虎-原文.html
create mode 100755 cmd/testdata/kb/原文版水浒传/第二十九回 施恩重霸孟州道 武松醉打蒋门神-原文.html
create mode 100755 cmd/testdata/kb/原文版水浒传/第二十二回 阎婆大闹郓城县 朱仝义释宋公明-原文.html
create mode 100755 cmd/testdata/kb/原文版水浒传/第二十五回 王婆计啜西门庆 淫妇药鸩武大郎-原文.html
create mode 100755 cmd/testdata/kb/原文版水浒传/第二十八回 武松威镇安平寨 施恩义夺快活林-原文.html
create mode 100755 cmd/testdata/kb/原文版水浒传/第二十六回 郓哥大闹授官厅 武松斗杀西门庆-原文.html
create mode 100755 cmd/testdata/kb/原文版水浒传/第二十四回 王婆贪贿说风情 郓哥不忿闹茶肆-原文.html
create mode 100755 cmd/testdata/kb/原文版水浒传/第二十回 梁山泊义士尊晁盖 郓城县月夜走刘唐-原文.html
create mode 100755 cmd/testdata/kb/原文版水浒传/第二回 王教头私走延安府 九纹龙大闹史家村-原文.html
create mode 100755 cmd/testdata/kb/原文版水浒传/第五十一回 插翅虎枷打白秀英 美髯公误失小衙内-原文.html
create mode 100755 cmd/testdata/kb/原文版水浒传/第五十七回 徐宁教使钩镰枪 宋江大破连环马-原文.html
create mode 100755 cmd/testdata/kb/原文版水浒传/第五十三回 戴宗智取公孙胜 李逵斧劈罗真人-原文.html
create mode 100755 cmd/testdata/kb/原文版水浒传/第五十九回 吴用赚金铃吊挂 宋江闹西岳华山-原文.html
create mode 100755 cmd/testdata/kb/原文版水浒传/第五十二回 李逵打死殷天锡 柴进失陷高唐州-原文.html
create mode 100755 cmd/testdata/kb/原文版水浒传/第五十五回 高太尉大兴三路兵 呼延灼摆布连环马-原文.html
create mode 100755 cmd/testdata/kb/原文版水浒传/第五十八回 三山聚义打青州 众虎同心归水泊-原文.html
create mode 100755 cmd/testdata/kb/原文版水浒传/第五十六回 吴用使时迁盗甲 汤隆赚徐宁上山-原文.html
create mode 100755 cmd/testdata/kb/原文版水浒传/第五十四回 入云龙斗法破高廉 黑旋风探穴救柴进-原文.html
create mode 100755 cmd/testdata/kb/原文版水浒传/第五十回 吴学究双用连环计 宋公明三打祝家庄-原文.html
create mode 100755 cmd/testdata/kb/原文版水浒传/第五回 小霸王醉入销金帐 花和尚大闹桃花村-原文.html
create mode 100755 cmd/testdata/kb/原文版水浒传/第八十一回 燕青月夜遇道君 戴宗定计赚萧让-原文.html
create mode 100755 cmd/testdata/kb/原文版水浒传/第八十七回 宋公明大战幽州 呼延灼力擒番将-原文.html
create mode 100755 cmd/testdata/kb/原文版水浒传/第八十三回 宋公明奉诏破大辽 陈桥驿滴泪斩小卒-原文.html
create mode 100755 cmd/testdata/kb/原文版水浒传/第八十九回 宋公明破阵成功 宿太尉颁恩降诏-原文.html
create mode 100755 cmd/testdata/kb/原文版水浒传/第八十二回 梁山泊分金大买市 宋公明全伙受招安-原文.html
create mode 100755 cmd/testdata/kb/原文版水浒传/第八十五回 宋公明夜度益津关 吴学究智取文安县-原文.html
create mode 100755 cmd/testdata/kb/原文版水浒传/第八十八回 颜统军阵列混天象 宋公明梦授玄女法-原文.html
create mode 100755 cmd/testdata/kb/原文版水浒传/第八十六回 宋公明大战独鹿山 卢俊义兵陷青石峪-原文.html
create mode 100755 cmd/testdata/kb/原文版水浒传/第八十四回 宋公明兵打蓟州城 卢俊义大战玉田县-原文.html
create mode 100755 cmd/testdata/kb/原文版水浒传/第八十回 张顺凿漏海鳅船 宋江三败高太尉-原文.html
create mode 100755 cmd/testdata/kb/原文版水浒传/第八回 林教头刺配沧州道 鲁智深大闹野猪林-原文.html
create mode 100755 cmd/testdata/kb/原文版水浒传/第六十一回 吴用智赚玉麒麟 张顺夜闹金沙渡-原文.html
create mode 100755 cmd/testdata/kb/原文版水浒传/第六十七回 宋江赏马步三军 关胜降水火二将-原文.html
create mode 100755 cmd/testdata/kb/原文版水浒传/第六十三回 宋江兵打北京城 关胜议取梁山泊-原文.html
create mode 100755 cmd/testdata/kb/原文版水浒传/第六十九回 东平府误陷九纹龙 宋公明义释双枪将-原文.html
create mode 100755 cmd/testdata/kb/原文版水浒传/第六十二回 放冷箭燕青救主 劫法场石秀跳楼-原文.html
create mode 100755 cmd/testdata/kb/原文版水浒传/第六十五回 托塔天王梦中显圣 浪里白跳水上报冤-原文.html
create mode 100755 cmd/testdata/kb/原文版水浒传/第六十八回 宋公明夜打曾头市 卢俊义活捉史文恭-原文.html
create mode 100755 cmd/testdata/kb/原文版水浒传/第六十六回 时迁火烧翠云楼 吴用智取大名府-原文.html
create mode 100755 cmd/testdata/kb/原文版水浒传/第六十四回 呼延灼夜月赚关胜 宋公明雪天擒索超-原文.html
create mode 100755 cmd/testdata/kb/原文版水浒传/第六十回 公孙胜芒砀山降魔 晁天王曾头市中箭-原文.html
create mode 100755 cmd/testdata/kb/原文版水浒传/第六回 九纹龙剪径赤松林 鲁智深火烧瓦罐寺-原文.html
create mode 100755 cmd/testdata/kb/原文版水浒传/第十一回 朱贵水亭施号箭 林冲雪夜上梁山-原文.html
create mode 100755 cmd/testdata/kb/原文版水浒传/第十七回 花和尚单打二龙山 青面兽双夺宝珠寺-原文.html
create mode 100755 cmd/testdata/kb/原文版水浒传/第十三回 急先锋东郭争功 青面兽北京斗武-原文.html
create mode 100755 cmd/testdata/kb/原文版水浒传/第十九回 林冲水寨大并火 晁盖梁山小夺泊-原文.html
create mode 100755 cmd/testdata/kb/原文版水浒传/第十二回 梁山泊林冲落草 汴京城杨志卖刀-原文.html
create mode 100755 cmd/testdata/kb/原文版水浒传/第十五回 吴学究说三阮撞筹 公孙胜应七星聚义-原文.html
create mode 100755 cmd/testdata/kb/原文版水浒传/第十八回 美髯公智稳插翅虎 宋公明私放晃天王-原文.html
create mode 100755 cmd/testdata/kb/原文版水浒传/第十六回 杨志押送金银担 吴用智取生辰纲-原文.html
create mode 100755 cmd/testdata/kb/原文版水浒传/第十四回 赤发鬼醉卧灵官殿 晁天王认义东溪村-原文.html
create mode 100755 cmd/testdata/kb/原文版水浒传/第十回 林教头风雪山神庙 陆虞候火烧草料场-原文.html
create mode 100755 cmd/testdata/kb/原文版水浒传/第四十一回 宋江智取无为军 张顺活捉黄文炳-原文.html
create mode 100755 cmd/testdata/kb/原文版水浒传/第四十七回 扑天雕双修生死书 宋公明一打祝家庄-原文.html
create mode 100755 cmd/testdata/kb/原文版水浒传/第四十三回 假李逵剪径劫单人 黑旋风沂岭杀四虎-原文.html
create mode 100755 cmd/testdata/kb/原文版水浒传/第四十九回 解珍解宝双越狱 孙立孙新大劫牢-原文.html
create mode 100755 cmd/testdata/kb/原文版水浒传/第四十二回 还道村受三卷天书 宋公明遇九天玄女-原文.html
create mode 100755 cmd/testdata/kb/原文版水浒传/第四十五回 杨雄醉骂潘巧云 石秀智杀裴如海-原文.html
create mode 100755 cmd/testdata/kb/原文版水浒传/第四十八回 一丈青单捉王矮虎 宋公明两打祝家庄-原文.html
create mode 100755 cmd/testdata/kb/原文版水浒传/第四十六回 病关索大闹翠屏山 拚命三火烧祝家庄-原文.html
create mode 100755 cmd/testdata/kb/原文版水浒传/第四十四回 锦豹子小径逢戴宗 病关索长街遇石秀-原文.html
create mode 100755 cmd/testdata/kb/原文版水浒传/第四十回 梁山泊好汉劫法场 白龙庙英雄小聚义-原文.html
create mode 100755 cmd/testdata/kb/原文版水浒传/第四回 赵员外重修文殊院 鲁智深大闹五台山-原文.html
create mode 100755 cmd/testdata/kb/水浒传.html
create mode 100644 cmd/testdata/main.go
create mode 100644 cmd/testdata/quickreply/init.go
create mode 100644 cmd/testdata/skill/init.go
create mode 100644 cmd/testdata/tag/init.go
create mode 100644 config/config.example.yaml
create mode 160000 docs
create mode 100644 go.mod
create mode 100644 go.sum
create mode 100644 internal/ai/embedding.go
create mode 100644 internal/ai/llm.go
create mode 100644 internal/ai/mcps/client.go
create mode 100644 internal/ai/mcps/providers/system_tools_provider.go
create mode 100644 internal/ai/mcps/providers/types.go
create mode 100644 internal/ai/mcps/registry.go
create mode 100644 internal/ai/mcps/runtime.go
create mode 100644 internal/ai/mcps/server.go
create mode 100644 internal/ai/mcps/types.go
create mode 100644 internal/ai/openai_client.go
create mode 100644 internal/ai/rag/answer.go
create mode 100644 internal/ai/rag/answer_test.go
create mode 100644 internal/ai/rag/chunk/fixed_provider.go
create mode 100644 internal/ai/rag/chunk/provider.go
create mode 100644 internal/ai/rag/chunk/registry.go
create mode 100644 internal/ai/rag/chunk/structured_provider.go
create mode 100644 internal/ai/rag/chunk/types.go
create mode 100644 internal/ai/rag/chunk/utils.go
create mode 100644 internal/ai/rag/index.go
create mode 100644 internal/ai/rag/rerank.go
create mode 100644 internal/ai/rag/retrieve.go
create mode 100644 internal/ai/rag/retrieve_log.go
create mode 100644 internal/ai/rag/retrieve_test.go
create mode 100644 internal/ai/rag/types.go
create mode 100644 internal/ai/rag/utils.go
create mode 100644 internal/ai/rag/utils_test.go
create mode 100644 internal/ai/rag/vectordb/payload.go
create mode 100644 internal/ai/rag/vectordb/provider.go
create mode 100644 internal/ai/rag/vectordb/qdrant.go
create mode 100644 internal/ai/runtime/internal/engine/service.go
create mode 100644 internal/ai/runtime/internal/engine/types.go
create mode 100644 internal/ai/runtime/internal/impl/adapter/ai_config_adapter.go
create mode 100644 internal/ai/runtime/internal/impl/adapter/conversation_adapter.go
create mode 100644 internal/ai/runtime/internal/impl/adapter/mcp_tool_adapter.go
create mode 100644 internal/ai/runtime/internal/impl/adapter/message_adapter.go
create mode 100644 internal/ai/runtime/internal/impl/agents/customer_service_agent.go
create mode 100644 internal/ai/runtime/internal/impl/callbacks/agent_trace_handler.go
create mode 100644 internal/ai/runtime/internal/impl/callbacks/runlog_callback.go
create mode 100644 internal/ai/runtime/internal/impl/callbacks/trace_callback.go
create mode 100644 internal/ai/runtime/internal/impl/factory/agent_factory.go
create mode 100644 internal/ai/runtime/internal/impl/factory/chat_model_factory.go
create mode 100644 internal/ai/runtime/internal/impl/factory/runner_factory.go
create mode 100644 internal/ai/runtime/internal/impl/factory/tool_factory.go
create mode 100644 internal/ai/runtime/internal/impl/retrievers/knowledge_retriever.go
create mode 100644 internal/ai/runtime/internal/impl/store/checkpoint_store.go
create mode 100644 internal/ai/runtime/registry/registry.go
create mode 100644 internal/ai/runtime/registry/types.go
create mode 100644 internal/ai/runtime/reply_service.go
create mode 100644 internal/ai/runtime/service.go
create mode 100644 internal/ai/runtime/tools/create_ticket_confirm_tool.go
create mode 100644 internal/ai/runtime/tools/helper.go
create mode 100644 internal/ai/runtime/types.go
create mode 100644 internal/ai/skills/execution.go
create mode 100644 internal/ai/skills/log.go
create mode 100644 internal/ai/skills/matcher.go
create mode 100644 internal/ai/skills/runtime.go
create mode 100644 internal/ai/skills/types.go
create mode 100644 internal/bootstrap/db.go
create mode 100644 internal/bootstrap/db_test.go
create mode 100644 internal/bootstrap/init.go
create mode 100644 internal/bootstrap/migration.go
create mode 100644 internal/bootstrap/server.go
create mode 100644 internal/builders/agent_profile_builder.go
create mode 100644 internal/builders/agent_run_log_builder.go
create mode 100644 internal/builders/asset_builder.go
create mode 100644 internal/builders/company_builder.go
create mode 100644 internal/builders/conversation_builder.go
create mode 100644 internal/builders/customer_builder.go
create mode 100644 internal/builders/customer_contact_builder.go
create mode 100644 internal/builders/knowledge_builder.go
create mode 100644 internal/builders/skill_builder.go
create mode 100644 internal/builders/tag_builder.go
create mode 100644 internal/builders/ticket_builder.go
create mode 100644 internal/builders/ticket_config_builder.go
create mode 100644 internal/builders/user_builder.go
create mode 100644 internal/controllers/api/auth_controller.go
create mode 100644 internal/controllers/console/agent_controller.go
create mode 100644 internal/controllers/console/agent_run_log_controller.go
create mode 100644 internal/controllers/console/agent_team_controller.go
create mode 100644 internal/controllers/console/agent_team_schedule_controller.go
create mode 100644 internal/controllers/console/ai_agent_controller.go
create mode 100644 internal/controllers/console/ai_config_controller.go
create mode 100644 internal/controllers/console/asset_controller.go
create mode 100644 internal/controllers/console/channel_controller.go
create mode 100644 internal/controllers/console/company_controller.go
create mode 100644 internal/controllers/console/conversation_controller.go
create mode 100644 internal/controllers/console/customer_contact_controller.go
create mode 100644 internal/controllers/console/customer_controller.go
create mode 100644 internal/controllers/console/dashboard_controller.go
create mode 100644 internal/controllers/console/knowledge_base_controller.go
create mode 100644 internal/controllers/console/knowledge_document_controller.go
create mode 100644 internal/controllers/console/knowledge_faq_controller.go
create mode 100644 internal/controllers/console/knowledge_retrieve_controller.go
create mode 100644 internal/controllers/console/knowledge_retrieve_log_controller.go
create mode 100644 internal/controllers/console/mcp_controller.go
create mode 100644 internal/controllers/console/permission_controller.go
create mode 100644 internal/controllers/console/quick_reply_controller.go
create mode 100644 internal/controllers/console/role_controller.go
create mode 100644 internal/controllers/console/session_controller.go
create mode 100644 internal/controllers/console/skill_definition_controller.go
create mode 100644 internal/controllers/console/tag_controller.go
create mode 100644 internal/controllers/console/ticket_controller.go
create mode 100644 internal/controllers/console/ticket_priority_config_controller.go
create mode 100644 internal/controllers/console/ticket_resolution_code_controller.go
create mode 100644 internal/controllers/console/user_controller.go
create mode 100644 internal/controllers/open/im_conversation_controller.go
create mode 100644 internal/controllers/open/im_message_controller.go
create mode 100644 internal/controllers/open/im_widget_controller.go
create mode 100644 internal/controllers/third/wechat_controller.go
create mode 100644 internal/middleware/auth_middleware.go
create mode 100644 internal/middleware/open_im_middleware.go
create mode 100644 internal/middleware/ws_middleware.go
create mode 100644 internal/migration/000001_init_schema.go
create mode 100644 internal/migration/000002_init_auth_data.go
create mode 100644 internal/migration/000003_init_ticket_priority_configs.go
create mode 100644 internal/migration/README.md
create mode 100644 internal/migration/migration.go
create mode 100644 internal/models/models.go
create mode 100644 internal/pkg/config/config.go
create mode 100644 internal/pkg/config/runtime.go
create mode 100644 internal/pkg/constants/auth.go
create mode 100644 internal/pkg/constants/constants.go
create mode 100644 internal/pkg/dto/dto.go
create mode 100644 internal/pkg/dto/request/admin_request.go
create mode 100644 internal/pkg/dto/request/agent_request.go
create mode 100644 internal/pkg/dto/request/ai_request.go
create mode 100644 internal/pkg/dto/request/asset_request.go
create mode 100644 internal/pkg/dto/request/auth_request.go
create mode 100644 internal/pkg/dto/request/channel_request.go
create mode 100644 internal/pkg/dto/request/company_request.go
create mode 100644 internal/pkg/dto/request/conversation_request.go
create mode 100644 internal/pkg/dto/request/customer_contact_request.go
create mode 100644 internal/pkg/dto/request/customer_request.go
create mode 100644 internal/pkg/dto/request/knowledge_request.go
create mode 100644 internal/pkg/dto/request/mcp_request.go
create mode 100644 internal/pkg/dto/request/message_request.go
create mode 100644 internal/pkg/dto/request/quick_reply_request.go
create mode 100644 internal/pkg/dto/request/skill_request.go
create mode 100644 internal/pkg/dto/request/tag_request.go
create mode 100644 internal/pkg/dto/request/ticket_config_request.go
create mode 100644 internal/pkg/dto/request/ticket_request.go
create mode 100644 internal/pkg/dto/response/admin_response.go
create mode 100644 internal/pkg/dto/response/agent_response.go
create mode 100644 internal/pkg/dto/response/ai_response.go
create mode 100644 internal/pkg/dto/response/asset_response.go
create mode 100644 internal/pkg/dto/response/auth_response.go
create mode 100644 internal/pkg/dto/response/channel_response.go
create mode 100644 internal/pkg/dto/response/company_response.go
create mode 100644 internal/pkg/dto/response/conversation_response.go
create mode 100644 internal/pkg/dto/response/customer_contact_response.go
create mode 100644 internal/pkg/dto/response/customer_response.go
create mode 100644 internal/pkg/dto/response/dashboard_response.go
create mode 100644 internal/pkg/dto/response/knowledge_response.go
create mode 100644 internal/pkg/dto/response/mcp_response.go
create mode 100644 internal/pkg/dto/response/message_response.go
create mode 100644 internal/pkg/dto/response/quick_reply_response.go
create mode 100644 internal/pkg/dto/response/skill_response.go
create mode 100644 internal/pkg/dto/response/tag_response.go
create mode 100644 internal/pkg/dto/response/ticket_config_response.go
create mode 100644 internal/pkg/dto/response/ticket_response.go
create mode 100644 internal/pkg/dto/response/widget_response.go
create mode 100644 internal/pkg/enums/agent.go
create mode 100644 internal/pkg/enums/ai.go
create mode 100644 internal/pkg/enums/asset.go
create mode 100644 internal/pkg/enums/customer.go
create mode 100644 internal/pkg/enums/enums.go
create mode 100644 internal/pkg/enums/external_identity.go
create mode 100644 internal/pkg/enums/im.go
create mode 100644 internal/pkg/enums/knowledge.go
create mode 100644 internal/pkg/enums/skill.go
create mode 100644 internal/pkg/enums/ticket.go
create mode 100644 internal/pkg/enums/wxwork_kf.go
create mode 100644 internal/pkg/errorsx/errors.go
create mode 100644 internal/pkg/irisx/context.go
create mode 100644 internal/pkg/logx/logger.go
create mode 100644 internal/pkg/openidentity/openidentity.go
create mode 100644 internal/pkg/utils/content_chunk.go
create mode 100644 internal/pkg/utils/content_chunk_test.go
create mode 100644 internal/pkg/utils/utils.go
create mode 100644 internal/repositories/agent_profile_repository.go
create mode 100644 internal/repositories/agent_run_log_repository.go
create mode 100644 internal/repositories/agent_team_repository.go
create mode 100644 internal/repositories/agent_team_schedule_repository.go
create mode 100644 internal/repositories/ai_agent_repository.go
create mode 100644 internal/repositories/ai_config_repository.go
create mode 100644 internal/repositories/asset_repository.go
create mode 100644 internal/repositories/channel_message_outbox_repository.go
create mode 100644 internal/repositories/channel_repository.go
create mode 100644 internal/repositories/company_repository.go
create mode 100644 internal/repositories/conversation_assignment_repository.go
create mode 100644 internal/repositories/conversation_event_log_repository.go
create mode 100644 internal/repositories/conversation_interrupt_repository.go
create mode 100644 internal/repositories/conversation_participant_repository.go
create mode 100644 internal/repositories/conversation_read_state_repository.go
create mode 100644 internal/repositories/conversation_repository.go
create mode 100644 internal/repositories/conversation_tag_repository.go
create mode 100644 internal/repositories/customer_contact_repository.go
create mode 100644 internal/repositories/customer_identity_repository.go
create mode 100644 internal/repositories/customer_repository.go
create mode 100644 internal/repositories/dashboard_repository.go
create mode 100644 internal/repositories/knowledge_base_repository.go
create mode 100644 internal/repositories/knowledge_chunk_repository.go
create mode 100644 internal/repositories/knowledge_document_repository.go
create mode 100644 internal/repositories/knowledge_faq_repository.go
create mode 100644 internal/repositories/login_credential_log_repository.go
create mode 100644 internal/repositories/login_session_repository.go
create mode 100644 internal/repositories/message_repository.go
create mode 100644 internal/repositories/migration_repository.go
create mode 100644 internal/repositories/permission_repository.go
create mode 100644 internal/repositories/quick_reply_repository.go
create mode 100644 internal/repositories/role_permission_repository.go
create mode 100644 internal/repositories/role_repository.go
create mode 100644 internal/repositories/skill_definition_repository.go
create mode 100644 internal/repositories/skill_run_log_repository.go
create mode 100644 internal/repositories/system_config_repository.go
create mode 100644 internal/repositories/tag_repository.go
create mode 100644 internal/repositories/ticket_collaborator_repository.go
create mode 100644 internal/repositories/ticket_comment_repository.go
create mode 100644 internal/repositories/ticket_event_log_repository.go
create mode 100644 internal/repositories/ticket_mention_repository.go
create mode 100644 internal/repositories/ticket_no_sequence_repository.go
create mode 100644 internal/repositories/ticket_priority_config_repository.go
create mode 100644 internal/repositories/ticket_relation_repository.go
create mode 100644 internal/repositories/ticket_repository.go
create mode 100644 internal/repositories/ticket_resolution_code_repository.go
create mode 100644 internal/repositories/ticket_sla_record_repository.go
create mode 100644 internal/repositories/ticket_tag_repository.go
create mode 100644 internal/repositories/ticket_view_repository.go
create mode 100644 internal/repositories/ticket_watcher_repository.go
create mode 100644 internal/repositories/user_identity_repository.go
create mode 100644 internal/repositories/user_permission_repository.go
create mode 100644 internal/repositories/user_repository.go
create mode 100644 internal/repositories/user_role_repository.go
create mode 100644 internal/repositories/wx_work_kf_conversation_repository.go
create mode 100644 internal/repositories/wx_work_kf_message_ref_repository.go
create mode 100644 internal/repositories/wx_work_kf_sync_state_repository.go
create mode 100644 internal/services/agent_profile_service.go
create mode 100644 internal/services/agent_run_log_service.go
create mode 100644 internal/services/agent_team_schedule_service.go
create mode 100644 internal/services/agent_team_service.go
create mode 100644 internal/services/ai_agent_service.go
create mode 100644 internal/services/ai_config_service.go
create mode 100644 internal/services/ai_reply_hook.go
create mode 100644 internal/services/asset_service.go
create mode 100644 internal/services/auth_service.go
create mode 100644 internal/services/auth_service_test.go
create mode 100644 internal/services/channel_message_outbox_service.go
create mode 100644 internal/services/channel_service.go
create mode 100644 internal/services/company_service.go
create mode 100644 internal/services/conversation_assignment_service.go
create mode 100644 internal/services/conversation_dispatch_service.go
create mode 100644 internal/services/conversation_event_log_service.go
create mode 100644 internal/services/conversation_interrupt_service.go
create mode 100644 internal/services/conversation_participant_service.go
create mode 100644 internal/services/conversation_read_state_service.go
create mode 100644 internal/services/conversation_service.go
create mode 100644 internal/services/conversation_tag_service.go
create mode 100644 internal/services/cronx/cron.go
create mode 100644 internal/services/customer_contact_service.go
create mode 100644 internal/services/customer_identity_service.go
create mode 100644 internal/services/customer_service.go
create mode 100644 internal/services/dashboard_service.go
create mode 100644 internal/services/im_message_asset.go
create mode 100644 internal/services/knowledge_base_service.go
create mode 100644 internal/services/knowledge_base_service_test.go
create mode 100644 internal/services/knowledge_document_service.go
create mode 100644 internal/services/knowledge_faq_service.go
create mode 100644 internal/services/knowledge_retrieve_log_service.go
create mode 100644 internal/services/login_credential_log_service.go
create mode 100644 internal/services/login_session_service.go
create mode 100644 internal/services/mcp_debug_service.go
create mode 100644 internal/services/message_service.go
create mode 100644 internal/services/migration_service.go
create mode 100644 internal/services/permission_service.go
create mode 100644 internal/services/quick_reply_service.go
create mode 100644 internal/services/role_permission_service.go
create mode 100644 internal/services/role_service.go
create mode 100644 internal/services/skill_definition_service.go
create mode 100644 internal/services/skill_run_log_service.go
create mode 100644 internal/services/skill_runtime_service.go
create mode 100644 internal/services/storage/dto.go
create mode 100644 internal/services/storage/local.go
create mode 100644 internal/services/storage/oss.go
create mode 100644 internal/services/storage/provider.go
create mode 100644 internal/services/storage/utils.go
create mode 100644 internal/services/system_config_service.go
create mode 100644 internal/services/tag_service.go
create mode 100644 internal/services/ticket_collaborator_service.go
create mode 100644 internal/services/ticket_comment_service.go
create mode 100644 internal/services/ticket_event_log_service.go
create mode 100644 internal/services/ticket_mention_service.go
create mode 100644 internal/services/ticket_no_service.go
create mode 100644 internal/services/ticket_priority_config_service.go
create mode 100644 internal/services/ticket_relation_service.go
create mode 100644 internal/services/ticket_resolution_code_service.go
create mode 100644 internal/services/ticket_service.go
create mode 100644 internal/services/ticket_service_test.go
create mode 100644 internal/services/ticket_sla_record_service.go
create mode 100644 internal/services/ticket_tag_service.go
create mode 100644 internal/services/ticket_view_service.go
create mode 100644 internal/services/ticket_watcher_service.go
create mode 100644 internal/services/user_identity_service.go
create mode 100644 internal/services/user_permission_service.go
create mode 100644 internal/services/user_role_service.go
create mode 100644 internal/services/user_service.go
create mode 100644 internal/services/ws_connection_manager.go
create mode 100644 internal/services/ws_realtime_types.go
create mode 100644 internal/services/ws_service.go
create mode 100644 internal/services/wx_callback_handlers/kf_msg_or_event_handler.go
create mode 100644 internal/services/wx_work_kf_conversation_service.go
create mode 100644 internal/services/wx_work_kf_message_ref_service.go
create mode 100644 internal/services/wx_work_kf_sync_state_service.go
create mode 100644 internal/services/wxwork_kf_inbound_service.go
create mode 100644 internal/services/wxwork_kf_outbound_service.go
create mode 100644 internal/services/wxwork_login_service.go
create mode 100644 internal/wxwork/callback_dispatcher.go
create mode 100644 internal/wxwork/login.go
create mode 100644 internal/wxwork/wxwork.go
create mode 160000 qdrant
create mode 100644 web/.env.example
create mode 100644 web/.gitignore
create mode 100644 web/README.md
create mode 100644 web/app/(console)/_components/alert-list.tsx
create mode 100644 web/app/(console)/_components/summary-cards.tsx
create mode 100644 web/app/(console)/_components/team-load-panel.tsx
create mode 100644 web/app/(console)/_components/trend-panel.tsx
create mode 100644 web/app/(console)/agent-run-logs/page.tsx
create mode 100644 web/app/(console)/agent-team-schedules/_components/edit.tsx
create mode 100644 web/app/(console)/agent-team-schedules/page.tsx
create mode 100644 web/app/(console)/agents/_components/edit.tsx
create mode 100644 web/app/(console)/agents/_components/team-edit.tsx
create mode 100644 web/app/(console)/agents/_components/team-sidebar.tsx
create mode 100644 web/app/(console)/agents/page.tsx
create mode 100644 web/app/(console)/ai-agents/_components/edit.tsx
create mode 100644 web/app/(console)/ai-agents/page.tsx
create mode 100644 web/app/(console)/ai-configs/_components/edit.tsx
create mode 100644 web/app/(console)/ai-configs/_components/option-combobox.tsx
create mode 100644 web/app/(console)/ai-configs/page.tsx
create mode 100644 web/app/(console)/channels/_components/edit.tsx
create mode 100644 web/app/(console)/channels/page.tsx
create mode 100644 web/app/(console)/companies/_components/edit.tsx
create mode 100644 web/app/(console)/companies/page.tsx
create mode 100644 web/app/(console)/conversation-monitor/_components/detail.tsx
create mode 100644 web/app/(console)/conversation-monitor/page.tsx
create mode 100644 web/app/(console)/conversations/_components/chat-panel.tsx
create mode 100644 web/app/(console)/conversations/_components/conversation-info-panel.tsx
create mode 100644 web/app/(console)/conversations/_components/conversation-list.tsx
create mode 100644 web/app/(console)/conversations/_components/conversation-tag-picker.tsx
create mode 100644 web/app/(console)/conversations/page.tsx
create mode 100644 web/app/(console)/customers/_components/edit.tsx
create mode 100644 web/app/(console)/customers/page.tsx
create mode 100644 web/app/(console)/help/page.tsx
create mode 100644 web/app/(console)/knowledge/_components/debug-panel.tsx
create mode 100644 web/app/(console)/knowledge/_components/document-edit.tsx
create mode 100644 web/app/(console)/knowledge/_components/document-list.tsx
create mode 100644 web/app/(console)/knowledge/_components/faq-edit.tsx
create mode 100644 web/app/(console)/knowledge/_components/faq-import-dialog.tsx
create mode 100644 web/app/(console)/knowledge/_components/faq-list.tsx
create mode 100644 web/app/(console)/knowledge/_components/knowledge-base-edit.tsx
create mode 100644 web/app/(console)/knowledge/_components/knowledge-base-list.tsx
create mode 100644 web/app/(console)/knowledge/_components/retrieve-log-detail.tsx
create mode 100644 web/app/(console)/knowledge/_components/retrieve-log-list.tsx
create mode 100644 web/app/(console)/knowledge/page.tsx
create mode 100644 web/app/(console)/layout.tsx
create mode 100644 web/app/(console)/mcp/page.tsx
create mode 100644 web/app/(console)/page.tsx
create mode 100644 web/app/(console)/permissions/page.tsx
create mode 100644 web/app/(console)/quick-replies/_components/edit.tsx
create mode 100644 web/app/(console)/quick-replies/page.tsx
create mode 100644 web/app/(console)/roles/_components/assign-permissions.tsx
create mode 100644 web/app/(console)/roles/page.tsx
create mode 100644 web/app/(console)/settings/page.tsx
create mode 100644 web/app/(console)/skill-definition/_components/edit.tsx
create mode 100644 web/app/(console)/skill-definition/page.tsx
create mode 100644 web/app/(console)/tags/_components/edit.tsx
create mode 100644 web/app/(console)/tags/page.tsx
create mode 100644 web/app/(console)/ticket-priorities/page.tsx
create mode 100644 web/app/(console)/ticket-resolution-codes/page.tsx
create mode 100644 web/app/(console)/ticket-risk/page.tsx
create mode 100644 web/app/(console)/tickets/_components/create-ticket-from-conversation-dialog.tsx
create mode 100644 web/app/(console)/tickets/_components/edit.tsx
create mode 100644 web/app/(console)/tickets/_components/ticket-assign-dialog.tsx
create mode 100644 web/app/(console)/tickets/_components/ticket-collaborator-dialog.tsx
create mode 100644 web/app/(console)/tickets/_components/ticket-customer-panel.tsx
create mode 100644 web/app/(console)/tickets/_components/ticket-priority-badge.tsx
create mode 100644 web/app/(console)/tickets/_components/ticket-reason-dialog.tsx
create mode 100644 web/app/(console)/tickets/_components/ticket-relation-dialog.tsx
create mode 100644 web/app/(console)/tickets/_components/ticket-reply-dialog.tsx
create mode 100644 web/app/(console)/tickets/_components/ticket-sla-badge.tsx
create mode 100644 web/app/(console)/tickets/_components/ticket-status-badge.tsx
create mode 100644 web/app/(console)/tickets/_components/ticket-status-dialog.tsx
create mode 100644 web/app/(console)/tickets/detail/page.tsx
create mode 100644 web/app/(console)/tickets/page.tsx
create mode 100644 web/app/(console)/users/_components/assign-roles.tsx
create mode 100644 web/app/(console)/users/_components/create.tsx
create mode 100644 web/app/(console)/users/_components/edit.tsx
create mode 100644 web/app/(console)/users/_components/initial-password-dialog.tsx
create mode 100644 web/app/(console)/users/_components/reset-password.tsx
create mode 100644 web/app/(console)/users/page.tsx
create mode 100644 web/app/favicon.ico
create mode 100644 web/app/globals.css
create mode 100644 web/app/layout.tsx
create mode 100644 web/app/login/page.tsx
create mode 100644 web/app/login/wxwork/callback/page.tsx
create mode 100644 web/components.json
create mode 100644 web/components/app-sidebar.tsx
create mode 100644 web/components/auth-provider.tsx
create mode 100644 web/components/change-password-dialog.tsx
create mode 100644 web/components/chart-area-interactive.tsx
create mode 100644 web/components/company-picker.tsx
create mode 100644 web/components/confirm-provider.tsx
create mode 100644 web/components/content-editor/convert.ts
create mode 100644 web/components/content-editor/editor-mode-switch.tsx
create mode 100644 web/components/content-editor/html-editor.tsx
create mode 100644 web/components/content-editor/index.tsx
create mode 100644 web/components/content-editor/markdown-editor.css
create mode 100644 web/components/content-editor/markdown-editor.tsx
create mode 100644 web/components/content-editor/toolbar.css
create mode 100644 web/components/content-editor/toolbar.tsx
create mode 100644 web/components/content-editor/types.ts
create mode 100644 web/components/conversation-actions/close-dialog.tsx
create mode 100644 web/components/conversation-actions/transfer-dialog.tsx
create mode 100644 web/components/customer-form-dialog.tsx
create mode 100644 web/components/customer-form.tsx
create mode 100644 web/components/customer-link-or-create-dialog.tsx
create mode 100644 web/components/dashboard-placeholder.tsx
create mode 100644 web/components/data-table.tsx
create mode 100644 web/components/editor/DESIGN.md
create mode 100644 web/components/editor/html.tsx
create mode 100644 web/components/editor/index.tsx
create mode 100644 web/components/editor/markdown.tsx
create mode 100644 web/components/editor/toolbar.tsx
create mode 100644 web/components/editor/types.ts
create mode 100644 web/components/im-message-editor.tsx
create mode 100644 web/components/im-message-html.tsx
create mode 100644 web/components/image-input.tsx
create mode 100644 web/components/image-lightbox.tsx
create mode 100644 web/components/json-code-editor.tsx
create mode 100644 web/components/json-viewer.tsx
create mode 100644 web/components/list-pagination.tsx
create mode 100644 web/components/login-form.tsx
create mode 100644 web/components/nav-documents.tsx
create mode 100644 web/components/nav-main.tsx
create mode 100644 web/components/nav-secondary.tsx
create mode 100644 web/components/nav-user.tsx
create mode 100644 web/components/option-combobox.tsx
create mode 100644 web/components/project-dialog.tsx
create mode 100644 web/components/rich-text-editor.tsx
create mode 100644 web/components/section-cards.tsx
create mode 100644 web/components/site-header.tsx
create mode 100644 web/components/theme-provider.tsx
create mode 100644 web/components/theme-toggle.tsx
create mode 100644 web/components/ui/avatar.tsx
create mode 100644 web/components/ui/badge.tsx
create mode 100644 web/components/ui/breadcrumb.tsx
create mode 100644 web/components/ui/button-group.tsx
create mode 100644 web/components/ui/button.tsx
create mode 100644 web/components/ui/calendar.tsx
create mode 100644 web/components/ui/card.tsx
create mode 100644 web/components/ui/chart.tsx
create mode 100644 web/components/ui/checkbox.tsx
create mode 100644 web/components/ui/combobox.tsx
create mode 100644 web/components/ui/command.tsx
create mode 100644 web/components/ui/context-menu.tsx
create mode 100644 web/components/ui/dialog.tsx
create mode 100644 web/components/ui/drawer.tsx
create mode 100644 web/components/ui/dropdown-menu.tsx
create mode 100644 web/components/ui/field.tsx
create mode 100644 web/components/ui/input-group.tsx
create mode 100644 web/components/ui/input.tsx
create mode 100644 web/components/ui/label.tsx
create mode 100644 web/components/ui/menubar.tsx
create mode 100644 web/components/ui/popover.tsx
create mode 100644 web/components/ui/resizable.tsx
create mode 100644 web/components/ui/scroll-area.tsx
create mode 100644 web/components/ui/select.tsx
create mode 100644 web/components/ui/separator.tsx
create mode 100644 web/components/ui/sheet.tsx
create mode 100644 web/components/ui/sidebar.tsx
create mode 100644 web/components/ui/skeleton.tsx
create mode 100644 web/components/ui/sonner.tsx
create mode 100644 web/components/ui/switch.tsx
create mode 100644 web/components/ui/table.tsx
create mode 100644 web/components/ui/tabs.tsx
create mode 100644 web/components/ui/textarea.tsx
create mode 100644 web/components/ui/toggle-group.tsx
create mode 100644 web/components/ui/toggle.tsx
create mode 100644 web/components/ui/tooltip.tsx
create mode 100644 web/eslint.config.mjs
create mode 100644 web/hooks/use-agent-conversation-realtime.ts
create mode 100644 web/hooks/use-lg-media.ts
create mode 100644 web/hooks/use-mobile.ts
create mode 100644 web/lib/api/admin.ts
create mode 100644 web/lib/api/agent.ts
create mode 100644 web/lib/api/auth.ts
create mode 100644 web/lib/api/client.ts
create mode 100644 web/lib/api/company.ts
create mode 100644 web/lib/api/customer-contact.ts
create mode 100644 web/lib/api/customer.ts
create mode 100644 web/lib/api/dashboard.ts
create mode 100644 web/lib/api/im.ts
create mode 100644 web/lib/api/ticket-config.ts
create mode 100644 web/lib/api/ticket.ts
create mode 100644 web/lib/auth.ts
create mode 100644 web/lib/enums.ts
create mode 100644 web/lib/generated/enums.ts
create mode 100644 web/lib/im-message.ts
create mode 100644 web/lib/navigation.tsx
create mode 100644 web/lib/services/notification.ts
create mode 100644 web/lib/stores/agent-conversations.ts
create mode 100644 web/lib/ticket-priority.ts
create mode 100644 web/lib/utils.ts
create mode 100644 web/next.config.ts
create mode 100644 web/package.json
create mode 100644 web/pnpm-lock.yaml
create mode 100644 web/pnpm-workspace.yaml
create mode 100644 web/postcss.config.mjs
create mode 100644 web/public/wxwork.svg
create mode 100644 web/styles/main.scss
create mode 100644 web/tsconfig.json
create mode 100644 widget/.env.example
create mode 100644 widget/.gitignore
create mode 100644 widget/app/frame/page.tsx
create mode 100644 widget/app/globals.css
create mode 100644 widget/app/layout.tsx
create mode 100644 widget/app/page.tsx
create mode 100644 widget/components.json
create mode 100644 widget/components/im/chat-shell.tsx
create mode 100644 widget/components/im/connection-status.tsx
create mode 100644 widget/components/im/message-editor.tsx
create mode 100644 widget/components/im/message-html.tsx
create mode 100644 widget/components/im/message-input.tsx
create mode 100644 widget/components/im/message-list.tsx
create mode 100644 widget/components/image-lightbox.tsx
create mode 100644 widget/eslint.config.mjs
create mode 100644 widget/lib/services/conversation.ts
create mode 100644 widget/lib/services/http.ts
create mode 100644 widget/lib/services/message-asset.ts
create mode 100644 widget/lib/services/message.ts
create mode 100644 widget/lib/services/notification.ts
create mode 100644 widget/lib/services/realtime.ts
create mode 100644 widget/lib/services/types.ts
create mode 100644 widget/lib/services/widget-config.ts
create mode 100644 widget/lib/store/chat-store.ts
create mode 100644 widget/lib/utils.ts
create mode 100644 widget/lib/widget/config.ts
create mode 100644 widget/lib/widget/host-bridge.ts
create mode 100644 widget/lib/widget/sdk-template.js
create mode 100644 widget/lib/widget/visitor.ts
create mode 100644 widget/next.config.ts
create mode 100644 widget/package.json
create mode 100644 widget/pnpm-lock.yaml
create mode 100644 widget/pnpm-workspace.yaml
create mode 100644 widget/postcss.config.mjs
create mode 100644 widget/public/sdk/cs-agent-widget.js
create mode 100644 widget/scripts/build-sdk.mjs
create mode 100644 widget/tsconfig.json
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..3e3424b
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,14 @@
+.DS_Store
+.worktrees
+.idea
+.pnpm-store
+
+data/
+config/config.yaml
+
+dist/
+__debug_bin*
+server
+node_modules/
+.next/
+*.tsbuildinfo
diff --git a/.gitmodules b/.gitmodules
new file mode 100644
index 0000000..f990bc7
--- /dev/null
+++ b/.gitmodules
@@ -0,0 +1,6 @@
+[submodule "qdrant"]
+ path = qdrant
+ url = git@github.com:huabeitech/cs-ai-agend-qdrant.git
+[submodule "docs"]
+ path = docs
+ url = git@github.com:huabeitech/cs-ai-agent-docs.git
diff --git a/.vscode/launch.json b/.vscode/launch.json
new file mode 100644
index 0000000..9175861
--- /dev/null
+++ b/.vscode/launch.json
@@ -0,0 +1,34 @@
+{
+ "version": "0.2.0",
+ "configurations": [
+ {
+ "name": "server",
+ "type": "go",
+ "request": "launch",
+ "mode": "auto",
+ "program": "${workspaceFolder}/cmd/server/main.go",
+ "cwd": "${workspaceFolder}",
+ "args": [
+ "-config",
+ "${workspaceFolder}/config/config.yaml"
+ ]
+ },
+ {
+ "name": "web",
+ "type": "node-terminal",
+ "request": "launch",
+ "command": "pnpm dev",
+ "cwd": "${workspaceFolder}/web"
+ }
+ ],
+ "compounds": [
+ {
+ "name": "server + web",
+ "configurations": [
+ "server",
+ "web"
+ ],
+ "stopAll": true
+ }
+ ]
+}
diff --git a/.vscode/tasks.json b/.vscode/tasks.json
new file mode 100644
index 0000000..ae1d924
--- /dev/null
+++ b/.vscode/tasks.json
@@ -0,0 +1,30 @@
+{
+ "version": "2.0.0",
+ "tasks": [
+ {
+ "label": "web:dev",
+ "type": "shell",
+ "command": "pnpm dev",
+ "options": {
+ "cwd": "${workspaceFolder}/web"
+ },
+ "isBackground": true,
+ "presentation": {
+ "reveal": "always",
+ "panel": "dedicated",
+ "group": "web"
+ },
+ "problemMatcher": {
+ "owner": "custom",
+ "pattern": {
+ "regexp": "."
+ },
+ "background": {
+ "activeOnStart": true,
+ "beginsPattern": ".",
+ "endsPattern": "Ready in|Local:"
+ }
+ }
+ }
+ ]
+}
diff --git a/AGENTS.md b/AGENTS.md
new file mode 100644
index 0000000..b519e12
--- /dev/null
+++ b/AGENTS.md
@@ -0,0 +1,510 @@
+# AGENTS.md
+
+本文件定义本项目内 AI Agent 的强制开发规则。除非用户明确要求偏离,否则必须遵循。
+
+## 1. 基本原则
+
+- 适用范围:仓库根目录及所有子目录
+- 优先级:用户明确指令 > 本文件 > 默认实现习惯
+- 若与用户要求冲突:先执行用户要求,并在变更说明中标注偏离点
+
+## 2. 固定技术栈
+
+- 后端:`Golang` + `Iris` + `GORM` + `github.com/mlogclub/simple`
+- 数据库:同时兼容 `SQLite` 和 `MySQL`
+- 前端:`Next.js(App Router)` + `React` + `shadcn/ui` + `Tailwind CSS`
+- 前端包管理器:`pnpm`
+
+## 3. 目录约定
+
+```text
+.
+├── cmd/
+│ ├── server/
+│ ├── migration/
+│ └── generator/
+├── internal/
+│ ├── bootstrap/
+│ ├── config/
+│ ├── models/
+│ ├── repositories/
+│ ├── services/
+│ ├── controllers/
+│ ├── migration/
+│ ├── dto/
+│ ├── convert/
+│ ├── errorsx/
+│ └── logx/
+├── web/
+└── docs/
+```
+
+## 4. 后端分层
+
+必须遵循单向依赖:`models -> repositories -> services -> controllers`
+
+- `models`:只定义实体和表映射
+- `repositories`:只封装数据访问
+- `services`:负责业务规则、事务编排、聚合逻辑
+- `controllers`:只做参数解析、权限校验、service 调用、响应封装
+
+禁止:
+
+- controller 直接调用 repository
+- 直接将 GORM model 返回前端
+- 在 models/repositories 中写业务编排
+
+## 4.1 分层全链路(models -> repositories -> services -> controllers -> builders)
+
+本章是对“分层”规则的**可执行细化**:每一层只做该层应该做的事情,数据以 DTO 为中心流转,GORM 细节集中在 repository,事务边界集中在 service,返回组装集中在 builders/controller。
+
+### 4.1.1 依赖方向(必须)
+
+只允许如下依赖(单向):
+
+- `models` → 不依赖任何业务层
+- `repositories` → 依赖 `models`、基础库(`gorm`/`simple/sqls`)
+- `services` → 依赖 `repositories`、`models`、`enums/errorsx/utils`,负责事务与业务编排
+- `builders` → 依赖 `models`、`dto/response`(必要时可依赖少量 `services` 用于补充展示字段,但优先在 service 里聚合好)
+- `controllers` → 依赖 `services`、`builders`、`dto/request`、`web/params`、`web` 响应封装
+
+禁止反向依赖:
+
+- `repositories` 不能依赖 `services/controllers/builders`
+- `models` 不能依赖 `repositories/services/controllers/builders`
+- `controllers` 不能依赖 `repositories`(必须通过 service)
+
+### 4.1.2 数据形态与流转(推荐统一)
+
+一条典型 CRUD/业务动作的数据流:
+
+1. **controller** 读取参数(query/body/form/path),做权限校验,调用 **service**
+2. **service** 执行业务规则(校验、幂等、状态机、聚合),必要时开启事务,调用 **repository**
+3. **repository** 只做数据读写(CRUD + 查询),返回 `models` 或必要的聚合结构
+4. **builders** 将 `models`/聚合结果映射为 `response DTO`
+5. **controller** 返回 `web.JsonData(...)` 或 `web.JsonPageData(...)`
+
+强约束:
+
+- **controller 入参使用 request DTO**
+- **controller 出参使用 response DTO**
+- **禁止直接返回 models 到前端**
+
+### 4.1.3 各层“允许/禁止”清单
+
+#### models(实体层)
+
+- **允许**
+ - 字段定义、表名、索引/约束标签、关联关系(GORM tags)
+ - 轻量常量/枚举字段类型(更推荐放 `internal/pkg/enums`)
+- **禁止**
+ - 业务方法(例如 `CanDispatch()` 这类规则判断应在 service)
+ - DB 访问、事务、复杂计算
+
+#### repositories(数据访问层)
+
+- **允许**
+ - CRUD:`Get/Take/Find/FindOne/FindPageBy.../Create/Update/Updates/UpdateColumn/Delete`
+ - 与查询相关的“可复用”方法:`FindByUserID`、`CountByStatus`、`FindActiveBy...`
+ - 只要是“数据访问细节”,都应在这里(SQL 条件、排序、分页、锁)
+- **禁止**
+ - 业务编排(跨表流程、状态流转、事件发布等)
+ - 权限判断、登录态判断
+ - 直接拼接返回 DTO(DTO 映射属于 builders/controller)
+
+Repository 最佳实践:
+
+- **按主键读写优先提供统一方法**:`Get/Updates/Delete`,避免 service 层重复写 `id = ?`
+- **查询条件优先使用 `sqls.Cnd` / `sqls.NewCnd()`**
+- **repository 方法签名统一接收 `db *gorm.DB`**(支持 `sqls.DB()` 与 `ctx.Tx`)
+
+#### services(业务层)
+
+- **允许**
+ - 业务规则:参数规范化、跨实体校验、状态机、幂等、并发语义
+ - 聚合:需要组合多个 repository 结果
+ - 事务编排:`sqls.WithTransaction(func(ctx *sqls.TxContext) error { ... })`
+ - 调用 builders 前的领域对象整理(如果 builders 只做映射更干净)
+- **禁止**
+ - controller 才该做的事情:参数解析/HTTP 细节/响应封装
+ - repository 才该做的事情:散落 GORM 查询(除非一次性复杂 SQL 且不值得抽)
+
+Service 最佳实践:
+
+- **事务只在“需要原子性”的地方开**,并确保事务内所有 DB 操作都走 `ctx.Tx`
+- **service 内调用 repository**,不要“既有 repo 又直接 GORM”混搭造成风格分裂
+
+#### builders(输出构建层)
+
+定位:将 `models`(或 service 聚合结果)转换为 `response DTO`,避免 controller 写一堆映射样板代码。
+
+- **允许**
+ - `Model -> ResponseDTO` 的纯映射
+ - 时间格式化、枚举 label 填充(必要时)
+ - 批量构建:`BuildXxxList([]models.Xxx) []response.Xxx`
+- **禁止**
+ - DB 访问(builders 不应查询数据库)
+ - 权限判断、事务、复杂业务流程
+
+builders 推荐形式:
+
+- 位置:`internal/builders/*_builder.go`
+- 方法:`BuildXxx(item *models.Xxx) *response.Xxx` / `BuildXxxList(list []models.Xxx) []response.Xxx`
+
+#### controllers(接口层)
+
+- **允许**
+ - 参数解析:`params.ReadJSON/ReadForm/NewPagedSqlCnd/GetInt64...`
+ - 权限:`AuthService.GetAuthPrincipal/RequirePermission/HasPermission`
+ - 调 service,调 builders,包装 `web.JsonData/JsonPageData/JsonError`
+- **禁止**
+ - 直接调用 repository
+ - 直接返回 models
+ - 在 controller 内写业务编排(例如“先写 A 再写 B”)
+
+### 4.1.4 “事务”最佳实践(替代模糊口号)
+
+事务边界应由 service 决定,原则如下:
+
+- **必须开事务**(`sqls.WithTransaction`)
+ - 多条写 SQL(例如更新主表 + 写日志表/事件表/关系表)
+ - “读-改-写”且要求一致性(并发下不能错)
+ - 跨多个 repository 的写操作需要原子性
+- **不需要开事务**
+ - 只有一次写 SQL(单条 `Create/Updates/UpdateColumn/Delete`)
+ - 只有一次写 SQL + 纯计算/参数清洗
+
+事务内规则:
+
+- 在事务内,所有 DB 调用必须使用 `ctx.Tx`(repository 方法的 `db` 参数传 `ctx.Tx`)
+- 禁止事务内混用 `sqls.DB()`(会脱离事务)
+
+### 4.1.5 一个“标准接口”的代码骨架(示例)
+
+```go
+// Controller: 参数/权限/响应
+func (c *XxxController) PostUpdate() web.JsonResult {
+ operator := services.AuthService.GetAuthPrincipal(c.Ctx)
+ if operator == nil {
+ return web.JsonErrorMsg("未登录或登录已过期")
+ }
+ var req request.UpdateXxxRequest
+ if err := params.ReadJSON(c.Ctx, &req); err != nil {
+ return web.JsonError(err)
+ }
+ if err := services.XxxService.UpdateXxx(req, operator); err != nil {
+ return web.JsonError(err)
+ }
+ return web.JsonSuccess()
+}
+```
+
+```go
+// Service: 业务规则 + 事务编排 + 调 repository
+func (s *xxxService) UpdateXxx(req request.UpdateXxxRequest, operator *dto.AuthPrincipal) error {
+ current := repositories.XxxRepository.Get(sqls.DB(), req.ID)
+ if current == nil {
+ return errorsx.InvalidParam("对象不存在")
+ }
+ // 只有一次写 SQL:不需要事务
+ return repositories.XxxRepository.Updates(sqls.DB(), req.ID, map[string]any{
+ "name": strings.TrimSpace(req.Name),
+ "update_user_id": operator.UserID,
+ "update_user_name": operator.Username,
+ "updated_at": time.Now(),
+ })
+}
+```
+
+```go
+// Builder: Model -> ResponseDTO
+func BuildXxx(item *models.Xxx) *response.Xxx {
+ if item == nil {
+ return nil
+ }
+ return &response.Xxx{
+ id: item.ID,
+ // ...
+ }
+}
+```
+
+## 5. simple 使用约定
+
+- DB 初始化后必须执行:`sqls.SetDB(db)`
+- 查询条件优先使用:`sqls.Cnd`
+- 参数绑定优先使用:`web/params`
+- HTTP 响应统一使用:`web.JsonData`、`web.JsonPageData`、`web.JsonError`
+- 写操作事务边界按 **4.1.4 事务最佳实践** 执行(禁止“单条写 SQL 也默认开事务”的口号式规则)
+
+## 6. 数据库兼容规则
+
+- 字段类型使用兼容集合:`varchar`、`text`、`int`、`bigint`、`datetime`
+- 主键统一使用 `int64`
+- 避免数据库私有语法和方言特性
+- 时间存储和解析策略保持统一,MySQL 使用 `parseTime=True`
+
+## 7. 代码生成与 Migration
+
+### 7.1 代码生成
+
+- 入口:`cmd/generator/generator.go`
+- 命令:`make generator`
+- 生成库:`github.com/mlogclub/codegen`
+- 注册方式:`codegen.GetGenerateStruct(&models.XXX{})`
+- 生成文件建议放在 `generated` 目录,命名为 `*_gen.go`
+- 生成代码只负责基础 CRUD,业务逻辑必须写在手写 service/controller 中
+
+标准流程:
+
+1. 定义或修改 model
+2. 在 generator 中注册
+3. 执行 `make generator`
+4. 在手写层补业务逻辑
+5. 执行测试与自检
+
+### 7.2 Migration
+
+- DDL 变更默认不走 `internal/migration/runner.go`
+- 表结构新增、修改、索引变更统一通过 `sqls.DB().AutoMigrate(models.Models...)`
+- `internal/migration/runner.go` 只用于 DML:初始化数据、回填、修复、重映射等
+- migration 必须幂等,且 `version` 单调递增
+- 执行顺序:先 `AutoMigrate`,再 `migration.Migrate(...)`
+
+## 8. 接口规范
+
+### 8.1 DTO 与返回
+
+- DTO 分离:`request` / `response` 分开定义
+- JSON 字段统一使用 `camelCase`
+- 禁止透传底层 SQL 错误
+- 错误码分段:
+ - `1000-1999` 参数错误
+ - `2000-2999` 业务错误
+ - `3000-3999` 认证/权限错误
+ - `5000-5999` 系统错误
+
+### 8.2 路径分层
+
+- `/api/console/*`:业务后台接口,默认归属
+- `/api/admin/*`:平台总后台接口
+- `/api/open/*`:开放接口;IM 优先使用 `/api/open/im/*`
+- `/api/auth/*`:认证接口,可未登录访问
+
+禁止新增 `/api/v1` 这类版本前缀。
+
+### 8.3 console 接口风格
+
+- 资源路径优先平铺:如 `/api/console/project`
+- 列表、创建、更新、删除优先使用 `/list`、`/create`、`/update`、`/delete`
+- 查询条件优先通过 `query` 或 `body` 传递
+- 除详情接口外,尽量不使用 path param
+- 详情接口允许 `GET /api/console/project/{id}`
+- 从属资源优先通过 `projectId`、`episodeId` 等普通参数过滤,不鼓励深层嵌套路由
+
+### 8.4 路由注册
+
+- 业务后台统一在 `internal/bootstrap/server.go` 中通过 `mvc.Configure(app.Party("/api/console"), ...)` 注册
+- 平台接口统一通过 `mvc.Configure(app.Party("/api/admin"), ...)` 注册
+- 开放接口按领域归档,如 `mvc.Configure(app.Party("/api/open/im"), ...)`
+- 在分组内部通过 `m.Party("/xxx").Handle(...)` 挂载资源
+- 不要为每个资源单独再写一层顶级 `mvc.Configure(app.Party("/api/console/xxx"), ...)`
+- 认证与鉴权中间件优先挂在 `/api/console` 或 `/api/admin` 这一层
+
+### 8.5 Iris MVC 自动路由规则
+
+本项目使用 Iris MVC 自动路由,controller 方法名必须按 Iris 规则命名,不能按个人习惯随意写。
+
+- controller 挂载方式示例:
+
+```go
+m.Party("/quick-reply").Handle(new(console.QuickReplyController))
+```
+
+在上面的注册下,controller 的基础路径就是 `/quick-reply`,最终完整路径再拼上外层分组,如 `/api/console/quick-reply`。
+
+- controller 名称 `QuickReplyController` 不会自动变成路径,路径以 `m.Party("/quick-reply")` 为准
+- 方法名前缀决定 HTTP Method:
+ - `AnyXxx`:匹配任意方法
+ - `GetXxx`:匹配 `GET`
+ - `PostXxx`:匹配 `POST`
+ - `PutXxx`:匹配 `PUT`
+ - `DeleteXxx`:匹配 `DELETE`
+- 方法名后缀决定子路径:
+ - `Any()` -> `/`
+ - `AnyList()` -> `/list`
+ - `PostCreate()` -> `/create`
+ - `PostUpdate()` -> `/update`
+ - `PostDelete()` -> `/delete`
+ - `GetBy(id int64)` -> `/{id}`
+ - `GetMessageList()` -> `/message/list`
+ - `PostSendMessage()` -> `/send/message`
+ - `PostSend_message()` -> `/send_message`
+- `By` 表示路径参数,不是普通单词:
+ - `GetBy(id int64)` -> `GET /{id}`
+ - `GetUserBy(id int64)` -> `GET /user/{id}`
+ - 不要把本来想要 `/list`、`/detail`、`/create` 的接口误写成带 `By` 的方法
+- 多个参数会继续追加路径参数:
+ - `GetBy(projectId int64, episodeId int64)` -> `GET /{projectId}/{episodeId}`
+ - 本项目默认不鼓励这样设计,除详情场景外优先用 query/body
+- `AnyList()` 虽然可匹配任意方法,但本项目约定它用于列表查询,前端应按 `GET /list` 使用
+- 写接口统一用 `PostCreate()`、`PostUpdate()`、`PostDelete()`,不要写成 `AnyCreate()`、`GetUpdate()` 这类不符合语义的命名
+
+当前项目常见正确映射示例:
+
+- `m.Party("/user").Handle(new(console.UserController))`
+ - `AnyList()` -> `ANY /api/console/user/list`
+ - `GetBy(id int64)` -> `GET /api/console/user/{id}`
+ - `PostCreate()` -> `POST /api/console/user/create`
+ - `PostUpdate()` -> `POST /api/console/user/update`
+ - `PostDelete()` -> `POST /api/console/user/delete`
+- `m.Party("/conversation").Handle(new(console.ConversationController))`
+ - `AnyList()` -> `ANY /api/console/conversation/list`
+ - `GetBy(id int64)` -> `GET /api/console/conversation/{id}`
+ - `AnyMessageList()` -> `ANY /api/console/conversation/message/list`
+ - `PostSendMessage()` -> `POST /api/console/conversation/send/message`
+ - 如果业务明确要求下划线路径,则方法名写成 `PostSend_message()`,对应 `POST /api/console/conversation/send_message`
+
+容易写错的点:
+
+- 不要以为 `GetList()` 会生成 `/list` 的通用查询接口;它只会是 `GET /list`,而当前项目统一使用 `AnyList()`
+- 不要把详情接口写成 `GetDetail()`,那会生成 `GET /detail`,不是 `GET /{id}`
+- 不要把动作接口写成 `PostBy(id int64)` 这类混合命名,除非你真的需要 `POST /{id}`
+- CamelCase 方法名会按单词切分成多段路径,不要想当然把 `PostSendMessage()` 理解成 `/sendMessage`
+- 如果接口契约要求单段路径或下划线形式,优先显式使用下划线命名,例如 `PostSend_message()` -> `/send_message`
+- controller 新增方法前,先根据方法名手工推导一次最终 URL,确认与前端约定一致再落代码
+
+### 8.6 Controller 约定
+
+- 每个资源一个 controller 文件
+- 结构体统一:`type XxxController struct { Ctx iris.Context }`
+- 方法命名遵循 Iris MVC:
+ - `AnyList()`
+ - `GetBy(id int64)`
+ - `PostCreate()`
+ - `PostUpdate()`
+ - `PostDelete()`
+ - 业务动作可扩展 `PostTest()`、`PostGenerate()` 等
+
+- `AnyList()` 分页列表要求:
+ - 使用 `params.NewPagedSqlCnd(...)`
+ - 每个筛选字段通过 `params.QueryFilter` 显式声明
+ - 默认排序优先 `.Desc("id")`;特殊排序需明确理由
+ - service 层优先使用 `FindPageByCnd(...)`
+ - controller 层做 DTO 映射,禁止直接返回 model 列表
+
+- 分页返回统一为:
+
+```go
+return web.JsonData(&web.PageResult{Results: results, Page: paging})
+```
+
+- 分页 `data` 结构必须为:
+ - `data.results`
+ - `data.page.page`
+ - `data.page.limit`
+ - `data.page.total`
+
+- 详情优先返回 `web.JsonData(dto)`
+- 删除优先返回 `web.JsonSuccess()`
+- JSON body 优先使用 `params.ReadJSON`
+- form 参数优先使用 `params.ReadForm`
+- 获取单个参数可以使用 `params.GetInt64`、`params.GetInt64Arr`、`params.Get` 等
+- 分页和 query 优先使用 `params.NewPagedSqlCnd`
+- 登录态用户通过 `services.AuthService.GetAuthPrincipal(c.Ctx)` 获取
+- 权限判断统一通过 `services.AuthService.HasPermission(...)` 或 `RequirePermission(...)`
+- 鉴权失败统一返回 `web.JsonErrorMsg(...)`
+- `gorm.ErrRecordNotFound` 等错误应转换成明确业务提示
+- 后端数据返回时,将数据转换成Response DTO相关的逻辑可以放到 `internal/builders` 包下。
+
+### 8.7 枚举的定义
+
+- 系统中的常量统一定义到 `/internal/pkg/enums` 包下
+- 模型的状态优先使用 `/internal/pkg/enums/enums.go` 中的 `Status`,只有不满足需求的时候在考虑新增状态枚举
+- 前后端共用枚举必须遵循文档 [docs/backend-frontend-enum-ast-spec.md](docs/backend-frontend-enum-ast-spec.md)
+- 前后端共用枚举只允许在后端定义,前端必须使用 `make enums` 生成结果,禁止手写重复业务枚举
+
+## 9. Go 代码规范
+
+- 日志统一使用标准库 `log/slog`
+- 新增日志禁止引入其他日志库
+- 日志字段优先使用结构化键值对
+- 新增 Go 代码统一使用 `any`,禁止新增 `interface{}`
+- 修改 Go 代码后必须执行 `gofmt`
+
+## 10. 前端规范
+
+### 10.1 工程事实
+
+- 前端目录:`web`
+- 框架:`Next.js 16` + App Router
+- 页面目录:`web/app/*`
+- 组件目录:`web/components/*`
+- shadcn/ui 基础组件目录:`web/components/ui/*`
+- 工具目录:`web/lib/*`、`web/hooks/*`
+- 别名:`@/*`
+- 样式入口:`web/app/globals.css`
+- shadcn 配置:`web/components.json`
+
+### 10.2 组件与页面
+
+- 基础组件优先使用 `shadcn/ui`
+- 已有 `shadcn/ui` 能覆盖的场景,禁止重复封装等价基础组件
+- 缺少 `dialog`、`textarea`、`select` 等基础组件且业务确实需要时,必须按规范安装,不要手写替代品
+- 不要修改 `web/components/ui/*`
+- 业务组件放在 `web/components/*` 或对应业务目录
+- API 调用统一封装在服务层,禁止页面里散落裸 `fetch`
+- 前端业务接口统一通过 `web/lib/api/*` 下的 service 方法发起,禁止在 `page.tsx`、业务组件、store 中直接对业务接口使用裸 `fetch`
+- `web/lib/api/client.ts` 是默认请求入口;新增业务 API 优先复用 `request()`,不要重复实现一套新的请求客户端
+- 后端返回为统一 `JsonResult` 时,前端必须统一处理 `success`、`errorCode`、`message`、`data`,禁止只按 HTTP status 判断成功失败
+- 业务代码中不要自行解析 `JsonResult.data`、拼装通用错误处理、手写鉴权刷新逻辑;这些逻辑必须收敛在公共请求封装内
+- 需要登录态的请求必须复用统一封装附带的认证头、`3000/3002` 刷新 token、登录失效清理能力,禁止在页面层各自处理
+- 仅在调用第三方外部服务、下载二进制流、SSE/流式响应、WebSocket 握手等统一封装暂不适配的场景下,才允许直接使用底层 `fetch`;使用时必须在代码中注明原因
+
+### 10.3 shadcn 使用流程
+
+- 先确认 `web/components.json` 已存在;存在时禁止再次 `init`
+- 命令统一在 `web` 目录执行
+- 安装依赖统一使用 `pnpm`
+- 新增基础组件优先使用:
+ - `cd web && pnpm dlx shadcn@latest add button`
+ - `cd web && pnpm dlx shadcn@latest add button dialog form table`
+
+### 10.4 Next.js 约定
+
+- 优先使用 App Router
+- 需要客户端状态或副作用时显式加 `"use client"`
+- 页面与布局遵循 `layout.tsx`、`page.tsx` 约定
+- 检查优先复用现有 scripts:`dev`、`build`、`start`、`lint`、`format`、`typecheck`
+
+### 10.5 枚举管理
+
+- 前端所有枚举统一定义在 `web/lib/enums.ts` 文件中
+- 枚举统一由后端定义,前端枚举使用 `make enums` 指令生成
+
+### 10.6 后台列表与表单基线
+
+- 后台类 CRUD 页面优先参考:`docs/frontend-list-form-best-practice.md`
+- 基线案例:`web/app/dashboard/quick-replies`
+- 默认采用“`page.tsx` 管列表与状态,`_components/edit.tsx` 管弹窗表单”的两层结构
+- 表单默认采用:`react-hook-form` + `zod` + `web/components/ui/field.tsx`
+- API 调用统一留在页面层或服务层,表单组件不直接请求接口
+- 新增或修改后台列表/表单页面后,AI Agent 必须先自查是否符合该文档约定,再执行 `cd web && pnpm typecheck`
+
+### 10.7 前端其他规范
+
+- 所有前端展示时间统一格式化为 `yyyy-MM-dd HH:mm:ss` 推荐统一使用 `web/lib/utils.ts` 中的 `formatDateTime` 方法
+- 下拉框组件不要使用shadcn的select组件,而是使用shadcn的combobox组件。项目级别使用combobox封装了一个下拉框组件 `web/components/option-combobox.tsx`,尽量通用。
+- 如果是组件中使用到的数据,尽量组件中自己去加载,不要在外面加载之后传到组件中。要保证组件的独立性。
+
+## 11. 提交前检查清单
+
+每次修改后至少确认:
+
+1. 没有跨层调用或反向依赖
+2. 写操作有明确事务边界
+3. 返回仍符合统一 JsonResult 结构
+4. 兼容 SQLite 与 MySQL
+5. 补充了必要测试,至少覆盖 service 核心路径
+6. Go 改动已执行 `gofmt`
+7. 前端改动至少通过 `pnpm lint` 或 `pnpm typecheck`(在 `web` 目录)
diff --git a/LICENSE b/LICENSE
new file mode 100644
index 0000000..261eeb9
--- /dev/null
+++ b/LICENSE
@@ -0,0 +1,201 @@
+ Apache License
+ Version 2.0, January 2004
+ http://www.apache.org/licenses/
+
+ TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
+
+ 1. Definitions.
+
+ "License" shall mean the terms and conditions for use, reproduction,
+ and distribution as defined by Sections 1 through 9 of this document.
+
+ "Licensor" shall mean the copyright owner or entity authorized by
+ the copyright owner that is granting the License.
+
+ "Legal Entity" shall mean the union of the acting entity and all
+ other entities that control, are controlled by, or are under common
+ control with that entity. For the purposes of this definition,
+ "control" means (i) the power, direct or indirect, to cause the
+ direction or management of such entity, whether by contract or
+ otherwise, or (ii) ownership of fifty percent (50%) or more of the
+ outstanding shares, or (iii) beneficial ownership of such entity.
+
+ "You" (or "Your") shall mean an individual or Legal Entity
+ exercising permissions granted by this License.
+
+ "Source" form shall mean the preferred form for making modifications,
+ including but not limited to software source code, documentation
+ source, and configuration files.
+
+ "Object" form shall mean any form resulting from mechanical
+ transformation or translation of a Source form, including but
+ not limited to compiled object code, generated documentation,
+ and conversions to other media types.
+
+ "Work" shall mean the work of authorship, whether in Source or
+ Object form, made available under the License, as indicated by a
+ copyright notice that is included in or attached to the work
+ (an example is provided in the Appendix below).
+
+ "Derivative Works" shall mean any work, whether in Source or Object
+ form, that is based on (or derived from) the Work and for which the
+ editorial revisions, annotations, elaborations, or other modifications
+ represent, as a whole, an original work of authorship. For the purposes
+ of this License, Derivative Works shall not include works that remain
+ separable from, or merely link (or bind by name) to the interfaces of,
+ the Work and Derivative Works thereof.
+
+ "Contribution" shall mean any work of authorship, including
+ the original version of the Work and any modifications or additions
+ to that Work or Derivative Works thereof, that is intentionally
+ submitted to Licensor for inclusion in the Work by the copyright owner
+ or by an individual or Legal Entity authorized to submit on behalf of
+ the copyright owner. For the purposes of this definition, "submitted"
+ means any form of electronic, verbal, or written communication sent
+ to the Licensor or its representatives, including but not limited to
+ communication on electronic mailing lists, source code control systems,
+ and issue tracking systems that are managed by, or on behalf of, the
+ Licensor for the purpose of discussing and improving the Work, but
+ excluding communication that is conspicuously marked or otherwise
+ designated in writing by the copyright owner as "Not a Contribution."
+
+ "Contributor" shall mean Licensor and any individual or Legal Entity
+ on behalf of whom a Contribution has been received by Licensor and
+ subsequently incorporated within the Work.
+
+ 2. Grant of Copyright License. Subject to the terms and conditions of
+ this License, each Contributor hereby grants to You a perpetual,
+ worldwide, non-exclusive, no-charge, royalty-free, irrevocable
+ copyright license to reproduce, prepare Derivative Works of,
+ publicly display, publicly perform, sublicense, and distribute the
+ Work and such Derivative Works in Source or Object form.
+
+ 3. Grant of Patent License. Subject to the terms and conditions of
+ this License, each Contributor hereby grants to You a perpetual,
+ worldwide, non-exclusive, no-charge, royalty-free, irrevocable
+ (except as stated in this section) patent license to make, have made,
+ use, offer to sell, sell, import, and otherwise transfer the Work,
+ where such license applies only to those patent claims licensable
+ by such Contributor that are necessarily infringed by their
+ Contribution(s) alone or by combination of their Contribution(s)
+ with the Work to which such Contribution(s) was submitted. If You
+ institute patent litigation against any entity (including a
+ cross-claim or counterclaim in a lawsuit) alleging that the Work
+ or a Contribution incorporated within the Work constitutes direct
+ or contributory patent infringement, then any patent licenses
+ granted to You under this License for that Work shall terminate
+ as of the date such litigation is filed.
+
+ 4. Redistribution. You may reproduce and distribute copies of the
+ Work or Derivative Works thereof in any medium, with or without
+ modifications, and in Source or Object form, provided that You
+ meet the following conditions:
+
+ (a) You must give any other recipients of the Work or
+ Derivative Works a copy of this License; and
+
+ (b) You must cause any modified files to carry prominent notices
+ stating that You changed the files; and
+
+ (c) You must retain, in the Source form of any Derivative Works
+ that You distribute, all copyright, patent, trademark, and
+ attribution notices from the Source form of the Work,
+ excluding those notices that do not pertain to any part of
+ the Derivative Works; and
+
+ (d) If the Work includes a "NOTICE" text file as part of its
+ distribution, then any Derivative Works that You distribute must
+ include a readable copy of the attribution notices contained
+ within such NOTICE file, excluding those notices that do not
+ pertain to any part of the Derivative Works, in at least one
+ of the following places: within a NOTICE text file distributed
+ as part of the Derivative Works; within the Source form or
+ documentation, if provided along with the Derivative Works; or,
+ within a display generated by the Derivative Works, if and
+ wherever such third-party notices normally appear. The contents
+ of the NOTICE file are for informational purposes only and
+ do not modify the License. You may add Your own attribution
+ notices within Derivative Works that You distribute, alongside
+ or as an addendum to the NOTICE text from the Work, provided
+ that such additional attribution notices cannot be construed
+ as modifying the License.
+
+ You may add Your own copyright statement to Your modifications and
+ may provide additional or different license terms and conditions
+ for use, reproduction, or distribution of Your modifications, or
+ for any such Derivative Works as a whole, provided Your use,
+ reproduction, and distribution of the Work otherwise complies with
+ the conditions stated in this License.
+
+ 5. Submission of Contributions. Unless You explicitly state otherwise,
+ any Contribution intentionally submitted for inclusion in the Work
+ by You to the Licensor shall be under the terms and conditions of
+ this License, without any additional terms or conditions.
+ Notwithstanding the above, nothing herein shall supersede or modify
+ the terms of any separate license agreement you may have executed
+ with Licensor regarding such Contributions.
+
+ 6. Trademarks. This License does not grant permission to use the trade
+ names, trademarks, service marks, or product names of the Licensor,
+ except as required for reasonable and customary use in describing the
+ origin of the Work and reproducing the content of the NOTICE file.
+
+ 7. Disclaimer of Warranty. Unless required by applicable law or
+ agreed to in writing, Licensor provides the Work (and each
+ Contributor provides its Contributions) on an "AS IS" BASIS,
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
+ implied, including, without limitation, any warranties or conditions
+ of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
+ PARTICULAR PURPOSE. You are solely responsible for determining the
+ appropriateness of using or redistributing the Work and assume any
+ risks associated with Your exercise of permissions under this License.
+
+ 8. Limitation of Liability. In no event and under no legal theory,
+ whether in tort (including negligence), contract, or otherwise,
+ unless required by applicable law (such as deliberate and grossly
+ negligent acts) or agreed to in writing, shall any Contributor be
+ liable to You for damages, including any direct, indirect, special,
+ incidental, or consequential damages of any character arising as a
+ result of this License or out of the use or inability to use the
+ Work (including but not limited to damages for loss of goodwill,
+ work stoppage, computer failure or malfunction, or any and all
+ other commercial damages or losses), even if such Contributor
+ has been advised of the possibility of such damages.
+
+ 9. Accepting Warranty or Additional Liability. While redistributing
+ the Work or Derivative Works thereof, You may choose to offer,
+ and charge a fee for, acceptance of support, warranty, indemnity,
+ or other liability obligations and/or rights consistent with this
+ License. However, in accepting such obligations, You may act only
+ on Your own behalf and on Your sole responsibility, not on behalf
+ of any other Contributor, and only if You agree to indemnify,
+ defend, and hold each Contributor harmless for any liability
+ incurred by, or claims asserted against, such Contributor by reason
+ of your accepting any such warranty or additional liability.
+
+ END OF TERMS AND CONDITIONS
+
+ APPENDIX: How to apply the Apache License to your work.
+
+ To apply the Apache License to your work, attach the following
+ boilerplate notice, with the fields enclosed by brackets "[]"
+ replaced with your own identifying information. (Don't include
+ the brackets!) The text should be enclosed in the appropriate
+ comment syntax for the file format. We also recommend that a
+ file or class name and description of purpose be included on the
+ same "printed page" as the copyright notice for easier
+ identification within third-party archives.
+
+ Copyright [yyyy] [name of copyright owner]
+
+ Licensed under the Apache License, Version 2.0 (the "License");
+ you may not use this file except in compliance with the License.
+ You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+ Unless required by applicable law or agreed to in writing, software
+ distributed under the License is distributed on an "AS IS" BASIS,
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ See the License for the specific language governing permissions and
+ limitations under the License.
diff --git a/Makefile b/Makefile
new file mode 100644
index 0000000..789b0f1
--- /dev/null
+++ b/Makefile
@@ -0,0 +1,124 @@
+.PHONY: run run-server run-web build build-all build-assets build-web build-widget \
+ package-current package-platform build-server-linux-amd64 clean-dist clean-temp test tidy generator enums migration testdata
+
+DIST_DIR ?= dist
+TMP_DIR := $(DIST_DIR)/.tmp
+APP_NAME ?= cs-agent
+ARCHIVE_NAME ?= cs-ai-agent
+PACKAGE_ROOT_DIR ?= $(ARCHIVE_NAME)
+APP_ENTRY ?= ./cmd/server
+PLATFORMS := linux-amd64 linux-arm64 darwin-amd64 darwin-arm64 windows-amd64
+CURRENT_GOOS := $(shell go env GOOS)
+CURRENT_GOARCH := $(shell go env GOARCH)
+CURRENT_PLATFORM := $(CURRENT_GOOS)-$(CURRENT_GOARCH)
+PLATFORM ?= $(CURRENT_PLATFORM)
+
+run:
+ @server_pid=0; \
+ web_pid=0; \
+ trap 'kill $$server_pid $$web_pid 2>/dev/null || true' INT TERM EXIT; \
+ $(MAKE) run-server & \
+ server_pid=$$!; \
+ $(MAKE) run-web & \
+ web_pid=$$!; \
+ while kill -0 $$server_pid 2>/dev/null && kill -0 $$web_pid 2>/dev/null; do \
+ sleep 1; \
+ done; \
+ status=0; \
+ wait $$server_pid || status=$$?; \
+ wait $$web_pid || status=$$?; \
+ kill $$server_pid $$web_pid 2>/dev/null || true; \
+ exit $$status
+
+run-server:
+ go run ./cmd/server
+
+run-web:
+ cd web && pnpm dev
+
+build: clean-dist build-assets package-current clean-temp
+ @echo "[build] done"
+
+build-all: clean-dist build-assets
+ @echo "[build-all] packaging platforms: $(PLATFORMS)"
+ @for platform in $(PLATFORMS); do \
+ echo "[build-all] packaging $$platform"; \
+ $(MAKE) package-platform PLATFORM=$$platform; \
+ done
+ @$(MAKE) clean-temp
+ @echo "[build-all] done"
+
+build-assets: build-web build-widget
+ @echo "[build-assets] done"
+
+build-web:
+ @echo "[build-web] building web app"
+ cd web && pnpm build
+ @echo "[build-web] done"
+
+build-widget:
+ @echo "[build-widget] building widget sdk"
+ cd widget && pnpm build:sdk && pnpm build
+ @echo "[build-widget] done"
+
+package-current:
+ @$(MAKE) package-platform PLATFORM=$(CURRENT_PLATFORM)
+
+build-server-linux-amd64:
+ @dist_dir="$(CURDIR)/$(DIST_DIR)"; \
+ output="$$dist_dir/$(APP_NAME)-linux-amd64"; \
+ echo "[build-server] start linux-amd64"; \
+ mkdir -p "$$dist_dir"; \
+ echo "[build-server] go build -> $$output"; \
+ GOOS=linux GOARCH=amd64 go build -o "$$output" $(APP_ENTRY); \
+ echo "[build-server] done -> $$output"
+
+package-platform:
+ @platform="$(PLATFORM)"; \
+ goos=$${platform%-*}; \
+ goarch=$${platform#*-}; \
+ stage_dir="$(TMP_DIR)/$$platform"; \
+ package_dir="$$stage_dir/$(PACKAGE_ROOT_DIR)"; \
+ dist_dir="$(CURDIR)/$(DIST_DIR)"; \
+ archive_base="$$dist_dir/$(ARCHIVE_NAME)-$$platform"; \
+ binary_name="$(APP_NAME)"; \
+ if [ "$$goos" = "windows" ]; then binary_name="$(APP_NAME).exe"; fi; \
+ echo "[package] start $$platform"; \
+ rm -rf "$$stage_dir"; \
+ mkdir -p "$$package_dir/web" "$$package_dir/widget" "$$package_dir/config" "$$dist_dir"; \
+ echo "[package] go build $$platform"; \
+ GOOS=$$goos GOARCH=$$goarch go build -o "$$package_dir/$$binary_name" $(APP_ENTRY); \
+ echo "[package] copy assets $$platform"; \
+ cp -R web/out "$$package_dir/web/out"; \
+ cp -R widget/out "$$package_dir/widget/out"; \
+ echo "[package] include example config only"; \
+ cp config/config.example.yaml "$$package_dir/config/config.example.yaml"; \
+ rm -f "$$archive_base.zip"; \
+ echo "[package] archive $$archive_base.zip"; \
+ cd "$$stage_dir" && zip -rXq "$$archive_base.zip" "$(PACKAGE_ROOT_DIR)" -x "*.DS_Store" "__MACOSX/*"; \
+ rm -rf "$$stage_dir"; \
+ echo "[package] done $$platform -> $$archive_base.zip"
+
+clean-dist:
+ rm -rf $(DIST_DIR)
+
+clean-temp:
+ rm -rf $(TMP_DIR)
+
+test:
+ go test ./...
+
+tidy:
+ go mod tidy
+
+generator:
+ go run ./cmd/generator/generator.go
+
+enums:
+ go run ./cmd/enums/generator.go
+
+migration:
+ go run ./cmd/migration
+
+testdata:
+ go run ./cmd/testdata
diff --git a/README.md b/README.md
new file mode 100644
index 0000000..b1e27b1
--- /dev/null
+++ b/README.md
@@ -0,0 +1,176 @@
+# 贝壳AI客服
+
+> An AI-agent-first customer support system that unifies live chat, knowledge retrieval, ticketing, and seamless human handoff.
+
+`贝壳AI客服`是一个以 **AI Agent 为核心** 的智能客服系统,面向需要同时处理在线咨询、知识库问答、人工接管和工单流转的团队。
+
+它不是一个简单的聊天机器人,而是一套围绕客服场景设计的完整系统:
+
+- AI Agent 先接待,优先处理常见问题与标准流程
+- 知识库检索驱动回答,支持 RAG 场景
+- 在低置信度、无答案或命中规则时转人工
+- 后台可管理会话、工单、客服组、知识库、AI 配置、Skills 和 MCP
+- 提供管理后台与客服工作台两套视角
+
+## 核心能力
+
+- AI-first 客服流程:AI Agent 优先接待,支持自动回复、兜底与人工协同
+- 在线会话系统:支持访客会话、会话分配、转接、关闭、未读状态与实时消息
+- 知识库 RAG:支持知识库、文档、切片、检索日志与检索质量分析
+- 工单系统:支持会话转工单、工单分类、状态流转与处理闭环
+- 客服组织管理:支持客服档案、客服组、排班与分配能力
+- AI 扩展能力:支持 Skills、MCP 调试与外部能力接入
+- 双工作区体验:管理后台负责配置和运营,客服工作台负责处理会话
+
+## AI Agent 工作流程
+
+```mermaid
+flowchart TD
+ A[用户发起咨询
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:
+ modelType: llm
+ modelName: qwen3.5-plus
+ dimension: 0
+ maxContextTokens: 128000
+ maxOutputTokens: 4096
+ timeoutMs: 30000
+ maxRetryCount: 1
+ rpmLimit: 0
+ tpmLimit: 0
+ sortNo: 10
+ remark: LLM模型
+
+ - name: 向量模型
+ provider: openai
+ baseUrl: https://dashscope.aliyuncs.com/compatible-mode/v1
+ apiKey:
+ modelType: embedding
+ modelName: text-embedding-v4
+ dimension: 1536
+ maxContextTokens: 0
+ maxOutputTokens: 0
+ timeoutMs: 30000
+ maxRetryCount: 1
+ rpmLimit: 0
+ tpmLimit: 0
+ sortNo: 20
+ remark: 向量模型
+
+ - name: 重排模型
+ provider: openai
+ baseUrl: https://api.cohere.com/v1
+ apiKey:
+ modelType: rerank
+ modelName: qwen3-vl-rerank
+ dimension: 0
+ maxContextTokens: 0
+ maxOutputTokens: 0
+ timeoutMs: 30000
+ maxRetryCount: 1
+ rpmLimit: 0
+ tpmLimit: 0
+ sortNo: 30
+ remark: 重排模型
diff --git a/cmd/testdata/aiconfig/init.go b/cmd/testdata/aiconfig/init.go
new file mode 100644
index 0000000..aff3292
--- /dev/null
+++ b/cmd/testdata/aiconfig/init.go
@@ -0,0 +1,237 @@
+package aiconfig
+
+import (
+ "fmt"
+ "os"
+ "strings"
+ "time"
+
+ "cs-agent/internal/models"
+ "cs-agent/internal/pkg/constants"
+ "cs-agent/internal/pkg/enums"
+ "cs-agent/internal/repositories"
+
+ "github.com/mlogclub/simple/sqls"
+ "gopkg.in/yaml.v3"
+ "gorm.io/gorm"
+)
+
+const (
+ defaultSeedFilePath = "cmd/testdata/aiconfig/ai_config.yaml"
+ seedFileEnvKey = "TESTDATA_AI_CONFIG_FILE"
+)
+
+type SeedItem struct {
+ Name string `yaml:"name"`
+ Provider string `yaml:"provider"`
+ BaseURL string `yaml:"baseUrl"`
+ APIKey string `yaml:"apiKey"`
+ ModelType string `yaml:"modelType"`
+ ModelName string `yaml:"modelName"`
+ Dimension int `yaml:"dimension"`
+ MaxContextTokens int `yaml:"maxContextTokens"`
+ MaxOutputTokens int `yaml:"maxOutputTokens"`
+ TimeoutMS int `yaml:"timeoutMs"`
+ MaxRetryCount int `yaml:"maxRetryCount"`
+ RPMLimit int `yaml:"rpmLimit"`
+ TPMLimit int `yaml:"tpmLimit"`
+ SortNo int `yaml:"sortNo"`
+ Remark string `yaml:"remark"`
+}
+
+type InitResult struct {
+ FilePath string
+ Skipped bool
+ Created int
+ Updated int
+}
+
+type seedPayload struct {
+ Items []SeedItem `yaml:"items"`
+}
+
+func Init() (*InitResult, error) {
+ return InitFromFile(resolveSeedFilePath())
+}
+
+func InitFromFile(filePath string) (*InitResult, error) {
+ result := &InitResult{FilePath: filePath}
+
+ payload, err := loadSeedPayload(filePath)
+ if err != nil {
+ if os.IsNotExist(err) {
+ result.Skipped = true
+ return result, nil
+ }
+ return nil, err
+ }
+ if len(payload.Items) == 0 {
+ result.Skipped = true
+ return result, nil
+ }
+
+ err = sqls.WithTransaction(func(ctx *sqls.TxContext) error {
+ for _, item := range payload.Items {
+ created, upsertErr := upsertAIConfig(ctx.Tx, item)
+ if upsertErr != nil {
+ return upsertErr
+ }
+ if created {
+ result.Created++
+ } else {
+ result.Updated++
+ }
+ }
+ return nil
+ })
+ if err != nil {
+ return nil, err
+ }
+
+ return result, nil
+}
+
+func loadSeedPayload(filePath string) (*seedPayload, error) {
+ content, err := os.ReadFile(filePath)
+ if err != nil {
+ return nil, err
+ }
+ payload := &seedPayload{}
+ if err := yaml.Unmarshal(content, payload); err != nil {
+ return nil, fmt.Errorf("parse ai config seed file failed: %w", err)
+ }
+ return payload, nil
+}
+
+func resolveSeedFilePath() string {
+ if filePath := strings.TrimSpace(os.Getenv(seedFileEnvKey)); filePath != "" {
+ return filePath
+ }
+ return defaultSeedFilePath
+}
+
+func upsertAIConfig(db *gorm.DB, item SeedItem) (bool, error) {
+ model, err := buildAIConfigModel(item)
+ if err != nil {
+ return false, err
+ }
+
+ now := time.Now()
+ existing := repositories.AIConfigRepository.FindOne(db, sqls.NewCnd().Eq("name", model.Name).Eq("model_type", model.ModelType))
+ if existing == nil {
+ model.AuditFields = models.AuditFields{
+ CreatedAt: now,
+ CreateUserID: constants.SystemAuditUserID,
+ CreateUserName: constants.SystemAuditUserName,
+ UpdatedAt: now,
+ UpdateUserID: constants.SystemAuditUserID,
+ UpdateUserName: constants.SystemAuditUserName,
+ }
+ if err := repositories.AIConfigRepository.Create(db, model); err != nil {
+ return false, err
+ }
+ return true, nil
+ }
+
+ err = repositories.AIConfigRepository.Updates(db, existing.ID, map[string]any{
+ "provider": model.Provider,
+ "base_url": model.BaseURL,
+ "api_key": model.APIKey,
+ "model_name": model.ModelName,
+ "dimension": model.Dimension,
+ "max_context_tokens": model.MaxContextTokens,
+ "max_output_tokens": model.MaxOutputTokens,
+ "timeout_ms": model.TimeoutMS,
+ "max_retry_count": model.MaxRetryCount,
+ "rpm_limit": model.RPMLimit,
+ "tpm_limit": model.TPMLimit,
+ "status": model.Status,
+ "sort_no": model.SortNo,
+ "remark": model.Remark,
+ "update_user_id": constants.SystemAuditUserID,
+ "update_user_name": constants.SystemAuditUserName,
+ "updated_at": now,
+ })
+ if err != nil {
+ return false, err
+ }
+ return false, nil
+}
+
+func buildAIConfigModel(item SeedItem) (*models.AIConfig, error) {
+ name := strings.TrimSpace(item.Name)
+ if name == "" {
+ return nil, fmt.Errorf("ai config seed name is required")
+ }
+
+ provider, err := parseProvider(item.Provider)
+ if err != nil {
+ return nil, fmt.Errorf("ai config %s: %w", name, err)
+ }
+ modelType, err := parseModelType(item.ModelType)
+ if err != nil {
+ return nil, fmt.Errorf("ai config %s: %w", name, err)
+ }
+
+ baseURL := strings.TrimSpace(item.BaseURL)
+ if baseURL == "" {
+ return nil, fmt.Errorf("ai config %s: baseUrl is required", name)
+ }
+ modelName := strings.TrimSpace(item.ModelName)
+ if modelName == "" {
+ return nil, fmt.Errorf("ai config %s: modelName is required", name)
+ }
+
+ return &models.AIConfig{
+ Name: name,
+ Provider: provider,
+ BaseURL: baseURL,
+ APIKey: strings.TrimSpace(item.APIKey),
+ ModelType: modelType,
+ ModelName: modelName,
+ Dimension: normalizeNonNegative(item.Dimension),
+ MaxContextTokens: normalizeNonNegative(item.MaxContextTokens),
+ MaxOutputTokens: normalizeNonNegative(item.MaxOutputTokens),
+ TimeoutMS: defaultIfNotPositive(item.TimeoutMS, 30000),
+ MaxRetryCount: normalizeNonNegative(item.MaxRetryCount),
+ RPMLimit: normalizeNonNegative(item.RPMLimit),
+ TPMLimit: normalizeNonNegative(item.TPMLimit),
+ SortNo: normalizeNonNegative(item.SortNo),
+ Status: enums.StatusOk,
+ Remark: strings.TrimSpace(item.Remark),
+ }, nil
+}
+
+func parseProvider(raw string) (enums.AIProvider, error) {
+ provider := enums.AIProvider(strings.TrimSpace(raw))
+ switch provider {
+ case enums.AIProviderOpenAI:
+ return provider, nil
+ default:
+ return "", fmt.Errorf("unsupported provider: %s", raw)
+ }
+}
+
+func parseModelType(raw string) (enums.AIModelType, error) {
+ modelType := enums.AIModelType(strings.TrimSpace(raw))
+ switch modelType {
+ case enums.AIModelTypeLLM, enums.AIModelTypeEmbedding, enums.AIModelTypeRerank:
+ return modelType, nil
+ default:
+ return "", fmt.Errorf("unsupported modelType: %s", raw)
+ }
+}
+
+func normalizeNonNegative(value int) int {
+ if value < 0 {
+ return 0
+ }
+ return value
+}
+
+func defaultIfNotPositive(value int, def int) int {
+ if value > 0 {
+ return value
+ }
+ return def
+}
diff --git a/cmd/testdata/channel/init.go b/cmd/testdata/channel/init.go
new file mode 100644
index 0000000..f853b37
--- /dev/null
+++ b/cmd/testdata/channel/init.go
@@ -0,0 +1,89 @@
+package channel
+
+import (
+ "cs-agent/internal/models"
+ "cs-agent/internal/pkg/enums"
+ "cs-agent/internal/repositories"
+ "fmt"
+ "time"
+
+ "github.com/mlogclub/simple/common/strs"
+ "github.com/mlogclub/simple/sqls"
+)
+
+type InitResult struct {
+ Created int
+ Updated int
+}
+
+// Init 初始化 Channel 测试数据。
+// 依赖于 AI Agent 已初始化。
+func Init() (*InitResult, error) {
+ result := &InitResult{}
+
+ aiAgentID, err := getDefaultAIAgentID()
+ if err != nil {
+ return result, fmt.Errorf("get default ai agent id failed: %w", err)
+ }
+ if aiAgentID == 0 {
+ return result, fmt.Errorf("no default ai agent found, please init ai agent first")
+ }
+
+ seedItems := buildSeedItems(aiAgentID)
+ for _, item := range seedItems {
+ itemCopy := item
+ if err := sqls.WithTransaction(func(ctx *sqls.TxContext) error {
+ existing := repositories.ChannelRepository.Take(ctx.Tx, "name = ? AND channel_type = ?", itemCopy.Name, itemCopy.ChannelType)
+ 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 channel failed: %w", err)
+ }
+ }
+
+ return result, nil
+}
+
+func buildSeedItems(aiAgentID int64) []models.Channel {
+ now := time.Now()
+ return []models.Channel{
+ {
+ Name: "官网客服",
+ ChannelType: enums.ChannelTypeWeb,
+ ChannelID: strs.UUID(),
+ AIAgentID: aiAgentID,
+ Status: enums.StatusOk,
+ Remark: "Local testdata seed",
+ AuditFields: models.AuditFields{
+ CreatedAt: now,
+ CreateUserID: 0,
+ CreateUserName: "System",
+ UpdatedAt: now,
+ UpdateUserID: 0,
+ UpdateUserName: "System",
+ },
+ },
+ }
+}
+
+func getDefaultAIAgentID() (int64, error) {
+ aiAgent := repositories.AIAgentRepository.Take(
+ sqls.DB(),
+ "status = ?",
+ enums.StatusOk,
+ )
+ if aiAgent == nil {
+ return 0, nil
+ }
+ return aiAgent.ID, nil
+}
diff --git a/cmd/testdata/kb/README.md b/cmd/testdata/kb/README.md
new file mode 100644
index 0000000..931c353
--- /dev/null
+++ b/cmd/testdata/kb/README.md
@@ -0,0 +1,4 @@
+# 将四大名著初始化为知识库数据
+
+内容来源于:
+
diff --git a/cmd/testdata/kb/kb.go b/cmd/testdata/kb/kb.go
new file mode 100644
index 0000000..a11428e
--- /dev/null
+++ b/cmd/testdata/kb/kb.go
@@ -0,0 +1,591 @@
+package kb
+
+import (
+ "bytes"
+ "cs-agent/internal/models"
+ "cs-agent/internal/pkg/constants"
+ "cs-agent/internal/pkg/enums"
+ "cs-agent/internal/repositories"
+ "encoding/json"
+ "fmt"
+ "os"
+ "path/filepath"
+ "strings"
+ "time"
+
+ "github.com/mlogclub/simple/sqls"
+ "golang.org/x/net/html"
+ "gorm.io/gorm"
+)
+
+type Chapter struct {
+ Title string
+ Link string
+ Content string
+}
+
+type InitResult struct {
+ DocumentKnowledgeBaseID int64
+ FAQKnowledgeBaseID int64
+ TotalChapters int
+ CreatedDocuments int
+ UpdatedDocuments int
+ TotalFAQs int
+ CreatedFAQs int
+ UpdatedFAQs int
+}
+
+type faqSeed struct {
+ Question string
+ Answer string
+ SimilarQuestions []string
+ Remark string
+}
+
+func Init() (*InitResult, error) {
+ chapters, err := read("水浒传")
+ if err != nil {
+ return nil, fmt.Errorf("read books failed: %w", err)
+ }
+
+ if len(chapters) == 0 {
+ return nil, fmt.Errorf("no chapters found")
+ }
+
+ faqSeeds := knowledgeFAQSeeds()
+ result := &InitResult{
+ TotalChapters: len(chapters),
+ TotalFAQs: len(faqSeeds),
+ }
+ err = sqls.WithTransaction(func(ctx *sqls.TxContext) error {
+ kbModel, ensureErr := ensureKnowledgeBase(ctx.Tx)
+ if ensureErr != nil {
+ return ensureErr
+ }
+ result.DocumentKnowledgeBaseID = kbModel.ID
+
+ for _, chapter := range chapters {
+ created, upsertErr := upsertKnowledgeDocument(ctx.Tx, kbModel.ID, chapter)
+ if upsertErr != nil {
+ return upsertErr
+ }
+ if created {
+ result.CreatedDocuments++
+ } else {
+ result.UpdatedDocuments++
+ }
+ }
+
+ faqKnowledgeBase, ensureFAQErr := ensureFAQKnowledgeBase(ctx.Tx)
+ if ensureFAQErr != nil {
+ return ensureFAQErr
+ }
+ result.FAQKnowledgeBaseID = faqKnowledgeBase.ID
+
+ for _, faq := range faqSeeds {
+ created, upsertErr := upsertKnowledgeFAQ(ctx.Tx, faqKnowledgeBase.ID, faq)
+ if upsertErr != nil {
+ return upsertErr
+ }
+ if created {
+ result.CreatedFAQs++
+ } else {
+ result.UpdatedFAQs++
+ }
+ }
+ return nil
+ })
+ if err != nil {
+ return nil, err
+ }
+
+ return result, nil
+}
+
+func ensureKnowledgeBase(db *gorm.DB) (*models.KnowledgeBase, error) {
+ now := time.Now()
+ item := repositories.KnowledgeBaseRepository.FindOne(db, sqls.NewCnd().Eq("name", "水浒传"))
+ if item == nil {
+ item = &models.KnowledgeBase{
+ Name: "水浒传",
+ Description: "四大名著测试数据",
+ Status: enums.StatusOk,
+ DefaultTopK: 10,
+ DefaultScoreThreshold: 0.2,
+ DefaultRerankLimit: 5,
+ ChunkProvider: string(enums.KnowledgeChunkProviderStructured),
+ ChunkTargetTokens: 300,
+ ChunkMaxTokens: 400,
+ ChunkOverlapTokens: 40,
+ AnswerMode: int(enums.KnowledgeAnswerModeStrict),
+ FallbackMode: int(enums.KnowledgeFallbackModeNoAnswer),
+ AuditFields: models.AuditFields{
+ CreatedAt: now,
+ CreateUserID: constants.SystemAuditUserID,
+ CreateUserName: constants.SystemAuditUserName,
+ UpdatedAt: now,
+ UpdateUserID: constants.SystemAuditUserID,
+ UpdateUserName: constants.SystemAuditUserName,
+ },
+ }
+ if err := repositories.KnowledgeBaseRepository.Create(db, item); err != nil {
+ return nil, err
+ }
+ return item, nil
+ }
+
+ err := repositories.KnowledgeBaseRepository.Updates(db, item.ID, map[string]any{
+ "description": "四大名著测试数据",
+ "status": enums.StatusOk,
+ "chunk_provider": string(enums.KnowledgeChunkProviderStructured),
+ "chunk_target_tokens": 300,
+ "chunk_max_tokens": 400,
+ "chunk_overlap_tokens": 40,
+ "update_user_id": constants.SystemAuditUserID,
+ "update_user_name": constants.SystemAuditUserName,
+ "updated_at": now,
+ })
+ if err != nil {
+ return nil, err
+ }
+ return repositories.KnowledgeBaseRepository.Get(db, item.ID), nil
+}
+
+func ensureFAQKnowledgeBase(db *gorm.DB) (*models.KnowledgeBase, error) {
+ now := time.Now()
+ item := repositories.KnowledgeBaseRepository.FindOne(db, sqls.NewCnd().Eq("name", "贝壳客服平台 FAQ"))
+ if item == nil {
+ item = &models.KnowledgeBase{
+ Name: "贝壳客服平台 FAQ",
+ Description: "模拟真实客服场景的 FAQ 测试数据,覆盖账号、坐席、机器人、知识库、工单、计费与发票等常见问题。",
+ KnowledgeType: string(enums.KnowledgeBaseTypeFAQ),
+ Status: enums.StatusOk,
+ DefaultTopK: 8,
+ DefaultScoreThreshold: 0.35,
+ DefaultRerankLimit: 5,
+ ChunkProvider: string(enums.KnowledgeChunkProviderFAQ),
+ ChunkTargetTokens: 0,
+ ChunkMaxTokens: 0,
+ ChunkOverlapTokens: 0,
+ AnswerMode: int(enums.KnowledgeAnswerModeStrict),
+ FallbackMode: int(enums.KnowledgeFallbackModeNoAnswer),
+ Remark: "测试数据初始化自动生成",
+ AuditFields: models.AuditFields{
+ CreatedAt: now,
+ CreateUserID: constants.SystemAuditUserID,
+ CreateUserName: constants.SystemAuditUserName,
+ UpdatedAt: now,
+ UpdateUserID: constants.SystemAuditUserID,
+ UpdateUserName: constants.SystemAuditUserName,
+ },
+ }
+ if err := repositories.KnowledgeBaseRepository.Create(db, item); err != nil {
+ return nil, err
+ }
+ return item, nil
+ }
+
+ err := repositories.KnowledgeBaseRepository.Updates(db, item.ID, map[string]any{
+ "description": "模拟真实客服场景的 FAQ 测试数据,覆盖账号、坐席、机器人、知识库、工单、计费与发票等常见问题。",
+ "knowledge_type": string(enums.KnowledgeBaseTypeFAQ),
+ "status": enums.StatusOk,
+ "default_top_k": 8,
+ "default_score_threshold": 0.35,
+ "default_rerank_limit": 5,
+ "chunk_provider": string(enums.KnowledgeChunkProviderFAQ),
+ "chunk_target_tokens": 0,
+ "chunk_max_tokens": 0,
+ "chunk_overlap_tokens": 0,
+ "answer_mode": int(enums.KnowledgeAnswerModeStrict),
+ "fallback_mode": int(enums.KnowledgeFallbackModeNoAnswer),
+ "remark": "测试数据初始化自动生成",
+ "update_user_id": constants.SystemAuditUserID,
+ "update_user_name": constants.SystemAuditUserName,
+ "updated_at": now,
+ })
+ if err != nil {
+ return nil, err
+ }
+ return repositories.KnowledgeBaseRepository.Get(db, item.ID), nil
+}
+
+func upsertKnowledgeDocument(db *gorm.DB, knowledgeBaseID int64, chapter Chapter) (bool, error) {
+ now := time.Now()
+ item := repositories.KnowledgeDocumentRepository.FindOne(db, sqls.NewCnd().Eq("knowledge_base_id", knowledgeBaseID).Eq("title", chapter.Title))
+ if item == nil {
+ item = &models.KnowledgeDocument{
+ KnowledgeBaseID: knowledgeBaseID,
+ Title: chapter.Title,
+ ContentType: enums.KnowledgeDocumentContentTypeMarkdown,
+ Content: chapter.Content,
+ Status: enums.StatusOk,
+ AuditFields: models.AuditFields{
+ CreatedAt: now,
+ CreateUserID: constants.SystemAuditUserID,
+ CreateUserName: constants.SystemAuditUserName,
+ UpdatedAt: now,
+ UpdateUserID: constants.SystemAuditUserID,
+ UpdateUserName: constants.SystemAuditUserName,
+ },
+ }
+ if err := repositories.KnowledgeDocumentRepository.Create(db, item); err != nil {
+ return false, err
+ }
+ return true, nil
+ }
+
+ err := repositories.KnowledgeDocumentRepository.Updates(db, item.ID, map[string]any{
+ "content_type": enums.KnowledgeDocumentContentTypeMarkdown,
+ "content": chapter.Content,
+ "status": enums.StatusOk,
+ "update_user_id": constants.SystemAuditUserID,
+ "update_user_name": constants.SystemAuditUserName,
+ "updated_at": now,
+ })
+ if err != nil {
+ return false, err
+ }
+ return false, nil
+}
+
+func upsertKnowledgeFAQ(db *gorm.DB, knowledgeBaseID int64, seed faqSeed) (bool, error) {
+ now := time.Now()
+ similarQuestions, err := json.Marshal(seed.SimilarQuestions)
+ if err != nil {
+ return false, err
+ }
+
+ item := repositories.KnowledgeFAQRepository.Find(db, sqls.NewCnd().
+ Eq("knowledge_base_id", knowledgeBaseID).
+ Eq("question", seed.Question))
+ if len(item) == 0 {
+ faq := &models.KnowledgeFAQ{
+ KnowledgeBaseID: knowledgeBaseID,
+ Question: seed.Question,
+ Answer: seed.Answer,
+ SimilarQuestions: string(similarQuestions),
+ Status: enums.StatusOk,
+ IndexStatus: enums.KnowledgeDocumentIndexStatusPending,
+ Remark: seed.Remark,
+ AuditFields: models.AuditFields{
+ CreatedAt: now,
+ CreateUserID: constants.SystemAuditUserID,
+ CreateUserName: constants.SystemAuditUserName,
+ UpdatedAt: now,
+ UpdateUserID: constants.SystemAuditUserID,
+ UpdateUserName: constants.SystemAuditUserName,
+ },
+ }
+ if err := repositories.KnowledgeFAQRepository.Create(db, faq); err != nil {
+ return false, err
+ }
+ return true, nil
+ }
+
+ if err := repositories.KnowledgeFAQRepository.Updates(db, item[0].ID, map[string]any{
+ "answer": seed.Answer,
+ "similar_questions": string(similarQuestions),
+ "status": enums.StatusOk,
+ "index_status": enums.KnowledgeDocumentIndexStatusPending,
+ "indexed_at": nil,
+ "index_error": "",
+ "remark": seed.Remark,
+ "update_user_id": constants.SystemAuditUserID,
+ "update_user_name": constants.SystemAuditUserName,
+ "updated_at": now,
+ }); err != nil {
+ return false, err
+ }
+ return false, nil
+}
+
+func knowledgeFAQSeeds() []faqSeed {
+ return []faqSeed{
+ {Question: "登录后台时提示账号或密码错误怎么办?", Answer: "请先确认账号是否输入完整,邮箱登录要区分公司域名,密码需注意大小写。如果连续输错 5 次,系统会临时锁定 15 分钟。仍无法登录时,可让企业管理员在“组织设置-成员管理”中重置密码,或通过登录页的“忘记密码”重新设置。", SimilarQuestions: []string{"后台密码错误怎么处理", "账号登录不上去怎么办", "提示用户名或密码错误"}, Remark: "账号登录"},
+ {Question: "忘记登录密码后怎么找回?", Answer: "在登录页点击“忘记密码”,输入绑定邮箱后获取验证码即可重置密码。如果账号未绑定邮箱或邮箱无法使用,请联系企业管理员协助重置。为了安全,重置成功后系统会让该账号在所有设备重新登录。", SimilarQuestions: []string{"忘记密码了怎么办", "怎么重置后台密码", "找回登录密码"}, Remark: "账号登录"},
+ {Question: "为什么收不到登录验证码邮件?", Answer: "请先检查垃圾邮箱、广告邮件和企业邮箱的安全隔离区。若 5 分钟内仍未收到,建议确认邮箱地址是否填写正确,并联系企业 IT 将平台发信域名加入白名单。如果多次重发都未收到,可能是该邮箱服务商限流,建议改用备用邮箱。", 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: "支持对接企业微信、钉钉和标准 SAML/OIDC 单点登录。开通后成员可通过企业身份系统直接登录平台,无需单独维护密码。具体配置需要管理员在“安全设置-单点登录”中填写回调地址、应用凭证并完成测试。", SimilarQuestions: []string{"能接企业微信登录吗", "支持钉钉 SSO 吗", "单点登录怎么接"}, Remark: "成员管理"},
+ {Question: "坐席在线、忙碌、离线状态有什么区别?", Answer: "在线表示可正常接待新会话,忙碌表示当前暂不分配新会话但仍可处理已有会话,离线表示不参与会话分配也不接收实时提醒。若开启自动状态切换,长时间无操作或退出登录后,系统会自动变更为离线。", SimilarQuestions: []string{"客服状态怎么理解", "在线忙碌离线区别", "坐席状态说明"}, Remark: "坐席接待"},
+ {Question: "会话是怎么分配给坐席的?", Answer: "默认按技能组和轮询策略分配,也可结合坐席当前负载、最近响应时长和优先级规则进行智能分流。若客户命中了指定渠道、语言或标签条件,系统会优先路由到匹配该条件的团队或坐席。", SimilarQuestions: []string{"客户咨询怎么分配", "会话路由规则是什么", "新会话按什么分给客服"}, Remark: "坐席接待"},
+ {Question: "如何把会话转接给其他团队?", Answer: "在会话详情页点击“转接”,选择目标团队或指定坐席,并填写转接备注即可。转接后,原坐席仍可在历史记录中查看会话内容,但新消息会优先提醒接收方。若目标团队离线人数过多,建议先确认有人值班。", SimilarQuestions: []string{"会话怎么转给别的组", "转接客服在哪里", "咨询如何分配给其他团队"}, Remark: "坐席接待"},
+ {Question: "客户长时间不回复,会话会自动结束吗?", Answer: "可以。在“接待设置-会话规则”里可配置无响应超时时间,例如客户 30 分钟未回复后自动关闭会话。关闭后客户再次发消息会新建会话,历史内容仍可在客户轨迹中查看。", SimilarQuestions: []string{"会话会自动关闭吗", "多久不回复算结束", "无响应自动结束如何设置"}, Remark: "坐席接待"},
+ {Question: "坐席最大并发接待数在哪里设置?", Answer: "管理员可在“接待设置-分配规则”中设置团队级默认并发数,也可以在成员详情中为个别坐席单独调整。建议新手坐席从 3 到 5 路并发开始,成熟团队再根据平均处理时长逐步放开。", SimilarQuestions: []string{"并发接待数怎么改", "一个客服最多接多少会话", "坐席并发设置"}, Remark: "坐席接待"},
+ {Question: "如何查看客服首响应时长和平均处理时长?", Answer: "进入“数据报表-会话分析”,可按团队、坐席、渠道和日期查看首响应时长、平均响应时长、平均处理时长和超时占比。报表支持导出 CSV,便于做班次复盘或绩效分析。", SimilarQuestions: []string{"首响时长在哪里看", "客服处理时长报表", "怎么查响应效率"}, Remark: "坐席接待"},
+ {Question: "客户排队时能看到前面还有多少人吗?", Answer: "支持。你可以在 Web 小部件或 H5 咨询页里开启“排队展示”,客户会看到预估等待人数和等待时长。若不希望暴露具体排队数据,也可以只展示“当前咨询较多,请耐心等待”的文案。", SimilarQuestions: []string{"排队人数能展示吗", "客户能看到等待人数吗", "排队提示怎么设置"}, Remark: "坐席接待"},
+ {Question: "机器人什么时候会接管会话?", Answer: "当渠道启用了 AI 机器人优先接待,且用户消息命中机器人的服务时段、语言和业务范围时,会先由机器人回复。若客户主动要求人工、命中转人工规则,或机器人连续多轮未命中知识,系统会自动切换到人工。", SimilarQuestions: []string{"机器人接待规则是什么", "什么情况下先走 AI", "机器人何时转人工"}, Remark: "AI机器人"},
+ {Question: "机器人回答不准时应该先调哪里?", Answer: "优先检查知识库是否覆盖了该问题、FAQ 表述是否过于口语化、召回阈值是否设得过高,以及是否开启了错误的知识库。若命中很多但答案仍跑偏,再看模型提示词、回答模式和重排策略。", SimilarQuestions: []string{"AI 回复不准确怎么办", "机器人答非所问怎么调", "先检查哪些配置"}, Remark: "AI机器人"},
+ {Question: "怎么限制机器人只回答售后相关问题?", Answer: "可以给该机器人绑定专门的售后知识库,并在意图路由里限定只处理售后渠道、售后入口或带售后标签的会话。同时建议把兜底策略设为“引导转人工”,避免机器人回答超出范围的问题。", SimilarQuestions: []string{"只让机器人回答售后", "机器人回答范围怎么限制", "怎么绑定指定业务"}, Remark: "AI机器人"},
+ {Question: "机器人可以识别情绪激动的客户并转人工吗?", Answer: "可以。你可以在策略中配置敏感词、情绪标签和高风险场景,例如投诉、退款争议、辱骂等。一旦命中条件,系统会立即转人工并在会话顶部提示“高风险用户”,方便优先接待。", SimilarQuestions: []string{"负面情绪自动转人工", "投诉用户能自动升级吗", "敏感客户转人工"}, Remark: "AI机器人"},
+ {Question: "AI 生成答案时会引用知识来源吗?", Answer: "支持。在开启知识引用后,机器人会在调试信息或内部坐席辅助面板中展示命中的知识条目、相似度分数和引用片段。面向终端用户是否展示引用,可按渠道单独控制。", SimilarQuestions: []string{"AI 能显示知识来源吗", "答案引用在哪里看", "机器人回复能带出处吗"}, Remark: "AI机器人"},
+ {Question: "如何测试机器人而不影响线上客户?", Answer: "建议使用“调试问答”或专门的测试渠道。测试渠道可以绑定同一套知识库和机器人配置,但不进入正式报表,也不会分配给真实坐席。上线前至少验证高频问题、异常问法和兜底流程。", SimilarQuestions: []string{"怎么做机器人测试", "测试 AI 会不会影响线上", "有没有沙箱渠道"}, Remark: "AI机器人"},
+ {Question: "机器人支持多语言回复吗?", Answer: "支持中文、英文及其他已接入模型支持的语种,但前提是知识库内容本身有对应语言版本。若只上传中文知识,机器人对英文问题通常无法稳定给出英文业务答案,建议按语言拆分知识库。", SimilarQuestions: []string{"AI 支持英文吗", "机器人能多语言吗", "英文客户怎么接待"}, Remark: "AI机器人"},
+ {Question: "机器人能读取客户订单信息后再回答吗?", Answer: "可以,但需要先对接业务系统接口,并在机器人工具能力中开放“查询订单”“查询物流”等受控动作。建议只返回回答所需的最小字段,并记录调用日志,避免暴露不必要的隐私信息。", SimilarQuestions: []string{"AI 能查订单吗", "机器人结合业务数据回答", "怎么让机器人调用接口"}, Remark: "AI机器人"},
+ {Question: "知识库和 FAQ 知识库有什么区别?", Answer: "文档知识库更适合长文档、帮助中心、制度说明等内容,系统会做分段切片后检索;FAQ 知识库适合标准问答,一问一答更利于高频咨询命中。若内容天然就是固定问法和固定口径,优先用 FAQ。", SimilarQuestions: []string{"FAQ 知识库和文档库区别", "什么场景用 FAQ", "知识库类型怎么选"}, Remark: "知识库"},
+ {Question: "上传帮助文档后多久可以被机器人检索到?", Answer: "通常上传成功后会进入索引队列,文本量不大时 1 到 3 分钟内即可完成。若文档很长、图片较多或当前索引任务拥堵,时间会更久。你可以在知识库列表里查看索引状态。", SimilarQuestions: []string{"文档多久生效", "知识库上传后多久可用", "索引需要多久"}, Remark: "知识库"},
+ {Question: "FAQ 支持批量导入吗?", Answer: "支持。你可以下载模板后按“问题、答案、相似问、备注”四列填写,再通过 FAQ 页面批量导入。相似问建议用换行或分号分隔,导入前最好先去重,避免同一问题出现多个口径。", SimilarQuestions: []string{"FAQ 能批量上传吗", "FAQ 导入模板在哪", "问答怎么批量导入"}, Remark: "知识库"},
+ {Question: "为什么上传的文档显示索引失败?", Answer: "常见原因包括文档为空、正文被复制成图片、内容长度超限、向量服务不可用或模型配置异常。可以先查看索引错误提示,再尝试把文档转换成纯文本或 Markdown 重新上传。", SimilarQuestions: []string{"文档索引失败怎么办", "知识库处理失败原因", "上传后报索引错误"}, Remark: "知识库"},
+ {Question: "如何让一个机器人只使用指定知识库?", Answer: "在机器人配置页里把“可用知识库”限制为目标知识库即可。若同时勾选多个知识库,系统会按统一策略检索。为避免跨业务串答,建议按产品线、渠道或场景拆分知识库并独立绑定。", SimilarQuestions: []string{"机器人绑定知识库", "只用一个知识库回答", "知识库范围怎么限制"}, Remark: "知识库"},
+ {Question: "知识库内容更新后需要手动重建索引吗?", Answer: "平台默认会在你更新文档或 FAQ 后自动重新索引,不需要手动全量重建。只有在向量配置、分块策略或模型发生较大变化时,才建议执行一次全量重建以保证检索效果一致。", SimilarQuestions: []string{"更新文档后要重建吗", "知识库修改会自动生效吗", "FAQ 改完要重新索引吗"}, Remark: "知识库"},
+ {Question: "相似问应该怎么写效果更好?", Answer: "建议保留用户真实提问方式,包括口语、省略句和错别字变体,但不要为了凑数量写过多语义无关的问法。每条 FAQ 一般补 3 到 8 个高频相似问即可,重点覆盖客户常见表达。", SimilarQuestions: []string{"相似问怎么整理", "FAQ 别名怎么写", "类似问题怎么配"}, Remark: "知识库"},
+ {Question: "知识库支持权限隔离吗?", Answer: "支持。你可以按团队、角色或机器人配置知识库可见范围。比如售后团队只能看到售后知识库,财务 FAQ 只对财务机器人和财务坐席开放,这样可以减少误检索和越权查看。", SimilarQuestions: []string{"知识库能分权限吗", "不同团队看不同知识", "FAQ 可以隔离吗"}, Remark: "知识库"},
+ {Question: "网站咨询按钮怎么嵌入到官网?", Answer: "进入“渠道接入-Web Widget”,复制系统生成的脚本代码,粘贴到官网页面的 `
第一回 张天师祈禳瘟疫 洪太尉误走妖魔 原文
诗曰:
绛帻鸡人报晓筹,尚衣方进翠云裘。九天阊阖开宫殿,万国衣冠拜冕旒。
日色才临仙掌动,香烟欲傍衮龙浮。朝罢须裁五色诏,佩声归到凤池头。
话说大宋仁宗天子在位,嘉祐三年三月三日五更三点,天子驾坐紫宸殿,受百官朝贺。但见:
祥云迷凤阁,瑞气罩龙楼。含烟御柳拂旌旗,带露宫花迎剑戟。天香影里,玉簪珠履聚丹墀;仙乐声中,绣袄锦衣扶御驾。珍珠帘卷,黄金殿上现金舆;凤尾扇开,白玉阶前停宝辇。隐隐净鞭三下响,层层文武两班齐。
当有殿头官喝道:“有事出班早奏,无事卷帘退朝。”只见班部丛中,宰相赵哲、参政文彦博出班奏曰:“目今京师瘟疫盛行,民不聊生,伤损军民多矣。伏望陛下释罪宽恩,省刑薄税,以禳天灾,救济万民。”天子听奏,急敕翰林院随即草诏:一面降赦天下罪囚,应有民间税赋悉皆赦免;一面命在京宫观寺院,修设好事禳灾。不料其年瘟疫转盛。仁宗天子闻知,龙体不安。复会百官,众皆计议。向那班部中,有一大臣越班启奏。天子看时,乃是参知政事范仲淹。拜罢起居,奏曰:“目今天灾盛行,军民涂炭,日夕不能聊生,人遭缧绁之厄。以臣愚意,要禳此灾,可宣嗣汉天师星夜临朝,就京师禁院修设三千六百分罗天大醮,奏闻上帝,可以禳保民间瘟疫。”仁宗天子准奏。急令翰林学士草诏一道,天子御笔亲书,并降御香一炷,钦差内外提点殿前太尉洪信为天使,前往江西信州龙虎山,宣请嗣汉天师张真人星夜临朝,祈禳瘟疫。就金殿上焚起御香,亲将丹诏付与洪太尉为使,即便登程前去。
洪信领了圣敕,辞别天子,不敢久停。从人背了诏书,金盒子盛了御香,带了数十人,上了铺马,一行部从,离了东京,取路径投信州贵溪县来。于路上但见:
遥山叠翠,远水澄清。奇花绽锦绣铺林,嫩柳舞金丝拂地。风和日暖,时过野店山村;路直沙平,夜宿邮亭驿馆。罗衣荡漾红尘内,骏马驱驰紫陌中。
且说太尉洪信赍擎御书丹诏,一行人从上了路途,夜宿邮亭,朝行驿站,远程近接,渴饮饥餐,不止一日,来到江西信州。大小官员出郭迎接,随即差人报知龙虎山上清宫住持道众,准备接诏。次日,众位官同送太尉到于龙虎山下。只见上清宫许多道众,鸣钟击鼓,香花灯烛,幢幡宝盖,一派仙乐,都下山来迎接丹诏,直至上清宫前下马。太尉看那宫殿时,端的是好座上清宫。但见:
青松屈曲,翠柏阴森。门悬敕额金书,户列灵符玉篆。虚皇坛畔,依稀垂柳名花;炼药炉边,掩映苍松老桧。左壁厢天丁力士,参随着太乙真君;右势下玉女金童,簇捧定紫微大帝。披发仗剑,北方真武踏龟蛇;靸履顶冠,南极老人伏龙虎。前排二十八宿星君,后列三十二帝天子。阶砌下流水潺湲,墙院后好山环绕。鹤生丹顶,龟长绿毛。树梢头献果苍猿,莎草内衔芝白鹿。三清殿上鸣金钟,道士步虚;四圣堂前敲玉磬,真人礼斗。献香台砌,彩霞光射碧琉璃;召将瑶坛,赤日影摇红玛瑙。早来门外祥云现,疑是天师送老君。
当下上至住持真人,下及道童侍从,前迎后引,接至三清殿上,请将诏书,居中供养着。洪太尉便问监宫真人道:“天师今在何处?”住持真人向前禀道:“好教太尉得知:这代祖师号曰‘虚靖天师’,性好清高,倦于迎送,自向龙虎山顶,结一茅庵,修真养性。因此不住本宫。”太尉道:“目今天子宣诏,如何得见?”真人答道:“容禀:诏敕权供在殿上,贫道等亦不敢开读。且请太尉到方丈献茶,再烦计议。”当时将丹诏供养在三清殿上,与众官都到方丈。太尉居中坐下,执事人等献茶,就进斋供,水陆俱备。斋罢,太尉再问真人道:“既然天师在山顶庵中,何不着人请将下来相见,开宣丹诏?”真人禀道:“太尉,这代祖师虽在山顶,其实道行非常,清高自在,倦惹凡尘。能驾雾兴云,踪迹不定,未尝下山。贫道等如常亦难得见,怎生教人请得下来!”太尉道:“似此如何得见!目今京师瘟疫盛行,今上天子特遣下官为使,赍捧御书丹诏,亲奉龙香,来请天师,要做三千六百分罗天大醮,以禳天灾,救济万民。似此怎生奈何?”真人禀道:“朝廷天子要救万民,只除是太尉办一点志诚心,斋戒沐浴,更换布衣,休带从人,自背诏书,焚烧御香,步行上山礼拜,叩请天师,方许得见。如若心不志诚,空走一遭,亦难得见。”太尉听说便道:“俺从京师食素到此,如何心不志诚!既然恁地,依着你说,明日绝早上山。”当晚各自权歇。
次日五更时分,众道士起来,备下香汤斋供。请太尉起来,香汤沐浴,换了一身新鲜布衣,脚下穿上麻鞋草履,吃了素斋,取过丹诏,用黄罗包袱背在脊梁上,手里提着银手炉,降降地烧着御香。许多道众人等,送到后山,指与路径。真人又禀道:“太尉要救万民,休生退悔之心,只顾志诚上去。”太尉别了众人,口诵天尊宝号,纵步上山来。将至半山,望见大顶直侵霄汉,果然好座大山。正是:
根盘地角,顶接天心。远观磨断乱云痕,近看平吞明月魄。高低不等谓之山,侧石通道谓之岫,孤岭崎岖谓之路,上面极平谓之顶,头圆下壮谓之峦,隐虎藏豹谓之穴,隐风隐云谓之岩,高人隐居谓之洞,有境有界谓之府,樵人出没谓之径,能通车马谓之道,流水有声谓之涧,古渡源头谓之溪,岩崖滴水谓之泉。左壁为掩,右壁为映。出的是云,纳的是雾。锥尖象小,崎峻似峭,悬空似险,削如平。千峰竞秀,万壑争流。瀑布斜飞,藤萝倒挂。虎啸时风生谷口,猿啼时月坠山腰。恰似青黛染成千块玉,碧纱笼罩万堆烟。
这洪太尉独自一个,行了一回,盘坡转径,揽葛攀藤。约莫走过了数个山头,三二里多路,看看脚酸腿软,正走不动,口里不说,肚里踌躇,心中想道:“我是朝廷贵官公子,在京师时重茵而卧,列鼎而食,尚兀自倦怠;何曾穿草鞋,走这般山路!知他天师在那里,却教下官受这般苦!”又行不到三五十步,掇着肩气喘。只见山凹里起一阵风,风过处,向那松树背后奔雷也似吼一声,扑地跳出一个吊睛白额锦毛大虫来。洪太尉吃了一惊,叫声:“阿呀!”扑地望后便倒。偷眼看那大虫时,但见:
毛披一带黄金色,爪露银钩十八只。睛如闪电尾如鞭,口似血盆牙似戟。
伸腰展臂势狰狞,摆尾摇头声霹雳。山中狐兔尽潜藏,涧下獐狍皆敛迹。
那大虫望着洪太尉,左盘右旋,咆哮了一回,托地望后山坡下跳了去。洪太尉倒在树根底下,唬的三十六个牙齿捉对儿厮打,那心头一似十五个吊桶,七上八落的响,浑身却如重风麻木,两腿一似斗败公鸡,口里连声叫苦。大虫去了一盏茶时,方才爬将起来,再收拾地上香炉,还把龙香烧着,再上山来,务要寻见天师。又行过三五十步,口里叹了数口气,怨道:皇帝御限,差俺来这里,教我受这场惊恐。”说犹未了,只觉得那里又一阵风,吹得毒气直冲将来。太尉定睛看时,山边竹藤里簌簌地响,抢出一条吊桶大小、雪花也似蛇来。太尉见了,又吃一惊,撇了手炉,叫一声:“我今番死也!”望后便倒在盘砣石边。微闪开眼来看那蛇时,但见:
昂首惊飙起,掣目电光生。动荡则折峡倒冈,呼吸则吹云吐雾。鳞甲乱分千片玉,尾梢斜卷一堆银。
那条大蛇径抢到盘砣石边,朝着洪太尉盘做一堆,两只眼迸出金光,张开巨口,吐出舌头,喷那毒气在洪太尉脸上。惊得太尉三魂荡荡,七魄悠悠。那蛇看了洪太尉一回,望山下一溜,却早不见了。太尉方才爬得起来,说道:“惭愧!惊杀下官!”看身上时,寒粟子比馉饳儿大小。口里骂那道士:“叵耐无礼,戏弄下官,教俺受这般惊恐!若山上寻不见天师,下去和他别有话说。”再拿了银提炉,整顿身上诏敕并衣服巾帻,却待再要上山去。正欲移步,只听得松树背后隐隐地笛声吹响,渐渐近来。太尉定睛看时,只见那一个道童,倒骑着一头黄牛,横吹着一管铁笛,转出山凹来。太尉看那道童时,但见:
头绾两枚丫髻,身穿一领青衣;腰间绦结草来编,脚下芒鞋麻间隔。明眸皓齿,飘飘并不染尘埃;绿鬓朱颜,耿耿全然无俗态。
昔日吕洞宾有首牧童诗道得好:
草铺横野六七里,笛弄晚风三四声。归来饱饭黄昏后,不脱蓑衣卧月明。
只见那个道童,笑吟吟地骑着黄牛,横吹着那管铁笛,正过山来。洪太尉见了,便唤那个道童:“你从那里来?认得我么?”道童不采,只顾吹笛。太尉连问数声,道童呵呵大笑,拿着铁笛,指着洪太尉说道:“你来此间,莫非要见天师么?”太尉大惊,便道:“你是牧童,如何得知?”道童笑说:“我早间在草庵中伏侍天师,听得天师说道:‘朝中今上仁宗天子,差个洪太尉赍擎丹诏御香,到来山中,宣我往东京做三千六百分罗天大醮,祈禳天下瘟疫。我如今乘鹤驾云去也。’这早晚想是去了,不在庵中。你休上去,山内毒虫猛兽极多,恐伤害了你性命。”太尉再问道:“你不要说谎?”道童笑了一声,也不回应,又吹着铁笛转过山坡去了。太尉寻思道:“这小的如何尽知此事?想是天师分付他,已定是了。”欲待再上山去,方才惊唬的苦,争些儿送了性命,不如下山去罢。
太尉拿着提炉,再寻旧路,奔下山来。众道士接着,请至方丈坐下。真人便问太尉道:“曾见天师么?”太尉说道:“我是朝廷中贵官,如何教俺走得山路,吃了这般辛苦,争些儿送了性命!为头上至半山里,跳出一只吊睛白额大虫,惊得下官魂魄都没了。又行不过一个山嘴,竹藤里抢出一条雪花大蛇来,盘做一堆,拦住去路。若不是俺福分大,如何得性命回京。尽是你这道众,戏弄下官!”真人复道:“贫道等怎敢轻慢大臣,这是祖师试探太尉之心。本山虽有蛇虎,并不伤人。”太尉又道:“我正走不动,方欲再上山坡,只见松树傍边转出一个道童,骑着一头黄牛,吹着管铁笛,正过山来。我便问他:‘那里来识得俺么?’他道:‘已都知了。’说天师分付,早晨乘鹤驾云望东京去了。下官因此回来。”真人道:“太尉可惜错过,这个牧童正是天师。”太尉道:“他既是天师,如何这等猥獕?”真人答道:“这代天师非同小可,虽然年幼,其实道行非常。他是额外之人,四方显化,极是灵验。世人皆称为道通祖师。”洪太尉道:“我直如此有眼不识真师,当面错过!”真人道:“太尉但请放心,既然祖师法旨道是去了,比及太尉回京之日,这场醮事祖师已都完了。”太尉见说,方才放心。真人一面教安排筵宴,管待太尉;请将丹诏收藏于御书匣内放了,留在上清宫中,龙香就三清殿上烧了。当日方丈内大排斋供,设宴饮酌。至晚席罢,止宿到晓。
次日早膳已后,真人道众并提点执事人等请太尉游山。太尉大喜。许多人从跟随着,步行出方丈,前面两个道童引路,行至宫前宫后,看玩许多景致。三清殿上,富贵不可尽言。左廊下,九天殿、紫微殿、北极殿;右廊下,太乙殿、三官殿、驱邪殿。诸宫看遍,行到右廊后一所去处。洪太尉看时,另外一所殿宇:一遭都是捣椒红泥墙;正面两扇朱红槅子;门上使着胳膊大锁锁着,交叉上面贴着十数道封皮,封皮上又是重重叠叠使着朱印;檐前一面朱红漆金字牌额,上书四个金字,写道:“伏魔之殿”。太尉尉指着门道:“此殿是甚么去处?”真人答道:“此乃是前代老祖天师锁镇魔王之殿。”太尉又问道:“如何上面重重叠叠贴着许多封皮?”真人答道:“此是祖老大唐洞玄国师封锁魔王在此。但是经传一代天师,亲手便添一道封皮,使其子子孙孙不敢妄开。走了魔君,非常利害。今经八九代祖师,誓不敢开。锁用铜汁灌铸,谁知里面的事。小道自来住持本宫三十余年,也只听闻。”洪太尉听了,心中惊怪,想道:“我且试看魔王一看。”便对真人说道:“你且开门来,我看魔王甚么模样。”
真人告道:“太尉,此殿决不敢开。先祖天师叮咛告戒:今后诸人不许擅开。”太尉笑道:“胡说!你等要妄生怪事,煽惑百姓良民,故意安排这等去处,假称锁镇魔王,显耀你们道术。我读一鉴之书,何曾见锁魔之法。神鬼之道,处隔幽冥,我不信有魔王在内。快疾与我打开,我看魔王如何。”真人三回五次禀说:“此殿开不得,恐惹利害,有伤于人。”太尉大怒,指着道众说道:“你等不开与我看,回到朝廷,先奏你们众道士阻当宣诏,违别圣旨,不令我见天师的罪犯;后奏你等私设此殿,假称锁镇魔王,煽惑军民百姓。把你都追了度牒,刺配远恶军州受苦。”真人等惧怕太尉权势,只得唤几个火工道人来,先把封皮揭了,将铁锤打开大锁。众人把门推开,看里面时,黑洞洞地,但见:
昏昏默默,查查冥冥。数百年不见太阳光,亿万载难瞻明月影。不分南北,怎辨东西。黑烟霭霭扑人寒,冷气阴阴侵体颤。人迹不到之处,妖精往来之乡。闪开双目有如盲,伸出两手不见掌。常如三十夜,却似五更时。
众人一齐都到殿内,黑暗暗不见一物。太尉教从人取十数个火把点着,将来打一照时,四边并无别物,只中央一个石碑,约高五六尺,下面石龟趺坐,太半陷在泥里。照那碑碣上时,前面都是龙章凤篆,天书符箓,人皆不识。照那碑后时,却有回个真字大书,凿着“遇洪而开”。却不是一来天罡星合当出世,二来宋朝必显忠良,三来凑巧遇着洪信。岂不是天数!洪太尉看了这四个字,大喜,便对真人说道:“你等阻当我,却怎地数百年前已注我姓字在此?‘遇洪而开’,分明是教我开看,却何妨!我想这个魔王,都只在石碑底下。汝等从人与我多唤几个火工人等,将锄头铁锹来掘开。”真人慌忙谏道:“太尉,不可掘动!恐有利害,伤犯于人,不当稳便。”太尉大怒,喝道:“你等道众,省得甚么!碑上分明凿着遇我教开,你如何阻当!快与我唤人来开。”真人又三回五次禀道:“恐有不好。”太尉那里肯听。只得聚集众人,先把石碑放倒,一齐并力掘那石龟,半日方才掘得起。又掘下去,约有三四尺深,见一片大青石板,可方丈围。洪太尉叫再掘起来。真人又苦禀道:“不可掘动!”太尉那里肯听。众人只得把石板一齐扛起,看时,石板底下却是一个万丈深浅地穴。只见穴内刮剌剌一声响亮,那响非同小可,恰似:
天摧地塌,岳撼山崩。钱塘江上,潮头浪拥出海门来;泰华山头,巨灵神一劈山峰碎。共工奋怒,去盔撞倒了不周山;力士施威,飞锤击碎了始皇辇。一风撼折千竿竹,十万军中半夜雷。
那一声响亮过处,只见一道黑气,从穴里滚将起来,掀塌了半个殿角。那道黑气直冲上半天里,空中散作百十道金光,望四面八方去了。众人吃了一惊,发声喊,都走了,撇下锄头铁锹,尽从殿内奔将出来,推倒攧翻无数。惊得洪太尉目睁痴呆,罔知所措,面色如土。奔到廊下,只见真人向前叫苦不迭。太尉问道:“走了的却是甚么妖魔?”那真人言不过数句,话不过一席,说出这个缘由。有分教:
一朝皇帝,夜眠不稳,昼食忘餐。直使宛子城中藏猛虎,蓼儿洼内聚飞龙。
毕竟龙虎山真人说出甚言语来,且听下回分解。
` 前即可。若你们站点启用了 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 @@
+