Files
cunkebao_v3/Server/application/chukebao/controller/QuickReplyExtController.php
Manus AI 8b83269b28 sync(本地→GitHub): 2026-06-08 23:40 全量单向同步
## GitHub 同步说明

| 项 | 内容 |
|:---|:---|
| 仓库 | https://github.com/fnvtk/cunkebao_v3 |
| 分支 | develop |
| 方向 | 本地 → GitHub(单向 push) |
| 是否拉取远程 | 否(未 fetch / pull / merge) |
| 是否改本地源文件 | 否(仅 git add/commit,未编辑任何业务/文档正文) |
| 远程基准 | origin/develop @ 2753265e |
| 本批工作区变更 | 563 files, +30324 / -7013 lines |

## 一并推送的本地已有 commit(此前未 push)

1. f7a7c56c chore(触客宝): 全量构建部署 kr-kf 生产并通过线上 smoke
2. 8801f5ff feat(触客宝): TKB-120 快捷语整组互拷与 AI 五步整理
3. c396ed35 docs(触客宝): 0603 已完成项归档并交付 TKB-117-B 超管存储
4. e4ed53bc fix(部署): SSH 主机密钥变更兼容并完成五端全量上线

## 本 commit 目录统计

| 目录 | 文件数 | 说明 |
|:---|---:|:---|
| 开发文档/ | 251 | 触客宝 0603~0608 归档、未完成项、五端账号速查 |
| Touchkebao/ | 109 | 微信客服 CustomerList、快捷语、AI 获客等 |
| Server/ | 108 | BFF/API、TaskCustomerList 等 |
| Cunkebao/ | 53 | 存客宝移动端迭代 |
| SuperAdmin/ | 24 | 超管后台 |
| 官网/ | 7 | 官网小改 |
| 其他 | 21 | .cursor、Moncter、docker-compose 等 |

## 重点文件(本地为准)

- Touchkebao/.../CustomerList/index.tsx(微信客户列表)
- 开发文档/1、需求/修改/触客宝_进行中_20260608.md
- 开发文档/1、需求/修改/未完成项与跨仓验收汇总.md
- 开发文档/10、项目管理/线上五端网址与账号密码速查.md
- 开发文档/10、项目管理/已完成/触客宝_0607* 归档
- 开发文档/5、接口/02-存客宝对接/触客宝PC接口对接索引.md

## 刻意未上传

- node_modules/、.DS_Store、.smart-env/
- .开发文档_nested_git_backup/
- **/__pycache__/

## 验收

- 本提交说明: https://github.com/fnvtk/cunkebao_v3/commit/HEAD
- 分支: https://github.com/fnvtk/cunkebao_v3/tree/develop

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-08 23:41:22 +08:00

724 lines
27 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<?php
namespace app\chukebao\controller;
use library\ResponseHelper;
use think\Db;
/**
* 模块 E · 快捷语扩展(需求六 §6.5
* 置顶 / 导出 / 导出为知识库 / AI 生成话术目录。
* 置顶表ck_kf_reply_pin懒建
*/
class QuickReplyExtController extends BaseController
{
private function ensureKbType(int $companyId, int $userId): int
{
$typeName = '触客宝快捷语导入';
$exist = Db::name('ai_knowledge_base_type')
->where('companyId', $companyId)
->where('name', $typeName)
->where('isDel', 0)
->find();
if ($exist) {
return (int)$exist['id'];
}
return (int)Db::name('ai_knowledge_base_type')->insertGetId([
'type' => 1,
'name' => $typeName,
'description' => '触客宝快捷语导入生成',
'label' => json_encode([], 256),
'prompt' => '',
'status' => 1,
'companyId' => $companyId,
'userId' => $userId,
'createTime' => time(),
'updateTime' => time(),
'isDel' => 0,
]);
}
private function writeKbMarkdown(int $companyId, string $kbName, string $content): string
{
$dir = root_path() . 'public/uploads/knowledge/' . $companyId . '/';
if (!is_dir($dir)) {
@mkdir($dir, 0777, true);
}
$safe = preg_replace('/[^a-zA-Z0-9_\x{4e00}-\x{9fa5}-]+/u', '_', $kbName);
$filename = date('Ymd_His') . '_' . $safe . '.md';
$path = $dir . $filename;
file_put_contents($path, $content);
return '/uploads/knowledge/' . $companyId . '/' . $filename;
}
private function ensureTable()
{
Db::execute("CREATE TABLE IF NOT EXISTS `ck_kf_reply_pin` (
`id` int(11) NOT NULL AUTO_INCREMENT,
`companyId` int(11) NOT NULL DEFAULT 0,
`userId` int(11) NOT NULL DEFAULT 0,
`type` varchar(16) NOT NULL DEFAULT 'reply',
`targetId` int(11) NOT NULL DEFAULT 0,
`pinned` tinyint(1) NOT NULL DEFAULT 1,
`updateTime` int(11) NOT NULL DEFAULT 0,
PRIMARY KEY (`id`),
UNIQUE KEY `uniq` (`userId`,`type`,`targetId`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4");
}
/**
* 置顶 / 取消置顶(目录或条目)
* POST /v1/kefu/reply/pin body: { id, type: group|reply, pinned }
*/
public function pin()
{
$this->ensureTable();
$companyId = $this->getUserInfo('companyId');
$userId = $this->getUserInfo('id');
$id = (int)$this->request->param('id', 0);
$type = (string)$this->request->param('type', 'reply');
$pinned = (bool)$this->request->param('pinned', true);
if (!$id || !in_array($type, ['group', 'reply'])) {
return ResponseHelper::error('参数错误', 400);
}
$exist = Db::name('kf_reply_pin')
->where(['userId' => $userId, 'type' => $type, 'targetId' => $id])
->find();
if ($pinned) {
if ($exist) {
Db::name('kf_reply_pin')->where('id', $exist['id'])
->update(['pinned' => 1, 'updateTime' => time()]);
} else {
Db::name('kf_reply_pin')->insert([
'companyId' => $companyId,
'userId' => $userId,
'type' => $type,
'targetId' => $id,
'pinned' => 1,
'updateTime' => time(),
]);
}
} elseif ($exist) {
Db::name('kf_reply_pin')->where('id', $exist['id'])->delete();
}
return ResponseHelper::success(['id' => $id, 'type' => $type, 'pinned' => $pinned], '已更新');
}
/**
* 当前客服的置顶列表REQ-TKB-93进页同步服务端避免只信 localStorage
* GET /v1/kefu/reply/pins
* @return \think\response\Json
*/
public function pins()
{
$this->ensureTable();
$userId = (int)$this->getUserInfo('id');
$rows = Db::name('kf_reply_pin')
->where('userId', $userId)
->where('pinned', 1)
->field('type,targetId')
->select();
$list = [];
$map = [];
foreach ($rows ?: [] as $r) {
$type = (string)$r['type'];
$tid = (int)$r['targetId'];
$key = $type . '-' . $tid;
$list[] = ['type' => $type, 'targetId' => $tid, 'key' => $key];
$map[$key] = true;
}
return ResponseHelper::success(['list' => $list, 'map' => $map], 'ok');
}
/**
* 个人快捷语 → 公司快捷语REQ-TKB-93
* POST /v1/kefu/quick-reply/promote-to-company
* body: { ids:[reply...], targetGroupId?, deleteSource? }
* @return \think\response\Json
*/
public function promoteToCompany()
{
$companyId = (int)$this->getUserInfo('companyId');
$userId = (int)$this->getUserInfo('id');
$idsParam = $this->request->param('ids', []);
if (is_string($idsParam)) {
$idsParam = array_filter(array_map('trim', explode(',', $idsParam)));
}
$ids = array_values(array_unique(array_map('intval', (array)$idsParam)));
$ids = array_filter($ids, function ($v) {
return $v > 0;
});
if (empty($ids)) {
return ResponseHelper::error('请选择要转移的快捷语', 400);
}
$targetGroupId = (int)$this->request->param('targetGroupId', 0);
$deleteSource = (bool)$this->request->param('deleteSource', false);
return $this->doPromoteToCompany($companyId, $userId, $ids, $targetGroupId, $deleteSource);
}
/**
* @param int[] $ids
* @return \think\response\Json
*/
private function doPromoteToCompany(int $companyId, int $userId, array $ids, int $targetGroupId, bool $deleteSource)
{
if ($targetGroupId > 0) {
$tg = Db::name('kf_reply_group')
->where('id', $targetGroupId)
->where('companyId', $companyId)
->where('replyType', 2)
->where('isDel', 0)
->find();
if (!$tg) {
return ResponseHelper::error('目标公司分组不存在', 404);
}
} else {
$targetGroupId = $this->ensureCompanyInboxGroup($companyId, $userId);
}
$now = time();
$okCount = 0;
$skipped = 0;
foreach ($ids as $rid) {
$reply = Db::name('kf_reply')->where('id', $rid)->where('isDel', 0)->find();
if (!$reply) {
$skipped++;
continue;
}
// 仅允许转移本人个人快捷语(其所在分组 replyType=1 且 userId 一致)
$srcGroup = Db::name('kf_reply_group')
->where('id', (int)$reply['groupId'])
->where('isDel', 0)
->find();
$isPersonal = $srcGroup
&& (int)$srcGroup['replyType'] === 1
&& (int)$srcGroup['userId'] === $userId
&& (int)$srcGroup['companyId'] === $companyId;
if (!$isPersonal) {
$skipped++;
continue;
}
// 仅用 ck_kf_reply 实际存在的列(兼容生产库:无 tenantId/accountId
$insertRow = [
'groupId' => $targetGroupId,
'title' => $reply['title'],
'msgType' => (int)$reply['msgType'],
'content' => $reply['content'],
'sortIndex' => $reply['sortIndex'] ?? 50,
'createTime' => $now,
'lastUpdateTime' => $now,
'userId' => $userId,
'isDel' => 0,
];
Db::name('kf_reply')->insert($insertRow);
if ($deleteSource) {
Db::name('kf_reply')->where('id', $rid)->update([
'isDel' => 1,
'delTime' => $now,
]);
}
$okCount++;
}
if ($okCount === 0) {
return ResponseHelper::error('没有可转移的个人快捷语(仅支持转移本人个人话术)', 400);
}
return ResponseHelper::success([
'promoted' => $okCount,
'skipped' => $skipped,
'targetGroupId' => $targetGroupId,
'deleteSource' => $deleteSource,
], "已转移 {$okCount} 条到公司快捷语");
}
/**
* 整组快捷语互拷REQ-TKB-120
* POST /v1/kefu/quick-reply/copy-group
* body: { groupId, direction: to-company|to-personal, targetGroupId?, deleteSource? }
*/
public function copyGroup()
{
$companyId = (int)$this->getUserInfo('companyId');
$userId = (int)$this->getUserInfo('id');
$groupId = (int)$this->request->param('groupId', 0);
$direction = (string)$this->request->param('direction', '');
$targetGroupId = (int)$this->request->param('targetGroupId', 0);
$deleteSource = (bool)$this->request->param('deleteSource', false);
if ($groupId <= 0 || !in_array($direction, ['to-company', 'to-personal'], true)) {
return ResponseHelper::error('参数错误', 400);
}
$srcGroup = Db::name('kf_reply_group')
->where('id', $groupId)
->where('companyId', $companyId)
->where('isDel', 0)
->find();
if (!$srcGroup) {
return ResponseHelper::error('源分组不存在', 404);
}
$replies = Db::name('kf_reply')
->where('groupId', $groupId)
->where('isDel', 0)
->order('sortIndex asc,id asc')
->select();
if (empty($replies)) {
return ResponseHelper::error('该分组下没有可复制的快捷语', 400);
}
$replyIds = array_map(function ($r) {
return (int)$r['id'];
}, $replies ?: []);
if ($direction === 'to-company') {
if ((int)$srcGroup['replyType'] !== 1 || (int)$srcGroup['userId'] !== $userId) {
return ResponseHelper::error('仅支持将本人个人分组复制到公司', 400);
}
if ($targetGroupId <= 0) {
$targetGroupId = $this->ensureCompanyMirrorGroup($companyId, $userId, (string)$srcGroup['groupName']);
}
$resp = $this->doPromoteToCompany($companyId, $userId, $replyIds, $targetGroupId, $deleteSource);
if ($deleteSource) {
$this->softDeleteReplyGroup($groupId, time());
}
return $resp;
}
// to-personal公司 → 个人
if ((int)$srcGroup['replyType'] !== 2) {
return ResponseHelper::error('仅支持将公司分组复制到个人', 400);
}
if ($targetGroupId <= 0) {
$targetGroupId = $this->ensurePersonalMirrorGroup($companyId, $userId, (string)$srcGroup['groupName']);
} else {
$tg = Db::name('kf_reply_group')
->where('id', $targetGroupId)
->where('companyId', $companyId)
->where('replyType', 1)
->where('userId', $userId)
->where('isDel', 0)
->find();
if (!$tg) {
return ResponseHelper::error('目标个人分组不存在', 404);
}
}
$now = time();
$okCount = 0;
foreach ($replies as $reply) {
Db::name('kf_reply')->insert([
'groupId' => $targetGroupId,
'title' => $reply['title'],
'msgType' => (int)$reply['msgType'],
'content' => $reply['content'],
'sortIndex' => $reply['sortIndex'] ?? 50,
'createTime' => $now,
'lastUpdateTime' => $now,
'userId' => $userId,
'isDel' => 0,
]);
$okCount++;
if ($deleteSource) {
Db::name('kf_reply')->where('id', (int)$reply['id'])->update([
'isDel' => 1,
'delTime' => $now,
]);
}
}
if ($deleteSource) {
$this->softDeleteReplyGroup($groupId, $now);
}
return ResponseHelper::success([
'copied' => $okCount,
'targetGroupId' => $targetGroupId,
'deleteSource' => $deleteSource,
'direction' => $direction,
], "已复制 {$okCount} 条到个人快捷语");
}
private function softDeleteReplyGroup(int $groupId, int $now): void
{
Db::name('kf_reply_group')->where('id', $groupId)->update([
'isDel' => 1,
'delTime' => $now,
]);
Db::name('kf_reply')->where('groupId', $groupId)->update([
'isDel' => 1,
'delTime' => $now,
]);
}
/**
* AI 整理分组话术(五步漏斗 + 补充归类REQ-TKB-120
* POST /v1/kefu/quick-reply/ai-organize-group body: { groupId }
*/
public function aiOrganizeGroup()
{
$companyId = (int)$this->getUserInfo('companyId');
$userId = (int)$this->getUserInfo('id');
$groupId = (int)$this->request->param('groupId', 0);
if ($groupId <= 0) {
return ResponseHelper::error('请选择分组', 400);
}
$group = Db::name('kf_reply_group')
->where('id', $groupId)
->where('companyId', $companyId)
->where('isDel', 0)
->find();
if (!$group) {
return ResponseHelper::error('分组不存在', 404);
}
if ((int)$group['replyType'] === 1 && (int)$group['userId'] !== $userId) {
return ResponseHelper::error('无权整理该个人分组', 403);
}
$replies = Db::name('kf_reply')
->where('groupId', $groupId)
->where('isDel', 0)
->order('sortIndex asc,id asc')
->select();
if (empty($replies)) {
return ResponseHelper::error('分组内无话术可整理', 400);
}
$payload = [];
foreach ($replies as $r) {
$payload[] = [
'id' => (int)$r['id'],
'title' => (string)$r['title'],
'msgType' => (int)$r['msgType'],
'content' => (string)$r['content'],
];
}
$sys = "你是私域销售话术整理专家。参考苹果 Apple Store 在线客服/iMessage 导购话术风格:"
. "短句、利益清晰、结尾用轻问句促回复。"
. "\n将用户提供的快捷语重组为【五步销售漏斗】+【补充话术】。"
. "\n输出严格 JSON不要 markdown"
. "{\"replies\":[{\"id\":数字,\"title\":\"标题\",\"content\":\"正文\",\"sortIndex\":数字}]}"
. "\n规则:"
. "\n1) 必须产出 5 条核心话术标题分别为「1. 打招呼」「2. 产品介绍」「3. 报价说明」「4. 异议处理·觉得贵」「5. 沉默跟进·B单唤醒」"
. "\n2) 第2条须有明确行动号召CTA引导对方回复"
. "\n3) 第3条写清价格/套餐"
. "\n4) 第4条处理嫌贵"
. "\n5) 第5条久未回复跟进"
. "\n6) 原有多余文本条目标题用「6. 补充-」前缀sortIndex 按 10,20,30… 递增"
. "\n7) 仅改写 msgType=1 的 content/titlemsgType 非 1 的保持 title/content 不变,排在列表末尾"
. "\n8) 每条 id 必须来自输入,不可编造新 id";
$aiRes = $this->callQuickReplyAi($sys, json_encode([
'groupName' => $group['groupName'],
'replies' => $payload,
], 256), $companyId, $userId);
if ($aiRes['error'] !== '') {
return ResponseHelper::error($aiRes['error'], 500);
}
$parsed = $aiRes['parsed'];
$items = $parsed['replies'] ?? [];
if (!is_array($items) || empty($items)) {
return ResponseHelper::error('AI 未返回有效结构,请重试', 500);
}
$idMap = [];
foreach ($replies as $r) {
$idMap[(int)$r['id']] = true;
}
$updated = 0;
$now = time();
foreach ($items as $item) {
$rid = (int)($item['id'] ?? 0);
if ($rid <= 0 || !isset($idMap[$rid])) {
continue;
}
$row = [];
if (!empty($item['title'])) {
$row['title'] = mb_substr((string)$item['title'], 0, 120);
}
if (isset($item['content'])) {
$row['content'] = (string)$item['content'];
}
if (isset($item['sortIndex'])) {
$row['sortIndex'] = (string)(int)$item['sortIndex'];
}
if (empty($row)) {
continue;
}
$row['lastUpdateTime'] = $now;
Db::name('kf_reply')->where('id', $rid)->update($row);
$updated++;
}
return ResponseHelper::success([
'updated' => $updated,
'groupId' => $groupId,
'preview' => array_slice($items, 0, 12),
'cost' => (int)($aiRes['deducted'] ?? 0),
], "已整理 {$updated} 条话术");
}
/** @return array{error:string,parsed:?array,deducted?:int} */
private function callQuickReplyAi(string $system, string $userJson, int $companyId, int $userId, bool $allowPlainText = false): array
{
$gw = \app\common\service\AiGatewayService::complete(
'ai_rewrite',
[
['role' => 'system', 'content' => $system],
['role' => 'user', 'content' => $userJson],
],
$companyId,
$userId,
'',
['maxTokens' => 8192]
);
if (empty($gw['ok'])) {
return [
'error' => (string)($gw['error'] ?? 'AI 网关调用失败'),
'parsed' => null,
'deducted' => (int)($gw['deducted'] ?? 0),
];
}
$content = (string)($gw['content'] ?? '');
$parsed = null;
if (preg_match('/\{[\s\S]*\}/', $content, $m)) {
$parsed = json_decode($m[0], true);
}
if (!is_array($parsed) && $allowPlainText) {
$parsed = $this->plainTextQuickReplyGroups($content);
}
if (!is_array($parsed)) {
return ['error' => 'AI 返回无法解析为 JSON', 'parsed' => null, 'deducted' => (int)($gw['deducted'] ?? 0)];
}
return ['error' => '', 'parsed' => $parsed, 'deducted' => (int)($gw['deducted'] ?? 0)];
}
private function plainTextQuickReplyGroups(string $content): ?array
{
$replies = [];
$lines = preg_split('/\R/u', trim($content)) ?: [];
foreach ($lines as $line) {
$line = trim((string)$line);
$line = preg_replace('/^(?:[-*]\s*|\d+[.、)]\s*)/u', '', $line);
$line = trim((string)$line, " \t\n\r\0\x0B*#");
if ($line === '' || mb_strlen($line) < 4 || strpos($line, '结论') === 0) {
continue;
}
$replies[] = [
'title' => '话术' . (count($replies) + 1),
'content' => mb_substr($line, 0, 500),
];
if (count($replies) >= 12) {
break;
}
}
if (empty($replies)) {
return null;
}
return ['groups' => [['groupName' => 'AI 生成话术', 'replies' => $replies]]];
}
private function ensureCompanyMirrorGroup(int $companyId, int $userId, string $srcName): int
{
$name = '来自个人·' . $srcName;
$exist = Db::name('kf_reply_group')
->where('companyId', $companyId)
->where('replyType', 2)
->where('groupName', $name)
->where('isDel', 0)
->find();
if ($exist) {
return (int)$exist['id'];
}
return (int)Db::name('kf_reply_group')->insertGetId([
'groupName' => $name,
'parentId' => 0,
'replyType' => 2,
'sortIndex' => 50,
'companyId' => $companyId,
'userId' => $userId,
'isDel' => 0,
]);
}
private function ensurePersonalMirrorGroup(int $companyId, int $userId, string $srcName): int
{
$name = '来自公司·' . $srcName;
$exist = Db::name('kf_reply_group')
->where('companyId', $companyId)
->where('replyType', 1)
->where('userId', $userId)
->where('groupName', $name)
->where('isDel', 0)
->find();
if ($exist) {
return (int)$exist['id'];
}
return (int)Db::name('kf_reply_group')->insertGetId([
'groupName' => $name,
'parentId' => 0,
'replyType' => 1,
'sortIndex' => 50,
'companyId' => $companyId,
'userId' => $userId,
'isDel' => 0,
]);
}
/** 确保存在公司侧「个人转入」默认分组replyType=2返回分组 id */
private function ensureCompanyInboxGroup(int $companyId, int $userId): int
{
$name = '个人转入';
$exist = Db::name('kf_reply_group')
->where('companyId', $companyId)
->where('replyType', 2)
->where('groupName', $name)
->where('isDel', 0)
->find();
if ($exist) {
return (int)$exist['id'];
}
return (int)Db::name('kf_reply_group')->insertGetId([
'groupName' => $name,
'parentId' => 0,
'replyType' => 2,
'sortIndex' => 50,
'companyId' => $companyId,
'userId' => $userId,
'isDel' => 0,
]);
}
/**
* 导出快捷语(个人/公司)
* GET /v1/kefu/reply/export?scope=personal|company&format=json|markdown
*/
public function export()
{
$companyId = $this->getUserInfo('companyId');
$userId = $this->getUserInfo('id');
$scope = (string)$this->request->param('scope', 'personal');
$format = (string)$this->request->param('format', 'markdown');
// 公司分组 or 个人分组
$groupWhere = ['isDel' => 0, 'companyId' => $companyId];
if ($scope === 'personal') {
$groupWhere['userId'] = $userId;
}
$groups = Db::name('kf_reply_group')->where($groupWhere)->order('sortIndex desc,id asc')->select();
$tree = [];
foreach ($groups as $g) {
$replies = Db::name('kf_reply')
->where(['groupId' => $g['id'], 'isDel' => 0])
->field('title,content,msgType')
->order('sortIndex desc,id asc')
->select();
$tree[] = ['group' => $g['groupName'], 'replies' => $replies];
}
if ($format === 'json') {
return ResponseHelper::success([
'format' => 'json',
'content' => json_encode($tree, 256),
]);
}
// markdown
$md = "# 快捷语导出(" . ($scope === 'company' ? '公司' : '个人') . "\n\n";
foreach ($tree as $node) {
$md .= "## " . $node['group'] . "\n\n";
foreach ($node['replies'] as $r) {
$md .= "- **" . $r['title'] . "**";
if ($r['msgType'] == 1 && !empty($r['content'])) {
$md .= "" . str_replace("\n", " ", $r['content']);
}
$md .= "\n";
}
$md .= "\n";
}
return ResponseHelper::success(['format' => 'markdown', 'content' => $md]);
}
/**
* 快捷语 → 生成知识库记录意图KB 真源在存客宝,待联调)
* POST /v1/kefu/quick-reply/export-kb body: { scope, kbName? }
*/
public function exportKb()
{
$scope = (string)$this->request->param('scope', 'personal');
$kbName = (string)$this->request->param('kbName', '');
$companyId = (int)$this->getUserInfo('companyId');
$userId = (int)$this->getUserInfo('id');
// 复用 export 产出 markdown并直接写入存客宝 ai_knowledge_base
$exportResp = $this->export();
$data = json_decode($exportResp->getContent(), true);
$content = $data['data']['content'] ?? '';
if ($content === '') {
return ResponseHelper::error('导出内容为空,无法入库', 400);
}
$finalKbName = $kbName ?: ('客服知识库-' . date('md'));
$typeId = $this->ensureKbType($companyId, $userId);
$fileUrl = $this->writeKbMarkdown($companyId, $finalKbName, $content);
$kbId = (int)Db::name('ai_knowledge_base')->insertGetId([
'typeId' => $typeId,
'name' => $finalKbName,
'label' => json_encode([], 256),
'fileUrl' => $fileUrl,
'companyId' => $companyId,
'userId' => $userId,
'createTime' => time(),
'updateTime' => time(),
'isDel' => 0,
]);
return ResponseHelper::success([
'scope' => $scope,
'kbId' => $kbId,
'kbName' => $finalKbName,
'typeId' => $typeId,
'fileUrl' => $fileUrl,
'content' => $content,
'pending' => false,
'tip' => '已写入存客宝知识库(若需对触客宝可见,请在存客宝开启协同分配)',
], '已生成并写入知识库');
}
/**
* AI 生成整套话术目录+条目
* POST /v1/kefu/quick-reply/ai-generate body: { productDesc, kbId? }
*/
public function aiGenerate()
{
$productDesc = trim((string)$this->request->param('productDesc', ''));
if ($productDesc === '') {
return ResponseHelper::error('请输入产品描述', 400);
}
$companyId = $this->getUserInfo('companyId');
$userId = $this->getUserInfo('id');
$sys = "你是私域客服话术专家。根据【产品描述】生成一套微信客服快捷语目录,"
. "输出严格 JSON{\"groups\":[{\"groupName\":\"分组名\",\"replies\":[{\"title\":\"标题\",\"content\":\"话术正文\"}]}]}"
. "不要输出 JSON 以外任何内容。分组 3-5 个,每组 3-6 条。";
try {
$aiRes = $this->callQuickReplyAi($sys, '【产品描述】' . $productDesc, (int)$companyId, (int)$userId, true);
if ($aiRes['error'] !== '') {
return ResponseHelper::error('AI 生成失败:' . $aiRes['error'], 500);
}
$parsed = $aiRes['parsed'];
return ResponseHelper::success([
'groups' => $parsed['groups'] ?? [],
'raw' => null,
'cost' => (int)($aiRes['deducted'] ?? 0),
], '已生成');
} catch (\Exception $e) {
return ResponseHelper::error('AI 调用异常:' . $e->getMessage(), 500);
}
}
}