229 lines
7.8 KiB
PHP
229 lines
7.8 KiB
PHP
<?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/completions(DeepSeek 官方 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;
|
||
}
|
||
}
|