Files
Leantime/app/Domain/Ai/Services/AiChat.php

229 lines
7.8 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 Leantime\Domain\Ai\Services;
use Illuminate\Support\Facades\Http;
use Laravel\Mcp\Server\Tool;
use Leantime\Core\Domains\BaseService;
use Leantime\Domain\Setting\Services\Setting as SettingService;
/**
* OneBot 界面内 AI 聊天代理。
*
* 调用 OpenAI 兼容的 /chat/completionsDeepSeek 官方 API 或本地千问 Ollama
* 支持 function-calling把 MCP Tools 类转成 OpenAI tools 定义LLM 请求工具调用时
* 执行对应 Tool::handle(),结果回填 messages 继续,直到 LLM 返回最终文本。
*/
class AiChat extends BaseService
{
/** 默认 LLM 配置(.env 或 settings 可覆盖) */
private const DEFAULTS = [
'provider' => 'deepseek', // deepseek | ollama
'baseUrl' => 'https://api.deepseek.com/v1',
'model' => 'deepseek-chat',
'apiKey' => '',
'temperature' => '0.7',
'maxTokens' => '2048',
];
public function __construct(
private SettingService $settingService,
) {}
/**
* 发送一条用户消息,返回 AI 最终回复文本(已执行工具调用)。
*
* @param array<int,array{role:string,content:string}> $messages 历史消息(含本次 user 消息)
* @return array{success:bool,content?:string,error?:string}
*/
public function chat(array $messages): array
{
$cfg = $this->config();
if (empty($cfg['apiKey'])) {
return ['success' => false, 'error' => '尚未配置 AI 服务apiKey 为空)。请在 .env 或「AI 设置」中填写 provider/baseUrl/model/apiKey。'];
}
$tools = $this->buildTools();
// 最多循环 8 轮,防止工具调用死循环
for ($round = 0; $round < 8; $round++) {
$resp = Http::withToken($cfg['apiKey'])
->withoutVerifying()
->timeout(120)
->post(rtrim($cfg['baseUrl'], '/').'/chat/completions', [
'model' => $cfg['model'],
'messages' => $messages,
'temperature' => (float) $cfg['temperature'],
'max_tokens' => (int) $cfg['maxTokens'],
'tools' => $tools,
'tool_choice' => 'auto',
]);
if (! $resp->successful()) {
return ['success' => false, 'error' => "LLM 调用失败 HTTP {$resp->status()}: ".substr($resp->body(), 0, 300)];
}
$body = $resp->json();
$choice = $body['choices'][0] ?? null;
if ($choice === null) {
return ['success' => false, 'error' => 'LLM 返回为空'];
}
$message = $choice['message'] ?? [];
$toolCalls = $message['tool_calls'] ?? [];
// 无工具调用 → 最终回复
if (empty($toolCalls)) {
return ['success' => true, 'content' => (string) ($message['content'] ?? '')];
}
// 有工具调用 → 把 assistant 消息(含 tool_calls追加逐个执行工具回填 tool 结果
$messages[] = $message;
foreach ($toolCalls as $tc) {
$toolName = $tc['function']['name'] ?? '';
$toolArgs = json_decode($tc['function']['arguments'] ?? '{}', true) ?: [];
$toolResult = $this->callTool($toolName, $toolArgs);
$messages[] = [
'role' => 'tool',
'tool_call_id' => $tc['id'] ?? '',
'content' => $toolResult,
];
}
}
return ['success' => false, 'error' => '工具调用轮次过多,已中止。'];
}
/**
* 当前 AI 配置settings 优先,其次 .env最后默认值
*/
private function config(): array
{
$cfg = self::DEFAULTS;
foreach (['provider', 'baseUrl', 'model', 'apiKey', 'temperature', 'maxTokens'] as $k) {
$envKey = 'AI_'.strtoupper(preg_replace('/(?<!^)[A-Z]/', '_$0', $k));
$env = env($envKey);
$stored = $this->settingService->getSetting('ai.'.$k, false);
if ($stored !== false && $stored !== '' && $stored !== null) {
$cfg[$k] = $stored;
} elseif ($env !== null && $env !== '') {
$cfg[$k] = $env;
}
}
return $cfg;
}
/**
* 返回当前 AI 配置脱敏版apiKey 只显示前 6 位)。
* 供前端「AI 设置」面板展示。
*/
public function publicConfig(): array
{
$cfg = $this->config();
$key = (string) $cfg['apiKey'];
$cfg['apiKeyMasked'] = $key === '' ? '' : substr($key, 0, 6).'...';
$cfg['apiKey'] = ''; // 不向前端回传完整 key
return $cfg;
}
/**
* 把 MCP Tools 类转成 OpenAI function-calling 的 tools 定义。
*/
private function buildTools(): array
{
$tools = [];
$pattern = app()->basePath('app/Domain/*/Tools/*Tool.php');
foreach (glob($pattern) ?: [] as $file) {
$class = $this->classFromFile($file);
if ($class === null || ! class_exists($class) || ! is_subclass_of($class, Tool::class)) {
continue;
}
try {
/** @var Tool $instance */
$instance = app()->make($class);
$arr = $instance->toArray();
$tools[] = [
'type' => 'function',
'function' => [
'name' => $arr['name'],
'description' => $arr['description'],
'parameters' => $arr['inputSchema'],
],
];
} catch (\Throwable $e) {
// 单个工具失败不阻断
}
}
return $tools;
}
/**
* 执行一个工具(按 name 匹配 Tools 类),返回文本结果。
*/
private function callTool(string $toolName, array $args): string
{
$pattern = app()->basePath('app/Domain/*/Tools/*Tool.php');
foreach (glob($pattern) ?: [] as $file) {
$class = $this->classFromFile($file);
if ($class === null || ! class_exists($class) || ! is_subclass_of($class, Tool::class)) {
continue;
}
try {
/** @var Tool $instance */
$instance = app()->make($class);
if ($instance->name() !== $toolName) {
continue;
}
$result = $instance->handle($args);
$arr = $result->toArray();
// ToolResult 的 content 是 [{type,text}] 结构
$texts = [];
foreach (($arr['content'] ?? []) as $c) {
if (isset($c['text'])) {
$texts[] = $c['text'];
}
}
return $texts !== [] ? implode("\n", $texts) : json_encode($arr, JSON_UNESCAPED_UNICODE);
} catch (\Throwable $e) {
return '工具执行出错: '.$e->getMessage();
}
}
return "未知工具: {$toolName}";
}
/**
* 从文件路径推导类名app/Domain/Projects/Tools/GetAllProjectsTool.php
* → Leantime\Domain\Projects\Tools\GetAllProjectsTool
*/
private function classFromFile(string $file): ?string
{
$appRoot = rtrim(app()->basePath('app'), '/\\');
if (! str_starts_with($file, $appRoot)) {
return null;
}
$relative = substr($file, strlen($appRoot));
$relative = ltrim(str_replace(['/', '\\'], '\\', $relative), '\\');
$relative = preg_replace('/\.php$/', '', $relative);
if ($relative === '' || $relative === null) {
return null;
}
return app()->getNamespace().$relative;
}
}