AI对话
AI对话
业务用途
平台提供两种 AI 对话形态:
- 普通问答(
POST /api/ai/chat):面向「操作指引」类问题,内置规则模板回答;同时具备菜单导航意图识别--用户说「打开采购订单」能直接返回对应菜单路径,前端跳转。 - 表单设计器流式对话(
POST /api/ai/form-designer-chat):面向 的「用自然语言改表单」场景,用 SSE(text/event-stream)回传结果,让大模型直接产出可应用的 form-create 规则 JSON。
两者实现差别
普通问答 /chat 不调用大模型,靠关键词匹配 + 模板字符串回答(响应快、零成本)。 表单设计器对话 /form-designer-chat 调用默认大模型,并把结果通过 SSE 推给前端。注意:当前实现是「阻塞调用 + 一次性推送」,并非逐 token 流式--后端调 generateText 拿到完整回答后,作为一个 SSE 事件发出,再发 [DONE]。
涉及文件
| 角色 | 文件路径 |
|---|---|
| AI 助手控制器 | backend/src/main/java/com/lowcode/controller/AiAssistantController.java |
| AI 调用服务 | backend/src/main/java/com/lowcode/service/AiProviderService.java |
| 模型配置服务 | backend/src/main/java/com/lowcode/service/AiModelConfigService.java |
| 表关系服务 | backend/src/main/java/com/lowcode/service/TableRelationService.java |
| 菜单服务 | backend/src/main/java/com/lowcode/service/ISysMenuService.java |
| JWT 工具 | backend/src/main/java/com/lowcode/security/JwtUtil.java |
| 前端 API | frontend/src/api/ai.ts |
| 表单设计器 | frontend/src/views/designer/form/builder.vue |
后端实现(关键代码)
端点一览
| 方法 | 路径 | Content-Type | 说明 |
|---|---|---|---|
POST | /api/ai/chat | application/json | 普通问答 + 菜单导航 |
POST | /api/ai/form-designer-chat | text/event-stream | 表单设计器流式对话(SSE) |
普通问答 /chat
AiAssistantController.chat 接收 ChatRequest{question, context},流程:
- 先
tryResolveNavigation(question, httpRequest)尝试解析为菜单导航意图。 - 若命中导航:返回
type=navigation+answer(「已为您找到 xxx,正在打开」)+navigation{menuId, menuName, path, confidence, matchedText}。 - 否则走
generateAnswer(question, context)规则回答,返回type=answer+answer+suggestions(建议追问)。
@PostMapping("/chat")
public Result<Map<String, Object>> chat(@RequestBody ChatRequest request, HttpServletRequest httpRequest) {
String question = request.getQuestion();
try {
Map<String, Object> navigation = tryResolveNavigation(question, httpRequest);
Map<String, Object> result = new HashMap<>();
if (navigation != null) {
result.put("type", "navigation");
result.put("answer", "已为您找到「" + navigation.get("menuName") + "」,正在打开。");
result.put("navigation", navigation);
return Result.success(result);
}
result.put("type", "answer");
result.put("answer", generateAnswer(question, context));
result.put("suggestions", generateSuggestions(question));
return Result.success(result);
} catch (Exception e) {
return Result.error("AI处理失败: " + e.getMessage());
}
}菜单导航意图识别
tryResolveNavigation 的逻辑:
- 从请求里取 JWT(
jwtUtil.getTokenFromRequest),解析userId/tenantId;无 token 直接返回 null(不导航)。 sysMenuService.getUserMenuTree(userId, tenantId)取当前用户可见菜单树。flattenMenuTree递归展平,跳过按钮类菜单(menuType=F/button),只保留目录/菜单类(M/C/dir/page/menu/directory),拼接完整fullPath。matchNavigationMenu:对问题做normalize(转小写、去标点、剥离导航动词),再与每个菜单名做包含匹配打分(完全相等 100 分、问题包含菜单名 80+长度、菜单名包含问题 60+长度),取最高分。- 命中则返回
{menuId, menuName, path, confidence:1.0, matchedText}。
导航动词词典
NAV_VERBS 是有序列表,从长到短匹配剥离:「帮我打开」「请帮我打开」「麻烦打开一下」「帮我跳转」「跳转至」「导航到」「帮我去」「我想去」「打开」「进入」「跳转」「前往」「访问」「去」等。normalize 会先去掉标点再按前缀剥离这些动词,剩下的文本再去匹配菜单名。
generateAnswer 是纯关键词模板回答(含「怎么建表」「怎么表单」「怎么流程」「怎么列表」「怎么权限」「怎么部署」等分支),不调用模型。
表单设计器流式对话 /form-designer-chat
@PostMapping(value = "/form-designer-chat", produces = MediaType.TEXT_EVENT_STREAM_VALUE) 返回 SseEmitter,超时 120 秒。控制器起一个新线程执行:
- 查默认模型:
enabled='1' AND defaultModel='1' LIMIT 1,查不到通过 SSE 回「请先在系统设置中配置并启用默认AI模型」并结束。 buildFormRelationContext(tableId, appId):取主表字段 + 子表关系(tableRelationService.listByAppId或listByTableId),排除系统列与外键列,组装关系上下文。buildFormDesignerPrompt(request):拼接长 prompt,约束模型只输出```fcRuleDiff代码块、内容为 form-create rule 数组 JSON。aiProviderService.generateText(config, prompt, 8192)阻塞调用拿完整回答。normalizeFormDesignerAnswer(answer):提取fcRuleDiff代码块 JSON,用 Jackson 规范化(删内部字段_fc_id/_fc_drag_tag等、el-row/div拍平为col、el-col转col、输入类组件补style.width:100%),重新包成```fcRuleDiff\n<json>\n```。sendSseContent把结果作为一个 SSE event 发出(数据形状仿 OpenAI delta),sendSseDone发[DONE]并complete()。
@PostMapping(value = "/form-designer-chat", produces = MediaType.TEXT_EVENT_STREAM_VALUE)
public SseEmitter formDesignerChat(@RequestBody FormDesignerAiRequest request,
@RequestParam(required = false) Long tableId,
@RequestParam(required = false) Long appId) {
SseEmitter emitter = new SseEmitter(120000L);
new Thread(() -> {
try {
AiModelConfig config = aiModelConfigService.getOne(new LambdaQueryWrapper<AiModelConfig>()
.eq(AiModelConfig::getEnabled, "1")
.eq(AiModelConfig::getDefaultModel, "1")
.last("LIMIT 1"));
if (config == null) {
sendSseContent(emitter, "请先在系统设置中配置并启用默认AI模型");
sendSseDone(emitter);
return;
}
request.setRelationContext(buildFormRelationContext(tableId, appId));
String prompt = buildFormDesignerPrompt(request);
String answer = aiProviderService.generateText(config, prompt, 8192);
sendSseContent(emitter, normalizeFormDesignerAnswer(answer));
sendSseDone(emitter);
} catch (Exception e) {
sendSseContent(emitter, "AI助手调用失败: " + e.getMessage());
sendSseDone(emitter);
}
}).start();
return emitter;
}SSE 数据格式
sendSseContent 把内容包成仿 OpenAI 流式 delta 的 JSON 字符串,前缀加一个空格:
private void sendSseContent(SseEmitter emitter, String content) throws IOException {
String data = objectMapper.writeValueAsString(Map.of(
"choices", List.of(Map.of("delta", Map.of("content", content)))
));
emitter.send(SseEmitter.event().data(" " + data));
}
private void sendSseDone(SseEmitter emitter) throws IOException {
emitter.send(SseEmitter.event().data("[DONE]"));
emitter.complete();
}不是逐 token 流
generateText 是阻塞 POST,拿到的是完整回答。所以前端收到的 SSE 实际是「一个 content 事件 + 一个 [DONE]」,不是逐字推送。若要真正的逐 token 流,需改造 AiProviderService 支持 stream=true 并逐块 emitter.send。
form-designer prompt 关键约束
buildFormDesignerPrompt 用 13 条规则约束模型输出,核心几条:
- 回复必须以
```fcRuleDiff开头,只输出一个代码块,内容只能是新的 form-create rule 数组 JSON。 - 删除
_fc_id/name/display/hidden/_fc_drag_tag等内部字段。 - 每个字段只保留
type/field/title/props/validate。 - 输入类组件
props必须含{"style":{"width":"100%"}}。 - 多列布局只能用
col容器(span12=二分之一,8=三分之一,6=四分之一,24=整行)。 - 严禁输出
div/p/el-row/el-col/row/grid作为 type。 - 主子表单时,子表只影响主表布局取舍,不要把外键字段暴露给业务用户填写。
请求体 FormDesignerAiRequest 含:basic / ui / form(当前表单 JSON)/ relationContext(主子表关系)/ messages(对话历史,取最后一条 content 作为用户要求)。
前端实现
frontend/src/api/ai.ts 封装普通对话:
export interface AiChatResponse {
type: 'navigation' | 'answer'
answer: string
suggestions?: string[]
navigation?: AiNavigationAction
}
export const chatWithAiAssistant = (data: AiChatRequest) =>
request.post<AiChatResponse>('/ai/chat', data)前端收到 type=navigation 时,用 navigation.path 做路由跳转;type=answer 时展示 answer 文本并渲染 suggestions 为可点追问。
表单设计器对话(views/designer/form/builder.vue)用 fetch / EventSource 消费 SSE:逐个读取 event,解析 choices[0].delta.content 累加,遇 [DONE] 结束;提取 ```fcRuleDiff 代码块应用到设计器画布。
操作步骤
普通问答
- 确保已登录(导航需 JWT)。
- 在平台右上角 AI 助手输入框输入问题,如「怎么建表」「打开采购订单」。
- 后端返回
type:navigation则前端跳菜单,answer则展示文本 + 建议追问。

表单设计器对话
- 先在 里配好默认模型。
- 进入「表单设计器」,打开某张表的主表单。
- 在设计器 AI 面板输入要求,如「加一个联系电话字段,必填,放在名称下面一行两列布局」。
- 前端以 SSE 调
POST /api/ai/form-designer-chat?tableId=<主表>&appId=<应用>,body 含当前formJSON 与messages。 - 后端调模型生成
fcRuleDiff,规范化后回推。 - 前端提取代码块 JSON,diff 应用到画布(保留未改字段,替换/新增目标字段)。


常见问题
表单设计器对话报「请先在系统设置中配置并启用默认AI模型」
form-designer-chat 直接查 enabled='1' AND defaultModel='1'。先去 新增并设默认。
导航不生效 / 说不打开菜单
导航需要请求带有效 JWT(jwtUtil.getTokenFromRequest),未登录或 token 失效时 tryResolveNavigation 直接返回 null,走普通回答。另外菜单名匹配靠 normalize 去标点 + 动词剥离 + 包含打分,若菜单名与问题描述差异太大会漏匹配。
SSE 收不到数据 / 一直转圈
SseEmitter 超时 120 秒。模型 max_tokens=8192 且较慢时,阻塞调用可能超 60 秒(RestTemplate 读超时)导致异常,会通过 SSE 回「AI助手调用失败」。检查模型响应速度,或换更快的模型。前端需正确处理 SSE 事件流(不要用普通 axios,要用 EventSource 或 fetch ReadableStream)。
模型返回的表单规则预览错乱
normalizeFormDesignerAnswer 会把 el-row/div 拍平、el-col 转 col、补宽度样式。若模型仍输出被禁止的 type(如 grid),规范化不会自动转,需调整 prompt 或换遵循指令更好的模型(DeepSeek/Qwen 通常较稳)。
普通问答回答很「套路」
/chat 的 generateAnswer 是固定模板,不调模型。想要更智能的问答需自行改造:在 generateAnswer 里改为调 aiProviderService.generateText。当前设计是为省成本与保证响应速度。
