Files
toolbox/app/Clients/AiClient.php

276 lines
8.4 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
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 系统提示词
* @return string AI 响应内容
*/
public function chat(array $messages, ?string $systemPrompt = null): 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';
$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()) {
$error = $response->json('error.message') ?? $response->body();
throw new RuntimeException("AI 请求失败: {$error}");
}
return $response->json('choices.0.message.content', '');
}
/**
* 分析日志并返回结构化结果
*
* @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 格式。
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
{
// 尝试提取 JSON
if (preg_match('/\{[\s\S]*}/', $response, $matches)) {
$json = $matches[0];
$decoded = json_decode($json, true);
if (json_last_error() === JSON_ERROR_NONE) {
return $decoded;
}
}
// 如果无法解析 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);
}
}