Files
toolbox/app/Clients/AiClient.php

329 lines
11 KiB
PHP
Raw Permalink 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
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);
}
}