工程项目生成
本节重点
在前面的章节中,我们已经实现了对话历史功能,让 AI 能够基于历史上下文进行网站的迭代优化。但目前平台只支持生成原生网站,在实际使用中有一定局限性。
本节我们将挑战一个有点儿难度的功能 —— 生成更复杂的前端工程化项目,提高本平台的实用性。
主要内容包括:
- 工程项目生成方案设计
- 工程项目生成(工具调用流式输出)
- 工程项目构建和浏览
- 工程项目部署
- 前端开发
一、需求分析
前端工程化项目是指使用现代化工具链、规范化流程和组件化架构来构建的前端应用。相比传统的 HTML、CSS、JavaScript 三件套,它具备模块管理、自动化构建、代码分割、热更新等现代开发特性,能够更轻松地开发复杂网站。
现在很多前端工程化项目都是使用 Vue 或 React 框架,结合 Vite 等打包构建工具,再加上 ESLint 之类的代码规范校验库来实现的。
调研其他平台,美团 NoCode 平台支持 React 工程项目:
我们不妨就以 Vue 作为示例,让平台能够生成完整的 Vue3 + Vite 工程项目,并且跟其他两种生成模式一样,实现流式输出、网站浏览和部署。这样一来,我们的平台也能够生成更符合企业标准的项目。
友情提示,受限于大模型的能力,有时效果可能没有那么理想,但大家重点学习思路和方法。
二、方案设计
方案选型分析
通过对一些大厂生成平台的调研,我们可以初步想到 3 种复杂工程项目的生成方案。
方案 1 - 直接输出 Markdown
这种方案延续之前的思路,直接让 AI 在输出的 Markdown 中包含代码块,然后通过解析的方式保存文件。
这是我生成的项目:
```vue
App.vue ```
``` json
package.json ```
优点是实现简单、好理解、实时展示效果好。但缺点也很明显:如果代码量较大,一次对话可能无法完整输出,容易出现代码不全或者解析错误的情况。
方案 2 - 工具调用
给 AI 提供保存文件等工具,让 AI 来决定什么时候保存文件、保存哪些文件、要保存什么代码到文件中。
这种方式的基本实现很简单,不需要自己解析 AI 的输出并保存为文件,全都交给 AI 和框架来处理。
但如果想实时展示工具调用信息(比如要保存为文件的代码内容),就很复杂了!你需要解析 AI 响应的工具调用信息。由于流式输出的特性,AI 是一点点将 JSON 格式的工具调用信息 “吐” 出来的,你很难实时从中提取出有意义的、需要返回给用户的工具调用参数。
举个例子,完整的工具调用信息:
{
"id": "123",
"name": "writeFile",
"arguments": {
"relativePath": "src/App.vue",
"content": "网页代码"
}
}
但实际上,AI 会流式输出上述信息,比如可能是这样输出的:
"
relative
Path
:
"
src
/
App
.vue
"
content
":
也就是说,AI 的输出会把保存文件的路径和写入文件的内容拆开。如果想在前端实时展示出这些信息,就需要比较复杂的拼接逻辑,比如拼接好 relativePath 后,立刻展示出保存文件的路径。
假设这是后端实时返回给前端的工具调用信息,你应该如何解析和拼接参数呢?
data:{"d":"{\\"id\\":\\"call_1_06579b58-4206-4b3e-bd7f-2de949e39783\\",\\"name\\":\\"writeFile\\",\\"arguments\\":\\"{\\\\\\"\\",\\"type\\":\\"tool_request\\"}"}
data:{"d":"relative"}
data:{"d":"FilePath"}
data:{"d":"\\":\\""}
data:{"d":"v"}
data:{"d":"ite"}
data:{"d":".config"}
data:{"d":".js"}
data:{"d":"\\",\\""}
data:{"d":"content"}
data:{"d":"\\":\\""}
data:{"d":"import"}
data:{"d":" {"}
data:{"d":" define"}
data:{"d":"Config"}
data:{"d":" }"}
data:{"d":" from"}
想想就很复杂。。。而且不同工具的调用参数是不一样的!
此外,由于我们有对话历史模块,需要尽可能确保实时流式输出的内容和保存到数据库中的对话历史一致,这就进一步增加了开发成本。
方案 3 - Agent 模式
智能体(Agent)是指能够感知环境、进行推理、制定计划、做出决策并自主采取行动以实现特定目标的 AI 系统。它以大语言模型为核心,集成 记忆、知识库和工具 等能力为一体,构造了完整的决策能力、执行能力和记忆能力,就像一个有主观能动性的人类一样。
简单来说,Agent 的特点是 先规划再执行。比如先制定网站生成计划、然后分别在每个步骤中生成一个文件并解析。
步骤 1:我要生成网站文件 page1.vue、page2.vue、page3.vue
步骤 2:生成 page1.vue,然后保存
步骤 3:生成 page2.vue,然后保存
步骤 4:生成 page3.vue,然后保存
步骤 5:生成网站成功,退出执行
这样做的优点是,每一个步骤都非常清晰,在步骤内我可以通过工具调用来实现文件保存,也可以单独调用 AI 获取到生成的代码,然后再通过程序保存(就像我们之前一样),这样实现流式输出就很简单了。
此外,由于划分了多个步骤,即使某一个步骤出错,也能中断恢复,从而能够处理长逻辑的复杂任务。像鱼皮之前分享过的 Flowith 智能体,我一句话,让它执行了整整一天!
但 Agent 模式的缺点是整体架构更复杂,需要自己设计 Agent 流程;而且由于需要多次调用 AI,输出结果更加不可控,成本也更高。
💡 在 编程导航的 AI 超级智能体项目 中,鱼皮带大家分析了 OpenManus 智能体的源码,并且复刻了一个 Java 智能体,感兴趣的同学可以学习。
从美团 NoCode 的输出效果来看,很有可能采用了 Agent 模式,每一个步骤都可以做到流式输出,用户能即时看到生成的代码内容和 AI 的操作情况。
刷新页面后,你依然能看到上面的内容,跟实时流式输出时用户看到的效果一样。
这是怎么做到的呢?美团是怎么维护对话历史的呢?
通过分析网络请求,我们能获取到后端返回给前端的完整对话信息:
原来如此,美团自己约定了一些 工具标签,比如 mc-add-dependency 安装依赖、mc-write 保存文件,这样前端就能识别到展示什么内容了。
"我将为您创建一个复古波普艺术风格的电商网页,包含鲜艳的橙色和粉色撞色背景、漫画风格圆点图案和手绘插图,以及波普画框形式的商品展示区。\\n\\nSTEP 1: 安装必要的依赖\\n<mc-code>\\n<mc-add-dependency>framer-motion</mc-add-dependency>\\n</mc-code>\\n\\nSTEP 2: 更新首页为复古波普风格电商页面\\n<mc-code>\\n<mc-write file=\\"src/pages/Index.jsx\\">import { motion } from \\"framer-motion\\";\\nimport { ShoppingBag, Star, Heart } from \\"lucide-react\\";\\n\\nconst Index = () => {\\n const products = [\\n {\\n id: 1,\\n;</mc-write>\\n</mc-code>\\n\\n创建了一个充满活力的复古波普艺术风格电商首页,包含鲜艳的橙色粉色渐变背景、漫画风格圆点图案、波普画框形式的商品展示区以及带有跳跃动画的交互元素。"
最终方案确定
由于 LangChain4j 本身就支持 AI 多次调用工具,相当于已经实现了基础的 Agent 多步骤执行能力,因此考虑到开发复杂度,我们选择第 2 种方案 - 工具调用。
为了提升用户体验,需要为工具调用支持流式输出能力,但是只流式输出工具调用的基本信息,让用户能看到 AI 调用了哪些工具,避免了复杂的拼接解析逻辑(不利于项目扩展)。
由于不知道要生成多少个文件、以及文件的层级组织关系,需要使用工具调用来写入文件。工具调用中需要特别注意文件路径的处理。如果不使用工具调用,就要输出一大堆的代码块,自己解析,比如下列提示词格式:
## 必须遵守下列输出格式
- 你的整个回复必须是一系列 Markdown 代码块
- 每个代码块代表一个完整的文件
- 每个代码块的第一行必须是注释,用 `[FILE_PATH: ...]` 的格式清晰地标明该文件的路径
文件代码块示例:
格式 vue
// [FILE_PATH: src/components/NewComponent.vue]
然后是完整的代码内容
系统提示词
我们需要定义新的生成模式 Vue 工程模式(vue_project),这种模式使用 DeepSeek 的推理模型,提供的系统提示词也会更复杂。如下:
你是一位资深的 Vue3 前端架构师,精通现代前端工程化开发、组合式 API、组件化设计和企业级应用架构。
你的任务是根据用户提供的项目描述,创建一个完整的、可运行的 Vue3 工程项目
## 核心技术栈
- Vue 3.x(组合式 API)
- Vite
- Vue Router 4.x
- Node.js 18+ 兼容
## 项目结构
项目根目录/
├── index.html # 入口 HTML 文件
├── package.json # 项目依赖和脚本
├── vite.config.js # Vite 配置文件
├── src/
│ ├── main.js # 应用入口文件
│ ├── App.vue # 根组件
│ ├── router/
│ │ └── index.js # 路由配置
│ ├── components/ # 组件
│ ├── pages/ # 页面
│ ├── utils/ # 工具函数(如果需要)
│ ├── assets/ # 静态资源(如果需要)
│ └── styles/ # 样式文件
└── public/ # 公共静态资源(如果需要)
## 开发约束
1)组件设计:严格遵循单一职责原则,组件具有良好的可复用性和可维护性
2)API 风格:优先使用 Composition API,合理使用 `` 语法糖
3)样式规范:使用原生 CSS 实现响应式设计,支持桌面端、平板端、移动端的响应式适配
4)代码质量:代码简洁易读,避免过度注释,优先保证功能完整和样式美观
5)禁止使用任何状态管理库、类型校验库、代码格式化库
6)将可运行作为项目生成的第一要义,尽量用最简单的方式满足需求,避免使用复杂的技术或代码逻辑
## 参考配置
1)vite.config.js 必须配置 base 路径以支持子路径部署、需要支持通过 @ 引入文件、不要配置端口号
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
export default defineConfig({
base: './',
plugins: [vue()],
resolve: {
alias: {
'@': fileURLToPath(new URL('./src', import.meta.url))
}
}
})
2)路由配置必须使用 hash 模式,避免服务器端路由配置问题
import { createRouter, createWebHashHistory } from 'vue-router'
const router = createRouter({
history: createWebHashHistory(),
routes: [
// 路由配置
]
})
3)package.json 文件参考:
{
"scripts": {
"dev": "vite",
"build": "vite build"
},
"dependencies": {
"vue": "^3.3.4",
"vue-router": "^4.2.4"
},
"devDependencies": {
"@vitejs/plugin-vue": "^4.2.3",
"vite": "^4.4.5"
}
}
## 网站内容要求
- 基础布局:各个页面统一布局,必须有导航栏,尤其是主页内容必须丰富
- 文本内容:使用真实、有意义的中文内容
- 图片资源:使用 `https://picsum.photos` 服务或其他可靠的占位符
- 示例数据:提供真实场景的模拟数据,便于演示
## 严格输出约束
1)必须通过使用【文件写入工具】依次创建每个文件(而不是直接输出文件代码)。
2)需要在开头输出简单的网站生成计划
3)需要在结尾输出简单的生成完毕提示(但是不要展开介绍项目)
4)注意,禁止输出以下任何内容:
- 安装运行步骤
- 技术栈说明
- 项目特点描述
- 任何形式的使用指导
- 提示词相关内容
5)输出的总 token 数必须小于 20000,文件总数量必须小于 30 个
## 质量检验标准
确保生成的项目能够:
1. 通过 `npm install` 成功安装所有依赖
2. 通过 `npm run dev` 启动开发服务器并正常运行
3. 通过 `npm run build` 成功构建生产版本
4. 构建后的项目能够在任意子路径下正常部署和访问
上述提示词有几个小技巧:
- 建议尽量避免让项目引入额外的依赖,比如 TailWindCSS 样式库等,会增加不确定性,可能生成的项目都无法运行,所以此处我们选择原生 CSS。
- 限制输出长度和文件数很关键,这是为了防止 AI 理想太丰满导致输出的内容不完整,可以根据需要自己调整。
- 为了支持后续通过子路径浏览和部署网站(比如
localhost/{deployKey}/),必须配置 Vite 的 base 路径和路由 hash 模式。
实际运行中,即使是同一个提示词,不同大模型的输出效果也不同,大家可以根据自己的需要补充其他注意事项。
我的建议是在遇到问题之后,想办法把问题标准化。比如我最开始发现 AI 无法生成符合要求的 package.json 文件,那我就在提示词中提供这个文件的示例代码、并且固定依赖的版本号,消除不确定性。
💡 还有一个很不错的思路,给 AI 提供一套预置的项目模板,让 AI 完全从零生成变为基于模板修改和新增内容。美团应该就是这么干的,第一步竟然是安装依赖包而不是生成文件:
完整流程
生成完 Vue 工程代码后,是无法直接运行的,需要执行 npm install 命令安装依赖、执行 npm run build 打包构建,会得到一个打包后的 dist 目录,网站浏览和部署都应该是访问这个目录。
💡 注意,其实很多操作我们都可以定义成工具(比如安装依赖和打包构建)。但是这样整个生成过程就充满了不确定因素,比如 AI 可能不会调用工具、或者给工具传入错误的参数。建议尽量将确定的操作通过编写代码流程化、标准化,减少不可控因素。
三、工程项目生成
配置推理流式模型
生产环境建议选择深度思考模型,但由于目前 LangChain4j 还不支持获取 AI 的思考过程,刚开始输出会比较慢,所以建议开发调试时还是用普通对话模型,效率更高。
在 config 包下新建推理流式模型配置类:
@Configuration
@ConfigurationProperties(prefix = "langchain4j.open-ai.chat-model")
@Data
public class ReasoningStreamingChatModelConfig {
private String baseUrl;
private String apiKey;
/**
* 推理流式模型(用于 Vue 项目生成,带工具调用)
*/
@Bean
public StreamingChatModel reasoningStreamingChatModel() {
// 为了测试方便临时修改
final String modelName = "deepseek-chat";
final int maxTokens = 8192;
// 生产环境使用:
// final String modelName = "deepseek-reasoner";
// final int maxTokens = 32768;
return OpenAiStreamingChatModel.builder()
.apiKey(apiKey)
.baseUrl(baseUrl)
.modelName(modelName)
.maxTokens(maxTokens)
.logRequests(true)
.logResponses(true)
.build();
}
}
注意引入大模型的名称不要冲突,将之前引入的流式对话模型改为 openAiStreamingChatModel:
public class AiCodeGeneratorServiceFactory {
@Resource
private ChatModel chatModel;
@Resource
private StreamingChatModel openAiStreamingChatModel;
@Resource
private StreamingChatModel reasoningStreamingChatModel;
}
开发写文件工具
按照 LangChain4j 的工具开发方法,新建一个文件写入工具类,编写 writeFile 方法,并给方法打上 @Tool 工具注解。为了减轻工具幻觉(错误调用工具或者传参错误),尽量给工具和每个参数添加描述:
@Slf4j
public class FileWriteTool {
@Tool("写入文件到指定路径")
public String writeFile(
@P("文件的相对路径")
String relativeFilePath,
@P("要写入文件的内容")
String content
) {
// 具体实现
}
}
由于每个 appId 对应一个生成的网站,因此需要根据 appId 构造文件保存路径,可以利用 LangChain4j 工具调用提供的 上下文传参 能力。在 AI Service 对话方法中加上 memoryId 参数,然后就能在工具中使用 memoryId 了。
在 ai.tools 包下新建工具,代码如下:
/**
* 文件写入工具
* 支持 AI 通过工具调用的方式写入文件
*/
@Slf4j
public class FileWriteTool {
@Tool("写入文件到指定路径")
public String writeFile(
@P("文件的相对路径")
String relativeFilePath,
@P("要写入文件的内容")
String content,
@ToolMemoryId Long appId
) {
try {
Path path = Paths.get(relativeFilePath);
if (!path.isAbsolute()) {
// 相对路径处理,创建基于 appId 的项目目录
String projectDirName = "vue_project_" + appId;
Path projectRoot = Paths.get(AppConstant.CODE_OUTPUT_ROOT_DIR, projectDirName);
path = projectRoot.resolve(relativeFilePath);
}
// 创建父目录(如果不存在)
Path parentDir = path.getParent();
if (parentDir != null) {
Files.createDirectories(parentDir);
}
// 写入文件内容
Files.write(path, content.getBytes(),
StandardOpenOption.CREATE,
StandardOpenOption.TRUNCATE_EXISTING);
log.info("成功写入文件: {}", path.toAbsolutePath());
// 注意要返回相对路径,不能让 AI 把文件绝对路径返回给用户
return "文件写入成功: " + relativeFilePath;
} catch (IOException e) {
String errorMessage = "文件写入失败: " + relativeFilePath + ", 错误: " + e.getMessage();
log.error(errorMessage, e);
return errorMessage;
}
}
}
注意,工具必须要返回 相对路径,不能让 AI 把文件绝对路径返回给用户,会直接暴露服务器的存储路径!
支持 Vue 项目生成
1)保存提示词到资源目录下,给 AI Service 补充新的流式生成方法,注意参数中必须包含 @MemoryId,支持工具调用时获取到 appId。
/**
* 生成 Vue 项目代码(流式)
*
* @param userMessage 用户消息
* @return 生成过程的流式响应
*/
@SystemMessage(fromResource = "prompt/codegen-vue-project-system-prompt.txt")
Flux<String> generateVueProjectCodeStream(@MemoryId long appId, @UserMessage String userMessage);
注意工具调用 memoryId 必须也给方法参数补上 memoryId,而且有了 memoryId 就必须指定 memoryProvider。
2)修改 AiCodeGeneratorServiceFactory 服务构造工厂,根据代码生成类型选择不同的模型配置。
记得先给 CodeGenTypeEnum 补充新的枚举类型:
VUE_PROJECT("Vue 工程模式", "vue_project");
然后编写工厂代码:
/**
* 创建新的 AI 服务实例
*/
private AiCodeGeneratorService createAiCodeGeneratorService(long appId, CodeGenTypeEnum codeGenType) {
// 根据 appId 构建独立的对话记忆
MessageWindowChatMemory chatMemory = MessageWindowChatMemory
.builder()
.id(appId)
.chatMemoryStore(redisChatMemoryStore)
.maxMessages(20)
.build();
// 从数据库加载历史对话到记忆中
chatHistoryService.loadChatHistoryToMemory(appId, chatMemory, 20);
// 根据代码生成类型选择不同的模型配置
return switch (codeGenType) {
// Vue 项目生成使用推理模型
case VUE_PROJECT -> AiServices.builder(AiCodeGeneratorService.class)
.streamingChatModel(reasoningStreamingChatModel)
.chatMemoryProvider(memoryId -> chatMemory)
.tools(new FileWriteTool())
.hallucinatedToolNameStrategy(toolExecutionRequest -> ToolExecutionResultMessage.from(
toolExecutionRequest, "Error: there is no tool called " + toolExecutionRequest.name()
))
.build();
// HTML 和多文件生成使用默认模型
case HTML, MULTI_FILE -> AiServices.builder(AiCodeGeneratorService.class)
.chatModel(chatModel)
.streamingChatModel(openAiStreamingChatModel)
.chatMemory(chatMemory)
.build();
default -> throw new BusinessException(ErrorCode.SYSTEM_ERROR,
"不支持的代码生成类型: " + codeGenType.getValue());
};
}
注意,上述代码中,构造 Vue 模式的 AI Service 时,我们必须指定 chatMemoryProvider 的配置,为每个 memoryId 绑定会话记忆,否则调用对话方法时可能会报错。
此外,我们通过 hallucinatedToolNameStrategy(幻觉工具名称策略)配置了找不到工具时的处理策略,可以让框架帮我们处理 AI 出现幻觉的情况,比如告诉 AI “找不到工具”
💡 注意!这里最好做一些调整,防止 AI 一直无限循环调用工具,包括:
- 调大对话记忆的容量,否则 AI 会中途断片儿,忘记已经生成了哪些文件
- 尝试换其他的 AI 大模型
- 优化提示词
此外,还有其他的方法解决这个问题,在本项目第 11 期《系统优化》中有讲。
3)调整获取 AI Service 缓存的逻辑。因为现在不同生成模式获取到的 AI Service 不同,所以需要额外将 codeGenType 作为缓存 key 的构造条件:
/**
* AI 服务实例缓存
*/
private final Cache<String, AiCodeGeneratorService> serviceCache = Caffeine.newBuilder()
.maximumSize(1000)
.expireAfterWrite(Duration.ofMinutes(30))
.expireAfterAccess(Duration.ofMinutes(10))
.removalListener((key, value, cause) -> {
log.debug("AI 服务实例被移除,缓存键: {}, 原因: {}", key, cause);
})
.build();
/**
* 根据 appId 获取服务(带缓存)这个方法是为了兼容历史逻辑
*/
public AiCodeGeneratorService getAiCodeGeneratorService(long appId) {
return getAiCodeGeneratorService(appId, CodeGenTypeEnum.HTML);
}
/**
* 根据 appId 和代码生成类型获取服务(带缓存)
*/
public AiCodeGeneratorService getAiCodeGeneratorService(long appId, CodeGenTypeEnum codeGenType) {
String cacheKey = buildCacheKey(appId, codeGenType);
return serviceCache.get(cacheKey, key -> createAiCodeGeneratorService(appId, codeGenType));
}
/**
* 构建缓存键
*/
private String buildCacheKey(long appId, CodeGenTypeEnum codeGenType) {
return appId + "_" + codeGenType.getValue();
}
4)AiCodeGeneratorFacade#generateAndSaveCodeStream 方法新增 Vue 工程生成的 AI 调用:
// 根据 appId 获取对应的 AI 服务实例
AiCodeGeneratorService aiCodeGeneratorService = aiCodeGeneratorServiceFactory.getAiCodeGeneratorService(appId, codeGenTypeEnum);
return switch (codeGenTypeEnum) {
case HTML -> {
Flux<String> codeStream = aiCodeGeneratorService.generateHtmlCodeStream(userMessage);
yield processCodeStream(codeStream, CodeGenTypeEnum.HTML, appId);
}
case MULTI_FILE -> {
Flux<String> codeStream = aiCodeGeneratorService.generateMultiFileCodeStream(userMessage);
yield processCodeStream(codeStream, CodeGenTypeEnum.MULTI_FILE, appId);
}
case VUE_PROJECT -> {
Flux<String> codeStream = aiCodeGeneratorService.generateVueProjectCodeStream(appId, userMessage);
yield processCodeStream(codeStream, CodeGenTypeEnum.MULTI_FILE, appId);
}
default -> {
String errorMessage = "不支持的生成类型:" + codeGenTypeEnum.getValue();
throw new BusinessException(ErrorCode.SYSTEM_ERROR, errorMessage);
}
};
5)编写单元测试:
@Test
void generateVueProjectCodeStream() {
Flux<String> codeStream = aiCodeGeneratorFacade.generateAndSaveCodeStream(
"简单的任务记录网站,总代码量不超过 200 行",
CodeGenTypeEnum.VUE_PROJECT, 1L);
// 阻塞等待所有数据收集完成
List<String> result = codeStream.collectList().block();
// 验证结果
Assertions.assertNotNull(result);
String completeContent = String.join("", result);
Assertions.assertNotNull(completeContent);
}
执行单元测试,会发现调用工具成功,文件成功保存。可以通过查看 Redis 中的对话记忆来分析 AI 的调用过程,发现 AI 可以同时调用多个工具(如果用的是推理模型,这种现象会更明显)。
[
{
"text": "系统预设",
"type": "SYSTEM"
},
{
"contents": [
{
"text": "简单的任务记录网站,总代码量不超过 200 行",
"type": "TEXT"
}
],
"type": "USER"
},
{
"text": "下面我将为您创建一个简单但功能完整的任务记录网站,代码总量控制在200行以内。这个应用包含任务添加、完成状态切换和删除功能,并使用Tailwind CSS实现响应式设计。\\n\\n### 项目文件结构\\n\\n1. **入口HTML文件**\\n2. **Vite配置文件**\\n3. **Tailwind配置**\\n4. **项目依赖**\\n5. **应用入口**\\n6. **路由配置**\\n7. **根组件**\\n8. **主页面**\\n9. **任务组件**\\n10. **状态存储**\\n\\n现在开始逐个创建文件:",
"toolExecutionRequests": [
{
"id": "call_0_ef3e77f7-6392-42e5-aaa2-7e9b3e2fc78e",
"name": "writeFile",
"arguments": "{\\"relativeFilePath\\":\\"index.html\\",\\"content\\":\\"<!DOCTYPE html>\\\\n<html lang=\\\\\\"zh-CN\\\\\\">\\\\n<head>\\\\n \\\\n \\\\n <title>任务记录管理器</title>\\\\n</head>\\\\n<body>\\\\n <div id=\\\\\\"app\\\\\\"></div>\\\\n \\\\n</body>\\\\n</html>\\"}"
},
{
"id": "call_1_69ac4a83-01b4-4c33-8207-b2fd05c6e337",
"name": "writeFile",
"arguments": "{\\"relativeFilePath\\":\\"vite.config.js\\",\\"content\\":\\"import { defineConfig } from 'vite'\\\\nimport vue from '@vitejs/plugin-vue'\\\\n\\\\nexport default defineConfig({\\\\n plugins: [vue()],\\\\n server: {\\\\n port: 3000,\\\\n open: true\\\\n }\\\\n})\\"}"
},
]
"type": "TOOL_EXECUTION_RESULT"
},
{
"text": "我已经为您创建了一个简单但功能完整的任务记录网站,代码总量控制在200行以内。这个应用包含任务添加、完成状态切换和删除功能,并使用Tailwind CSS实现了响应式设计。\\n\\n### 项目结构说明\\n\\n1. **核心文件**:\\n - `index.html`:应用入口\\n - `vite.config.js`:Vite构建配置\\n - `tailwind.config.js`:Tailwind CSS配置\\n - `package.json`:项目依赖和脚本\\n\\n2. **源代码目录**:\\n 项目启动后,您可以在浏览器中访问 `http://localhost:3000` 查看效果。整个应用简洁高效,完全满足任务记录的基本需求。",
"toolExecutionRequests": [],
"type": "AI"
}
]
工具调用流式输出
虽然文件成功写入,但是目前要等好久才会返回结果。如何获取到工具调用的流式输出呢?
还记得之前我们提到 LangChain4j 的另一种流式返回方法 TokenStream 么?它提供了更多监听处理流的事件,其中就包括工具执行完成。
TokenStream tokenStream = assistant.chat("Tell me a joke");
tokenStream.onPartialResponse((String partialResponse) -> System.out.println(partialResponse))
.onRetrieved((List<Content> contents) -> System.out.println(contents))
.onToolExecuted((ToolExecution toolExecution) -> System.out.println(toolExecution))
.onCompleteResponse((ChatResponse response) -> System.out.println(response))
.onError((Throwable error) -> error.printStackTrace())
.start();
早期解决方案
截止到鱼皮写教程时,LangChain4j 对工具调用流式输出的支持度并不好,没有相应的回调,GitHub 上也有人提出了类似的问题:https://github.com/langchain4j/langchain4j/issues/2790
很快 GitHub 上也提供了解决方案:https://github.com/langchain4j/langchain4j/pull/3303
我的做法是,复制 Issues 的相关源码到项目的 dev.langchain4j 包(跟 LangChain4j 里的包相同路径),如果 src/main/java 中有与依赖 jar 包中相同包名和类名的类,本地类会优先被加载,依赖 jar 中的类会被完全忽略。
这样就实现了对源码的覆盖和增强,给 TokenStream 增加了两个回调函数。
之后我就可以通过 onPartialToolExecutionRequest 事件实时获取工具调用的流式输出了:
tokenStream.onPartialResponse((String partialResponse) -> {
System.out.println("{" + partialResponse + "}");
})
.onPartialToolExecutionRequest((index, toolExecutionRequest) -> {
System.out.println("{" + toolExecutionRequest + "}");
})
.onToolExecuted((ToolExecution toolExecution) -> {
System.out.println("{" + toolExecution + "}");
})
.onCompleteResponse((ChatResponse response) -> {
System.out.println("{" + response + "}");
})
.onError((Throwable error) -> {
error.printStackTrace();
})
.start();
可以通过 Debug 查看工具调用的实时流式参数:
工具调用完成时,触发 onToolExecuted 事件,可以获取到完整的工具调用参数:

分享这个方案也是希望给大家开拓一下思路,学会利用开源社区,要相信没有什么问题是解决不了的!
新版本解决方案
好消息是,就在鱼皮直播讲解这期之前,LangChain4j 更新了!官方在 1.2 版本支持了工具调用流式输出、深度思考事件、准备调用工具事件、调用完工具后的 AI 响应事件。
但是比较难受的一点是,TokenStream 只加了 3 个事件,并没有工具调用流式输出!
tokenStream
.onPartialResponse((String partialResponse) -> System.out.println(partialResponse))
.onPartialThinking((PartialThinking partialThinking) -> System.out.println(partialThinking))
.beforeToolExecution((BeforeToolExecution beforeToolExecution) -> System.out.println(beforeToolExecution))
.onRetrieved((List<Content> contents) -> System.out.println(contents))
.onIntermediateResponse((ChatResponse intermediateResponse) -> System.out.println(intermediateResponse))
.onToolExecuted((ToolExecution toolExecution) -> System.out.println(toolExecution))
.onCompleteResponse((ChatResponse response) -> System.out.println(response))
.onError((Throwable error) -> error.printStackTrace())
.start();
不过 1.2 版本为 ChatModel 提供了工具流式输出的能力,参考 官方文档:
但是,经过我的测试,这个版本目前并不稳定。比如工具调用过程中 PartialToolCall 会不断地封装流式输出的文本块,但是如果本文块中只有空格字符,就会触发报错:
我估计这是官方的一个小 Bug 吧,之后的版本应该会修复。
所以如果升级到 1.2 版本,不仅没办法使用 TokenStream 的工具流式输出方法,项目还可能会出现很多报错,大可不必,可以等稳定一些再说。
因此,我们还是选择早期解决方案,通过覆盖依赖包源码的方式,给 TokenStream 加上流式输出。
大家直接从 我的 GitHub 上获取这部分源码 即可,相信官方会尽快给 TokenStream 加上对应的事件的。
- 📎dev.zip
👆 资源获取文档:密码 tpkb
语雀显示 404 无权限,请重新再输入一次链接和密码
💡 如果你一定要使用 1.2 版本,可以通过覆盖 PartialToolCall 源码的方式修复,把校验逻辑去掉就好了,这个思路大家一定要掌握。
public PartialToolCall(Builder builder) {
this.index = ensureNotNegative(builder.index, "index");
this.id = builder.id;
this.name = ensureNotBlank(builder.name, "name");
this.partialArguments = builder.partialArguments;
}
统一消息格式
之前我们只需要给前端返回 AI 的响应信息,但现在还需要返回工具调用信息(后续还有可能需要返回深度思考信息),因此需要约定一种消息格式,来区分不同的信息类型。
包括:
- AI 响应消息
- 工具调用消息
- 工具调用完成消息
在 ai.model.message 包下新建 StreamMessage 流式消息基类,并提供几种类型的消息实现子类、以及消息类型枚举类。
注意封装对象必须有无参构造函数,否则解析 JSON 会失败。
消息基类代码如下:
/**
* 流式消息响应基类
*/
@Data
@AllArgsConstructor
@NoArgsConstructor
public class StreamMessage {
private String type;
}
每种具体消息类的构造函数中,要给基类传递消息类别 type 属性:
/**
* AI 响应消息
*/
@EqualsAndHashCode(callSuper = true)
@Data
@NoArgsConstructor
public class AiResponseMessage extends StreamMessage {
private String data;
public AiResponseMessage(String data) {
super(StreamMessageTypeEnum.AI_RESPONSE.getValue());
this.data = data;
}
}
/**
* 工具调用消息
*/
@Data
@EqualsAndHashCode(callSuper = true)
@NoArgsConstructor
public class ToolRequestMessage extends StreamMessage {
private String id;
private String name;
private String arguments;
public ToolRequestMessage(ToolExecutionRequest toolExecutionRequest) {
super(StreamMessageTypeEnum.TOOL_REQUEST.getValue());
this.id = toolExecutionRequest.id();
this.name = toolExecutionRequest.name();
this.arguments = toolExecutionRequest.arguments();
}
}
/**
* 工具执行结果消息
*/
@Data
@EqualsAndHashCode(callSuper = true)
@NoArgsConstructor
public class ToolExecutedMessage extends StreamMessage {
private String id;
private String name;
private String arguments;
private String result;
public ToolExecutedMessage(ToolExecution toolExecution) {
super(StreamMessageTypeEnum.TOOL_EXECUTED.getValue());
this.id = toolExecution.request().id();
this.name = toolExecution.request().name();
this.arguments = toolExecution.request().arguments();
this.result = toolExecution.result();
}
}
消息枚举类:
/**
* 流式消息类型枚举
*/
@Getter
public enum StreamMessageTypeEnum {
AI_RESPONSE("ai_response", "AI响应"),
TOOL_REQUEST("tool_request", "工具请求"),
TOOL_EXECUTED("tool_executed", "工具执行结果");
private final String value;
private final String text;
StreamMessageTypeEnum(String value, String text) {
this.value = value;
this.text = text;
}
/**
* 根据值获取枚举
*/
public static StreamMessageTypeEnum getEnumByValue(String value) {
for (StreamMessageTypeEnum typeEnum : values()) {
if (typeEnum.getValue().equals(value)) {
return typeEnum;
}
}
return null;
}
}
TokenStream 流处理过程
调用 AI 对话方法时,我们可以获得 TokenStream 流,接下来应该怎么对 TokenStream 进行处理呢?
这就要从需求出发了,考虑后端对话记忆要保存什么内容?前端需要看到什么内容?
1)假设 AI 原始返回的内容是:
AI 响应 {"为你生成代码"}
工具调用请求 {index=0, id="call_0", name="writeFile", arguments="流式参数"}
工具调用请求 {index=0, id="call_0", name="writeFile", arguments="流式参数"}
工具调用请求 {index=0, id="call_0", name="writeFile", arguments="流式参数"}
工具调用完成 {index=0, id="call_0", name="writeFile", arguments="完整参数"}
工具调用请求 {index=1, id="call_1", name="writeFile", arguments="流式参数"}
工具调用请求 {index=1, id="call_1", name="writeFile", arguments="流式参数"}
工具调用请求 {index=1, id="call_1", name="writeFile", arguments="流式参数"}
工具调用完成 {index=1, id="call_1", name="writeFile", arguments="完整参数"}
AI 响应 {"生成代码结束"}
2)接下来,我们要统一封装消息,便于下游处理:
{type="ai_response", data="为你生成代码"}
{type="tool_request", index=0, id="call_0", name="writeFile", arguments="流式参数"}
{type="tool_request", index=0, id="call_0", name="writeFile", arguments="流式参数"}
{type="tool_request", index=0, id="call_0", name="writeFile", arguments="流式参数"}
{type="tool_executed", index=0, id="call_0", name="writeFile", arguments="完整参数"}
{type="tool_request", index=1, id="call_1", name="writeFile", arguments="流式参数"}
{type="tool_request", index=1, id="call_1", name="writeFile", arguments="流式参数"}
{type="tool_request", index=1, id="call_1", name="writeFile", arguments="流式参数"}
{type="tool_executed", index=1, id="call_1", name="writeFile", arguments="完整参数"}
{type="ai_response", data="生成代码结束"}
3)拿到这些信息后,后端需要对流进行处理,一方面是按需返回给前端,另外一方面是保存对话记忆到数据库中。保存到数据库中的对话记忆格式为:
为你生成代码:
[工具调用] 写入文件 src/index.html
```html
写入的代码
[工具调用] 写入文件 src/about.html
```html
写入的代码
生成代码结束!
上述内容可以直接通过 ToolExecutedMessage 工具调用完成消息获取到。
4)返回给前端的内容也是类似的,只不过为了减少用户等待,首次调用某一个工具时就应该告诉用户 “选择工具” 信息。其他的内容跟要保存的对话记忆是一致的,这样设计不仅减少了定制开发的成本,用户刷新后看到的内容和实时生成时看到的内容也是一致的。
为你生成代码:
[选择工具] 写入文件
[工具调用] 写入文件 src/index.html
```html
写入的代码 ```
[选择工具] 写入文件
[工具调用] 写入文件 src/about.html
```html
写入的代码 ```
生成代码结束!
整个 AI 流式处理过程图:
TokenStream 流适配
了解流处理过程后,我们就可以开发了,这里有一些小技巧。
还记得么,我们目前是通过门面模式统一对外提供 AI 生成服务的,方法的返回值是 Flux 响应流:
public Flux<String> generateAndSaveCodeStream(String userMessage, CodeGenTypeEnum codeGenTypeEnum, Long appId) {}
但是 Vue 模式生成的流是 TokenStream,跟 Flux 不一致呀,那怎么用同一个方法实现 AI 生成呢?
其实很简单,如果我们的手机不支持某个类型的充电线头,找一个适配器不就好了?
没错,我们可以在 AiCodeGeneratorFacade 门面类中编写一个 适配方法,将 TokenStream 转换为 Flux 对象,之后下游可以愉快地对 Flux 进行处理了,也便于返回给前端。
适配方法中,需要监听 tokenStream 的 AI 响应、工具调用、工具调用完成等事件,将不同事件封装为不同的消息。代码如下:
/**
* 将 TokenStream 转换为 Flux<String>,并传递工具调用信息
*
* @param tokenStream TokenStream 对象
* @return Flux<String> 流式响应
*/
private Flux<String> processTokenStream(TokenStream tokenStream) {
return Flux.create(sink -> {
tokenStream.onPartialResponse((String partialResponse) -> {
AiResponseMessage aiResponseMessage = new AiResponseMessage(partialResponse);
sink.next(JSONUtil.toJsonStr(aiResponseMessage));
})
.onPartialToolExecutionRequest((index, toolExecutionRequest) -> {
ToolRequestMessage toolRequestMessage = new ToolRequestMessage(toolExecutionRequest);
sink.next(JSONUtil.toJsonStr(toolRequestMessage));
})
.onToolExecuted((ToolExecution toolExecution) -> {
ToolExecutedMessage toolExecutedMessage = new ToolExecutedMessage(toolExecution);
sink.next(JSONUtil.toJsonStr(toolExecutedMessage));
})
.onCompleteResponse((ChatResponse response) -> {
sink.complete();
})
.onError((Throwable error) -> {
error.printStackTrace();
sink.error(error);
})
.start();
});
}
💡 由于之前 processCodeStream 方法也是写在门面类里的,所以我才选择将 processTokenStream 也写在同一个地方。其实也可以单独新建一个适配器类,会更符合适配器模式的基本写法。
Flux 流处理器
接下来,我们还要编写下游的 Flux 流处理器,之前我们是在 AppService 的 chatToGenCode 生成方法内处理了原生模式生成的流。现在由于 Vue 生成模式的消息被封装为了 JSON 格式消息,所以我们最好针对每类生成模式单独定义一个流处理器,防止逻辑互相影响。
- 原生文本流处理器(原生模式使用)
- JSON 消息流处理器(Vue 工程使用)
然后再定义一个执行器,根据生成类型调用不同的流处理器。(这个操作在之前的策略模式、模板方法模式中都用过)
代码结构如下:
1)开发原生文本流处理器,其实就是把之前的流处理逻辑移到了单独的类中:
/**
* 简单文本流处理器
* 处理 HTML 和 MULTI_FILE 类型的流式响应
*/
@Slf4j
public class SimpleTextStreamHandler {
/**
* 处理传统流(HTML, MULTI_FILE)
* 直接收集完整的文本响应
*
* @param originFlux 原始流
* @param chatHistoryService 聊天历史服务
* @param appId 应用ID
* @param loginUser 登录用户
* @return 处理后的流
*/
public Flux<String> handle(Flux<String> originFlux,
ChatHistoryService chatHistoryService,
long appId, User loginUser) {
StringBuilder aiResponseBuilder = new StringBuilder();
return originFlux
.map(chunk -> {
// 收集AI响应内容
aiResponseBuilder.append(chunk);
return chunk;
})
.doOnComplete(() -> {
// 流式响应完成后,添加AI消息到对话历史
String aiResponse = aiResponseBuilder.toString();
chatHistoryService.addChatMessage(appId, aiResponse, ChatHistoryMessageTypeEnum.AI.getValue(), loginUser.getId());
})
.doOnError(error -> {
// 如果AI回复失败,也要记录错误消息
String errorMessage = "AI回复失败: " + error.getMessage();
chatHistoryService.addChatMessage(appId, errorMessage, ChatHistoryMessageTypeEnum.AI.getValue(), loginUser.getId());
});
}
}
2)开发 JSON 消息流处理器。在原生流处理器的基础上增加了 2 个逻辑:
- 消息解析:需要根据消息类型,将 JSON 字符串转换为对应的消息对象,然后提取属性进行其他操作(比如返回给前端、或者拼接起来保存到数据库中)
- 输出选择工具消息:虽然我们后端实现了工具调用的流式输出,但是考虑到前端不好对这些消息进行解析和处理,因此我们只在 同一个工具第一次输出时,输出给前端 “选择工具” 的消息。可以利用一个集合来判断某个 id 的工具是否为首次输出。
完整代码如下:
/**
* JSON 消息流处理器
* 处理 VUE_PROJECT 类型的复杂流式响应,包含工具调用信息
*/
@Slf4j
@Component
public class JsonMessageStreamHandler {
/**
* 处理 TokenStream(VUE_PROJECT)
* 解析 JSON 消息并重组为完整的响应格式
*
* @param originFlux 原始流
* @param chatHistoryService 聊天历史服务
* @param appId 应用ID
* @param loginUser 登录用户
* @return 处理后的流
*/
public Flux<String> handle(Flux<String> originFlux,
ChatHistoryService chatHistoryService,
long appId, User loginUser) {
// 收集数据用于生成后端记忆格式
StringBuilder chatHistoryStringBuilder = new StringBuilder();
// 用于跟踪已经见过的工具ID,判断是否是第一次调用
Set<String> seenToolIds = new HashSet<>();
return originFlux
.map(chunk -> {
// 解析每个 JSON 消息块
return handleJsonMessageChunk(chunk, chatHistoryStringBuilder, seenToolIds);
})
.filter(StrUtil::isNotEmpty) // 过滤空字串
.doOnComplete(() -> {
// 流式响应完成后,添加 AI 消息到对话历史
String aiResponse = chatHistoryStringBuilder.toString();
chatHistoryService.addChatMessage(appId, aiResponse, ChatHistoryMessageTypeEnum.AI.getValue(), loginUser.getId());
})
.doOnError(error -> {
// 如果AI回复失败,也要记录错误消息
String errorMessage = "AI回复失败: " + error.getMessage();
chatHistoryService.addChatMessage(appId, errorMessage, ChatHistoryMessageTypeEnum.AI.getValue(), loginUser.getId());
});
}
/**
* 解析并收集 TokenStream 数据
*/
private String handleJsonMessageChunk(String chunk, StringBuilder chatHistoryStringBuilder, Set<String> seenToolIds) {
// 解析 JSON
StreamMessage streamMessage = JSONUtil.toBean(chunk, StreamMessage.class);
StreamMessageTypeEnum typeEnum = StreamMessageTypeEnum.getEnumByValue(streamMessage.getType());
switch (typeEnum) {
case AI_RESPONSE -> {
AiResponseMessage aiMessage = JSONUtil.toBean(chunk, AiResponseMessage.class);
String data = aiMessage.getData();
// 直接拼接响应
chatHistoryStringBuilder.append(data);
return data;
}
case TOOL_REQUEST -> {
ToolRequestMessage toolRequestMessage = JSONUtil.toBean(chunk, ToolRequestMessage.class);
String toolId = toolRequestMessage.getId();
// 检查是否是第一次看到这个工具 ID
if (toolId != null && !seenToolIds.contains(toolId)) {
// 第一次调用这个工具,记录 ID 并完整返回工具信息
seenToolIds.add(toolId);
return "\\n\\n[选择工具] 写入文件\\n\\n";
} else {
// 不是第一次调用这个工具,直接返回空
return "";
}
}
case TOOL_EXECUTED -> {
ToolExecutedMessage toolExecutedMessage = JSONUtil.toBean(chunk, ToolExecutedMessage.class);
JSONObject jsonObject = JSONUtil.parseObj(toolExecutedMessage.getArguments());
String relativeFilePath = jsonObject.getStr("relativeFilePath");
String suffix = FileUtil.getSuffix(relativeFilePath);
String content = jsonObject.getStr("content");
String result = String.format("""
[工具调用] 写入文件 %s
```%s
%s
```
""", relativeFilePath, suffix, content);
// 输出前端和要持久化的内容
String output = String.format("\\n\\n%s\\n\\n", result);
chatHistoryStringBuilder.append(output);
return output;
}
default -> {
log.error("不支持的消息类型: {}", typeEnum);
return "";
}
}
}
}
3)开发流处理器执行器,根据生成类别调用不同的流。
/**
* 流处理器执行器
* 根据代码生成类型创建合适的流处理器:
* 1. 传统的 Flux<String> 流(HTML、MULTI_FILE) -> SimpleTextStreamHandler
* 2. TokenStream 格式的复杂流(VUE_PROJECT) -> JsonMessageStreamHandler
*/
@Slf4j
@Component
public class StreamHandlerExecutor {
@Resource
private JsonMessageStreamHandler jsonMessageStreamHandler;
/**
* 创建流处理器并处理聊天历史记录
*
* @param originFlux 原始流
* @param chatHistoryService 聊天历史服务
* @param appId 应用ID
* @param loginUser 登录用户
* @param codeGenType 代码生成类型
* @return 处理后的流
*/
public Flux<String> doExecute(Flux<String> originFlux,
ChatHistoryService chatHistoryService,
long appId, User loginUser, CodeGenTypeEnum codeGenType) {
return switch (codeGenType) {
case VUE_PROJECT -> // 使用注入的组件实例
jsonMessageStreamHandler.handle(originFlux, chatHistoryService, appId, loginUser);
case HTML, MULTI_FILE -> // 简单文本处理器不需要依赖注入
new SimpleTextStreamHandler().handle(originFlux, chatHistoryService, appId, loginUser);
};
}
}
4)最后,AppService 的 chatToGenCode 方法可以直接调用执行器,自动根据生成类型调用不同的流处理器,简化了代码:
// 5. 通过校验后,添加用户消息到对话历史
chatHistoryService.addChatMessage(appId, message, ChatHistoryMessageTypeEnum.USER.getValue(), loginUser.getId());
// 6. 调用 AI 生成代码(流式)
Flux<String> codeStream = aiCodeGeneratorFacade.generateAndSaveCodeStream(message, codeGenTypeEnum, appId);
// 7. 收集 AI 响应内容并在完成后记录到对话历史
return streamHandlerExecutor.doExecute(codeStream, chatHistoryService, appId, loginUser, codeGenTypeEnum);
测试
临时修改创建 APP 时的生成模式:
// 暂时设置为 VUE 工程生成
app.setCodeGenType(CodeGenTypeEnum.VUE_PROJECT.getValue());
然后在前端和 AI 对话,查看运行效果,流式输出时:
流式输出完成,刷新页面,查看加载的对话历史:
效果符合预期,恭喜你,这块硬骨头终于啃下来了!
四、工程项目构建和浏览
代码生成后,需要安装依赖和打包构建,才能浏览 Vue 项目。
我们在 core.builder 目录下新建一个 VueProjectBuilder,专门用来编写 Vue 项目的构建过程。
1)首先编写一个执行任意命令的通用方法,通过 Hutool 的 RuntimeUtil 结合 Java 的 Process 实现命令执行。代码如下:
/**
* 执行命令
*
* @param workingDir 工作目录
* @param command 命令字符串
* @param timeoutSeconds 超时时间(秒)
* @return 是否执行成功
*/
private boolean executeCommand(File workingDir, String command, int timeoutSeconds) {
try {
log.info("在目录 {} 中执行命令: {}", workingDir.getAbsolutePath(), command);
Process process = RuntimeUtil.exec(
null,
workingDir,
command.split("\\\\s+") // 命令分割为数组
);
// 等待进程完成,设置超时
boolean finished = process.waitFor(timeoutSeconds, TimeUnit.SECONDS);
if (!finished) {
log.error("命令执行超时({}秒),强制终止进程", timeoutSeconds);
process.destroyForcibly();
return false;
}
int exitCode = process.exitValue();
if (exitCode == 0) {
log.info("命令执行成功: {}", command);
return true;
} else {
log.error("命令执行失败,退出码: {}", exitCode);
return false;
}
} catch (Exception e) {
log.error("执行命令失败: {}, 错误信息: {}", command, e.getMessage());
return false;
}
}
💡 还有一种更简单的写法:
Process installExec = RuntimeUtil.exec(null, directory, "npm", "i");
log.info("dir: {}", directory);
String installResult = RuntimeUtil.getResult(installExec);
log.info("依赖安装结果: {}", installResult);
2)然后分别编写执行安装依赖和执行打包构建命令的方法:
/**
* 执行 npm install 命令
*/
private boolean executeNpmInstall(File projectDir) {
log.info("执行 npm install...");
return executeCommand(projectDir, "npm install", 300); // 5分钟超时
}
/**
* 执行 npm run build 命令
*/
private boolean executeNpmBuild(File projectDir) {
log.info("执行 npm run build...");
return executeCommand(projectDir, "npm run build", 180); // 3分钟超时
}
注意,如果是 Windows 系统,命令需要添加 .cmd。
编写操作系统检测方法:
private boolean isWindows() {
return System.getProperty("os.name").toLowerCase().contains("windows");
}
编写根据操作系统构造命令的方法:
private String buildCommand(String baseCommand) {
if (isWindows()) {
return baseCommand + ".cmd";
}
return baseCommand;
}
上述方法改为:
/**
* 执行 npm install 命令
*/
private boolean executeNpmInstall(File projectDir) {
log.info("执行 npm install...");
String command = String.format("%s install", buildCommand("npm"));
return executeCommand(projectDir, command, 300); // 5分钟超时
}
/**
* 执行 npm run build 命令
*/
private boolean executeNpmBuild(File projectDir) {
log.info("执行 npm run build...");
String command = String.format("%s run build", buildCommand("npm"));
return executeCommand(projectDir, command, 180); // 3分钟超时
}
3)编写构建项目的方法,组合执行上述命令,并且校验是否构建成功。
注意,尽量不在这个方法中抛出异常,防止影响主业务流程。
/**
* 构建 Vue 项目
*
* @param projectPath 项目根目录路径
* @return 是否构建成功
*/
public boolean buildProject(String projectPath) {
File projectDir = new File(projectPath);
if (!projectDir.exists() || !projectDir.isDirectory()) {
log.error("项目目录不存在: {}", projectPath);
return false;
}
// 检查 package.json 是否存在
File packageJson = new File(projectDir, "package.json");
if (!packageJson.exists()) {
log.error("package.json 文件不存在: {}", packageJson.getAbsolutePath());
return false;
}
log.info("开始构建 Vue 项目: {}", projectPath);
// 执行 npm install
if (!executeNpmInstall(projectDir)) {
log.error("npm install 执行失败");
return false;
}
// 执行 npm run build
if (!executeNpmBuild(projectDir)) {
log.error("npm run build 执行失败");
return false;
}
// 验证 dist 目录是否生成
File distDir = new File(projectDir, "dist");
if (!distDir.exists()) {
log.error("构建完成但 dist 目录未生成: {}", distDir.getAbsolutePath());
return false;
}
log.info("Vue 项目构建成功,dist 目录: {}", distDir.getAbsolutePath());
return true;
}
4)由于打包构建是个耗时操作,为了不阻塞主流程,可以使用 Java 21 的虚拟线程特性,在单独的线程中执行构建。
代码如下:
@Slf4j
@Component
public class VueProjectBuilder {
/**
* 异步构建项目(不阻塞主流程)
*
* @param projectPath 项目路径
*/
public void buildProjectAsync(String projectPath) {
// 在单独的线程中执行构建,避免阻塞主流程
Thread.ofVirtual().name("vue-builder-" + System.currentTimeMillis()).start(() -> {
try {
buildProject(projectPath);
} catch (Exception e) {
log.error("异步构建 Vue 项目时发生异常: {}", e.getMessage(), e);
}
});
}
}
5)JSON 消息流式处理器 JsonMessageStreamHandler 中,当流式输出完成后,执行 Vue 项目的构建:
@Resource
private VueProjectBuilder vueProjectBuilder;
originFlux
.doOnComplete(() -> {
// 流式响应完成后,添加 AI 消息到对话历史
String aiResponse = chatHistoryStringBuilder.toString();
chatHistoryService.addChatMessage(appId, aiResponse, ChatHistoryMessageTypeEnum.AI.getValue(), loginUser.getId());
// 异步构造 Vue 项目
String projectPath = AppConstant.CODE_OUTPUT_ROOT_DIR + "/vue_project_" + appId;
vueProjectBuilder.buildProjectAsync(projectPath);
})
异步构建的优点是提高系统的并发量,但缺点是可能会导致前端无法实时浏览到 AI 生成的网站,因为不知道后端什么时候构建完成,后续章节中会调整这里的逻辑。
五、工程项目部署
如果部署的是 Vue 项目,则调用 VueProjectBuilder 执行安装依赖和打包命令,并且将得到的 dist 目录移动到部署目录。其他都能复用已有的部署流程。
修改 AppService 的 deployApp 方法:
// 6. 检查源目录是否存在
File sourceDir = new File(sourceDirPath);
if (!sourceDir.exists() || !sourceDir.isDirectory()) {
throw new BusinessException(ErrorCode.SYSTEM_ERROR, "应用代码不存在,请先生成代码");
}
// 7. Vue 项目特殊处理:执行构建
CodeGenTypeEnum codeGenTypeEnum = CodeGenTypeEnum.getEnumByValue(codeGenType);
if (codeGenTypeEnum == CodeGenTypeEnum.VUE_PROJECT) {
// Vue 项目需要构建
boolean buildSuccess = vueProjectBuilder.buildProject(sourceDirPath);
ThrowUtils.throwIf(!buildSuccess, ErrorCode.SYSTEM_ERROR, "Vue 项目构建失败,请检查代码和依赖");
// 检查 dist 目录是否存在
File distDir = new File(sourceDirPath, "dist");
ThrowUtils.throwIf(!distDir.exists(), ErrorCode.SYSTEM_ERROR, "Vue 项目构建完成但未生成 dist 目录");
// 将 dist 目录作为部署源
sourceDir = distDir;
log.info("Vue 项目构建成功,将部署 dist 目录: {}", distDir.getAbsolutePath());
}
// 8. 复制文件到部署目录
String deployDirPath = AppConstant.CODE_DEPLOY_ROOT_DIR + File.separator + deployKey;
六、前端开发
这次前端项目的改动不大。前端如果识别到是 Vue 项目,项目的浏览地址需要增加 dist 后缀。
1)补充枚举类型:
/**
* 代码生成类型枚举
*/
export enum CodeGenTypeEnum {
HTML = 'html',
MULTI_FILE = 'multi_file',
VUE_PROJECT = 'vue_project',
}
/**
* 代码生成类型配置
*/
export const CODE_GEN_TYPE_CONFIG = {
[CodeGenTypeEnum.HTML]: {
label: '原生 HTML 模式',
value: CodeGenTypeEnum.HTML,
},
[CodeGenTypeEnum.MULTI_FILE]: {
label: '原生多文件模式',
value: CodeGenTypeEnum.MULTI_FILE,
},
[CodeGenTypeEnum.VUE_PROJECT]: {
label: 'Vue 项目模式',
value: CodeGenTypeEnum.VUE_PROJECT,
},
}
2)修改获取网站浏览地址的函数:
// 获取静态资源预览URL
export const getStaticPreviewUrl = (codeGenType: string, appId: string) => {
const baseUrl = `${STATIC_BASE_URL}/${codeGenType}_${appId}/`
// 如果是 Vue 项目,浏览地址需要添加 dist 后缀
if (codeGenType === CodeGenTypeEnum.VUE_PROJECT) {
return `${baseUrl}dist/index.html`
}
return baseUrl
}
最后,完整测试一下生成、浏览和部署的流程。
由于打包构建是异步的,所以目前生成后可能无法立即浏览,需要刷新页面。不过别担心,后面的章节中,我们会解决这个问题。
效果还不错,有时可能会出现错误,大家可以自行调整提示词或者更换大模型来优化:



💡 注意!如果你发现 AI 一直无限循环调用工具,可以尝试下面的方法:
- 调大对话记忆的容量,否则 AI 会中途断片儿,忘记已经生成了哪些文件
- 尝试换其他的 AI 大模型
- 优化提示词
此外,还有其他的方法解决这个问题,在本项目第 11 期《系统优化》中有讲。
七、扩展思路
1、为 AI 提供代码模板
前面有提到,美团 NoCode 平台应该是使用了预置代码模板来提高 AI 生成的效率和稳定性。确保项目能正常运行、以及项目结构的完整。


这个实现思路并不难,每次调用 AI 之前,先根据用户的提示词找到一套合适的预置代码模板(比如简单的 Vue 工程),然后复制到应用生成目录中,把模板信息作为提示词提供给 AI,并且提供给 AI 文件读取和修改工具,接下来就让 AI 基于模板二次处理就好。
比如追加这么一段提示词:
## 现有文件内容
{
"filePath": "package.json",
"desc": "依赖管理文件",
"content": "文件主要内容"
},
...
这个思路其实有点儿缓存的感觉,如果正好有和预置代码模板一样的需求,搞不好就直接把预置模板返回了。
2、深度思考
前面提到,LangChain4j 的 v1.2 版本已经支持了 深度思考的流式输出特性,直接监听 TokenStream 的 onPartialThinking 事件就能实现了。
tokenStream
.onPartialThinking((PartialThinking partialThinking) -> System.out.println(partialThinking))
.start();
但是要注意:
- 有些大模型不支持输出深度思考信息,自然也获取不到
- 确保引入了 LangChain4j 以及 LangChain4j 整合大模型的最新依赖,否则可能不生效
这个功能我觉得还是挺值得尝试的,大家可以参考本节教程中输出工具调用信息的方式,将深度思考作为一种消息类型,流式输出给前端,在单独的位置进行展示。
💬 评论