utils->errorThrow('请输入模块名'); } if ($description === '') { $this->utils->errorThrow('请输入功能描述'); } $runtime = AiRuntimeConfigService::getInstance()->resolve(); $log = AiGenerationLogService::getInstance(); $generationId = $log->begin([ 'scene' => AiGenerationSceneEnum::CODE_GENERATION->value, 'name' => $moduleName, 'provider' => (string) ($runtime['provider'] ?? ''), 'model' => (string) ($runtime['model'] ?? ''), 'api_key_id' => (int) ($runtime['api_key_id'] ?? 0), 'admin_id' => $this->userId, 'input_snapshot' => [ 'module_name' => $moduleName, 'description' => $description, 'pid' => $pid, ], ]); try { $agent = AiAgentFactory::getInstance()->make(); $messages = [ ['role' => 'system', 'content' => $this->buildSystemPrompt()], ['role' => 'user', 'content' => $this->buildUserPrompt($moduleName, $description, $pid)], ]; $result = $agent->chatCompletions($messages, [ 'temperature' => 0.2, 'max_tokens' => 4096, ]); $parsed = $this->parseJsonContent($result->content); // 强制回填用户选择的父级菜单,避免模型改写 pid $parsed['pid'] = $pid; if (empty($parsed['class_comment'])) { $parsed['class_comment'] = $moduleName; } if (empty($parsed['icon'])) { $parsed['icon'] = 'lucide:box'; } if (!isset($parsed['sort']) || $parsed['sort'] === '') { $parsed['sort'] = 9999; } $this->validateResult($parsed); $log->markSuccess($generationId, [ 'provider' => $result->provider, 'model' => $result->model, 'api_key_id' => $result->apiKeyId, 'usage' => $result->usage, 'result_json' => $parsed, 'raw_response' => $result->content, 'name' => (string) ($parsed['class_comment'] ?? $moduleName), ]); return $parsed; } catch (Throwable $e) { $log->markFail($generationId, $e->getMessage(), [ 'provider' => (string) ($runtime['provider'] ?? ''), 'model' => (string) ($runtime['model'] ?? ''), 'api_key_id' => (int) ($runtime['api_key_id'] ?? 0), ]); throw $e; } } /** * 系统提示词:用「完整示例」约束输出,避免模型把说明文字当成字段值 * 为什么不用 schema 占位描述:模型常把 "snake_case 字段名,不要 id..." 原样抄进 name,导致建表 SQL 语法错误 */ private function buildSystemPrompt(): string { return <<<'PROMPT' 你是 nl-admin 后台代码生成助手。根据用户的模块名与功能描述,输出【仅一个】合法 JSON 对象。 禁止输出 markdown、代码围栏、解释文字。禁止把规则说明文字当作字段值。 【输出示例】(请按同样结构填写真实业务字段,不要照抄示例字段名): { "class_name": "VipMember", "class_comment": "VIP会员", "icon": "lucide:crown", "sort": 9999, "pid": 0, "field": [ { "name": "title", "type": "varchar", "type_length": "64", "default": "", "comment": "标题", "not_null": true, "formShow": true, "tableShow": true, "formType": "VbenInput", "search": true, "searchValue": "like" }, { "name": "level", "type": "tinyint", "type_length": "1", "default": "0", "comment": "等级", "not_null": true, "formShow": true, "tableShow": true, "formType": "InputNumber", "search": true, "searchValue": "=" }, { "name": "remark", "type": "varchar", "type_length": "255", "default": "", "comment": "备注", "not_null": false, "formShow": true, "tableShow": false, "formType": "Textarea", "search": false, "searchValue": "=" } ] } 硬性规则: 1. class_name:仅英文 PascalCase(如 User、OrderItem、VipMember),禁止中文、空格、下划线、连字符。 2. field[].name:仅小写 snake_case(如 user_name、phone),禁止中文、空格、说明性长句。 3. 禁止生成这些字段名:id、status、created_at、updated_at、deleted_at(系统自动加)。 4. type 只能是:varchar、int、tinyint、text、decimal、bigint、char、json。 5. formType 只能是:VbenInput、Textarea、InputNumber、VbenSelect、Upload、Avatar。 6. searchValue 只能是:=、like、>、<、between。 7. field 至少 2 个、最多 20 个业务字段;按描述合理推断,不要编造无关字段。 8. 只输出 JSON 对象本身,第一个字符必须是 {,最后一个字符必须是 }。 PROMPT; } /** * 用户提示词:带上模块上下文 */ private function buildUserPrompt(string $moduleName, string $description, int $pid): string { return "模块名:{$moduleName}\n功能描述:{$description}\n父级菜单ID:{$pid}\n请按系统示例结构输出 JSON(填真实业务字段,不要照抄示例)。"; } /** * 从模型回复中提取 JSON(兼容偶发的 ```json 包裹) */ private function parseJsonContent(string $content): array { $content = trim($content); if ($content === '') { $this->utils->errorThrow('AI 未返回内容'); } if (preg_match('/```(?:json)?\s*([\s\S]*?)```/i', $content, $m)) { $content = trim($m[1]); } $start = strpos($content, '{'); $end = strrpos($content, '}'); if ($start === false || $end === false || $end <= $start) { $this->utils->errorThrow('AI 返回不是有效 JSON'); } $json = substr($content, $start, $end - $start + 1); $data = json_decode($json, true); if (!is_array($data)) { $this->utils->errorThrow('AI 返回 JSON 解析失败'); } return $data; } /** * 严格校验 AI 结果,避免脏数据进入建表 SQL * 为什么要严:模型偶发把提示词说明抄进 name/class_name,会导致 CREATE TABLE 语法错误 */ private function validateResult(array $data): void { $className = trim((string) ($data['class_name'] ?? '')); if ($className === '') { $this->utils->errorThrow('AI 未生成有效类名'); } if (!preg_match('/^[A-Z][A-Za-z0-9]{0,63}$/', $className)) { $this->utils->errorThrow("类名非法「{$className}」,须为英文 PascalCase(如 VipMember)"); } if (empty($data['field']) || !is_array($data['field'])) { $this->utils->errorThrow('AI 未生成字段列表'); } if (count($data['field']) < 1) { $this->utils->errorThrow('AI 字段列表为空'); } $names = []; foreach ($data['field'] as $i => $field) { if (!is_array($field)) { $this->utils->errorThrow('字段第 ' . ($i + 1) . ' 项格式错误'); } $name = trim((string) ($field['name'] ?? '')); $type = strtolower(trim((string) ($field['type'] ?? ''))); if ($name === '') { $this->utils->errorThrow('字段第 ' . ($i + 1) . ' 项缺少 name'); } // 过滤模型把整段说明塞进 name 的情况 if (mb_strlen($name) > 64 || preg_match('/\s|,|。|:|:|\||(|)|\(|\)/u', $name)) { $this->utils->errorThrow("字段名非法「{$name}」,须为短小写 snake_case"); } if (!preg_match('/^[a-z][a-z0-9_]{0,63}$/', $name)) { $this->utils->errorThrow("字段名非法「{$name}」,须为小写 snake_case(如 user_name)"); } if (in_array($name, self::RESERVED_FIELDS, true)) { $this->utils->errorThrow("字段「{$name}」为系统保留字段,请勿生成"); } if (!in_array($type, self::ALLOWED_TYPES, true)) { $this->utils->errorThrow("字段「{$name}」类型非法「{$type}」"); } if (isset($names[$name])) { $this->utils->errorThrow("字段名重复「{$name}」"); } $names[$name] = true; } } }