345 lines
13 KiB
PHP
345 lines
13 KiB
PHP
<?php
|
||
|
||
/**
|
||
* 文件功能:AI 聊天服务
|
||
*
|
||
* 统一对接多个 AI 厂商 API(OpenAI 兼容协议),实现:
|
||
* - 多厂商自动故障转移(默认 → 备用按 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);
|
||
}
|
||
}
|