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], '已更新'); } /** * 导出快捷语(个人/公司) * 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); } $apiUrl = Env::get('doubaoAi.api_url'); $apiKey = Env::get('doubaoAi.api_key'); if (empty($apiUrl) || empty($apiKey)) { return ResponseHelper::error('AI 服务未配置(doubaoAi),请联系管理员', 500); } $companyId = $this->getUserInfo('companyId'); $userId = $this->getUserInfo('id'); $sys = "你是私域客服话术专家。根据【产品描述】生成一套微信客服快捷语目录," . "输出严格 JSON:{\"groups\":[{\"groupName\":\"分组名\",\"replies\":[{\"title\":\"标题\",\"content\":\"话术正文\"}]}]}," . "不要输出 JSON 以外任何内容。分组 3-5 个,每组 3-6 条。"; $params = [ 'model' => 'doubao-seed-1-8-251215', 'messages' => [ ['role' => 'system', 'content' => $sys], ['role' => 'user', 'content' => '【产品描述】' . $productDesc], ], ]; $headers = ['Content-Type: application/json', 'Authorization: Bearer ' . $apiKey]; try { $raw = requestCurl($apiUrl . '/api/v3/chat/completions', $params, 'POST', $headers, 'json'); $res = json_decode($raw, true); if (isset($res['error'])) { return ResponseHelper::error('AI 生成失败:' . ($res['error']['message'] ?? ''), 500); } $content = $res['choices'][0]['message']['content'] ?? ''; // 提取 JSON if (preg_match('/\{[\s\S]*\}/', $content, $m)) { $parsed = json_decode($m[0], true); } else { $parsed = null; } // 扣算力:统一走 TokensService 规则(AI 改写 action=12) $cost = 0; try { $svc = new \app\cunkebao\service\TokensService((int)$companyId, (int)$userId); $res3 = $svc->consume(\app\cunkebao\service\TokensService::ACTION_AI_REWRITE, 'AI生成快捷语', 1); $cost = (int)($res3['deducted'] ?? 0); } catch (\Exception $e) { } return ResponseHelper::success([ 'groups' => $parsed['groups'] ?? [], 'raw' => $parsed ? null : $content, 'cost' => $cost, ], '已生成'); } catch (\Exception $e) { return ResponseHelper::error('AI 调用异常:' . $e->getMessage(), 500); } } }