render();
// 正则提取 ... 标签内的核心业务规则
if (preg_match('/]*>(.*?)<\/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 <<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);
}
}