Files
Leantime/app/Domain/Ai/Services/AiChat.php
wangruiguo d647428529
Some checks failed
Security Audit / Composer Security Audit (push) Has been cancelled
Security Audit / NPM Security Audit (push) Has been cancelled
Security Audit / CodeQL Analysis (push) Has been cancelled
OneBot: Leantime 改造版源码(BOM/Univer 表格/AI 接管/品牌替换等)
2026-09-03 18:49:20 +08:00

229 lines
7.8 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 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;
}
}