329 lines
11 KiB
PHP
329 lines
11 KiB
PHP
<?php
|
||
|
||
namespace App\Clients;
|
||
|
||
use App\Services\ConfigService;
|
||
use Illuminate\Support\Facades\Http;
|
||
use Illuminate\Support\Facades\RateLimiter;
|
||
use RuntimeException;
|
||
|
||
class AiClient
|
||
{
|
||
private ConfigService $configService;
|
||
private ?array $currentProvider = null;
|
||
|
||
public function __construct(ConfigService $configService)
|
||
{
|
||
$this->configService = $configService;
|
||
}
|
||
|
||
/**
|
||
* 获取所有配置的 AI 提供商
|
||
*/
|
||
public function getProviders(): array
|
||
{
|
||
$providers = $this->configService->get('log_analysis.ai_providers', []);
|
||
|
||
// 如果数据库没有配置,使用 .env 默认配置
|
||
if (empty($providers)) {
|
||
$envConfig = config('services.ai');
|
||
if (!empty($envConfig['api_key'])) {
|
||
$providers = [
|
||
'default' => [
|
||
'name' => '默认 (环境变量)',
|
||
'endpoint' => $envConfig['endpoint'],
|
||
'api_key' => $envConfig['api_key'],
|
||
'model' => $envConfig['model'],
|
||
'temperature' => $envConfig['temperature'],
|
||
'timeout' => $envConfig['timeout'],
|
||
'max_tokens' => $envConfig['max_tokens'],
|
||
'enabled' => true,
|
||
],
|
||
];
|
||
}
|
||
}
|
||
|
||
return $providers;
|
||
}
|
||
|
||
/**
|
||
* 获取当前激活的 AI 提供商
|
||
*/
|
||
public function getActiveProvider(): ?array
|
||
{
|
||
if ($this->currentProvider !== null) {
|
||
return $this->currentProvider;
|
||
}
|
||
|
||
$activeKey = $this->configService->get('log_analysis.active_ai_provider');
|
||
$providers = $this->getProviders();
|
||
|
||
if ($activeKey && isset($providers[$activeKey]) && ($providers[$activeKey]['enabled'] ?? true)) {
|
||
$this->currentProvider = $providers[$activeKey];
|
||
$this->currentProvider['key'] = $activeKey;
|
||
return $this->currentProvider;
|
||
}
|
||
|
||
// 返回第一个启用的提供商
|
||
foreach ($providers as $key => $provider) {
|
||
if ($provider['enabled'] ?? true) {
|
||
$this->currentProvider = $provider;
|
||
$this->currentProvider['key'] = $key;
|
||
return $this->currentProvider;
|
||
}
|
||
}
|
||
|
||
return null;
|
||
}
|
||
|
||
/**
|
||
* 设置当前使用的提供商
|
||
*/
|
||
public function setActiveProvider(string $providerKey): void
|
||
{
|
||
$providers = $this->getProviders();
|
||
if (!isset($providers[$providerKey])) {
|
||
throw new RuntimeException("AI 提供商不存在: {$providerKey}");
|
||
}
|
||
|
||
$this->configService->set('log_analysis.active_ai_provider', $providerKey);
|
||
$this->currentProvider = $providers[$providerKey];
|
||
$this->currentProvider['key'] = $providerKey;
|
||
}
|
||
|
||
/**
|
||
* 检查是否已配置
|
||
*/
|
||
public function isConfigured(): bool
|
||
{
|
||
return $this->getActiveProvider() !== null;
|
||
}
|
||
|
||
/**
|
||
* 发送聊天请求 (OpenAI 兼容接口)
|
||
*
|
||
* @param array $messages 消息数组
|
||
* @param string|null $systemPrompt 系统提示词
|
||
* @param int $maxRetries 最大重试次数
|
||
* @return string AI 响应内容
|
||
*/
|
||
public function chat(array $messages, ?string $systemPrompt = null, int $maxRetries = 3): string
|
||
{
|
||
$provider = $this->getActiveProvider();
|
||
if (!$provider) {
|
||
throw new RuntimeException('未配置 AI 服务,请在设置页面配置 AI 提供商');
|
||
}
|
||
|
||
// 速率限制
|
||
$this->waitForRateLimit($provider['key'] ?? 'default');
|
||
|
||
$allMessages = [];
|
||
if ($systemPrompt) {
|
||
$allMessages[] = ['role' => 'system', 'content' => $systemPrompt];
|
||
}
|
||
$allMessages = array_merge($allMessages, $messages);
|
||
|
||
$endpoint = rtrim($provider['endpoint'], '/') . '/chat/completions';
|
||
|
||
$lastException = null;
|
||
for ($attempt = 1; $attempt <= $maxRetries; $attempt++) {
|
||
$response = Http::timeout($provider['timeout'] ?? 120)
|
||
->withHeaders([
|
||
'Authorization' => 'Bearer ' . $provider['api_key'],
|
||
'Content-Type' => 'application/json',
|
||
])
|
||
->post($endpoint, [
|
||
'model' => $provider['model'],
|
||
'messages' => $allMessages,
|
||
'temperature' => $provider['temperature'] ?? 0.3,
|
||
'max_tokens' => $provider['max_tokens'] ?? 4096,
|
||
]);
|
||
|
||
if ($response->successful()) {
|
||
return $response->json('choices.0.message.content', '');
|
||
}
|
||
|
||
// 处理 429 Too Many Requests 错误
|
||
if ($response->status() === 429) {
|
||
$retryAfter = $response->header('Retry-After');
|
||
$waitSeconds = $retryAfter ? (int) $retryAfter : min(pow(2, $attempt) * 5, 60);
|
||
|
||
if ($attempt < $maxRetries) {
|
||
sleep($waitSeconds);
|
||
continue;
|
||
}
|
||
}
|
||
|
||
$error = $response->json('error.message') ?? $response->body();
|
||
$lastException = new RuntimeException("AI 请求失败: {$error}");
|
||
|
||
// 对于非 429 错误,不重试
|
||
if ($response->status() !== 429) {
|
||
throw $lastException;
|
||
}
|
||
}
|
||
|
||
throw $lastException ?? new RuntimeException('AI 请求失败: 未知错误');
|
||
}
|
||
|
||
/**
|
||
* 分析日志并返回结构化结果
|
||
*
|
||
* @param string $logsContent 日志内容
|
||
* @param string|null $codeContext 相关代码上下文
|
||
* @return array 分析结果
|
||
*/
|
||
public function analyzeLogs(string $logsContent, ?string $codeContext = null): array
|
||
{
|
||
$systemPrompt = $this->buildAnalysisSystemPrompt();
|
||
$userContent = $this->buildAnalysisUserPrompt($logsContent, $codeContext);
|
||
|
||
$response = $this->chat([
|
||
['role' => 'user', 'content' => $userContent],
|
||
], $systemPrompt);
|
||
|
||
return $this->parseAnalysisResponse($response);
|
||
}
|
||
|
||
/**
|
||
* 测试连接
|
||
*/
|
||
public function testConnection(): array
|
||
{
|
||
$provider = $this->getActiveProvider();
|
||
if (!$provider) {
|
||
return [
|
||
'success' => false,
|
||
'message' => '未配置 AI 服务',
|
||
];
|
||
}
|
||
|
||
try {
|
||
$response = $this->chat([
|
||
['role' => 'user', 'content' => 'Hello, please respond with "OK" only.'],
|
||
]);
|
||
|
||
return [
|
||
'success' => true,
|
||
'message' => '连接成功',
|
||
'provider' => $provider['name'] ?? $provider['key'],
|
||
'model' => $provider['model'],
|
||
];
|
||
} catch (\Exception $e) {
|
||
return [
|
||
'success' => false,
|
||
'message' => $e->getMessage(),
|
||
];
|
||
}
|
||
}
|
||
|
||
private function buildAnalysisSystemPrompt(): string
|
||
{
|
||
return <<<'PROMPT'
|
||
你是一个专业的日志分析专家。你的任务是分析提供的日志内容,识别异常和问题,并给出分类和建议。
|
||
|
||
请严格按照以下 JSON 格式返回分析结果,不要添加任何额外的文字说明或代码块标记:
|
||
{
|
||
"core_anomalies": [
|
||
{
|
||
"type": "error|warning|performance|security",
|
||
"classification": "database|network|application|configuration|resource|other",
|
||
"count": 数量,
|
||
"sample": "示例日志内容",
|
||
"possible_cause": "可能的原因",
|
||
"suggestion": "建议的解决方案"
|
||
}
|
||
],
|
||
"summary": "整体分析摘要",
|
||
"impact": "high|medium|low",
|
||
"trends": "趋势分析(可选)"
|
||
}
|
||
|
||
分类说明:
|
||
- type: error(错误), warning(警告), performance(性能问题), security(安全问题)
|
||
- classification: database(数据库), network(网络), application(应用逻辑), configuration(配置), resource(资源), other(其他)
|
||
- impact: high(高影响), medium(中等影响), low(低影响)
|
||
|
||
重要:只返回纯 JSON,不要使用 markdown 代码块(```json)包裹。
|
||
PROMPT;
|
||
}
|
||
|
||
private function buildAnalysisUserPrompt(string $logsContent, ?string $codeContext): string
|
||
{
|
||
$prompt = "请分析以下日志内容:\n\n```\n{$logsContent}\n```";
|
||
|
||
if ($codeContext) {
|
||
$prompt .= "\n\n相关代码上下文:\n\n```\n{$codeContext}\n```";
|
||
}
|
||
|
||
return $prompt;
|
||
}
|
||
|
||
private function parseAnalysisResponse(string $response): array
|
||
{
|
||
$jsonContent = $response;
|
||
|
||
// 1. 首先尝试从 markdown 代码块中提取 JSON
|
||
// 匹配 ```json ... ``` 或 ``` ... ``` 格式(使用贪婪匹配获取最后一个代码块)
|
||
if (preg_match_all('/```(?:json)?\s*([\s\S]*?)```/', $response, $matches)) {
|
||
// 尝试每个匹配的代码块,找到有效的 JSON
|
||
foreach ($matches[1] as $match) {
|
||
$trimmed = trim($match);
|
||
$decoded = json_decode($trimmed, true);
|
||
if (json_last_error() === JSON_ERROR_NONE && is_array($decoded) && isset($decoded['core_anomalies'])) {
|
||
return $decoded;
|
||
}
|
||
}
|
||
// 如果没有找到有效的 JSON,使用第一个匹配
|
||
$jsonContent = trim($matches[1][0]);
|
||
}
|
||
|
||
// 2. 尝试直接解析(可能已经是纯 JSON)
|
||
$decoded = json_decode($jsonContent, true);
|
||
if (json_last_error() === JSON_ERROR_NONE && is_array($decoded)) {
|
||
return $decoded;
|
||
}
|
||
|
||
// 3. 尝试从内容中提取 JSON 对象(处理前后有其他文本的情况)
|
||
// 使用更精确的匹配:找到包含 core_anomalies 的 JSON 对象
|
||
if (preg_match('/\{[^{}]*"core_anomalies"[\s\S]*}/', $response, $matches)) {
|
||
$decoded = json_decode($matches[0], true);
|
||
if (json_last_error() === JSON_ERROR_NONE && is_array($decoded)) {
|
||
return $decoded;
|
||
}
|
||
}
|
||
|
||
// 4. 最后尝试匹配任意 JSON 对象
|
||
if (preg_match('/\{[\s\S]*}/', $jsonContent, $matches)) {
|
||
$decoded = json_decode($matches[0], true);
|
||
if (json_last_error() === JSON_ERROR_NONE && is_array($decoded)) {
|
||
return $decoded;
|
||
}
|
||
}
|
||
|
||
// 5. 如果无法解析 JSON,返回原始响应
|
||
return [
|
||
'core_anomalies' => [],
|
||
'summary' => $response,
|
||
'impact' => 'unknown',
|
||
'raw_response' => $response,
|
||
];
|
||
}
|
||
|
||
private function waitForRateLimit(string $providerKey): void
|
||
{
|
||
$key = "ai_rate_limit:{$providerKey}";
|
||
|
||
// 每分钟最多 60 次请求
|
||
$maxAttempts = 60;
|
||
$decaySeconds = 60;
|
||
|
||
if (RateLimiter::tooManyAttempts($key, $maxAttempts)) {
|
||
$seconds = RateLimiter::availableIn($key);
|
||
sleep($seconds);
|
||
}
|
||
|
||
RateLimiter::hit($key, $decaySeconds);
|
||
}
|
||
}
|