'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 $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('/(?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; } }