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); } }