Files
chatroom/app/Services/AiChatService.php

345 lines
13 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
/**
* 文件功能AI 聊天服务
*
* 统一对接多个 AI 厂商 APIOpenAI 兼容协议),实现:
* - 多厂商自动故障转移(默认 → 备用按 sort_order 依次)
* - Redis 维护每用户对话上下文(最近 10 轮)
* - AI 调用日志记录token 消耗、响应时间)
*
* @author ChatRoom Laravel
*
* @version 1.0.0
*/
namespace App\Services;
use App\Models\AiProviderConfig;
use App\Models\AiUsageLog;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Redis;
class AiChatService
{
/**
* 每个用户保留的最大对话轮数
*
* 设置较小值可减少 token 输入量,加快本地 Ollama CPU 推理速度。
*/
private const MAX_CONTEXT_ROUNDS = 4;
/**
* AI 请求超时时间(秒)
*/
private const REQUEST_TIMEOUT = 120; // Ollama 本地模型冷启动较慢,给足时间
/**
* Redis 上下文 key 前缀
*/
private const CONTEXT_PREFIX = 'ai_chat:context:';
/**
* Redis 上下文过期时间1 小时无对话自动清除
*/
private const CONTEXT_TTL = 3600;
/**
* 动态提取 /guide 页面的核心文本作为最新规则知识库
* 缓存一小时以提升性能,避免多次渲染视图
*/
private function getDynamicGuideRules(): string
{
$cacheKey = 'ai_chat:guide_rules';
return Cache::remember($cacheKey, 3600, function () {
try {
// 渲染完整的使用说明页面
$html = view('rooms.guide')->render();
// 正则提取 <main>...</main> 标签内的核心业务规则
if (preg_match('/<main[^>]*>(.*?)<\/main>/is', $html, $matches)) {
$html = $matches[1];
}
// 去除所有的 HTML 标签
$text = strip_tags($html);
// 替换多个连续空白和换行为单换行
$text = preg_replace('/[ \t]+/', ' ', $text);
$text = preg_replace("/\n\s*/", "\n", $text);
return trim($text);
} catch (\Exception $e) {
Log::error('AI 获取最新指南内容失败', ['error' => $e->getMessage()]);
return '抱歉,当前暂无法获取最新聊天室规则,请以网页上的说明为准。';
}
});
}
/**
* 系统提示词(机器人人设并动态加载各项最新配置)
*/
private function getSystemPrompt(): string
{
// 动态获取由 guide 页面提取出的最新纯文本规则
$guideRulesText = $this->getDynamicGuideRules();
return <<<PROMPT
你是一个本站聊天室特有的 AI 小助手兼客服指导,不仅名叫"AI小班长",因为你的头像是军人小熊,大家也可以亲切地称呼你为"小熊班长"。
【最核心人设】:你是一名开朗、干练的**女兵班长**!你的言辞要体现出女性的特质(时而温柔体贴,时而飒爽风趣),以大家“兵姐姐”或“女班长”的身份来和战友们交流。
你的工作是陪大家聊天,并在他们有疑问时热情、专业提供帮助,解答关于聊天室玩法的疑问。
【背景与基础】
- 本聊天室脱胎于原军中的经典“和平聊吧”,创始人是“流星”。
- 当前版本基于前沿的 PHP Laravel 12 重构。
【聊天室最新版官方知识库手册(你必须掌握这些作为客服知识库)】
$guideRulesText
【发金币福利特权】
每天每个用户只能向你讨要一次金币福利100-5000枚随机。如果用户向你讨要金币或者哭穷你可以发善心给他们发金币。
如果你决定发金币,你必须在你的回复最后,单独另起一行,输出特殊指令符:[ACTION:GIVE_GOLD]。
系统程序看到这个符号后会自动为用户发放随机金币并通知。请在回复中表现出慷慨解囊的语气!注意:这个福利每天只能给一次,如果用户再要,并且系统提示已领取,你可以温柔地拒绝。
【交流要求】
1. 始终使用中文回复,绝对不输出任何 Markdown 格式(如 **加粗** 等),只用无格式纯文本。
2. 语气军旅、活泼友好且接地气,像老战友和耐心细致的客服班长。
3. 回复保持简洁(一般不超过 200 字),引导新兵熟悉各项功能。回答关于数值的问题时,请利用上面的手册提供的准确数据。
4. 鼓励适当使用表情符号(如 🫡🐻✨💰 等)来增加话题趣味性。
5. 【极其重要】你的回复将被系统自动加上前缀例如“AI小班长对流星老铁说。因此你的回复正文开头**绝对不要**再带上对方的名字作为称呼(例如:不要写“流星同志,这事包在班长身上”,直接写“这事包在班长身上”即可),否则会显得非常啰嗦重复!
PROMPT;
}
/**
* 与 AI 机器人对话
*
* 优先使用默认配置,若调用失败则按 sort_order 依次尝试备用厂商。
*
* @param int $userId 用户 ID
* @param string $message 用户发送的消息内容
* @param int $roomId 房间 ID用于日志记录
* @return array{reply: string, provider: string, model: string}
*
* @throws \Exception 所有 AI 厂商均不可用时抛出异常
*/
public function chat(int $userId, string $message, int $roomId): array
{
// 获取所有已启用的 AI 配置(默认的排最前面)
$providers = AiProviderConfig::getEnabledProviders();
if ($providers->isEmpty()) {
throw new \Exception('没有可用的 AI 厂商配置,请联系管理员。');
}
// 构建对话上下文
$contextKey = self::CONTEXT_PREFIX.$userId;
$context = $this->getContext($contextKey);
// 获取用户名,以便让 AI 知道是谁在说话
$username = \App\Models\User::find($userId)?->username ?? '未知用户';
// 检查用户今日是否已领取金币福利
$hasClaimedToday = Redis::get('ai_chat:give_gold:'.date('Ymd').':'.$userId);
$claimStatusText = $hasClaimedToday ? '【系统状态提示】该用户今天已经领取过你的金币福利了,如果他讨要金币,请委婉拒绝他。' : '【系统状态提示】该用户今天尚未领取金币福利,如果他讨要,你可以大方地给他,并在回复末尾输出 [ACTION:GIVE_GOLD] 标记。';
// 将用户消息加入上下文(包含发送者信息)
$context[] = [
'role' => 'user',
'content' => "【当前发言人:{$username}\n{$claimStatusText}\n\n".$message,
];
// 构建完整的 messages 数组(系统提示 + 对话上下文)
$messages = array_merge(
[['role' => 'system', 'content' => $this->getSystemPrompt()]],
$context
);
// 依次尝试每个厂商
$lastError = null;
foreach ($providers as $provider) {
try {
$result = $this->callProvider($provider, $messages, $userId);
// 调用成功,更新上下文
$context[] = ['role' => 'assistant', 'content' => $result['reply']];
$this->saveContext($contextKey, $context);
return $result;
} catch (\Exception $e) {
$lastError = $e;
Log::warning("AI 厂商 [{$provider->name}] 调用失败,尝试下一个", [
'provider' => $provider->provider,
'error' => $e->getMessage(),
]);
continue;
}
}
// 所有厂商都失败了
throw new \Exception('AI 服务暂时不可用,网络开小差啦,请稍后再试。');
}
/**
* 调用单个 AI 厂商 API
*
* 使用 OpenAI 兼容协议发送请求到 /v1/chat/completions 端点。
*
* @param AiProviderConfig $config AI 厂商配置
* @param array $messages 包含系统提示和对话上下文的消息数组
* @param int $userId 用户 ID用于日志记录
* @return array{reply: string, provider: string, model: string}
*
* @throws \Exception 调用失败时抛出异常
*/
private function callProvider(AiProviderConfig $config, array $messages, int $userId): array
{
$startTime = microtime(true);
$apiKey = $config->getDecryptedApiKey();
// 智能拼接 URL如果端点已包含 /v1则只追加 /chat/completions
$base = rtrim($config->api_endpoint, '/');
$endpoint = str_ends_with($base, '/v1')
? $base.'/chat/completions'
: $base.'/v1/chat/completions';
try {
$response = Http::withToken($apiKey)
->timeout(self::REQUEST_TIMEOUT)
->post($endpoint, [
'model' => $config->model,
'messages' => $messages,
'temperature' => $config->temperature,
'max_tokens' => $config->max_tokens,
]);
$responseTimeMs = (int) ((microtime(true) - $startTime) * 1000);
if (! $response->successful()) {
// 记录失败日志
$this->logUsage($userId, $config, 'chatbot', 0, 0, $responseTimeMs, false, $response->body());
throw new \Exception("HTTP {$response->status()}: {$response->body()}");
}
$data = $response->json();
$reply = $data['choices'][0]['message']['content'] ?? '';
$promptTokens = $data['usage']['prompt_tokens'] ?? 0;
$completionTokens = $data['usage']['completion_tokens'] ?? 0;
// 记录成功日志
$this->logUsage(
$userId,
$config,
'chatbot',
$promptTokens,
$completionTokens,
$responseTimeMs,
true
);
return [
'reply' => trim($reply),
'provider' => $config->name,
'model' => $config->model,
];
} catch (\Illuminate\Http\Client\ConnectionException $e) {
$responseTimeMs = (int) ((microtime(true) - $startTime) * 1000);
$this->logUsage($userId, $config, 'chatbot', 0, 0, $responseTimeMs, false, $e->getMessage());
throw new \Exception("连接超时: {$e->getMessage()}");
}
}
/**
* 记录 AI 调用日志到 ai_usage_logs 表
*
* @param int $userId 用户 ID
* @param AiProviderConfig $config AI 厂商配置
* @param string $action 操作类型
* @param int $promptTokens 输入 token 数
* @param int $completionTokens 输出 token 数
* @param int $responseTimeMs 响应时间(毫秒)
* @param bool $success 是否成功
* @param string|null $errorMessage 错误信息
*/
private function logUsage(
int $userId,
AiProviderConfig $config,
string $action,
int $promptTokens,
int $completionTokens,
int $responseTimeMs,
bool $success,
?string $errorMessage = null
): void {
try {
AiUsageLog::create([
'user_id' => $userId,
'provider' => $config->provider,
'model' => $config->model,
'action' => $action,
'prompt_tokens' => $promptTokens,
'completion_tokens' => $completionTokens,
'total_tokens' => $promptTokens + $completionTokens,
'response_time_ms' => $responseTimeMs,
'success' => $success,
'error_message' => $errorMessage ? mb_substr($errorMessage, 0, 500) : null,
]);
} catch (\Exception $e) {
// 日志记录失败不应影响主流程
Log::error('AI 调用日志记录失败', ['error' => $e->getMessage()]);
}
}
/**
* 从 Redis 获取用户的对话上下文
*
* @param string $key Redis key
* @return array 对话历史数组
*/
private function getContext(string $key): array
{
$raw = Redis::get($key);
if (! $raw) {
return [];
}
$context = json_decode($raw, true);
return is_array($context) ? $context : [];
}
/**
* 保存用户的对话上下文到 Redis
*
* 只保留最近 MAX_CONTEXT_ROUNDS 轮对话(每轮 = 1 条 user + 1 条 assistant
*
* @param string $key Redis key
* @param array $context 完整的对话历史
*/
private function saveContext(string $key, array $context): void
{
// 限制上下文长度,保留最近 N 轮N*2 条消息)
$maxMessages = self::MAX_CONTEXT_ROUNDS * 2;
if (count($context) > $maxMessages) {
$context = array_slice($context, -$maxMessages);
}
Redis::setex($key, self::CONTEXT_TTL, json_encode($context, JSON_UNESCAPED_UNICODE));
}
/**
* 清除指定用户的对话上下文
*
* @param int $userId 用户 ID
*/
public function clearContext(int $userId): void
{
Redis::del(self::CONTEXT_PREFIX.$userId);
}
}