--- title: "09-工程项目生成" created: 2025-12-02 tags: - 项目 aliases: - 工程项目生成 --- # 工程项目生成 ## 本节重点 在前面的章节中‍,我们已经实现了对话历史功⁡能,让 AI 能够基于历史‎上下文进行网站的迭代优化。‎但目前平台只支持生成原生网站,在实际使用中有一定局限性。 本节我们将‍挑战一个有点儿难度⁡的功能 —— 生成‎更复杂的前端工程化‎项目,提高本平台的实用性。 主要内容包括: - 工程项目生成方案设计 - 工程项目生成(工具调用流式输出) - 工程项目构建和浏览 - 工程项目部署 - 前端开发 ## 一、需求分析 前端工程化项目是指使用‍现代化工具链、规范化流程和组件化架构来构建⁡的前端应用。相比传统的 HTML、CSS、‎JavaScript 三件套,它具备模块管‎理、自动化构建、代码分割、热更新等现代开发特性,能够更轻松地开发复杂网站。 现在很多前端工‍程化项目都是使用 Vue ⁡或 React 框架,结合‎ Vite 等打包构建工具‎,再加上 ESLint 之类的代码规范校验库来实现的。 ![[image-c6db36b1.png]] 调研其他平‍台,美团 NoCo⁡de 平台支持 R‎eact 工程项目‎: ![[image-27eb92d8.png]] 我们不妨就以 Vue 作为示例,让平台能够生成完整的 Vue3 + Vite 工程项目,并且跟其他两种生成模式一样,实现流式输出、网站浏览和部署。这样一来,我们的平台也能够生成更符合企业标准的项目。 友情提示,‍受限于大模型的能力⁡,有时效果可能没有‎那么理想,但大家重‎点学习思路和方法。 ## 二、方案设计 ### 方案选型分析 通过对一些‍大厂生成平台的调研⁡,我们可以初步想到‎ 3 种复杂工程项‎目的生成方案。 ### 方案 1 - 直接输出 Markdown 这种方案延‍续之前的思路,直接⁡让 AI 在输出的‎ Markdown‎ 中包含代码块,然后通过解析的方式保存文件。 ```text 这是我生成的项目: ```vue App.vue ``` ``` json package.json ``` ``` 优点是实现简‍单、好理解、实时展示效果⁡好。但缺点也很明显:如果‎代码量较大,一次对话可能‎无法完整输出,容易出现代码不全或者解析错误的情况。 ### 方案 2 - 工具调用 给 AI ‍提供保存文件等工具⁡,让 AI 来决定‎什么时候保存文件、‎保存哪些文件、要保存什么代码到文件中。 这种方式的‍基本实现很简单,不⁡需要自己解析 AI‎ 的输出并保存为文‎件,全都交给 AI 和框架来处理。 ![[image-d033c77c.png]] 但如果想实时展示工具调用‍信息(比如要保存为文件的代码内容),就很复杂了!⁡你需要解析 AI 响应的工具调用信息。由于流式输‎出的特性,AI 是一点点将 JSON 格式的工具‎调用信息 “吐” 出来的,你很难实时从中提取出有意义的、需要返回给用户的工具调用参数。 举个例子,完整的工具调用信息: ```json { "id": "123", "name": "writeFile", "arguments": { "relativePath": "src/App.vue", "content": "网页代码" } } ``` 但实际上,AI 会流式输出上述信息,比如可能是这样输出的: ```text " relative Path : " src / App .vue " content ": ``` 也就是说,AI 的‍输出会把保存文件的路径和写入文件的⁡内容拆开。如果想在前端实时展示出这‎些信息,就需要比较复杂的拼接逻辑,‎比如拼接好 relativePath 后,立刻展示出保存文件的路径。 假设这是后‍端实时返回给前端的⁡工具调用信息,你应‎该如何解析和拼接参‎数呢? ```java 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 的特点是 **先规划再执行**。比如先制定网站生成计划、然后分别在每个步骤中生成一个文件并解析。 ```text 步骤 1:我要生成网站文件 page1.vue、page2.vue、page3.vue 步骤 2:生成 page1.vue,然后保存 步骤 3:生成 page2.vue,然后保存 步骤 4:生成 page3.vue,然后保存 步骤 5:生成网站成功,退出执行 ``` 这样做的优点是,每‍一个步骤都非常清晰,在步骤内我可以⁡通过工具调用来实现文件保存,也可以‎单独调用 AI 获取到生成的代码,‎然后再通过程序保存(就像我们之前一样),这样实现流式输出就很简单了。 此外,由于划分了多个步骤,即使某一个步骤出错,也能中断恢复,从而能够处理长逻辑的复杂任务。像鱼皮之前分享过的 [Flowith 智能体](https://www.bilibili.com/video/BV1QFTJzzENP/),我一句话,让它执行了整整一天! 但 Agen‍t 模式的缺点是整体架构⁡更复杂,需要自己设计 A‎gent 流程;而且由于‎需要多次调用 AI,输出结果更加不可控,成本也更高。 💡 在 [编程导航的 AI 超级智能体项目](https://www.codefather.cn/course/1915010091721236482) 中,鱼皮带大家分析了 OpenManus 智能体的源码,并且复刻了一个 Java 智能体,感兴趣的同学可以学习。 --- 从美团 NoC‍ode 的输出效果来看,很有⁡可能采用了 Agent 模式‎,每一个步骤都可以做到流式输‎出,用户能即时看到生成的代码内容和 AI 的操作情况。 ![[image-aba66b23.png]] 刷新页面后‍,你依然能看到上面⁡的内容,跟实时流式‎输出时用户看到的效‎果一样。 这是怎么做到的呢?美团是怎么维护对话历史的呢? 通过分析网‍络请求,我们能获取⁡到后端返回给前端的‎完整对话信息: ![[image-c849d88d.png]] 原来如此,美团自己约定了一些 **工具标签**,比如 `mc-add-dependency` 安装依赖、`mc-write` 保存文件,这样前端就能识别到展示什么内容了。 ```text "我将为您创建一个复古波普艺术风格的电商网页,包含鲜艳的橙色和粉色撞色背景、漫画风格圆点图案和手绘插图,以及波普画框形式的商品展示区。\\n\\nSTEP 1: 安装必要的依赖\\n\\nframer-motion\\n\\n\\nSTEP 2: 更新首页为复古波普风格电商页面\\n\\nimport { motion } from \\"framer-motion\\";\\nimport { ShoppingBag, Star, Heart } from \\"lucide-react\\";\\n\\nconst Index = () => {\\n const products = [\\n {\\n id: 1,\\n;\\n\\n\\n创建了一个充满活力的复古波普艺术风格电商首页,包含鲜艳的橙色粉色渐变背景、漫画风格圆点图案、波普画框形式的商品展示区以及带有跳跃动画的交互元素。" ``` ### 最终方案确定 由于 LangC‍hain4j 本身就支持 AI ⁡多次调用工具,相当于已经实现了基‎础的 Agent 多步骤执行能力‎,因此考虑到开发复杂度,我们选择第 2 种方案 - 工具调用。 ![[image-bb3226bb.png]] 为了提升用户体验‍,需要为工具调用支持流式输出能⁡力,但是只流式输出工具调用的基‎本信息,让用户能看到 AI 调‎用了哪些工具,避免了复杂的拼接解析逻辑(不利于项目扩展)。 由于不知道要生成多‍少个文件、以及文件的层级组织关系,需⁡要使用工具调用来写入文件。工具调用中‎需要特别注意文件路径的处理。如果不使‎用工具调用,就要输出一大堆的代码块,自己解析,比如下列提示词格式: ```yaml ## 必须遵守下列输出格式 - 你的整个回复必须是一系列 Markdown 代码块 - 每个代码块代表一个完整的文件 - 每个代码块的第一行必须是注释,用 `[FILE_PATH: ...]` 的格式清晰地标明该文件的路径 文件代码块示例: 格式 vue // [FILE_PATH: src/components/NewComponent.vue] 然后是完整的代码内容 ``` ### 系统提示词 我们需要定义新‍的生成模式 Vue 工程模⁡式(vue\_project‎),这种模式使用 Deep‎Seek 的推理模型,提供的系统提示词也会更复杂。如下: ```text 你是一位资深的 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,合理使用 `\\\\n\\\\n\\"}" }, { "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" } ] ``` ### 工具调用流式输出 虽然文件成‍功写入,但是目前要⁡等好久才会返回‎结果。如何获取到工具调‎用的流式输出呢? 还记得之前我们‍提到 LangChain4⁡j 的另一种流式返回方法 ‎TokenStream 么‎?它提供了更多监听处理流的事件,其中就包括工具执行完成。 ```java TokenStream tokenStream = assistant.chat("Tell me a joke"); tokenStream.onPartialResponse((String partialResponse) -> System.out.println(partialResponse)) .onRetrieved((List 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 上也有人提出了类似的问题: 很快 GitHub 上也提供了解决方案: ![[image-6e302ad9.png]] 我的做法是,复制 Issues 的相关源码到项目的 `dev.langchain4j` 包(跟 LangChain4j 里的包相同路径),如果 `src/main/java` 中有与依赖 jar 包中相同包名和类名的类,本地类会优先被加载,依赖 jar 中的类会被完全忽略。 ![[image-fd55586f.png]] 这样就实现‍了对源码的覆盖和增⁡强,给 Token‎Stream 增加‎了两个回调函数。 ![[image-7a6fe258.png]] 之后我就可以通过 `onPartialToolExecutionRequest` 事件实时获取工具调用的流式输出了: ```java 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 查看工具调用的实时流式参数: ![[image-11a75300.png]] 工具调用完‍成时,触发 onT⁡oolExecut‎ed 事件,可以获‎取到完整的工具调用参数: ![](https://pic.code-nav.cn/course_picture/1608440217629360130/y9XBqU7YCpJLf5aR.webp) ![[image-6a1d7876.png]] 分享这个方‍案也是希望给大家开⁡拓一下思路,学会利‎用开源社区,要相信‎没有什么问题是解决不了的! ### 新版本解决方案 好消息是,就在鱼‍皮直播讲解这期之前,LangCh⁡ain4j 更新了!官方在 1.‎2 版本支持了工具调用流式输出、‎深度思考事件、准备调用工具事件、调用完工具后的 AI 响应事件。 ![[image-65923533.png]] 但是比较难‍受的一点是,Tok⁡enStream ‎只加了 3 个事件‎,并没有工具调用流式输出! ```java tokenStream .onPartialResponse((String partialResponse) -> System.out.println(partialResponse)) .onPartialThinking((PartialThinking partialThinking) -> System.out.println(partialThinking)) .beforeToolExecution((BeforeToolExecution beforeToolExecution) -> System.out.println(beforeToolExecution)) .onRetrieved((List 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 提供了工具流式输出的能力,参考 [官方文档](https://docs.langchain4j.dev/tutorials/tools#using-streamingchatmodel): ![[image-5d4c7883.png]] 但是,经过我的测‍试,这个版本目前并不稳定。比如⁡工具调用过程中 Partial‎ToolCall 会不断地封装‎流式输出的文本块,但是如果本文块中只有空格字符,就会触发报错: ![[image-59274c87.png]] 我估计这是官方的一个小 Bug 吧,之后的版本应该会修复。 ![[image-ae29b8b4.png]] 所以如果升级到‍ 1.2 版本,不仅没办法⁡使用 TokenStrea‎m 的工具流式输出方法,项‎目还可能会出现很多报错,大可不必,可以等稳定一些再说。 **因此,我们还是选择早期解决方案,通过覆盖依赖包源码的方式,给 TokenStream 加上流式输出。** 大家直接从 [我的 GitHub 上获取这部分源码](https://github.com/liyupi/yu-ai-code-mother) 即可,相信官方会尽快给 TokenStream 加上对应的事件的。 - 📎dev.zip [👆 资源获取文档](https://yuyuanweb.yuque.com/org-wiki-yuyuanweb-zvq1bg/webrf4/kquwk9bzruniczdg?singleDoc#):密码 tpkb > 语雀显示 404 无权限,请重新再输入一次链接和密码 💡 如果你一‍定要使用 1.2 版本,可⁡以通过覆盖 Partial‎ToolCall 源码的方‎式修复,把校验逻辑去掉就好了,这个思路大家一定要掌握。 ```java 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 流式消息基类,并提供几种类型的消息实现子类、以及消息类型枚举类。 ![[image-53734e35.png]] **注意封装对象必须有无参构造函数,否则解析 JSON 会失败。** 消息基类代码如下: ```java /** * 流式消息响应基类 */ @Data @AllArgsConstructor @NoArgsConstructor public class StreamMessage { private String type; } ``` 每种具体消‍息类的构造函数中,⁡要给基类传递消息类‎别 type 属性‎: ```java /** * 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; } } ``` ```java /** * 工具调用消息 */ @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(); } } ``` ```java /** * 工具执行结果消息 */ @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(); } } ``` 消息枚举类: ```java /** * 流式消息类型枚举 */ @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 ‍对话方法时,我们可以获⁡得 TokenStre‎am 流,接下来应该怎‎么对 TokenStream 进行处理呢? 这就要从需‍求出发了,考虑后端⁡对话记忆要保存什么‎内容?前端需要看到‎什么内容? 1)假设 AI 原始返回的内容是: ```java 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)接下来,我们要统一封装消息,便于下游处理: ```java {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)拿到这些信息后‍,后端需要对流进行处理,一方面是按需⁡返回给前端,另外一方面是保存对话记忆‎到数据库中。保存到数据库中的对话记忆‎格式为: ```text 为你生成代码: [工具调用] 写入文件 src/index.html ```html 写入的代码 [工具调用] 写入文件 src/about.html ```html 写入的代码 生成代码结束! ``` 上述内容可‍以直接通过 Too⁡lExecuted‎Message 工‎具调用完成消息获取到。 4)返回给前端的内容也‍是类似的,只不过为了减少用户等待,首次调用某⁡一个工具时就应该告诉用户 “选择工具” ‎信息。其他的内容跟要保存的对话记忆是一致的,这样‎设计不仅减少了定制开发的成本,用户刷新后看到的内容和实时生成时看到的内容也是一致的。 ```text 为你生成代码: [选择工具] 写入文件 [工具调用] 写入文件 src/index.html ```html 写入的代码 ``` [选择工具] 写入文件 [工具调用] 写入文件 src/about.html ```html 写入的代码 ``` 生成代码结束! ``` 整个 AI 流式处理过程图: ![[image-860de7b6.png]] ### TokenStream 流适配 了解流处理过程后,我们就可以开发了,这里有一些小技巧。 还记得么,我们目前是通过门面模式统一对外提供 AI 生成服务的,方法的返回值是 `Flux` 响应流: ```java public Flux generateAndSaveCodeStream(String userMessage, CodeGenTypeEnum codeGenTypeEnum, Long appId) {} ``` 但是 Vu‍e 模式生成的流是 ⁡TokenStrea‎m,跟 Flux 不‎一致呀,那怎么用同一个方法实现 AI 生成呢? 其实很简单‍,如果我们的手机不⁡支持某个类型的充电‎线头,找一个适配器‎不就好了? 没错,我们可以在 AiCodeGeneratorFacade 门面类中编写一个 **适配方法**,将 TokenStream 转换为 Flux 对象,之后下游可以愉快地对 Flux 进行处理了,也便于返回给前端。 适配方法中,‍需要监听 tokenSt⁡ream 的 AI 响应‎、工具调用、工具调用完成‎等事件,将不同事件封装为不同的消息。代码如下: ```java /** * 将 TokenStream 转换为 Flux,并传递工具调用信息 * * @param tokenStream TokenStream 对象 * @return Flux 流式响应 */ private Flux 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(); }); } ``` 💡 由于之前 pr‍ocessCodeStream 方法也是⁡写在门面类里的,所以我才选择将 proc‎essTokenStream 也写在同一‎个地方。其实也可以单独新建一个适配器类,会更符合适配器模式的基本写法。 ### Flux 流处理器 接下来,我们还要编写下游的 ‍Flux 流处理器,之前我们是在 AppService⁡ 的 chatToGenCode 生成方法内处理了原生‎模式生成的流。现在由于 Vue 生成模式的消息被封装为‎了 JSON 格式消息,所以我们最好针对每类生成模式单独定义一个流处理器,防止逻辑互相影响。 - 原生文本流处理器(原生模式使用) - JSON 消息流处理器(Vue 工程使用) 然后再定义‍一个执行器,根据生⁡成类型调用不同的流处‎理器。(这个操作‎在之前的策略模式、模板方法模式中都用过) ![[image-09ac4d14.png]] 代码结构如下: ![[image-f909a7b5.png]] 1)开发原‍生文本流处理器,其⁡实就是把之前的流处‎理逻辑移到了单独的‎类中: ```java /** * 简单文本流处理器 * 处理 HTML 和 MULTI_FILE 类型的流式响应 */ @Slf4j public class SimpleTextStreamHandler { /** * 处理传统流(HTML, MULTI_FILE) * 直接收集完整的文本响应 * * @param originFlux 原始流 * @param chatHistoryService 聊天历史服务 * @param appId 应用ID * @param loginUser 登录用户 * @return 处理后的流 */ public Flux handle(Flux 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 个逻辑: 1. 消息解析:需要根据消息类型,将 JSON 字符串转换为对应的消息对象,然后提取属性进行其他操作(比如返回给前端、或者拼接起来保存到数据库中) 2. 输出选择工具消息:虽然我们后端实现了工具调用的流式输出,但是考虑到前端不好对这些消息进行解析和处理,因此我们只在 **同一个工具第一次输出时**,输出给前端 “选择工具” 的消息。可以利用一个集合来判断某个 id 的工具是否为首次输出。 完整代码如‍下:       ⁡         ‎         ‎ ```java /** * JSON 消息流处理器 * 处理 VUE_PROJECT 类型的复杂流式响应,包含工具调用信息 */ @Slf4j @Component public class JsonMessageStreamHandler { /** * 处理 TokenStream(VUE_PROJECT) * 解析 JSON 消息并重组为完整的响应格式 * * @param originFlux 原始流 * @param chatHistoryService 聊天历史服务 * @param appId 应用ID * @param loginUser 登录用户 * @return 处理后的流 */ public Flux handle(Flux originFlux, ChatHistoryService chatHistoryService, long appId, User loginUser) { // 收集数据用于生成后端记忆格式 StringBuilder chatHistoryStringBuilder = new StringBuilder(); // 用于跟踪已经见过的工具ID,判断是否是第一次调用 Set 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 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)开发流处理器执行器,根据生成类别调用不同的流。 ```java /** * 流处理器执行器 * 根据代码生成类型创建合适的流处理器: * 1. 传统的 Flux 流(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 doExecute(Flux 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)最后,A‍ppService 的 ⁡chatToGenCod‎e 方法可以直接调用执行‎器,自动根据生成类型调用不同的流处理器,简化了代码: ```java // 5. 通过校验后,添加用户消息到对话历史 chatHistoryService.addChatMessage(appId, message, ChatHistoryMessageTypeEnum.USER.getValue(), loginUser.getId()); // 6. 调用 AI 生成代码(流式) Flux codeStream = aiCodeGeneratorFacade.generateAndSaveCodeStream(message, codeGenTypeEnum, appId); // 7. 收集 AI 响应内容并在完成后记录到对话历史 return streamHandlerExecutor.doExecute(codeStream, chatHistoryService, appId, loginUser, codeGenTypeEnum); ``` ### 测试 临时修改创建 APP 时的生成模式: ```java // 暂时设置为 VUE 工程生成 app.setCodeGenType(CodeGenTypeEnum.VUE_PROJECT.getValue()); ``` 然后在前端和 AI 对话,查看运行效果,流式输出时: ![[image-21b5aa8c.png]] 流式输出完成,刷新页面,查看加载的对话历史: ![[image-19bb0c04.png]] 效果符合预期,恭喜你,这块硬骨头终于啃下来了! ## 四、工程项目构建和浏览 代码生成后‍,需要安装依赖和打⁡包构建,才能浏览 ‎Vue 项目。 我们在 `core.builder` 目录下新建一个 VueProjectBuilder,专门用来编写 Vue 项目的构建过程。 1)首先编写一‍个执行任意命令的通用方法,⁡通过 Hutool 的 R‎untimeUtil 结合‎ Java 的 Process 实现命令执行。代码如下: ```java /** * 执行命令 * * @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; } } ``` 💡 还有一种更简单的写法: ```java Process installExec = RuntimeUtil.exec(null, directory, "npm", "i"); log.info("dir: {}", directory); String installResult = RuntimeUtil.getResult(installExec); log.info("依赖安装结果: {}", installResult); ``` 2)然后分别编写执行安装依赖和执行打包构建命令的方法: ```java /** * 执行 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`。 编写操作系统检测方法: ```java private boolean isWindows() { return System.getProperty("os.name").toLowerCase().contains("windows"); } ``` 编写根据操作系统构造命令的方法: ```java private String buildCommand(String baseCommand) { if (isWindows()) { return baseCommand + ".cmd"; } return baseCommand; } ``` 上述方法改为: ```java /** * 执行 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)编写构‍建项目的方法,组合⁡执行上述命令,并且‎校验是否构建成功。 注意,尽量不在这个方法中抛出异常,防止影响主业务流程。 ```java /** * 构建 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 2‎1 的虚拟线程特性,在单独的线程中执行构建。 代码如下: ```java @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‍ 消息流式处理器 Js⁡onMessageSt‎reamHandler‎ 中,当流式输出完成后,执行 Vue 项目的构建: ```java @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 生成的网站,因为不知‎道后端什么时候构建完成,后续章节中会调整这里的逻辑。 ## 五、工程项目部署 如果部署的是 V‍ue 项目,则调用 VuePr⁡ojectBuilder 执行‎安装依赖和打包命令,并且将得到‎的 dist 目录移动到部署目录。其他都能复用已有的部署流程。 修改 AppService 的 deployApp 方法: ```java // 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; ``` ## 六、前端开发 这次前端项‍目的改动不大。前端⁡如果识别到是 Vu‎e 项目,项目的浏‎览地址需要增加 dist 后缀。 1)补充枚举类型: ```javascript /** * 代码生成类型枚举 */ 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)修改获取网站浏览地址的函数: ```javascript // 获取静态资源预览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 } ``` 最后,完整测试一下生成、浏览和部署的流程。 由于打包构‍建是异步的,所以目前⁡生成后可能无法立即浏‎览,需要刷新页面。不‎过别担心,后面的章节中,我们会解决这个问题。 ![[image-55968353.png]] 效果还不错‍,有时可能会出现错⁡误,大家可以自行调‎整提示词或者更换大‎模型来优化: ![[image-55968353.png]]![[image-b7b0a161.png]]![[image-a51c262f.png]] 💡 注意‍!如果你发现 AI⁡ 一直无限循环调用‎工具,可以尝试下面‎的方法: 1. 调大对话记忆的容量,否则 AI 会中途断片儿,忘记已经生成了哪些文件 2. 尝试换其他的 AI 大模型 3. 优化提示词 此外,还有‍其他的方法解决这个⁡问题,在本项目第 ‎11 期《系统优化》‎中有讲。 ## 七、扩展思路 ### 1、为 AI 提供代码模板 前面有提到,‍美团 NoCode 平台⁡应该是使用了预置代码模板‎来提高 AI 生成的效率和‎稳定性。确保项目能正常运行、以及项目结构的完整。 ![[image-8597e7ee.png]]![[image-db316471.png]] 这个实现思路并不难,每次‍调用 AI 之前,先根据用户的提示词找到一套合适⁡的预置代码模板(比如简单的 Vue 工程),然后‎复制到应用生成目录中,把模板信息作为提示词提供给‎ AI,并且提供给 AI 文件读取和修改工具,接下来就让 AI 基于模板二次处理就好。 比如追加这么一段提示词: ```java ## 现有文件内容 { "filePath": "package.json", "desc": "依赖管理文件", "content": "文件主要内容" }, ... ``` 这个思路其‍实有点儿缓存的感觉⁡,如果正好有和预置‎代码模板一样的需求‎,搞不好就直接把预置模板返回了。 ### 2、深度思考 前面提到,LangChain4j 的 v1.2 版本已经支持了 [深度思考的流式输出特性](https://docs.langchain4j.dev/tutorials/response-streaming),直接监听 TokenStream 的 onPartialThinking 事件就能实现了。 ```java tokenStream .onPartialThinking((PartialThinking partialThinking) -> System.out.println(partialThinking)) .start(); ``` 但是要注意: 1. 有些大模型不支持输出深度思考信息,自然也获取不到 2. 确保引入了 LangChain4j 以及 LangChain4j 整合大模型的最新依赖,否则可能不生效 这个功能我觉得‍还是挺值得尝试的,大家可以⁡参考本节教程中输出工具调用‎信息的方式,将深度思考作为‎一种消息类型,流式输出给前端,在单独的位置进行展示。 --- **项目分区导航**:⬅️ [[08-对话历史模块|08-对话历史模块]] | 09-工程项目生成 | ➡️ [[10-应用模块|10-应用模块]]