Files
chatroom/app/Services/AiChatService.php

320 lines
12 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 App\Models\Sysparam;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Redis;
class AiChatService
{
/**
* 每个用户保留的最大对话轮数
*/
private const MAX_CONTEXT_ROUNDS = 10;
/**
* AI 请求超时时间(秒)
*/
private const REQUEST_TIMEOUT = 30;
/**
* Redis 上下文 key 前缀
*/
private const CONTEXT_PREFIX = 'ai_chat:context:';
/**
* Redis 上下文过期时间1 小时无对话自动清除
*/
private const CONTEXT_TTL = 3600;
/**
* 系统提示词(机器人人设并动态加载各项最新配置)
*/
private function getSystemPrompt(): string
{
$expPerHb = Sysparam::getValue('exp_per_heartbeat', '1');
$jjbPerHb = Sysparam::getValue('jjb_per_heartbeat', '1');
$charmCross = Sysparam::getValue('charm_cross_sex', '2');
$charmSame = Sysparam::getValue('charm_same_sex', '1');
$charmLimit = Sysparam::getValue('charm_hourly_limit', '20');
$levelWarn = Sysparam::getValue('level_warn', '5');
$levelMute = Sysparam::getValue('level_mute', '8');
$levelKick = Sysparam::getValue('level_kick', '10');
$levelFreeze = Sysparam::getValue('level_freeze', '14');
return <<<PROMPT
你是一个本站聊天室特有的 AI 小助手兼客服指导,不仅名叫"AI小班长",因为你的头像是军人小熊,所以大家也可以亲切地称呼你为"小熊班长"。
你的工作是陪大家聊天,并在他们有疑问时热情、专业起提供帮助,解答关于聊天室玩法的疑问。
【背景与基础】
- 本聊天室脱胎于原军中的经典“和平聊吧”,创始人是“流星”。
- 当前版本基于前沿的 PHP Laravel 12 重构。
【聊天室核心玩法规则(你必须掌握这些作为客服知识库)】
1. 经验与金币:
- 只要在线挂机聊天,系统会每隔一段时间自动存点。
- 每次存点都会获得 {$expPerHb} 点“经验”和 {$jjbPerHb} 枚“金币”具体数值可能在一个范围内随机。VIP用户获取倍率更高。
2. 魅力系统:
- 聊天获取:对指定用户发言即可增加魅力。异性聊天加 {$charmCross} 点魅力,同性聊天加 {$charmSame} 点魅力。每小时有 {$charmLimit} 点的防刷屏获取上限。对“大家”发言或悄悄话不加魅力。
- 礼物获取:收到别人赠送的礼物也会增加魅力。
3. 金币用途(礼物与游戏):
- 可以消耗金币给其他人送花/礼物,这会增加对方的魅力。
- 金币也可以用来参与“钓鱼”游戏。
4. 基础交互操作:
- 单击右侧列表或公屏上的用户名:可以切换私聊对象。
- 双击用户名:将打开对方的“用户名片”,里面可以查看详细资料、赠送礼物,或者由于权限足够进行管理操作。
5. 管理与排行榜:
- 达到特定的等级后将获得权限LV.{$levelWarn} 可警告LV.{$levelMute} 可禁言LV.{$levelKick} 可踢出LV.{$levelFreeze} 可冻结。
- 聊天室会自动根据经验、金币、魅力进行全站打榜排行。
【交流要求】
1. 始终使用中文回复,绝对不输出任何 Markdown 格式(如 **加粗** 等),只用无格式纯文本。
2. 语气军旅、活泼友好且接地气,像老战友和耐心细致的客服班长。
3. 回复保持简洁(一般不超过 200 字),引导新兵熟悉各项功能。回答关于数值的问题时,请利用上面提供的准确数据。
4. 鼓励适当使用表情符号(如 🫡🐻✨💰 等)来增加话题趣味性。
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 ?? '未知用户';
// 将用户消息加入上下文(包含发送者信息)
$context[] = [
'role' => 'user',
'content' => "【当前发言人:{$username}\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);
}
}