OneBot: Leantime 改造版源码(BOM/Univer 表格/AI 接管/品牌替换等)

This commit is contained in:
wangruiguo
2026-09-03 18:49:20 +08:00
commit d647428529
3501 changed files with 1988906 additions and 0 deletions

View File

@@ -0,0 +1,57 @@
<?php
namespace Leantime\Domain\Ai\Controllers;
use Illuminate\Http\Request;
use Leantime\Domain\Ai\Services\AiChat;
use Symfony\Component\HttpFoundation\Response;
/**
* 界面内 AI 聊天 JSON API原生 Laravel 控制器)。
* 前端右下角悬浮窗发 {messages},后端代理 DeepSeek/千问并执行工具调用。
*/
class ChatApi
{
public function __construct(
private AiChat $aiChat,
) {}
/**
* POST /ai/chat
*/
public function chat(Request $request): Response
{
$messages = $request->input('messages', []);
if (! is_array($messages) || empty($messages)) {
return response()->json(['status' => 'error', 'message' => 'messages 不能为空'], 400);
}
// 只保留 role/content 字段,防止注入多余字段
$clean = [];
foreach ($messages as $m) {
if (is_array($m) && isset($m['role'])) {
$clean[] = [
'role' => (string) $m['role'],
'content' => (string) ($m['content'] ?? ''),
];
}
}
$result = $this->aiChat->chat($clean);
return $result['success']
? response()->json(['status' => 'success', 'content' => $result['content']])
: response()->json(['status' => 'error', 'message' => $result['error'] ?? 'AI 调用失败'], 500);
}
/**
* GET /ai/config —— 返回当前 AI 配置脱敏apiKey 只显示前 6 位 + 掩码)。
*/
public function config(): Response
{
$cfg = $this->aiChat->publicConfig();
return response()->json(['status' => 'success', 'data' => $cfg]);
}
}

View File

@@ -0,0 +1,228 @@
<?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;
}
}

18
app/Domain/Ai/routes.php Normal file
View File

@@ -0,0 +1,18 @@
<?php
use Illuminate\Support\Facades\Route;
use Leantime\Domain\Ai\Controllers\ChatApi;
/*
|--------------------------------------------------------------------------
| AI Domain Routes
|--------------------------------------------------------------------------
| 界面内 AI 聊天面板 JSON API。
| POST /ai/chat —— 发送消息,后端代理 DeepSeek/千问 + 执行工具调用
| GET /ai/config —— 读取当前 AI 配置(脱敏)
*/
Route::prefix('ai')->group(function () {
Route::post('/chat', [ChatApi::class, 'chat']);
Route::get('/config', [ChatApi::class, 'config']);
});