263 Commits

Author SHA1 Message Date
ageerle
fdddec2f14 Merge pull request #219 from seven-ocean/feature/aihuman
Feature/aihuman
2025-10-10 12:41:19 +08:00
ageerle
57e23be82f Merge branch 'main' into feature/aihuman 2025-10-10 12:41:10 +08:00
ageerle
89f4976b7c Merge pull request #220 from seven-ocean/feature/aihuamn
Feature/aihuamn
2025-10-10 12:39:03 +08:00
Maxchen
a0d93b1ca8 Merge remote-tracking branch 'origin/feature/aihuman' into feature/aihuman 2025-10-10 12:23:55 +08:00
Maxchen
ca8ba6fe48 Merge remote-tracking branch 'origin/feature/aihuman' into feature/aihuman
# Conflicts:
#	script/sql/update/2025-10-10-实时交互数字人集成.sql
2025-10-10 12:23:41 +08:00
Maxchen
7994704c11 Merge remote-tracking branch 'origin/feature/aihuman' into feature/aihuman
# Conflicts:
#	script/sql/update/2025-10-10-实时交互数字人集成.sql
2025-10-10 12:09:17 +08:00
Maxchen
8d4fadc9a2 修改了sql文件 2025-10-10 12:06:10 +08:00
Maxchen
bd8dccf7b2 提交 菜单+字典+字典编码 sql 2025-10-10 12:00:17 +08:00
Maxchen
584cead6bf 提交 pom.xml 模块修改 2025-10-10 11:53:51 +08:00
Maxchen
a52529eefd 编码统一为utf8mb4_general_ci 2025-10-10 11:32:54 +08:00
ageerle
6e433237e0 Merge pull request #218 from seven-ocean/feature/aihuamn
修改了sql文件
2025-10-10 11:23:06 +08:00
Maxchen
abd4f01b69 修改了sql文件 2025-10-10 11:16:42 +08:00
Maxchen
5c430ee1d2 修改了sql文件 2025-10-10 11:10:33 +08:00
ageerle
e23e295d68 Merge pull request #217 from seven-ocean/feature/aihuman
添加数字人菜单sql,建表语句sql
2025-10-10 11:06:38 +08:00
Maxchen
3d6bbad616 添加数字人菜sql,建表语句sql 2025-10-10 11:03:39 +08:00
ageerle
5088c0e6d7 Merge pull request #216 from fangzhh/main
feat: 优化嵌入模型业务,使用策略模型加工厂模式动态加载嵌入模型,支持多供应商多嵌入模型动态接入;
2025-10-09 20:11:40 +08:00
Ariel Overcast
2995b6ddde Merge branch 'ageerle:main' into main 2025-10-09 20:04:50 +08:00
Robust_H
5475776caa feat: 优化通过知识库获取模型配置逻辑,修改为通过模型id查找模型配置,避免多供应商同模型映射错误。 2025-10-09 20:03:34 +08:00
ageerle
08d4977263 Merge pull request #215 from seven-ocean/feature/aihuman
数字人后端
2025-10-09 19:55:09 +08:00
Maxchen
31602cb85e 数字人后端 2025-10-09 18:53:28 +08:00
ageerle
5adc5f0006 Update README.md 2025-10-09 17:40:29 +08:00
ageerle
307d095cf1 Update README.md 2025-10-09 17:38:20 +08:00
Robust_H
2cef4e17dc perf: 优化‘嵌入模型’工厂,添加缓存机制 2025-10-04 18:27:54 +08:00
Robust_H
b47da3f438 feat: 初始化多供应商多嵌入模型集成,采用策略模式和工厂模式实现 2025-10-04 04:50:12 +08:00
evo
827ac48826 Merge pull request #200 from 20suiWXJB/fix/在-ruoyi-extend-中添加缺失的-ruoyi-ai-copilot-模块
fix(pom): 在 ruoyi-extend 中添加缺失的 ruoyi-ai-copilot 模块
2025-09-27 19:11:32 +08:00
evo
f906645708 Merge pull request #198 from wenxwang/fix/admin-knowledge-remove-error
fix(Knowledge): 知识库删除失败
2025-09-27 19:08:17 +08:00
evo
c17e16dd0f Merge pull request #181 from LM20230311/feat-model-priority
解决问答实现类中重新查询模型逻辑可能导致自动选择的模型被重置问题
2025-09-27 19:07:34 +08:00
evo
837236f1cc Merge pull request #180 from LM20230311/fix-upload-bucket
Fix upload bucket: 修复问答页面文件上传问题;
2025-09-27 19:07:15 +08:00
evo
60793b957a Merge pull request #179 from violateer/featur/remove_limit1_compat
feature: 移除limit 1写法,兼容不同数据库
2025-09-27 19:06:48 +08:00
evo
fa4dc87e76 Merge pull request #212 from MuSan-Li/feature_20250927_fix_oss_logic
feat: 删除oss创建桶配置
2025-09-27 19:06:14 +08:00
evo
4ac63c3268 feat: 删除oss创建桶配置 2025-09-27 19:05:27 +08:00
evo
54e7999fe3 Merge pull request #210 from MuSan-Li/feature_20250926_add_swagger
feat: 添加接口文档测试
2025-09-26 15:07:22 +08:00
lihao05
32fd910584 feat: 添加接口文档测试 2025-09-26 15:06:43 +08:00
evo
25e659dffa Merge pull request #208 from LM20230311/upgrade/upgrade-spring-ai-1.0.0
Upgrade/upgrade spring ai 1.0.0
2025-09-26 13:59:49 +08:00
LM20230311
585e5ff0f8 fix: 解决knife4j访问问题-暂未解决; 2025-09-24 20:47:22 +08:00
LM20230311
bd346f1e85 fix: 解决不登陆无法问答问题;修复余额不足后流不关闭问题; 2025-09-24 20:27:55 +08:00
LM20230311
2caf9a47ed upgrade: 放行knife4j需要的路径; 2025-09-24 19:28:34 +08:00
LM20230311
f10f44158c upgrade: 升级knife4j版本为3,4.5.0; 2025-09-24 19:01:33 +08:00
ageerle
76acd4a40b Update README.md 2025-09-24 17:32:19 +08:00
ageerle
6467af1d73 Update README.md 2025-09-24 17:31:44 +08:00
ageerle
35146f3495 Update README.md 2025-09-24 17:31:00 +08:00
likunlong
9e23587fb1 upgrade: 升级spring ai版本为正式1.0.0; 2025-09-24 10:08:51 +08:00
evo
a61bd57e22 Merge pull request #206 from MuSan-Li/feature_20250923_fix_wx_logic
feat:修复缺少的微信逻辑部分
2025-09-23 13:58:38 +08:00
lihao05
6bb7bc6eb5 feat:修复缺少的微信逻辑部分 2025-09-23 13:57:18 +08:00
ageerle
3f9e83a767 Merge pull request #204 from Cyclones-Y/main
feat(wechat): 添加企业微信SDK依赖引入weixin-java-cp4.4.0版本以支持企业微信功能开发
2025-09-21 16:08:12 +08:00
Yzm
0d711b1842 feat(wechat): 添加企业微信SDK依赖引入weixin-java-cp4.4.0版本以支持企业微信功能开发 2025-09-21 15:39:06 +08:00
ageerle
a33159f9a3 移除不必要配置 2025-09-19 14:53:35 +08:00
ageerle
6462752fd6 恢复微信模块,优化知识库切片功能 2025-09-19 14:50:02 +08:00
ageerle
afc1272ff5 恢复微信模块,优化知识库切片功能 2025-09-19 11:15:37 +08:00
ageerle
fa5dc80a93 Merge remote-tracking branch 'origin/mineru/dev'
# Conflicts:
#	ruoyi-admin/src/main/resources/application-dev.yml
#	ruoyi-admin/src/main/resources/application-prod.yml
#	ruoyi-modules-api/ruoyi-knowledge-api/src/main/java/org/ruoyi/chain/loader/ResourceLoaderFactory.java
2025-09-19 10:59:52 +08:00
ageerle
acc2d5d1a8 Merge remote-tracking branch 'origin/pdf-image'
# Conflicts:
#	ruoyi-admin/src/main/resources/application-dev.yml
#	ruoyi-admin/src/main/resources/application-prod.yml
#	ruoyi-modules-api/ruoyi-knowledge-api/pom.xml
#	ruoyi-modules-api/ruoyi-knowledge-api/src/main/java/org/ruoyi/service/PdfImageExtractService.java
#	ruoyi-modules-api/ruoyi-knowledge-api/src/main/java/org/ruoyi/service/impl/PdfImageExtractServiceImpl.java
#	ruoyi-modules-api/ruoyi-knowledge-api/src/main/java/org/ruoyi/service/impl/VectorStoreServiceImpl.java
#	ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/chat/controller/knowledge/KnowledgeController.java
#	ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/chat/service/knowledge/DealFileService.java
#	ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/chat/service/knowledge/KnowledgeInfoServiceImpl.java
#	script/sql/update/202505141010.sql
2025-09-19 10:56:26 +08:00
马宏跃
c22b9fdb9c fix(pom): 在 ruoyi-extend 中添加缺失的 ruoyi-ai-copilot 模块 2025-09-18 09:03:07 +08:00
ageerle
2a45776aeb 新增数据库开发规范 2025-09-17 17:35:51 +08:00
w
ff0a3d1016 fix(Knowledge): 知识库删除失败 2025-09-17 12:00:27 +08:00
ageerle
b2a589ed9c Update README.md 2025-09-16 13:45:27 +08:00
evo
13da60e151 Merge pull request #189 from MuSan-Li/feature_20250904_fix_sql
feat: 添加session表会话ID
2025-09-04 17:02:06 +08:00
l90215
1f0c0ba0a9 feat: 添加session表会话ID 2025-09-04 17:01:13 +08:00
ageerle
ef3541fe77 Merge pull request #188 from xiaonieli7/feature_20250813_fix_codeOptimization
补充统一计费代理类BillingChatServiceProxy
2025-09-04 16:44:52 +08:00
Administrator
2b5fd810a4 fix(billing): 统一计费代理类BillingChatServiceProxy 2025-09-04 16:41:14 +08:00
Administrator
4a8d21a742 fix(billing): 1. 新增统一计费代理 BillingChatServiceProxy位置:ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/chat/service/chat/proxy/BillingChatServiceProxy.java 作用:为所有ChatService实现类提供透明的计费代理包装
核心功能:
  AI回复前余额预检查,避免无效消耗
  自动收集AI回复内容
  统一处理AI回复的保存和计费
   适配多种AI服务的数据格式
  2. 重构工厂类
  ChatServiceFactory
  改进:自动为所有ChatService包装计费代理
 新增方法:getOriginalService() 用于获取未包装的原始服务优势:调用方无需关心计费逻辑,完全透明
 3. 增强计费服务 IChatCostService 接口
   新增方法:checkBalanceSufficient() - 余额预检查
   分离关注点:saveMessage() - 仅保存消息
    publishBillingEvent() - 仅发布计费事件
    deductToken() - 仅执行计费扣费
2025-09-04 16:35:55 +08:00
ageerle
c62530176f Merge pull request #187 from xiaonieli7/feature_20250813_fix_codeOptimization
修改了计费逻辑
2025-09-04 15:43:48 +08:00
Administrator
c7554d7e35 fix(billing): 1. 新增统一计费代理 BillingChatServiceProxy位置:ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/chat/service/chat/proxy/BillingChatServiceProxy.java 作用:为所有ChatService实现类提供透明的计费代理包装
核心功能:
  AI回复前余额预检查,避免无效消耗
  自动收集AI回复内容
  统一处理AI回复的保存和计费
   适配多种AI服务的数据格式
  2. 重构工厂类
  ChatServiceFactory
  改进:自动为所有ChatService包装计费代理
 新增方法:getOriginalService() 用于获取未包装的原始服务优势:调用方无需关心计费逻辑,完全透明
 3. 增强计费服务 IChatCostService 接口
   新增方法:checkBalanceSufficient() - 余额预检查
   分离关注点:saveMessage() - 仅保存消息
    publishBillingEvent() - 仅发布计费事件
    deductToken() - 仅执行计费扣费
2025-09-04 15:37:52 +08:00
Administrator
1e4af3d01b fix(billing): 修复Token计费逻辑和消息更新机制
* 修复Token计费算法:按批次计费而非Token数量计费
* 添加ChatRequest.messageId字段支持消息关联更新
* 优化消息保存流程:分离基础信息保存和计费信息更新
* 修复预检查逻辑:统一预检查和实际扣费计算方式
* 调整Token阈值:100 → 1000,减少扣费频次
* 完善事件传递:ChatMessageCreatedEvent增加messageId

Fixes: 余额预检查误判、消息计费信息缺失、Token计费不准确
2025-08-29 15:19:37 +08:00
Administrator
1e3b49c9b8 用户发送消息 → 预检查余额 → 保存用户消息 → 发布计费事件 → 异步扣费 → 保存账单记录
添加了billingType计费类型字段消息保存的时候写入进去
2025-08-27 16:48:48 +08:00
Administrator
9f7f00e50c 用户发送消息 → 预检查余额 → 保存用户消息 → 发布计费事件 → 异步扣费 → 保存账单记录
添加了billingType计费类型字段消息保存的时候写入进去
2025-08-27 15:30:59 +08:00
Administrator
1c721981db Merge remote-tracking branch 'origin/main'
# Conflicts:
#	ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/chat/service/chat/impl/DifyServiceImpl.java
#	ruoyi-modules/ruoyi-generator/src/main/java/org/ruoyi/generator/impl/GenTableServiceImpl.java
2025-08-27 10:49:37 +08:00
AmAzing129
6e6ba84fd2 docs: add contributors bubble 2025-08-27 10:47:34 +08:00
likunlong
ef69778bb7 feat: 下掉模型能力逻辑代码; 2025-08-27 10:47:34 +08:00
l90215
7a374d877b feat: 调整知识库问答接入提示词模板 2025-08-27 10:47:34 +08:00
likunlong
43426054ec feat: 兼容不选自动模型时的原先逻辑;封装通用方法,简化创建有监控的SSE,简化流式错误输出并通知重试; 2025-08-27 10:47:31 +08:00
likunlong
ccdbb20935 feat: 不选择模型自动选择时走原始默认逻辑; 2025-08-27 10:47:29 +08:00
likunlong
4b37cfe97d feat: 失败回调器中使用emitter对象的唯一hash作为key,不再使用session,不与业务进行绑定,同时也保证跨线程调用的正确性; 2025-08-27 10:47:26 +08:00
likunlong
c43d4784de feat: 处理在非Web线程中获取Request中token失败的问题; 2025-08-27 10:47:25 +08:00
likunlong
359cee28d5 feat: 修改目前实现类使用统一重试降级逻辑; 2025-08-27 10:47:22 +08:00
likunlong
aa11c1f233 feat: 问答时添加统一重试和降级逻辑; 2025-08-27 10:47:20 +08:00
likunlong
a0d029c142 feat: 自动设置请求参数中的模型名称; 2025-08-27 10:47:20 +08:00
likunlong
6ce52befe2 feat: 根据是否有附件和是否自动,自动选择模型并且获取服务; 2025-08-27 10:47:19 +08:00
likunlong
330bdc3761 feat: 数据库chat_model添加优先级字段; 2025-08-27 10:47:19 +08:00
likunlong
4f7ad59e46 feat: 添加自动获取高优先级模型和服务的逻辑; 2025-08-27 10:47:19 +08:00
l90215
b696fde881 feat: 合并代码 删除不需要的文件 2025-08-27 10:47:19 +08:00
fy53888
00f9a1a55b 修改字典下拉带查找功能 2025-08-27 10:47:19 +08:00
fy53888
a1c7b86e72 备分一下2 2025-08-27 10:47:19 +08:00
fy53888
62676a54fb 备分一下2 2025-08-27 10:47:19 +08:00
fy53888
e51425a951 备分一下 2025-08-27 10:47:17 +08:00
fy53888
268be2d9ec 更新后端生成類型 Integer出錯的問 2025-08-27 10:47:09 +08:00
fy53888
f448a18e44 更新后端生成類型 Integer出錯的問題 2025-08-27 10:47:07 +08:00
lixiang
22e59fe5a1 向量库sql查询去除匹配分值字段 2025-08-27 10:47:01 +08:00
violateer
0780e3b8c9 fix: 修改krole_group_ids字段名 2025-08-27 10:47:01 +08:00
violateer
0cdba56a07 feature: 添加开启知识库角色,用户可见个人知识库及角色分配知识库 2025-08-27 10:47:01 +08:00
l90215
ebc13c06af feat: 更新只是库角色默认不开启 2025-08-27 10:47:00 +08:00
l90215
416f011c73 feat: fix代码生成类型问题 2025-08-27 10:47:00 +08:00
likunlong
bfeb389171 feat: 获取模型接口支持返回模型能力;模型表增加模型能力字段; 2025-08-27 10:47:00 +08:00
violateer
caf7f14781 feature: 新增生成前端文件模板接口 2025-08-27 10:46:51 +08:00
l90215
a6eb98daab feat: fix代码生成类型问题 2025-08-27 10:46:37 +08:00
l90215
4834b615a6 feat: fix代码生成类型问题 2025-08-27 10:46:37 +08:00
l90215
42aabeed96 feat: 添加空格 2025-08-27 10:46:37 +08:00
likunlong
67303cf5be feat: 上传已经确定模型的选择,这里只需要根据名字获取模型直接使用就好; 2025-08-26 09:54:08 +08:00
likunlong
5bd95b496a feat: 默认上传服务修改为本地minio;添加部署时自启动minio服务;添加minio的服务启动docker-compose脚本; 2025-08-25 11:04:52 +08:00
likunlong
8fcaa7c90c feat: 默认上传服务修改为本地minio;添加部署时自启动minio服务;添加minio的服务启动docker-compose脚本; 2025-08-25 10:53:48 +08:00
violateer
27398c1000 feature: 移除limit 1写法,兼容不同数据库 2025-08-23 19:02:51 +08:00
ageerle
bd9ffb10a9 Merge pull request #178 from AmAzing129/main
docs: add contributors bubble
2025-08-22 14:33:27 +08:00
AmAzing129
bb9c85ac3c docs: add contributors bubble 2025-08-22 14:29:03 +08:00
evo
70ca78d935 Merge pull request #176 from LM20230311/feat-model-priority
feat: 下掉模型能力逻辑代码;
2025-08-20 17:55:15 +08:00
evo
1af8c4ee50 Merge pull request #177 from MuSan-Li/feature_20250820_fix_prompt_temp
feat: 调整知识库问答接入提示词模板
2025-08-20 17:55:05 +08:00
l90215
b9276c5dcc feat: 调整知识库问答接入提示词模板 2025-08-20 17:52:51 +08:00
likunlong
d1e98a2001 feat: 下掉模型能力逻辑代码; 2025-08-20 16:09:53 +08:00
evo
baf065a294 Merge pull request #175 from LM20230311/feat-model-priority
Feat model priority:支持自动选择模型;支持模型的重试;
2025-08-20 14:04:17 +08:00
likunlong
842a39d6d2 feat: 兼容不选自动模型时的原先逻辑;封装通用方法,简化创建有监控的SSE,简化流式错误输出并通知重试; 2025-08-19 20:28:53 +08:00
likunlong
9fba91c35f feat: 不选择模型自动选择时走原始默认逻辑; 2025-08-19 18:00:20 +08:00
likunlong
498135b7fd feat: 失败回调器中使用emitter对象的唯一hash作为key,不再使用session,不与业务进行绑定,同时也保证跨线程调用的正确性; 2025-08-19 17:53:27 +08:00
likunlong
c3ab13ae67 feat: 处理在非Web线程中获取Request中token失败的问题; 2025-08-19 17:39:20 +08:00
likunlong
1638b9dd75 feat: 修改目前实现类使用统一重试降级逻辑; 2025-08-19 16:51:51 +08:00
likunlong
4434d8346c feat: 问答时添加统一重试和降级逻辑; 2025-08-19 16:46:25 +08:00
likunlong
119483df86 feat: 自动设置请求参数中的模型名称; 2025-08-19 15:12:24 +08:00
evo
98f7e3ada2 Merge pull request #174 from MuSan-Li/feature_202500819_fix_merge
feat: 合并代码 删除不需要的文件
2025-08-19 12:44:59 +08:00
l90215
50d9e0e843 feat: 合并代码 删除不需要的文件 2025-08-19 12:43:34 +08:00
evo
2871cf7630 Merge pull request #171 from fy53888/main
修改字典功能 和模板生成id 太长19位 改为1,2,3
2025-08-19 12:33:40 +08:00
likunlong
07cb351807 feat: 根据是否有附件和是否自动,自动选择模型并且获取服务; 2025-08-19 10:32:17 +08:00
fy53888
951ee6bd8a 修改字典下拉带查找功能 2025-08-19 09:48:35 +08:00
fy53888
5b6605c345 备分一下2 2025-08-18 22:04:15 +08:00
fy53888
5db116ec88 备分一下2 2025-08-18 22:03:51 +08:00
fy53888
645c754dd0 备分一下 2025-08-18 19:56:26 +08:00
likunlong
8751bb5104 feat: 数据库chat_model添加优先级字段; 2025-08-18 14:49:56 +08:00
likunlong
8d0c557bdb feat: 添加自动获取高优先级模型和服务的逻辑; 2025-08-18 14:30:08 +08:00
evo
5ac785c570 Merge pull request #173 from lixiang-cell/lixiang8.18
向量库sql查询去除匹配分值字段
2025-08-18 12:30:26 +08:00
evo
60145f9291 Merge pull request #170 from violateer/feature_fix_k_role_20280815
feature: 添加开启知识库角色,用户可见个人知识库及角色分配知识库
2025-08-18 12:30:09 +08:00
lixiang
d7b89cd1b3 向量库sql查询去除匹配分值字段 2025-08-18 11:12:06 +08:00
fy53888
5264b47c2f 修改字典功能 和模板生成id 太长19位 改为1,2,3 2025-08-17 09:14:19 +08:00
violateer
e9cd9e84d4 fix: 修改krole_group_ids字段名 2025-08-15 20:43:14 +08:00
violateer
b52f7a7112 feature: 添加开启知识库角色,用户可见个人知识库及角色分配知识库 2025-08-15 20:41:59 +08:00
evo
2abdf762c1 Merge pull request #168 from MuSan-Li/feature_20250815_update_k_role_config
feat: 更新只是库角色默认不开启
2025-08-15 09:58:32 +08:00
l90215
099c94e3cb feat: 更新只是库角色默认不开启 2025-08-15 09:56:47 +08:00
Administrator
affdc5e3a6 问题概述
1.保存消息和计费逻辑存在耦合
2.修改计费逻辑:
按次计费被阈值限制:旧逻辑把 TIMES 分支放在 totalTokens ≥ 100 的大分支里,导致没到100 token时不扣费,违背“每次调用就扣费”的语义。
token累计不当:TIMES 分支只扣费不处理累计,同时在 totalTokens < 100 时不会进入任何TIMES逻辑,累计会无意义增长。
粒度不稳定:TOKEN 计费一旦达阈值就把 total 全扣完并清零,不利于对账与用户体验。
打印方式:使用 System.out.println,不利于生产追踪。

改动要点
1.新增独立方法
saveMessage(ChatRequest): 只落库。
publishBillingEvent(ChatRequest): 只发布异步计费事件。
保留组合方法 saveMessageAndPublishEvent(ChatRequest) 以便需要一行调用时使用。
调用处已改为“先保存,再发布事件”
SseServiceImpl: 先 saveMessage,再 publishBillingEvent。
SSEEventSourceListener: 同上。
DifyServiceImpl: 同上。

2.计费模式分流:
TIMES:每次调用直接扣费,不累计。
TOKEN:按阈值(100)批量扣费,保留余数,账单颗粒稳定。
保留余数:total = prev + delta;billable = floor(total/threshold)threshold;remainder = total % threshold。
日志替换:统一使用 log.debug。
结构更清晰、可维护。
所有金额计算统一用 BigDecimal,保留两位小数,RoundingMode.HALF_UP
按次计费:每次直接扣费(BigDecimal),边界转 Double
按 token 计费:按阈值批量结算,保留余数;费用=单价(BigDecimal)×可结算token数
1. 消息分类存储
用户消息:role="user", deductCost=null, totalTokens=本次token数, remark="用户消息"
系统账单:role="system", deductCost=实际扣费, totalTokens=计费token数, remark="TIMES_BILLING/TOKEN_BILLING"
2. 数据流程
用户发送消息 → 预检查余额 → 保存用户消息 → 发布计费事件 → 异步扣费 → 保存账单记录
2025-08-14 14:00:48 +08:00
evo
047044eb06 Merge pull request #166 from MuSan-Li/feature_202500807_fix_code_generate
feat: fix代码生成类型问题
2025-08-13 12:39:24 +08:00
l90215
7108727395 feat: fix代码生成类型问题 2025-08-13 12:37:10 +08:00
evo
d3732a155d Merge pull request #164 from MuSan-Li/feature_20250811_fix_code_generator
Feature 20250811 fix code generator
2025-08-11 22:00:20 +08:00
l90215
a0db91ebe6 Merge branch 'main' of https://github.com/MuSan-Li/ruoyi-ai into feature_20250811_fix_code_generator 2025-08-11 21:59:20 +08:00
l90215
e83d70e9c3 feat: fix代码生成类型问题 2025-08-11 21:59:02 +08:00
evo
5eb166839a Merge pull request #163 from LM20230311/feat-model-switching-association
feat: 获取模型接口支持返回模型能力;模型表增加模型能力字段;
2025-08-11 21:54:08 +08:00
evo
e27a6cb738 Merge pull request #162 from violateer/feature_gen_frontend_model_files
feature: 新增生成前端文件模板接口
2025-08-11 21:53:56 +08:00
likunlong
d964e86b23 feat: 获取模型接口支持返回模型能力;模型表增加模型能力字段; 2025-08-11 09:33:35 +08:00
violateer
86d7eab5b5 解决冲突 2025-08-10 17:22:48 +08:00
violateer
fa5ad8caf6 Merge remote-tracking branch 'origin/feature_gen_frontend_model_files' into feature_gen_frontend_model_files 2025-08-10 17:20:14 +08:00
violateer
e5011e0dd9 feature: 新增生成前端文件模板接口 2025-08-10 17:19:59 +08:00
violateer
5fe8bd7706 feature: 新增生成前端文件模板接口 2025-08-10 17:06:32 +08:00
evo
0f28e1f3f6 Merge pull request #161 from MuSan-Li/feature_202500807_fix_xx
fix 代码生成逻辑bug
2025-08-10 00:27:56 +08:00
l90215
503f86644e feat: fix代码生成类型问题 2025-08-10 00:25:49 +08:00
fy53888
579beb6833 修复后端生成类型 Integer 出错的问题 2025-08-09 22:03:31 +08:00
fy53888
22c0c733f6 更新获取Java类型后端生成類型 Integer出錯的問 2025-08-09 22:01:29 +08:00
fy53888
9f4a2256b4 更新后端生成類型 Integer出錯的問 2025-08-09 21:56:16 +08:00
fy53888
5a4d76ac09 更新后端生成類型 Integer出錯的問題 2025-08-09 21:55:23 +08:00
Administrator
5a2e08f87d 问题概述
1.保存消息和计费逻辑存在耦合
2.修改计费逻辑:
按次计费被阈值限制:旧逻辑把 TIMES 分支放在 totalTokens ≥ 100 的大分支里,导致没到100 token时不扣费,违背“每次调用就扣费”的语义。
token累计不当:TIMES 分支只扣费不处理累计,同时在 totalTokens < 100 时不会进入任何TIMES逻辑,累计会无意义增长。
粒度不稳定:TOKEN 计费一旦达阈值就把 total 全扣完并清零,不利于对账与用户体验。
打印方式:使用 System.out.println,不利于生产追踪。
3.建议数据库不要存扣除金额和累计消耗token,消息表里不需要存“累计到目前为止多少”,否则每条消息都变成快照,既冗余又易不一致

改动要点
1.新增独立方法
saveMessage(ChatRequest): 只落库。
publishBillingEvent(ChatRequest): 只发布异步计费事件。
保留组合方法 saveMessageAndPublishEvent(ChatRequest) 以便需要一行调用时使用。
调用处已改为“先保存,再发布事件”
SseServiceImpl: 先 saveMessage,再 publishBillingEvent。
SSEEventSourceListener: 同上。
DifyServiceImpl: 同上。

2.计费模式分流:
TIMES:每次调用直接扣费,不累计。
TOKEN:按阈值(100)批量扣费,保留余数,账单颗粒稳定。
保留余数:total = prev + delta;billable = floor(total/threshold)threshold;remainder = total % threshold。
日志替换:统一使用 log.debug。
结构更清晰、可维护。
所有金额计算统一用 BigDecimal,保留两位小数,RoundingMode.HALF_UP
按次计费:每次直接扣费(BigDecimal),边界转 Double
按 token 计费:按阈值批量结算,保留余数;费用=单价(BigDecimal)×可结算token数
2025-08-08 13:39:37 +08:00
l90215
838a393abc feat: 添加空格 2025-08-07 21:13:02 +08:00
evo
210a9d9b14 Merge pull request #159 from MuSan-Li/feature_20250807_fix_code_generator
feat: 代码生成模板优化
2025-08-07 09:03:08 +08:00
l90215
66518925c1 feat: 代码生成模板优化 2025-08-07 09:02:16 +08:00
evo
19d3f018b8 Merge pull request #158 from MuSan-Li/feature_20250805_fix_data_model_del
feat: 优化删除数据模型也删除模型字段sql条件
2025-08-05 22:11:00 +08:00
l90215
450ec6db44 feat: 优化删除数据模型也删除模型字段sql条件 2025-08-05 22:06:05 +08:00
evo
0bb897de1f Merge pull request #157 from MuSan-Li/feature_20250805_fix_data_model_del
feat: 优化删除数据模型也删除模型字段&添加mapper文件注解
2025-08-05 19:51:28 +08:00
l90215
ab9ff52200 feat: 优化删除数据模型也删除模型字段&添加mapper文件注解 2025-08-05 19:50:29 +08:00
evo
d94bcf250e Merge pull request #156 from MuSan-Li/feature_20250804_fix_bug
feat: 优化空指针bug&格式化代码&初始化用户知识库角色配置
2025-08-04 20:54:27 +08:00
l90215
10708b7625 feat: 优化空指针bug&格式化代码&初始化用户知识库角色配置 2025-08-04 20:50:49 +08:00
ageerle
5e032a68d5 Merge pull request #154 from violateer/feature/knowledge-role
添加删除知识库角色组时关联删除数据
2025-08-03 09:59:50 +08:00
violateer
bd41ff0f28 添加删除知识库角色组时关联删除数据 2025-08-02 22:29:02 +08:00
evo
3ffba456f4 Merge pull request #153 from MuSan-Li/feature_20250802_fix_k_sql
feat: 调整知识库角色sql
2025-08-02 21:25:52 +08:00
l90215
993346534e feat: 调整知识库角色sql 2025-08-02 21:25:16 +08:00
evo
38169ba90d 修复查询字段名称
feat: 修复一些逻辑问题
2025-08-02 19:39:23 +08:00
l90215
b003e1b3bb feat: 修复查询字段名称 2025-08-02 19:38:50 +08:00
l90215
f36dda74a1 feat: 修复一些逻辑问题 2025-08-02 19:37:07 +08:00
evo
c609095c69 Merge pull request #151 from MuSan-Li/feature_20250801_fix_menu_sql
feat: 调整菜单结构sql
2025-08-02 18:37:47 +08:00
l90215
93e34b3f03 feat: 调整菜单结构sql 2025-08-02 18:36:50 +08:00
ageerle
cc4ca69640 Merge pull request #143 from violateer/feature/knowledge-role
添加知识库权限控制功能
2025-08-02 15:56:42 +08:00
ageerle
85930f4b12 Merge pull request #132 from HHANG52/main
feat(chat): 修复仅非管理员设置根据用户 ID查询聊天记录
2025-08-02 15:56:31 +08:00
ageerle
3c19a2b7a4 Merge pull request #142 from keke-cxn/main
修复:请求地址'/system/role/authUser/selectAll',发生未知异常:cn.dev33.satoken.exce…
2025-08-02 15:56:16 +08:00
keke
21c390c4d6 修复:1.在使用dify的时候,发送1+1=?模型返回2,但是第二次问他为什么等于2的时候,模型无法获取上下文信息,经过排查,发现缺少conversationId,session表需要添加conversationId字段
2.在使用dify的时候message表中并没有存储模型返回的消息,并且扣除费用、
2025-08-02 13:18:30 +08:00
ageerle
cdef9e1c89 feat: 增加演示模式 2025-07-31 14:58:29 +08:00
evo
3c60f43daa Merge pull request #147 from MuSan-Li/feature_20250721_generate_code
feat: 调整生成代码结构
2025-07-30 09:31:04 +08:00
l90215
a24fa7a2b4 feat: 调整生成代码结构 2025-07-30 09:24:31 +08:00
ageerle
21f8462ebb Merge pull request #146 from MuSan-Li/feature_20250721_generate_code
feat: 调整生成代码结构
2025-07-30 09:10:32 +08:00
l90215
897222c41c feat: 调整生成代码结构 2025-07-29 23:34:39 +08:00
ageerle
227670df9d Merge pull request #145 from MuSan-Li/feature_20250721_generate_code
feat: 提交替换代码生成sql
2025-07-29 09:43:29 +08:00
l90215
6b2cf1a3c3 feat: 提交替换代码生成sql 2025-07-28 17:47:12 +08:00
ageerle
37f832c31c Merge pull request #144 from MuSan-Li/feature_20250721_generate_code
替换代码生成
2025-07-28 09:32:19 +08:00
l90215
25cf1f9744 feat: 更新数据库密码... 2025-07-27 22:47:55 +08:00
l90215
a48178685c feat: 更新代码生成功能-优化逻辑 2025-07-27 22:37:26 +08:00
l90215
7e60cb357f feat: 更新代码生成功能-优化逻辑 2025-07-27 00:33:33 +08:00
l90215
915a393427 feat: 更新代码生成功能-优化取数逻辑 2025-07-24 12:36:26 +08:00
l90215
de323d8c45 feat: 更新代码生成功能-添加数据模型批量添加字段数据 2025-07-23 10:46:29 +08:00
l90215
ffe4867d40 feat: 更新代码生成功能-二阶段 2025-07-22 18:50:37 +08:00
l90215
8b3c0b4134 feat: 更新代码生成功能-一阶段 2025-07-21 16:03:08 +08:00
violateer
999282210e 添加知识库权限控制功能 2025-07-20 10:17:50 +08:00
violateer
a99344813f 添加知识库权限控制功能 2025-07-20 10:05:38 +08:00
ageerle
63ec00cd71 Merge pull request #136 from Administratos-User/main
修复token不扣费和扣费异常的问题
2025-07-16 15:53:56 +08:00
ageerle
7eebd87cc8 Merge pull request #141 from lixiang-cell/lixiang
存储向量库同时存储元数据的fid和docId,提供根据fid docId删除
2025-07-16 15:52:19 +08:00
keke
9a816bb0c7 修复:当前用户【如果没有绑定任何部门的情况下】在查询用户列表分页的时候 调用 SysUserMapper 的 selectPageUserList() 时候报错:sql错误 2025-07-15 00:02:44 +08:00
keke
bc6ed508b0 修复:1.用户原本绑定了岗位,若想要取消所有的岗位,没有进行逻辑实现
2.用户原本绑定了角色,若想要取消所有的角色,没有进行逻辑实现
2025-07-14 23:47:47 +08:00
keke
d64abaaed3 修复:请求地址'/system/role/authUser/selectAll',发生未知异常:cn.dev33.satoken.exception.NotWebContextException: 非Web上下文无法获取Request 2025-07-14 23:39:47 +08:00
lixiang
285aa2ae62 存储向量库同时存储元数据的fid和docId,提供根据fid docId删除 2025-07-14 17:15:29 +08:00
Administratos-User
99114d3301 修复token不扣费和扣费异常的问题
1.本次提交的token数+未付费token数 判断大于100token的时候就进行扣费,当然这里还可以改成更多,我觉得100合适。
2.不需要进行扣费的地方屏蔽了相关代码。
3.SessionId传递异常 建议前端传递uuid,也就是每次会话的id。
2025-07-11 11:59:20 +08:00
HHANG
e857f0345b Merge branch 'ageerle:main' into main 2025-07-11 10:32:12 +08:00
ageerle
138fa5f0e9 Merge pull request #133 from MuSan-Li/feature_20250516_add_dept_error
feat: 修复打开岗位管理爆错
2025-07-11 09:49:55 +08:00
l90215
241c6dc57a feat: 修复打开岗位管理爆粗 2025-07-10 23:53:17 +08:00
ageerle
6005339ec8 upadate md 2025-07-10 23:00:04 +08:00
ageerle
db892d35fb feat: 更新项目说明 2025-07-10 17:12:20 +08:00
HHANG
605b223985 feat(chat): 修复仅非管理员设置根据用户 ID查询聊天记录
- 在 ChatMessageServiceImpl 类中,仅当用户不是超级管理员时,才自动设置消息的用户 ID,确保了超级管理员可以查看对话聊天
2025-07-09 16:19:48 +08:00
ageerle
e117ec8e27 feat: 更新项目说明 2025-07-09 14:27:55 +08:00
ageerle
5e8db861ea feat: 更新项目说明 2025-07-08 15:30:25 +08:00
ageerle
fa2791e2e3 feat: 更新项目说明 2025-07-08 14:31:01 +08:00
ageerle
f1f7cb1084 feat: 更新项目说明 2025-07-08 12:26:24 +08:00
ageerle
acb1f27c37 feat: 更新项目说明 2025-07-08 12:10:03 +08:00
ageerle
e46245d97d feat: 更新项目说明 2025-07-07 13:44:55 +08:00
ageerle
0eff37fa51 ai编程助手 2025-07-07 12:36:53 +08:00
ageerle
c1a178c0be Merge remote-tracking branch 'origin/main' 2025-07-07 12:33:21 +08:00
ageerle
94d4446321 feat: 编程助手 2025-07-07 12:33:06 +08:00
lindaxia
d7930ad713 修复导包问题 2025-07-06 08:12:38 +08:00
lindaxia
48270add01 Merge branch 'main' of https://github.com/ageerle/ruoyi-ai 2025-07-06 07:52:25 +08:00
lindaxia
3786644a25 新增小程序接口 2025-07-06 07:51:38 +08:00
ageerle
53532681d3 Merge pull request #131 from Cyclones-Y/main
[fix][feat](chat): 修复“自动注入警告 => 用户未登录”; 添加会话中SaToken-token值传递和使用;
2025-07-05 21:29:24 +08:00
Yzm
daa7b7315b [fix][feat](chat): 添加会话中SaToken-token值传递和使用;修复“自动注入警告 => 用户未登录”
- 新增 BaseContext 类,用于保存和获取当前登录用户的 token
- 在 OpenAIServiceImpl 和 imageServiceImpl 中获取当前会话 token
- 将 token 作为参数传递给 SSEEventSourceListener
- 在 SSEEventSourceListener 中使用 token 进行用户身份验证和权限控制
- 修改 LoginHelper,增加根据 token 获取登录用户信息的方法
- 更新 InjectionMetaObjectHandler,使用 BaseContext 获取当前 token
- 修复对话时出先”自动注入警告 => 用户未登录“
2025-07-05 19:12:33 +08:00
ageerle
df3b687be4 Merge pull request #129 from MuSan-Li/feature_20250703_add_model_category_select
feat: 模型管理增加模型分类下拉框
2025-07-03 10:48:43 +08:00
l90215
ecb5ef32fc feat: 模型管理增加模型分类下拉框 2025-07-03 10:29:25 +08:00
evo
e5116472ed Merge branch 'ageerle:main' into main 2025-07-03 09:50:59 +08:00
lindaxia
e58aeb5361 更新README.md 2025-07-02 11:04:35 +08:00
lindaxia
b4306289f0 fix weaviate向量库根据数据类进行删除 2025-07-01 18:50:12 +08:00
lindaxia
d9c47bd983 修复删除知识库清空相关表 2025-07-01 18:06:49 +08:00
evo
8ddbb43dde Merge branch 'ageerle:main' into main 2025-07-01 10:02:32 +08:00
ageerle
dfe8c7dc85 Merge pull request #127 from Cyclones-Y/main
feat(chat): 集成 FastGPT 聊天模型
2025-06-30 23:21:31 +08:00
Yzm
fd94a1772f feat(chat): 集成 FastGPT 聊天模型- 在 ChatModeType 枚举中添加 FASTGPT 选项- 新增 FastGPT 相关的实体类和请求响应类
- 实现 FastGPT聊天服务接口
- 添加 FastGPT SSE 事件监听器
2025-06-30 22:03:31 +08:00
ageerle
c105d47d99 Merge pull request #125 from MuSan-Li/feature_20250626_fix_update_role
修复修改角色时候报错
2025-06-27 10:45:32 +08:00
l90215
4e2ec2dc82 feat: 修复修改角色时候报错&优化一些代码风格 2025-06-26 10:19:08 +08:00
evo
614280d8ea Merge branch 'ageerle:main' into main 2025-06-25 12:57:49 +08:00
ageerle
2fae8d0ad0 feat: 更新任务规划演示 2025-06-24 14:21:43 +08:00
ageerle
d7c2d1bcf3 feat: 更新任务规划演示 2025-06-24 14:16:40 +08:00
ageerle
122f63dfbd Merge remote-tracking branch 'origin/main' 2025-06-24 13:52:01 +08:00
ageerle
719e968192 feat: 更新任务规划演示 2025-06-24 13:51:51 +08:00
evo
bf790ceb51 Merge branch 'ageerle:main' into main 2025-06-24 11:43:08 +08:00
ageerle
de5488bd8c Merge pull request #123 from abin0515/one-step-script
one-step-script: 修改MacOS上运行快速启动脚本遇到的bug
2025-06-24 10:30:49 +08:00
GH Action - Upstream Sync
c77a245a4d Merge branch 'main' of https://github.com/ageerle/ruoyi-ai 2025-06-24 01:57:42 +00:00
Bin Xiao
6dcd8823cd one-step-script: 修改MacOS上运行快速启动脚本遇到的bug 2025-06-23 18:21:01 -04:00
ageerle
8be480e06c Update README.md 2025-06-23 16:49:13 +08:00
ageerle
11286de676 Merge pull request #118 from MuSan-Li/feature_20250610_add_prompt_template
Feature 20250610 add prompt template
2025-06-23 16:36:40 +08:00
MuSan-Li
5aaf0a672c feat: 删除fork文件 2025-06-12 17:55:19 +08:00
MuSan-Li
0089706336 添加提示词模板字典相关SQL 2025-06-12 17:37:04 +08:00
MuSan-Li
cc129801b9 添加提示词模板 2025-06-12 17:06:06 +08:00
zhouweiyi
6a73e09ac7 refactor(DashscopeServiceImpl): 移除静态变量并改进日志输出逻辑
将静态变量改为实例变量以避免并发问题
重构日志输出逻辑,仅在最后响应时输出完整内容
添加异常堆栈打印以方便调试
2025-06-09 18:01:51 +08:00
GH Action - Upstream Sync
e1dc22348c Merge branch 'main' of https://github.com/ageerle/ruoyi-ai 2025-06-07 01:53:26 +00:00
ageerle
f37e4da669 feat: 更新二维码 2025-06-06 10:13:02 +08:00
GH Action - Upstream Sync
3e097d9a68 Merge branch 'main' of https://github.com/ageerle/ruoyi-ai 2025-06-06 01:54:02 +00:00
ageerle
97ae5a46cd feat: 调整sql脚本 2025-06-05 16:16:32 +08:00
ageerle
baa664ac4f feat: 图片识别功能优化 2025-06-05 16:00:06 +08:00
ageerle
353fbf26b8 Merge pull request #115 from Code-Mr-Jiu/main
上传图片支持使用后台image分类下通义千问模型
2025-06-05 13:53:20 +08:00
ageerle
f79b4ec012 Merge pull request #114 from Code-Mr-Jiu/jiuyi-dev
上传图片支持使用后台image分类模型
2025-06-05 13:53:09 +08:00
酒亦
0a73cb4e17 上传图片支持使用后台image分类下通义千问模型 2025-06-05 12:05:28 +08:00
zhouweiyi
cbe882af66 chore: 更新百炼模型配置的API密钥
将开发环境和本地环境的百炼模型配置中的API密钥统一更新为占位符'sk-xxxx'
2025-06-04 18:02:26 +08:00
zhouweiyi
1d51a103d0 feat: 集成阿里百炼API实现图片内容识别功能
添加DashscopeService接口及实现,用于调用阿里百炼API进行图片内容识别
修改PdfImageExtractService增加基于百炼API的图片处理逻辑
新增OSS服务方法支持临时文件处理和删除
更新配置文件添加百炼模型相关配置
2025-06-04 17:55:47 +08:00
酒亦
d635e30b4a 上传图片支持使用后台image分类模型 2025-06-02 08:11:22 +08:00
evo
ca50d1ddfb Create main.yml 2025-05-30 16:05:04 +08:00
张鹏翔
0f82711199 perf: 1.优化文件输出路径,避免其他盘符权限问题。
2.采用线程池异步调用多模板OCR图片识别。
2025-05-22 14:05:21 +08:00
ageerle
ed85fef0de Merge pull request #97 from zhangpengxiang/mineru/dev
feat:结合mineru新增pdf转换结构化数据功能
2025-05-21 14:42:47 +08:00
张鹏翔
22d9d9ba85 新增pdf转md后是否进行图片OCR判断 2025-05-21 14:37:51 +08:00
张鹏翔
86825eeb2e 结合mineru新增pdf转换结构化数据功能 2025-05-21 14:25:44 +08:00
ageerle
53e3180658 Merge pull request #93 from janzhou123/pdf-image
feat:增加knowledge_attach_pic表结构sql
2025-05-20 09:29:40 +08:00
zhouweiyi
e43e14454d feat:增加knowledge_attach_pic表结构sql 2025-05-20 09:22:41 +08:00
ageerle
a4e995d46c feat: 新增pdf图片解析分支 2025-05-19 15:33:29 +08:00
378 changed files with 27538 additions and 5807 deletions

453
README.md
View File

@@ -1,10 +1,6 @@
# RuoYi AI
<!-- PROJECT SHIELDS -->
<div align="center">
[![Contributors][contributors-shield]][contributors-url]
[![Forks][forks-shield]][forks-url]
@@ -13,294 +9,170 @@
[![MIT License][license-shield]][license-url]
<!-- PROJECT LOGO -->
<br />
<img style="text-align: center;" src="image/00.png" alt="Logo" width="150" height="150">
<h3 style="text-align: center;">快速搭建属于自己的 AI 助手平台</h3>
<p style="text-align: center;">
全新升级,开箱即用,简单高效
<br />
<a href="https://doc.pandarobot.chat"><strong>探索本项目的文档 »</strong></a>
<br />
<br />
<a href="https://web.pandarobot.chat">项目预览</a>
·
<a href="https://github.com/ageerle/ruoyi-ai/issues">报告Bug</a>
·
<a href="https://github.com/ageerle/ruoyi-ai/issues">提出新特性</a>
<p align="center">
<a href="https://trendshift.io/repositories/13209">
<img src="https://trendshift.io/api/badge/repositories/13209" alt="GitHub Trending">
</a>
</p>
## 快速启动
### 拉取镜像(最低配置2H2G):
```bash
script/deploy/deploy目录下执行: docker-compose up -d
```
<img src="image/00.png" alt="RuoYi AI Logo" width="120" height="120">
### 通过脚本启动(最低配置4H4G):
1. 确认系统内已经安装好以下软件
- docker
- docker-compose
- git
- unzip
2. **克隆项目**
```bash
git clone https://github.com/ageerle/ruoyi-ai
cd ruoyi-ai/script/deploy/one-step-script
```
3. **启动部署脚本**
### 企业级AI助手平台
中文界面部署脚本(拉取gitee仓库)
*开箱即用的智能AI平台深度集成 FastGPT、扣子(Coze)、DIFY 等主流AI平台提供先进的RAG技术和多模型支持*
```bash
./deploy-cn.sh
```
按照脚本提示一步步操作,如果是一台新服务器,选择默认配置,直接回车即可。
<img src="image/deploy-01.png" alt="drawing" style="width: 600px; height: 300px; border: 2px solid #ddd; border-radius: 8px; box-shadow: 0 4px 8px rgba(0,0,0,0.1);"/>
**[🇺🇸 English](README_EN.md)** | **[📖 使用文档](https://doc.pandarobot.chat)** | **[🚀 在线体验](https://web.pandarobot.chat)** | **[🐛 问题反馈](https://github.com/ageerle/ruoyi-ai/issues)** | **[💡 功能建议](https://github.com/ageerle/ruoyi-ai/issues)**
耐心等待安装完成...
英文界面部署脚本(拉取github仓库)
```bash
./deploy-en.sh
```
4. 如果在执行部署脚本过程中不需要在本地重新构建编译服务软件包以及重新封装容器镜像则需要在脚本交互提出以下问题时选择D按键进行直接部署否则就会执行全新的编译构建及容器封装之后再执行部署
```
已将模板文件复制到部署目录。
正在使用您的配置更新 .env 文件...
已使用您的配置更新 .env 文件。
正在使用您的配置更新 docker-compose.yaml 文件...
已使用您的配置更新 docker-compose.yaml 文件。
=== 构建或部署选项 ===
您想构建新镜像 (B) 还是直接使用现有镜像部署 (D)[B/d]:
```
5. **访问应用界面**
- 用户界面:`http://your-server-ip:8081`
- 管理员界面:`http://your-server-ip:8082`
## 目录
- [系统体验](#系统体验)
- [源码地址](#源码地址)
- [配套文档](#项目文档)
- [核心功能](#核心功能)
- [项目演示](#项目演示)
- [管理端](#管理端)
- [用户端](#用户端)
- [开发环境](#开发环境)
- [项目结构](#项目结构)
- [ruoyi-ai](#ruoyi-ai)
- [注意事项](#注意事项)
- [vben模板](#vben模板)
- [贡献者](#贡献者)
- [如何参与开源项目](#如何参与开源项目)
- [版本控制](#版本控制)
- [作者](#作者)
- [鸣谢](#鸣谢)
- [技术讨论群](#技术讨论群)
### 系统体验
- 用户端https://web.pandarobot.chat
- 演示账号: demo 密码demo123
- 管理端https://admin.pandarobot.chat
- 演示账号: admin 密码admin123
- 商业版体验商业版请联系下方小助手获取演示地址预计6月份上线
### 源码地址
[1]github
- 前端服务-用户端: https://github.com/ageerle/ruoyi-web
- 前端服务-管理端: https://github.com/ageerle/ruoyi-admin
- 后端服务https://github.com/ageerle/ruoyi-ai
[2]gitcode
- 前端服务-用户端https://gitcode.com/ageerle/ruoyi-web
- 前端服务-管理端: https://gitcode.com/ageerle/ruoyi-admin
- 后端服务https://gitcode.com/ageerle/ruoyi-ai
### 配套文档
- 配套文档: https://doc.pandarobot.chat
- 项目部署文档https://doc.pandarobot.chat/guide/introduction/
### 核心功能与技术亮点
#### 1. 全栈式开源系统
- 全套开源系统:提供完整的前端应用、后台管理,基于MIT协议开箱即用。
#### 2. 本地化 RAG 方案
- 基于 **Langchain4j** 框架,支持 Milvus/Weaviate/Qdrant 向量库,结合 BGE-large-zh-v1.5 本地向量化模型 实现高效文档检索与知识库构建。
- 支持 本地 LLM 接入,结合私有知识库实现安全可控的问答系统,避免依赖云端服务的隐私风险。
#### 3. 多模态 AI 引擎与工具集成
- 智能对话:支持 OpenAI GPT-4、Azure、ChatGLM 等主流模型,内置 SSE/WebSocket 协议实现低延迟交互,兼容 **扣子**、**DIFY** 等平台 API 调用。
- **Spring AI MCP** 支持:通过注解快速定义本地工具,支持调用 MCP 广场 的海量 MCP Server 服务,扩展模型能力边界。
#### 4. 企业级扩展与商业化支持
- 即时通讯集成:支持对接个人微信、企业微信及微信公众号,实现消息自动回复、用户管理与智能客服。
- 支付系统集成易支付、微信支付、Stripe 国际信用卡支付,满足商业化场景需求。
#### 5. 多媒体处理与创新功能
- AI 绘画:集成 DALL·E-3、MidJourney、Stable Diffusion支持文生图、图生图及风格化创作适用于营销素材生成与创意设计。
- PPT 制作:根据文本输入自动生成结构化幻灯片,支持自定义模板(需要使用三方平台 如:文多多)。
### 项目演示
#### mcp支持
<div style="display: flex; flex-wrap: wrap; gap: 20px; justify-content: center;">
<img src="image/mcp-01.png" alt="drawing" style="width: 600px; height: 300px; border: 2px solid #ddd; border-radius: 8px; box-shadow: 0 4px 8px rgba(0,0,0,0.1);"/>
<img src="image/mcp-02.png" alt="drawing" style="width: 600px; height: 300px; border: 2px solid #ddd; border-radius: 8px; box-shadow: 0 4px 8px rgba(0,0,0,0.1);"/>
<img src="image/mcp-03.png" alt="drawing" style="width: 600px; height: 300px; border: 2px solid #ddd; border-radius: 8px; box-shadow: 0 4px 8px rgba(0,0,0,0.1);"/>
<img src="image/mcp-04.png" alt="drawing" style="width: 600px; height: 300px; border: 2px solid #ddd; border-radius: 8px; box-shadow: 0 4px 8px rgba(0,0,0,0.1);"/>
</div>
## ✨ 核心亮点
### 🤖 智能AI引擎
- **多模型接入**:支持 OpenAI GPT-4、Azure、ChatGLM、通义千问、智谱AI 等主流模型
- **AI平台集成**:深度集成 **FastGPT**、**扣子(Coze)**、**DIFY** 等主流AI应用平台
- **Spring AI MCP 集成**基于模型上下文协议打造可扩展的AI工具生态系统
- **实时流式对话**:采用 SSE/WebSocket 技术,提供丝滑的对话体验
- **AI 编程助手**:内置智能代码分析和项目脚手架生成能力
### 🌟 AI平台生态集成
- **FastGPT 深度集成**:原生支持 FastGPT API包括知识库检索、工作流编排和上下文管理
- **扣子(Coze) 官方SDK**集成字节跳动扣子平台官方SDK支持Bot对话和流式响应
- **DIFY 完整兼容**:使用 DIFY Java Client支持应用编排、工作流和知识库管理
- **统一聊天接口**:提供统一的聊天服务接口,支持多平台无缝切换和负载均衡
### 🧠 本地化RAG方案
- **私有知识库**:基于 Langchain4j 框架 + BGE-large-zh-v1.5 中文向量模型
- **多种向量库**:支持 Milvus、Weaviate、Qdrant 等主流向量数据库
- **数据安全可控**:支持完全本地部署,保护企业数据隐私
- **灵活模型部署**:兼容 Ollama、vLLM 等本地推理框架
### 🎨 AI创作工具
- **AI 绘画创作**:深度集成 DALL·E-3、MidJourney、Stable Diffusion
- **智能PPT生成**:一键将文本内容转换为精美演示文稿
- **多模态理解**:支持文本、图片、文档等多种格式的智能处理
## 🚀 快速体验
### 在线演示
- **用户端体验**[web.pandarobot.chat](https://web.pandarobot.chat) (账号demo 密码demo123)
- **管理后台**[admin.pandarobot.chat](https://admin.pandarobot.chat) (账号admin 密码admin123)
### 项目源码
| 项目模块 | GitHub 仓库 | Gitee 仓库 | GitCode 仓库 |
|---------|------------|-----------|-------------|
| 🔧 后端服务 | [ruoyi-ai](https://github.com/ageerle/ruoyi-ai) | [ruoyi-ai](https://gitee.com/ageerle/ruoyi-ai) | [ruoyi-ai](https://gitcode.com/ageerle/ruoyi-ai) |
| 🎨 用户前端 | [ruoyi-web](https://github.com/ageerle/ruoyi-web) | [ruoyi-web](https://gitee.com/ageerle/ruoyi-web) | [ruoyi-web](https://gitcode.com/ageerle/ruoyi-web) |
| 🛠️ 管理后台 | [ruoyi-admin](https://github.com/ageerle/ruoyi-admin) | [ruoyi-admin](https://gitee.com/ageerle/ruoyi-admin) | [ruoyi-admin](https://gitcode.com/ageerle/ruoyi-admin) |
### 合作项目
| 项目介绍 | GitHub 仓库 | Gitee 仓库 |
|:--------:|:----------:|:----------:|
| 前端简化版 | [ruoyi-element-ai](https://github.com/element-plus-x/ruoyi-element-ai) | [ruoyi-element-ai](https://gitee.com/he-jiayue/ruoyi-element-ai) |
## 🛠️ 技术架构
### 🏗️ 核心框架
- **后端架构**Spring Boot 3.4 + Spring AI + Langchain4j
- **数据存储**MySQL 8.0 + Redis + 向量数据库Milvus/Weaviate/Qdrant
- **前端技术**Vue 3 + Vben Admin + Naive UI
- **安全认证**Sa-Token + JWT 双重保障
### 🔧 系统组件
- **文档处理**PDF、Word、Excel 解析,图像智能分析
- **实时通信**WebSocket 实时通信SSE 流式响应
- **系统监控**:完善的日志体系、性能监控、服务健康检查
## 📚 使用文档
想要深入了解安装部署、功能配置和二次开发?
**👉 [完整使用文档](https://doc.pandarobot.chat)**
## 🤝 参与贡献
我们热烈欢迎社区贡献!无论您是资深开发者还是初学者,都可以为项目贡献力量 💪
### 贡献方式
1. **Fork** 项目到您的账户
2. **创建分支** (`git checkout -b feature/新功能名称`)
3. **提交代码** (`git commit -m '添加某某功能'`)
4. **推送分支** (`git push origin feature/新功能名称`)
5. **发起 Pull Request**
> 💡 **小贴士**:建议将 PR 提交到 GitHub我们会自动同步到其他代码托管平台
<a href="https://openomy.com/ageerle/ruoyi-ai" target="_blank" style="display: block; width: 100%;" align="center">
<img src="https://openomy.com/svg?repo=ageerle/ruoyi-ai&chart=bubble&latestMonth=3" target="_blank" alt="Contribution Leaderboard" style="display: block; width: 100%;" />
</a>
## 📄 开源协议
本项目采用 **MIT 开源协议**,详情请查看 [LICENSE](LICENSE) 文件。
## 🙏 特别鸣谢
感谢以下优秀的开源项目为本项目提供支持:
- [Spring AI Alibaba Copilot](https://github.com/springaialibaba/spring-ai-alibaba-copilot) - 基于spring-ai-alibaba 的智能编码助手
- [Spring AI](https://spring.io/projects/spring-ai) - Spring 官方 AI 集成框架
- [Langchain4j](https://github.com/langchain4j/langchain4j) - 强大的 Java LLM 开发框架
- [RuoYi-Vue-Plus](https://gitee.com/dromara/RuoYi-Vue-Plus) - 成熟的企业级快速开发框架
- [Vben Admin](https://github.com/vbenjs/vue-vben-admin) - 现代化的 Vue 后台管理模板
- [chatgpt-java](https://github.com/Grt1228/chatgpt-java) - 优秀的 ChatGPT Java SDK
## 🌐 生态伙伴
- [PPIO 派欧云](https://ppinfra.com/user/register?invited_by=P8QTUY&utm_source=github_ruoyi-ai) - 提供高性价比的 GPU 算力和模型 API 服务
- [优云智算](https://www.compshare.cn/?ytag=GPU_YY-gh_ruoyi) - 万卡RTX40系GPU+海内外主流模型API服务秒级响应按量计费新客免费用。
- [胜算云](https://www.shengsuanyun.com/?from=CH_3WG71ZOS) - AI模型算力聚合超市云服务。
## 💬 社区交流
<div align="center">
<table>
<tr>
<td align="center">
<img src="image/wx.png" alt="微信二维码" width="200" height="200"><br>
<strong>扫码添加作者微信</strong><br>
<em>邀请进群学习</em>
</td>
<td align="center">
<img src="image/qq.png" alt="QQ群二维码" width="200" height="200"><br>
<strong>QQ技术交流群</strong><br>
<em>技术讨论</em>
</td>
<td align="center">
<img width="200" height="200" alt="95e8b1b3baeadbd24650bfb974ca5a58" src="https://github.com/user-attachments/assets/2a346218-6388-484d-aa75-6e98942193f7" /><br>
<strong>微信技术交流群</strong><br>
<em>技术讨论</em>
</td>
</tr>
</table>
#### 用户端
<div style="display: flex; flex-wrap: wrap; gap: 20px; justify-content: center;">
<img src="image/08.png" alt="drawing" style="width: 600px; height: 300px; border: 2px solid #ddd; border-radius: 8px; box-shadow: 0 4px 8px rgba(0,0,0,0.1);"/>
<img src="image/09.png" alt="drawing" style="width: 600px; height: 300px; border: 2px solid #ddd; border-radius: 8px; box-shadow: 0 4px 8px rgba(0,0,0,0.1);"/>
<img src="image/10.png" alt="drawing" style="width: 600px; height: 300px; border: 2px solid #ddd; border-radius: 8px; box-shadow: 0 4px 8px rgba(0,0,0,0.1);"/>
<img src="image/11.png" alt="drawing" style="width: 600px; height: 300px; border: 2px solid #ddd; border-radius: 8px; box-shadow: 0 4px 8px rgba(0,0,0,0.1);"/>
</div>
#### 管理端
<div style="display: flex; flex-wrap: wrap; gap: 20px; justify-content: center;">
<img src="image/02.png" alt="drawing" style="width: 600px; height: 300px; border: 2px solid #ddd; border-radius: 8px; box-shadow: 0 4px 8px rgba(0,0,0,0.1);"/>
<img src="image/03.png" alt="drawing" style="width: 600px; height: 300px; border: 2px solid #ddd; border-radius: 8px; box-shadow: 0 4px 8px rgba(0,0,0,0.1);"/>
<img src="image/04.png" alt="drawing" style="width: 600px; height: 300px; border: 2px solid #ddd; border-radius: 8px; box-shadow: 0 4px 8px rgba(0,0,0,0.1);"/>
<img src="image/05.png" alt="drawing" style="width: 600px; height: 300px; border: 2px solid #ddd; border-radius: 8px; box-shadow: 0 4px 8px rgba(0,0,0,0.1);"/>
---
<div align="center">
**[⭐ 点个Star支持一下](https://github.com/ageerle/ruoyi-ai)** • **[🍴 Fork 开始贡献](https://github.com/ageerle/ruoyi-ai/fork)** • **[📚 English](README_EN.md)** • **[📖 查看完整文档](https://doc.pandarobot.chat)**
*用 ❤️ 打造,由 RuoYi AI 开源社区维护*
</div>
### 开发环境
1. jdk 17
2. mysql 5.7、8.0
3. redis 版本必须 >= 5.X
4. maven 3.8+
5. nodejs 20+ & pnpm
- 附-部署配套视频https://www.bilibili.com/video/BV1jDXkYWEba
<div>
<img src="image/教程搭建.png" alt="drawing" width="600px" height="300px"/>
</div>
### 项目结构
- RuoYi-AI
```
├─ ruoyi-admin // 管理模块
│ └─ RuoYiApplication // 启动类
│ └─ RuoYiServletInitializer // 容器部署初始化类
│ └─ resources // 资源文件
│ └─ i18n/messages.properties // 国际化配置文件
│ └─ application.yml // 框架总配置文件
│ └─ application-dev.yml // 开发环境配置文件
│ └─ application-prod.yml // 生产环境配置文件
│ └─ banner.txt // 框架启动图标
│ └─ logback-plus.xml // 日志配置文件
│ └─ ip2region.xdb // IP区域地址库
├─ ruoyi-common // 通用模块
│ └─ ruoyi-common-bom // common依赖包管理
└─ ruoyi-common-chat // 聊天模块
│ └─ ruoyi-common-core // 核心模块
│ └─ ruoyi-common-doc // 系统接口模块
│ └─ ruoyi-common-encrypt // 数据加解密模块
│ └─ ruoyi-common-excel // excel模块
│ └─ ruoyi-common-idempotent // 幂等功能模块
│ └─ ruoyi-common-json // 序列化模块
│ └─ ruoyi-common-log // 日志模块
│ └─ ruoyi-common-mail // 邮件模块
│ └─ ruoyi-common-mybatis // 数据库模块
│ └─ ruoyi-common-oss // oss服务模块
│ └─ ruoyi-common-pay // 支付模块
│ └─ ruoyi-common-ratelimiter // 限流功能模块
│ └─ ruoyi-common-redis // 缓存服务模块
│ └─ ruoyi-common-satoken // satoken模块
│ └─ ruoyi-common-security // 安全模块
│ └─ ruoyi-common-sensitive // 脱敏模块
│ └─ ruoyi-common-sms // 短信模块
│ └─ ruoyi-common-tenant // 租户模块
│ └─ ruoyi-common-translation // 通用翻译模块
│ └─ ruoyi-common-web // web模块
├─ ruoyi-modules // 模块组
│ └─ ruoyi-demo // 演示模块
│ └─ ruoyi-system // 业务模块
├─ .run // 执行脚本文件
├─ .editorconfig // 编辑器编码格式配置
├─ LICENSE // 开源协议
├─ pom.xml // 公共依赖
├─ README.md // 框架说明文件
```
### 注意事项
- vben模板
Qvben5 的模板默认是没有的吗?
Avben模板是收费的 请联系vben-vue-plus作者获取。
### 版本控制
该项目使用Git进行版本管理。您可以在repository参看当前可用版本。
### 版权说明
该项目使用了MIT授权许可详情请参阅 [LICENSE.txt](https://github.com/ageerle/ruoyi-ai/blob/main/LICENSE)
### 项目现状
目前项目还处于早期阶段距离成熟还有很长的路要走。由于个人精力有限项目的发展速度受到了一定的限制。为了加快项目的进度我真诚地希望更多人能够参与到项目中来。无论是经验丰富的开发者还是刚刚入门的小白我都热烈欢迎你们提交Pull RequestPR👏👏👏。即使代码修改得很少或者存在一些错误都没有关系。我会认真审核每一位贡献者的代码并和大家一起完善项目⛽
### 开发计划
| 主题 | 方向 | 时间节点 |
| --- |-----------------------------------|--------|
| 前端简化版 | 与element-plus-x框架合作推出基于该框架的前端简化版 | 2025.5 |
| agent2agent | Agent2Agent协议支持 | 2025.6 |
| 流程编排 | 通过可视化界面和灵活的配置方式快速构建AI应用 | 2025.7 |
- 感谢
最后我要感谢RuoYi-Vue-Plus、chatgpt-java、chatgpt-web-midjourney-proxy等优秀框架。正是因为这些项目的开源和共享我才能够在这个基础上进行开发使我们的项目能够取得今天的成果。再次感谢这些项目及其背后的开发者们
希望更多志同道合的朋友能够加入我们共同推动这个项目的发展。让我们一起努力将这个项目打造成一个真正成熟、实用的AI平台
#### 如何参与开源项目
贡献使开源社区成为一个学习、激励和创造的绝佳场所。你所作的任何贡献,我们都非常感谢!🙏
1. Fork 这个项目
2. 创建你的功能分支 (`git checkout -b feature/dev`)
3. 提交你的更改 (`git commit -m 'Add some dev'`)
4. 推送到分支 (`git push origin feature/dev`)
5. 打开拉取请求
6. pr请提交到GitHub上会定时同步到gitee
#### 项目文档
1. 项目文档基于vitepress构建
2. 按照[如何参与开源项目](#如何参与开源项目)拉取https://github.com/ageerle/ruoyi-doc
3. 安装依赖npm install
4. 启动项目npm run docs:dev
5. 主页路径docs/guide/introduction/index.md
### 鸣谢
- [chatgpt-java](https://github.com/Grt1228/chatgpt-java)
- [RuoYi-Vue-Plus](https://gitee.com/dromara/RuoYi-Vue-Plus)
- [chatgpt-web-midjourney-proxy](https://github.com/Dooy/chatgpt-web-midjourney-proxy)
- [Vben Admin](https://github.com/vbenjs/vue-vben-admin)
- [Naive UI](https://www.naiveui.com)
<!-- links -->
[your-project-path]:https://github.com/ageerle/ruoyi-ai
<!-- Badge Links -->
[contributors-shield]: https://img.shields.io/github/contributors/ageerle/ruoyi-ai.svg?style=flat-square
[contributors-url]: https://github.com/ageerle/ruoyi-ai/graphs/contributors
[forks-shield]: https://img.shields.io/github/forks/ageerle/ruoyi-ai.svg?style=flat-square
@@ -308,29 +180,6 @@
[stars-shield]: https://img.shields.io/github/stars/ageerle/ruoyi-ai.svg?style=flat-square
[stars-url]: https://github.com/ageerle/ruoyi-ai/stargazers
[issues-shield]: https://img.shields.io/github/issues/ageerle/ruoyi-ai.svg?style=flat-square
[issues-url]: https://img.shields.io/github/issues/ageerle/ruoyi-ai.svg
[issues-url]: https://github.com/ageerle/ruoyi-ai/issues
[license-shield]: https://img.shields.io/github/license/ageerle/ruoyi-ai.svg?style=flat-square
[license-url]: https://github.com/ageerle/ruoyi-ai/blob/master/LICENSE.txt
[linkedin-shield]: https://img.shields.io/badge/-LinkedIn-black.svg?style=flat-square&logo=linkedin&colorB=555
## 🌿 第三方生态
- [PPIO 派欧云:一键调用高性价比的开源模型 API 和 GPU 容器](https://ppinfra.com/user/register?invited_by=P8QTUY&utm_source=github_ruoyi-ai)
### 附:技术讨论群
#### 技术交流(如需进群请添加小助手)
<div style="display: flex; flex-wrap: wrap; gap: 20px; justify-content: center;">
<img src="image/wx.png" alt="drawing" style="width: 400px; height: 400px; border: 2px solid #ddd; border-radius: 8px; box-shadow: 0 4px 8px rgba(0,0,0,0.1);"/>
</div>
#### 进群学习
🏠 小助手wxruoyi-ai加人备注ruoyi-ai
🏠 小助手qq1603234088 加人备注ruoyi-ai
👏👏👏 ruoyi-ai官方交流群qq区
<div style="display: flex; flex-wrap: wrap; gap: 20px; justify-content: center;">
<img src="image/qq.png" alt="drawing" style="width: 400px; height: 400px; border: 2px solid #ddd; border-radius: 8px; box-shadow: 0 4px 8px rgba(0,0,0,0.1);"/>
</div>
[license-url]: https://github.com/ageerle/ruoyi-ai/blob/main/LICENSE

167
README_EN.md Normal file
View File

@@ -0,0 +1,167 @@
# RuoYi AI
<div align="center">
[![Contributors][contributors-shield]][contributors-url]
[![Forks][forks-shield]][forks-url]
[![Stargazers][stars-shield]][stars-url]
[![Issues][issues-shield]][issues-url]
[![MIT License][license-shield]][license-url]
<img src="image/00.png" alt="RuoYi AI Logo" width="120" height="120">
### Enterprise-Grade AI Assistant Platform
*Production-ready AI platform with deep integration of FastGPT, Coze, DIFY and advanced RAG technology*
**[📖 中文文档](README.md)** | **[📚 Documentation](https://doc.pandarobot.chat)** | **[🚀 Live Demo](https://web.pandarobot.chat)** | **[🐛 Report Bug](https://github.com/ageerle/ruoyi-ai/issues)** | **[💡 Request Feature](https://github.com/ageerle/ruoyi-ai/issues)**
</div>
## ✨ Key Features
### 🤖 Advanced AI Engine
- **Multi-Model Support**: OpenAI GPT-4, Azure, ChatGLM, Qwen, ZhipuAI
- **AI Platform Integration**: Deep integration with **FastGPT**, **Coze**, **DIFY** and other leading AI platforms
- **Spring AI MCP Integration**: Extensible tool ecosystem with Model Context Protocol
- **Streaming Chat**: Real-time SSE/WebSocket communication
- **AI Copilot**: Intelligent code analysis and project scaffolding
### 🌟 AI Platform Ecosystem
- **FastGPT Deep Integration**: Native FastGPT API support with knowledge base retrieval, workflow orchestration and context management
- **Coze Official SDK**: Integration with ByteDance Coze platform official SDK, supporting Bot conversations and streaming responses
- **DIFY Full Compatibility**: Using DIFY Java Client for app orchestration, workflows and knowledge base management
- **Unified Chat Interface**: Standardized chat service interface supporting seamless platform switching and load balancing
### 🧠 Enterprise RAG Solution
- **Local Knowledge Base**: Langchain4j + BGE-large-zh-v1.5 embeddings
- **Vector Database Support**: Milvus, Weaviate, Qdrant
- **Privacy-First**: On-premise deployment with local LLM support
- **Ollama & vLLM Compatible**: Flexible model deployment options
### 🎨 Creative AI Tools
- **AI Art Generation**: DALL·E-3, MidJourney, Stable Diffusion integration
- **PPT Creation**: Automated slide generation from text input
- **Multi-Modal Processing**: Text, image, and document understanding
## 🚀 Quick Start
### Live Demo
- **User Portal**: [web.pandarobot.chat](https://web.pandarobot.chat) (demo/demo123)
- **Admin Panel**: [admin.pandarobot.chat](https://admin.pandarobot.chat) (admin/admin123)
### Source Code
| Component | GitHub | Gitee | GitCode |
|-----------|--------|-------|---------|
| Backend API | [ruoyi-ai](https://github.com/ageerle/ruoyi-ai) | [ruoyi-ai](https://gitee.com/ageerle/ruoyi-ai) | [ruoyi-ai](https://gitcode.com/ageerle/ruoyi-ai) |
| User Frontend | [ruoyi-web](https://github.com/ageerle/ruoyi-web) | [ruoyi-web](https://gitee.com/ageerle/ruoyi-web) | [ruoyi-web](https://gitcode.com/ageerle/ruoyi-web) |
| Admin Frontend | [ruoyi-admin](https://github.com/ageerle/ruoyi-admin) | [ruoyi-admin](https://gitee.com/ageerle/ruoyi-admin) | [ruoyi-admin](https://gitcode.com/ageerle/ruoyi-admin) |
### Collaborative Projects
| Project Description | GitHub Repository | Gitee Repository |
|:-------------------:|:-----------------:|:----------------:|
| Simplified Frontend | [ruoyi-element-ai](https://github.com/element-plus-x/ruoyi-element-ai) | [ruoyi-element-ai](https://gitee.com/he-jiayue/ruoyi-element-ai) |
## 🛠️ Tech Stack
### Core Framework
- **Backend**: Spring Boot 3.4, Spring AI, Langchain4j
- **Database**: MySQL 8.0, Redis, Vector Databases (Milvus/Weaviate/Qdrant)
- **Frontend**: Vue 3, Vben Admin, Naive UI
- **Authentication**: Sa-Token, JWT
### System Components
- **File Processing**: PDF, Word, Excel parsing, intelligent image analysis
- **Real-time Communication**: WebSocket real-time communication, SSE streaming
- **System Monitoring**: Comprehensive logging, performance monitoring, health checks
## 📚 Documentation
For detailed setup, configuration, and development guides, visit our comprehensive documentation:
**[📖 Official Documentation](https://doc.pandarobot.chat)**
## 🤝 Contributing
We welcome contributions from developers of all skill levels! Whether you're fixing bugs, adding features, or improving documentation, your help is appreciated.
### How to Contribute
1. Fork the repository
2. Create your feature branch (`git checkout -b feature/amazing-feature`)
3. Commit your changes (`git commit -m 'Add amazing feature'`)
4. Push to the branch (`git push origin feature/amazing-feature`)
5. Open a Pull Request
*Please submit PRs to GitHub - they will be synchronized to other platforms automatically.*
## 📄 License
This project is licensed under the MIT License - see the [LICENSE](LICENSE) file for details.
## 🙏 Acknowledgments
Special thanks to these amazing open source projects:
- [Spring AI Alibaba Copilot](https://github.com/springaialibaba/spring-ai-alibaba-copilot) - Intelligent coding assistant based on spring-ai-alibaba with MCP protocol integration for project analysis and code generation
- [Spring AI](https://spring.io/projects/spring-ai) - Spring's AI integration framework
- [Langchain4j](https://github.com/langchain4j/langchain4j) - Java LLM framework
- [RuoYi-Vue-Plus](https://gitee.com/dromara/RuoYi-Vue-Plus) - Enterprise development framework
- [Vben Admin](https://github.com/vbenjs/vue-vben-admin) - Vue admin template
- [chatgpt-java](https://github.com/Grt1228/chatgpt-java) - ChatGPT Java SDK
## 🌐 Ecosystem Partners
- [PPIO Cloud](https://ppinfra.com/user/register?invited_by=P8QTUY&utm_source=github_ruoyi-ai) - Cost-effective GPU containers and model APIs
## 💬 Community
<div align="center">
<table>
<tr>
<td align="center">
<img src="image/wx.png" alt="WeChat" width="200" height="200"><br>
<strong>Add Author WeChat</strong><br>
<em>Scan to join learning group</em>
</td>
<td align="center">
<img src="image/qq.png" alt="QQ Group" width="200" height="200"><br>
<strong>QQ Group</strong><br>
<em>Technical discussion</em>
</td>
</tr>
</table>
</div>
---
<div align="center">
**[⭐ Star this repo](https://github.com/ageerle/ruoyi-ai)** • **[🍴 Fork it](https://github.com/ageerle/ruoyi-ai/fork)** • **[📖 中文文档](README.md)** • **[📚 Documentation](https://doc.pandarobot.chat)**
*Built with ❤️ by the RuoYi AI community*
</div>
<!-- Badge Links -->
[contributors-shield]: https://img.shields.io/github/contributors/ageerle/ruoyi-ai.svg?style=flat-square
[contributors-url]: https://github.com/ageerle/ruoyi-ai/graphs/contributors
[forks-shield]: https://img.shields.io/github/forks/ageerle/ruoyi-ai.svg?style=flat-square
[forks-url]: https://github.com/ageerle/ruoyi-ai/network/members
[stars-shield]: https://img.shields.io/github/stars/ageerle/ruoyi-ai.svg?style=flat-square
[stars-url]: https://github.com/ageerle/ruoyi-ai/stargazers
[issues-shield]: https://img.shields.io/github/issues/ageerle/ruoyi-ai.svg?style=flat-square
[issues-url]: https://github.com/ageerle/ruoyi-ai/issues
[license-shield]: https://img.shields.io/github/license/ageerle/ruoyi-ai.svg?style=flat-square
[license-url]: https://github.com/ageerle/ruoyi-ai/blob/main/LICENSE

Binary file not shown.

Before

Width:  |  Height:  |  Size: 232 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 329 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 123 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 127 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 129 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 284 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 674 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.5 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 148 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 72 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 563 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 95 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 136 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 108 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 227 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 251 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 189 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 152 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 391 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 392 KiB

After

Width:  |  Height:  |  Size: 267 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 341 KiB

16
pom.xml
View File

@@ -40,6 +40,8 @@
<mapstruct-plus.lombok.version>0.2.0</mapstruct-plus.lombok.version>
<lombok.version>1.18.26</lombok.version>
<bouncycastle.version>1.72</bouncycastle.version>
<!-- Apache Commons Compress 版本 -->
<commons-compress.version>1.26.2</commons-compress.version>
<!-- 离线IP地址定位库 -->
<ip2region.version>2.7.0</ip2region.version>
<!-- OSS 配置 -->
@@ -283,6 +285,13 @@
<version>${bouncycastle.version}</version>
</dependency>
<!-- Apache Commons Compress -->
<dependency>
<groupId>org.apache.commons</groupId>
<artifactId>commons-compress</artifactId>
<version>${commons-compress.version}</version>
</dependency>
<dependency>
<groupId>io.github.linpeilie</groupId>
<artifactId>mapstruct-plus-spring-boot-starter</artifactId>
@@ -332,6 +341,13 @@
<artifactId>ruoyi-generator</artifactId>
<version>${revision}</version>
</dependency>
<dependency>
<groupId>org.ruoyi</groupId>
<artifactId>ruoyi-aihuman</artifactId>
<version>${revision}</version>
</dependency>
</dependencies>
</dependencyManagement>

View File

@@ -57,6 +57,11 @@
<artifactId>ruoyi-generator</artifactId>
</dependency>
<dependency>
<groupId>org.ruoyi</groupId>
<artifactId>ruoyi-aihuman</artifactId>
</dependency>
</dependencies>
<build>

View File

@@ -11,7 +11,7 @@ import org.springframework.scheduling.annotation.EnableScheduling;
*
* @author Lion Li
*/
@SpringBootApplication
@SpringBootApplication(scanBasePackages = {"org.ruoyi", "org.ruoyi.aihuman"})
@EnableScheduling
@EnableAsync
public class RuoYiAIApplication {
@@ -22,4 +22,4 @@ public class RuoYiAIApplication {
application.run(args);
System.out.println("(♥◠‿◠)ノ゙ RuoYiAI启动成功 ლ(´ڡ`ლ)゙");
}
}
}

View File

@@ -17,8 +17,8 @@ spring:
type: ${spring.datasource.type}
driverClassName: com.mysql.cj.jdbc.Driver
url: jdbc:mysql://127.0.0.1:3306/ruoyi-ai?useUnicode=true&characterEncoding=utf8&zeroDateTimeBehavior=convertToNull&useSSL=true&serverTimezone=GMT%2B8&autoReconnect=true&rewriteBatchedStatements=true
username: ruoyi-ai
password: ruoyi-ai
username: root
password: root
hikari:
# 最大连接池数量
@@ -85,4 +85,22 @@ sms:
# 腾讯专用
sdkAppId:
pdf:
extract:
service:
url: http://localhost:8080
ai-api:
url: https://api.pandarobot.chat/v1/chat/completions
key: sk-xxxx
transition:
# 是否开启mineru
enable-minerU: true
# mineru conda环境路径
conda-env-path: "F:\\ProgramData\\Computer\\Anaconda\\envs\\mineru"
# 是否开启图片OCR
enable-ocr: true
#百炼模型配置
dashscope:
key: sk-xxxx
model: qvq-max

View File

@@ -0,0 +1,111 @@
--- # 监控中心配置
spring.boot.admin.client:
# 增加客户端开关
enabled: false
url: http://localhost:9090/admin
instance:
service-host-type: IP
username: ruoyi
password: 123456
--- # 数据源配置
spring:
datasource:
type: com.zaxxer.hikari.HikariDataSource
# 动态数据源文档 https://www.kancloud.cn/tracy5546/dynamic-datasource/content
dynamic:
# 性能分析插件(有性能损耗 不建议生产环境使用)
p6spy: true
# 设置默认的数据源或者数据源组,默认值即为 master
primary: master
# 严格模式 匹配不到数据源则报错
strict: true
datasource:
# 主库数据源
master:
type: ${spring.datasource.type}
driverClassName: com.mysql.cj.jdbc.Driver
url: jdbc:mysql://localhost:3306/ruoyi-ai?useUnicode=true&characterEncoding=utf8&zeroDateTimeBehavior=convertToNull&useSSL=true&serverTimezone=GMT%2B8&autoReconnect=true&rewriteBatchedStatements=true
username: root
password: root
hikari:
# 最大连接池数量
maxPoolSize: 20
# 最小空闲线程数量
minIdle: 10
# 配置获取连接等待超时的时间
connectionTimeout: 30000
# 校验超时时间
validationTimeout: 5000
# 空闲连接存活最大时间默认10分钟
idleTimeout: 600000
# 此属性控制池中连接的最长生命周期值0表示无限生命周期默认30分钟
maxLifetime: 1800000
# 连接测试query配置检测连接是否有效
connectionTestQuery: SELECT 1
# 多久检查一次连接的活性
keepaliveTime: 30000
--- # redis 单机配置(单机与集群只能开启一个另一个需要注释掉)
spring.data:
redis:
# 地址
host: 127.0.0.1
# 端口默认为6379
port: 6379
# 数据库索引
database: 0
# 密码(如没有密码请注释掉)
password: root
# 连接超时时间
timeout: 10S
# 是否开启ssl
ssl: false
redisson:
# redis key前缀
keyPrefix:
# 线程池数量
threads: 4
# Netty线程池数量
nettyThreads: 8
# 单节点配置
singleServerConfig:
# 客户端名称
clientName: ${ruoyi.name}
# 最小空闲连接数
connectionMinimumIdleSize: 8
# 连接池大小
connectionPoolSize: 32
# 连接空闲超时,单位:毫秒
idleConnectionTimeout: 10000
# 命令等待超时,单位:毫秒
timeout: 3000
# 发布和订阅连接池大小
subscriptionConnectionPoolSize: 50
--- # sms 短信
sms:
enabled: false
# 阿里云 dysmsapi.aliyuncs.com
# 腾讯云 sms.tencentcloudapi.com
endpoint: "dysmsapi.aliyuncs.com"
accessKeyId: xxxxxxx
accessKeySecret: xxxxxx
signName: 测试
# 腾讯专用
sdkAppId:
pdf:
extract:
service:
url: http://localhost:8080
ai-api:
url: https://api.pandarobot.chat/v1/chat/completions
key: sk-xxxx
#百炼模型配置
dashscope:
key: sk-xxxx
model: qvq-max

View File

@@ -1,16 +1,13 @@
# 项目相关配置
ruoyi:
# 名称
name: "ruoyi"
name: "ruoyi-ai"
# 版本
version: ${revision}
# 版权年份
copyrightYear: 2025
# 实例演示开关
demoEnabled: true
# 获取ip地址开关
addressEnabled: false
demoEnabled: false
captcha:
enable: false
@@ -133,6 +130,9 @@ security:
- /chat/send
# 文件上传
- /chat/upload
# 代码生成调用
- /tool/gen/getByTableName
- /tool/gen/batchGenCode
# 静态资源
- /*.html
- /**/*.html
@@ -144,6 +144,15 @@ security:
# swagger 文档配置
- /*/api-docs
- /*/api-docs/**
- /v3/api-docs
- /v3/api-docs/**
- /v3/api-docs/swagger-config
- /swagger-ui.html
- /swagger-ui/**
- /doc.html
- /webjars/**
- /swagger-resources
- /swagger-resources/**
# actuator 监控配置
- /actuator
- /actuator/**
@@ -161,6 +170,9 @@ tenant:
- sys_user_post
- sys_user_role
knowledge-role:
enable: false
# MyBatisPlus配置
# https://baomidou.com/config/
mybatis-plus:
@@ -185,7 +197,7 @@ mybatis-plus:
# 更详细的日志输出 会有性能损耗 org.apache.ibatis.logging.stdout.StdOutImpl
# 关闭日志记录 (可单纯使用 p6spy 分析) org.apache.ibatis.logging.nologging.NoLoggingImpl
# 默认日志输出 org.apache.ibatis.logging.slf4j.Slf4jImpl
logImpl: org.apache.ibatis.logging.nologging.NoLoggingImpl
logImpl: org.apache.ibatis.logging.slf4j.Slf4jImpl
global-config:
# 是否打印 Logo banner
banner: true
@@ -228,9 +240,9 @@ springdoc:
# persistAuthorization: true
info:
# 标题
title: '标题:RuoYi-Vue-Plus多租户管理系统_接口文档'
title: '标题:ruoyi-ai 接口文档'
# 描述
description: ' 用于管理集团旗下公司的人员信息,具体包括XXX,XXX模块...'
description: ''
# 版本
version: '版本号: ${ruoyi.version}'
# 作者信息
@@ -246,16 +258,19 @@ springdoc:
in: HEADER
name: ${sa-token.token-name}
#这里定义了两个分组,可定义多个,也可以不定义
group-configs:
- group: 1.系统模块
packages-to-scan: org.ruoyi.system
packages-to-scan: org.ruoyi
knife4j:
enable: true
setting:
language: zh_cn
# 防止XSS攻击
xss:
# 过滤开关
enabled: true
# 排除链接(多个用逗号分隔)
excludes: /system/notice
excludes: /system/notice,/v3/api-docs/**,/doc.html,/swagger-ui/**,/swagger-ui.html,/swagger-resources/**,/webjars/**,/druid/**,/actuator/**,/favicon.ico
# 匹配链接
urlPatterns: /system/*,/monitor/*,/tool/*
@@ -287,6 +302,7 @@ management:
logfile:
external-file: ./logs/sys-console.log
# websocket
# websocket
websocket:
enabled: true

View File

@@ -0,0 +1,15 @@
package org.ruoyi.common.chat.entity.chat;
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
import lombok.Data;
import java.util.List;
@Data
@JsonIgnoreProperties(ignoreUnknown = true)
public class FastGPTAnswerResponse {
private String id;
private String object;
private long created;
private String model;
private List<FastGPTChatChoice> choices;
}

View File

@@ -0,0 +1,25 @@
package org.ruoyi.common.chat.entity.chat;
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
import com.fasterxml.jackson.annotation.JsonProperty;
import lombok.Data;
import java.io.Serializable;
@Data
@JsonIgnoreProperties(ignoreUnknown = true)
public class FastGPTChatChoice implements Serializable {
private long index;
/**
* 请求参数stream为true返回是delta
*/
@JsonProperty("delta")
private Message delta;
/**
* 请求参数stream为false返回是message
*/
@JsonProperty("message")
private Message message;
@JsonProperty("finish_reason")
private String finishReason;
}

View File

@@ -0,0 +1,41 @@
package org.ruoyi.common.chat.entity.chat;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
import lombok.experimental.SuperBuilder;
import java.io.Serializable;
@Data
@SuperBuilder
@AllArgsConstructor
@NoArgsConstructor
public class FastGPTChatCompletion extends ChatCompletion implements Serializable {
/**
* 是否使用FastGPT提供的上下文
*/
private String chatId;
/**
* 是否返回详细信息;stream模式下会通过event进行区分非stream模式结果保存在responseData中.
*/
private boolean detail;
/**
* 运行时变量
* 模块变量一个对象会替换模块中输入fastgpt框内容里的{{key}}
*/
private Variables variables;
/**
* responseChatItemId: string | undefined 。
* 如果传入,则会将该值作为本次对话的响应消息的 ID
* FastGPT 会自动将该 ID 存入数据库。请确保,
* 在当前chatId下responseChatItemId是唯一的。
*/
private String responseChatItemId;
}

View File

@@ -0,0 +1,20 @@
package org.ruoyi.common.chat.entity.chat;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.io.Serializable;
@Data
@Builder
@AllArgsConstructor
@NoArgsConstructor
public class Variables implements Serializable {
private String uid;
private String name;
}

View File

@@ -61,4 +61,30 @@ public class ChatRequest {
*/
private String role;
/**
* 对话id(每个聊天窗口都不一样)
*/
private Long uuid;
/**
* 是否有附件
*/
private Boolean hasAttachment;
/**
* 是否自动切换模型
*/
private Boolean autoSelectModel;
/**
* 会话令牌为避免在非Web线程中获取Request入口处注入
*/
private String token;
/**
* 消息ID保存消息成功后设置用于后续扣费更新
*/
private Long messageId;
}

View File

@@ -1,5 +1,6 @@
package org.ruoyi.common.core.config;
import lombok.extern.slf4j.Slf4j;
import org.apache.commons.lang3.concurrent.BasicThreadFactory;
import org.ruoyi.common.core.config.properties.ThreadPoolProperties;
import org.ruoyi.common.core.utils.Threads;
@@ -18,6 +19,7 @@ import java.util.concurrent.ThreadPoolExecutor;
*
* @author Lion Li
**/
@Slf4j
@AutoConfiguration
@EnableConfigurationProperties(ThreadPoolProperties.class)
public class ThreadPoolConfig {
@@ -43,7 +45,8 @@ public class ThreadPoolConfig {
* 执行周期性或定时任务
*/
@Bean(name = "scheduledExecutorService")
protected ScheduledExecutorService scheduledExecutorService() {
public ScheduledExecutorService scheduledExecutorService() {
log.info("====创建定时任务线程池====");
return new ScheduledThreadPoolExecutor(core,
new BasicThreadFactory.Builder().namingPattern("schedule-pool-%d").daemon(true).build(),
new ThreadPoolExecutor.CallerRunsPolicy()) {

View File

@@ -117,6 +117,16 @@ public class LoginUser implements Serializable {
*/
private Long roleId;
/**
* 关联角色类型
*/
private String kroleGroupType;
/**
* 关联角色id
*/
private String kroleGroupIds;
/**
* 获取登录id
*/

View File

@@ -1,6 +1,7 @@
package org.ruoyi.common.core.manager;
import jakarta.annotation.PreDestroy;
import jakarta.annotation.Resource;
import lombok.extern.slf4j.Slf4j;
import org.ruoyi.common.core.utils.Threads;
import org.springframework.beans.factory.annotation.Autowired;

View File

@@ -0,0 +1,25 @@
package org.ruoyi.common.core.service;
/**
* @description: 基于ThreadLocal封装工具类用户保存和获取当前登录用户Sa-Token token值
* @author: yzm
**/
public class BaseContext {
private static ThreadLocal<String> threadLocal = new ThreadLocal<>();
/**
* @description: 设置值
* @author: yzm
* @param: [token] 线程token
**/
public static void setCurrentToken(String token){
threadLocal.set(token);
}
/**
* @description: 获取值
* @author: yzm
**/
public static String getCurrentToken(){
return threadLocal.get();
}
}

View File

@@ -7,6 +7,7 @@ import lombok.extern.slf4j.Slf4j;
import org.apache.ibatis.reflection.MetaObject;
import org.ruoyi.common.core.domain.model.LoginUser;
import org.ruoyi.common.core.exception.ServiceException;
import org.ruoyi.common.core.service.BaseContext;
import org.ruoyi.common.core.utils.ObjectUtils;
import org.ruoyi.common.satoken.utils.LoginHelper;
import org.ruoyi.core.domain.BaseEntity;
@@ -91,7 +92,8 @@ public class InjectionMetaObjectHandler implements MetaObjectHandler {
private LoginUser getLoginUser() {
LoginUser loginUser;
try {
loginUser = LoginHelper.getLoginUser();
String token = BaseContext.getCurrentToken();
loginUser = LoginHelper.getLoginUser(token);
} catch (Exception e) {
log.warn("自动注入警告 => 用户未登录");
return null;

View File

@@ -11,9 +11,12 @@ import org.ruoyi.enums.DataBaseType;
import javax.sql.DataSource;
import java.sql.Connection;
import java.sql.DatabaseMetaData;
import java.sql.ResultSet;
import java.sql.SQLException;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
/**
* 数据库助手
@@ -78,4 +81,83 @@ public class DataBaseHelper {
public static List<String> getDataSourceNameList() {
return new ArrayList<>(DS.getDataSources().keySet());
}
/**
* 获取当前连接的所有表名称
*/
public static List<String> getCurrentDataSourceTableNameList() {
DataSource dataSource = DS.determineDataSource();
List<String> tableNames = new ArrayList<>();
try (Connection conn = dataSource.getConnection()) {
DatabaseMetaData metaData = conn.getMetaData();
String catalog = conn.getCatalog();
String schema = conn.getSchema();
// 获取所有表名
try (var resultSet = metaData.getTables(catalog, schema, "%", new String[]{"TABLE"})) {
while (resultSet.next()) {
String tableName = resultSet.getString("TABLE_NAME");
tableNames.add(tableName);
}
}
} catch (SQLException e) {
throw new ServiceException("获取表名称失败: " + e.getMessage());
}
return tableNames;
}
/**
* 获取指定表的字段信息
*
* @param tableName 表名
* @return 字段信息列表
*/
public static List<Map<String, Object>> getTableColumnInfo(String tableName) {
DataSource dataSource = DS.determineDataSource();
List<Map<String, Object>> columns = new ArrayList<>();
try (Connection conn = dataSource.getConnection()) {
DatabaseMetaData metaData = conn.getMetaData();
String catalog = conn.getCatalog();
String schema = conn.getSchema();
// 获取表字段信息
try (ResultSet resultSet = metaData.getColumns(catalog, schema, tableName, "%")) {
while (resultSet.next()) {
Map<String, Object> column = new HashMap<>();
column.put("columnName", resultSet.getString("COLUMN_NAME"));
column.put("columnComment", resultSet.getString("REMARKS"));
column.put("dataType", resultSet.getString("TYPE_NAME"));
column.put("columnSize", resultSet.getInt("COLUMN_SIZE"));
column.put("isNullable", "YES".equals(resultSet.getString("IS_NULLABLE")));
column.put("ordinalPosition", resultSet.getInt("ORDINAL_POSITION"));
// 设置默认值
String defaultValue = resultSet.getString("COLUMN_DEF");
column.put("columnDefault", defaultValue);
columns.add(column);
}
}
// 获取主键信息
try (ResultSet pkResultSet = metaData.getPrimaryKeys(catalog, schema, tableName)) {
List<String> primaryKeys = new ArrayList<>();
while (pkResultSet.next()) {
primaryKeys.add(pkResultSet.getString("COLUMN_NAME"));
}
// 标记主键字段
for (Map<String, Object> column : columns) {
String columnName = (String) column.get("columnName");
column.put("isPrimaryKey", primaryKeys.contains(columnName));
}
}
} catch (SQLException e) {
throw new ServiceException("获取表字段信息失败: " + e.getMessage());
}
return columns;
}
}

View File

@@ -68,7 +68,7 @@ public class OssClient {
}
this.client = build.build();
createBucket();
// createBucket();
} catch (Exception e) {
if (e instanceof OssException) {
throw e;

View File

@@ -9,6 +9,7 @@ import cn.hutool.core.convert.Convert;
import cn.hutool.core.util.ObjectUtil;
import lombok.AccessLevel;
import lombok.NoArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.ruoyi.common.core.constant.TenantConstants;
import org.ruoyi.common.core.constant.UserConstants;
import org.ruoyi.common.core.domain.model.LoginUser;
@@ -29,6 +30,7 @@ import java.util.Set;
*
* @author Lion Li
*/
@Slf4j
@NoArgsConstructor(access = AccessLevel.PRIVATE)
public class LoginHelper {
@@ -82,6 +84,15 @@ public class LoginHelper {
return loginUser;
}
public static <T extends LoginUser> T getLoginUser(String token) {
SaSession session = StpUtil.getTokenSessionByToken(token);
if (ObjectUtil.isNull(session)) {
return null;
}
return (T) session.get(LOGIN_USER_KEY);
}
/**
* 获取用户id
*/

View File

@@ -7,6 +7,7 @@ import org.springframework.beans.factory.InitializingBean;
import org.springframework.web.method.HandlerMethod;
import org.springframework.web.servlet.mvc.method.RequestMappingInfo;
import org.springframework.web.servlet.mvc.method.annotation.RequestMappingHandlerMapping;
import org.springframework.web.util.pattern.PathPattern;
import java.util.ArrayList;
import java.util.List;
@@ -35,7 +36,17 @@ public class AllUrlHandler implements InitializingBean {
Pattern pattern = Pattern.compile("\\{(.*?)\\}");
Set<String> handlerSet = handlerMethods.keySet().stream()
.flatMap(info -> info.getPatternsCondition().getPatterns().stream())
.flatMap(info -> {
// Spring 5 (AntPath) 风格
if (info.getPatternsCondition() != null && info.getPatternsCondition().getPatterns() != null) {
return info.getPatternsCondition().getPatterns().stream();
}
// Spring 6 (PathPattern) 风格
if (info.getPathPatternsCondition() != null && info.getPathPatternsCondition().getPatterns() != null) {
return info.getPathPatternsCondition().getPatterns().stream().map(PathPattern::getPatternString);
}
return java.util.stream.Stream.<String>empty();
})
.collect(Collectors.toSet());
// 获取注解上边的 path 替代 path variable 为 *

View File

@@ -2,6 +2,7 @@ package org.ruoyi.common.sms.config.properties;
import lombok.Data;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.stereotype.Component;
/**
* SMS短信 配置属性
@@ -10,6 +11,7 @@ import org.springframework.boot.context.properties.ConfigurationProperties;
* @version 4.2.0
*/
@Data
@Component
@ConfigurationProperties(prefix = "sms")
public class SmsProperties {

View File

@@ -1,6 +1,9 @@
package org.ruoyi.common.web.config;
import org.ruoyi.common.web.interceptor.DemoModeInterceptor;
import org.ruoyi.common.web.interceptor.PlusWebInvokeTimeInterceptor;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.autoconfigure.AutoConfiguration;
import org.springframework.context.annotation.Bean;
import org.springframework.web.cors.CorsConfiguration;
@@ -18,10 +21,35 @@ import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
@AutoConfiguration
public class ResourcesConfig implements WebMvcConfigurer {
@Autowired(required = false)
private DemoModeInterceptor demoModeInterceptor;
@Override
public void addInterceptors(InterceptorRegistry registry) {
// 全局访问性能拦截
registry.addInterceptor(new PlusWebInvokeTimeInterceptor());
// 演示模式拦截器
if (demoModeInterceptor != null) {
registry.addInterceptor(demoModeInterceptor)
.addPathPatterns("/**") // 拦截所有路径
.excludePathPatterns(
// 排除静态资源
"/css/**",
"/js/**",
"/images/**",
"/fonts/**",
"/favicon.ico",
// 排除错误页面
"/error",
// 排除API文档
"/*/api-docs/**",
"/swagger-ui/**",
"/webjars/**",
// 排除监控端点
"/actuator/**"
);
}
}
@Override

View File

@@ -4,6 +4,7 @@ import lombok.Data;
import org.ruoyi.common.web.enums.CaptchaCategory;
import org.ruoyi.common.web.enums.CaptchaType;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.stereotype.Component;
/**
* 验证码 配置属性
@@ -11,6 +12,7 @@ import org.springframework.boot.context.properties.ConfigurationProperties;
* @author Lion Li
*/
@Data
@Component
@ConfigurationProperties(prefix = "captcha")
public class CaptchaProperties {

View File

@@ -0,0 +1,100 @@
package org.ruoyi.common.web.interceptor;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import lombok.extern.slf4j.Slf4j;
import org.ruoyi.common.core.config.RuoYiConfig;
import org.ruoyi.common.core.exception.DemoModeException;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Component;
import org.springframework.web.servlet.HandlerInterceptor;
/**
* 演示模式拦截器
* 全局拦截所有编辑操作POST、PUT、DELETE、PATCH
*
* @author ruoyi
*/
@Slf4j
@Component
public class DemoModeInterceptor implements HandlerInterceptor {
@Autowired
private RuoYiConfig ruoYiConfig;
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
// 如果演示模式未开启,直接放行
if (!ruoYiConfig.isDemoEnabled()) {
return true;
}
String method = request.getMethod();
String requestURI = request.getRequestURI();
// 拦截所有编辑操作
if (isEditOperation(method)) {
// 排除一些特殊的只读操作
if (isExcludedPath(requestURI)) {
return true;
}
log.warn("演示模式拦截: {} {}", method, requestURI);
throw new DemoModeException();
}
return true;
}
/**
* 判断是否为编辑操作
*/
private boolean isEditOperation(String method) {
return "POST".equalsIgnoreCase(method)
|| "PUT".equalsIgnoreCase(method)
|| "DELETE".equalsIgnoreCase(method)
|| "PATCH".equalsIgnoreCase(method);
}
/**
* 判断是否为排除的路径这些路径即使是POST等方法也不拦截
*/
private boolean isExcludedPath(String requestURI) {
// 排除登录相关
if (requestURI.contains("/auth/login") || requestURI.contains("/auth/logout")) {
return true;
}
// 排除导出操作虽然是POST但是只读操作
if (requestURI.contains("/export")) {
return true;
}
// 排除查询操作一些复杂查询使用POST
if (requestURI.contains("/list") || requestURI.contains("/query")) {
return true;
}
// 排除聊天接口(核心功能)
if (requestURI.contains("/chat/send") || requestURI.contains("/chat/upload")) {
return true;
}
// 排除文件上传预览等只读操作
if (requestURI.contains("/upload") || requestURI.contains("/preview")) {
return true;
}
// 排除支付回调
if (requestURI.contains("/pay/returnUrl") || requestURI.contains("/pay/notifyUrl")) {
return true;
}
// 排除重置密码(用户自己的操作)
if (requestURI.contains("/auth/reset/password")) {
return true;
}
return false;
}
}

View File

@@ -15,6 +15,7 @@
<modules>
<module>ruoyi-mcp-server</module>
<module>ruoyi-ai-copilot</module>
</modules>
</project>

View File

@@ -0,0 +1,164 @@
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0
http://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<groupId>com.example</groupId>
<artifactId>ruoyi-ai-copilot</artifactId>
<version>1.0.0</version>
<packaging>jar</packaging>
<name>copilot</name>
<description>SpringAI - Alibaba - Copilot</description>
<properties>
<maven.compiler.source>17</maven.compiler.source>
<maven.compiler.target>17</maven.compiler.target>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
<spring-boot.version>3.4.5</spring-boot.version>
<spring-ai.version>1.0.0</spring-ai.version>
</properties>
<dependencyManagement>
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-dependencies</artifactId>
<version>${spring-boot.version}</version>
<type>pom</type>
<scope>import</scope>
</dependency>
<dependency>
<groupId>org.springframework.ai</groupId>
<artifactId>spring-ai-bom</artifactId>
<version>${spring-ai.version}</version>
<type>pom</type>
<scope>import</scope>
</dependency>
</dependencies>
</dependencyManagement>
<dependencies>
<!-- Spring Boot Starters -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-thymeleaf</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-aop</artifactId>
</dependency>
<!-- AspectJ Runtime -->
<dependency>
<groupId>org.aspectj</groupId>
<artifactId>aspectjrt</artifactId>
</dependency>
<dependency>
<groupId>org.aspectj</groupId>
<artifactId>aspectjweaver</artifactId>
</dependency>
<!-- Spring AI -->
<dependency>
<groupId>org.springframework.ai</groupId>
<artifactId>spring-ai-starter-model-openai</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.ai</groupId>
<artifactId>spring-ai-starter-mcp-client</artifactId>
</dependency>
<!-- JSON Processing -->
<dependency>
<groupId>com.fasterxml.jackson.core</groupId>
<artifactId>jackson-databind</artifactId>
</dependency>
<!-- JSON Schema Validation -->
<dependency>
<groupId>com.networknt</groupId>
<artifactId>json-schema-validator</artifactId>
<version>1.0.87</version>
</dependency>
<!-- Diff Utils -->
<dependency>
<groupId>io.github.java-diff-utils</groupId>
<artifactId>java-diff-utils</artifactId>
<version>4.12</version>
</dependency>
<!-- Apache Commons -->
<dependency>
<groupId>org.apache.commons</groupId>
<artifactId>commons-lang3</artifactId>
</dependency>
<!-- Logging -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-logging</artifactId>
</dependency>
<!-- Test Dependencies -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
</dependencies>
<build>
<plugins>
<!-- 添加Maven编译器插件配置 -->
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-compiler-plugin</artifactId>
<version>3.11.0</version>
<configuration>
<source>17</source>
<target>17</target>
<parameters>true</parameters>
<compilerArgs>
<arg>-parameters</arg>
</compilerArgs>
</configuration>
</plugin>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
<version>${spring-boot.version}</version>
<executions>
<execution>
<goals>
<goal>repackage</goal>
</goals>
</execution>
</executions>
</plugin>
</plugins>
</build>
<repositories>
<repository>
<id>spring-milestones</id>
<name>Spring Milestones</name>
<url>https://repo.spring.io/milestone</url>
<snapshots>
<enabled>false</enabled>
</snapshots>
</repository>
</repositories>
</project>

View File

@@ -0,0 +1,85 @@
package com.example.demo;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.boot.context.event.ApplicationReadyEvent;
import org.springframework.boot.context.properties.EnableConfigurationProperties;
import org.springframework.context.annotation.EnableAspectJAutoProxy;
import org.springframework.context.event.EventListener;
import org.springframework.core.env.Environment;
import com.example.demo.config.AppProperties;
import com.example.demo.util.BrowserUtil;
/**
* 主要功能:
* 1. 文件读取、写入、编辑
* 2. 目录列表和结构查看
* 4. 连续性文件操作
*/
@SpringBootApplication
@EnableConfigurationProperties(AppProperties.class)
@EnableAspectJAutoProxy
public class CopilotApplication {
private static final Logger logger = LoggerFactory.getLogger(CopilotApplication.class);
@Autowired
private AppProperties appProperties;
@Autowired
private Environment environment;
public static void main(String[] args) {
SpringApplication.run(CopilotApplication.class, args);
}
/**
* 应用启动完成后的事件监听器
* 自动打开浏览器访问应用首页
*/
@EventListener(ApplicationReadyEvent.class)
public void onApplicationReady() {
AppProperties.Browser browserConfig = appProperties.getBrowser();
if (!browserConfig.isAutoOpen()) {
logger.info("Browser auto-open is disabled");
return;
}
// 获取实际的服务器端口
String port = environment.getProperty("server.port", "8080");
String actualUrl = browserConfig.getUrl().replace("${server.port:8080}", port);
logger.info("Application started successfully!");
logger.info("Preparing to open browser in {} seconds...", browserConfig.getDelaySeconds());
// 在新线程中延迟打开浏览器,避免阻塞主线程
new Thread(() -> {
try {
Thread.sleep(browserConfig.getDelaySeconds() * 1000L);
if (BrowserUtil.isValidUrl(actualUrl)) {
boolean success = BrowserUtil.openBrowser(actualUrl);
if (success) {
logger.info("✅ Browser opened successfully: {}", actualUrl);
System.out.println("🌐 Web interface opened: " + actualUrl);
} else {
logger.warn("❌ Failed to open browser automatically");
System.out.println("⚠️ Please manually open: " + actualUrl);
}
} else {
logger.error("❌ Invalid URL: {}", actualUrl);
System.out.println("⚠️ Invalid URL configured: " + actualUrl);
}
} catch (InterruptedException e) {
logger.warn("Browser opening was interrupted", e);
Thread.currentThread().interrupt();
}
}).start();
}
}

View File

@@ -0,0 +1,140 @@
package com.example.demo.config;
import org.springframework.boot.context.properties.ConfigurationProperties;
import java.nio.file.Paths;
import java.util.List;
/**
* 应用配置属性
*/
@ConfigurationProperties(prefix = "app")
public class AppProperties {
private Workspace workspace = new Workspace();
private Security security = new Security();
private Tools tools = new Tools();
private Browser browser = new Browser();
// Getters and Setters
public Workspace getWorkspace() { return workspace; }
public void setWorkspace(Workspace workspace) { this.workspace = workspace; }
public Security getSecurity() { return security; }
public void setSecurity(Security security) { this.security = security; }
public Tools getTools() { return tools; }
public void setTools(Tools tools) { this.tools = tools; }
public Browser getBrowser() { return browser; }
public void setBrowser(Browser browser) { this.browser = browser; }
/**
* 工作空间配置
*/
public static class Workspace {
// 使用 Paths.get() 和 File.separator 实现跨平台兼容
private String rootDirectory = Paths.get(System.getProperty("user.dir"), "workspace").toString();
private long maxFileSize = 10485760L; // 10MB
private List<String> allowedExtensions = List.of(
".txt", ".md", ".java", ".js", ".ts", ".json", ".xml",
".yml", ".yaml", ".properties", ".html", ".css", ".sql"
);
// Getters and Setters
public String getRootDirectory() { return rootDirectory; }
public void setRootDirectory(String rootDirectory) {
// 确保设置的路径也是跨平台兼容的
this.rootDirectory = Paths.get(rootDirectory).toString();
}
public long getMaxFileSize() { return maxFileSize; }
public void setMaxFileSize(long maxFileSize) { this.maxFileSize = maxFileSize; }
public List<String> getAllowedExtensions() { return allowedExtensions; }
public void setAllowedExtensions(List<String> allowedExtensions) { this.allowedExtensions = allowedExtensions; }
}
/**
* 安全配置
*/
public static class Security {
private ApprovalMode approvalMode = ApprovalMode.DEFAULT;
private List<String> dangerousCommands = List.of("rm", "del", "format", "fdisk", "mkfs");
// Getters and Setters
public ApprovalMode getApprovalMode() { return approvalMode; }
public void setApprovalMode(ApprovalMode approvalMode) { this.approvalMode = approvalMode; }
public List<String> getDangerousCommands() { return dangerousCommands; }
public void setDangerousCommands(List<String> dangerousCommands) { this.dangerousCommands = dangerousCommands; }
}
/**
* 工具配置
*/
public static class Tools {
private ToolConfig readFile = new ToolConfig(true);
private ToolConfig writeFile = new ToolConfig(true);
private ToolConfig editFile = new ToolConfig(true);
private ToolConfig listDirectory = new ToolConfig(true);
private ToolConfig shell = new ToolConfig(true);
// Getters and Setters
public ToolConfig getReadFile() { return readFile; }
public void setReadFile(ToolConfig readFile) { this.readFile = readFile; }
public ToolConfig getWriteFile() { return writeFile; }
public void setWriteFile(ToolConfig writeFile) { this.writeFile = writeFile; }
public ToolConfig getEditFile() { return editFile; }
public void setEditFile(ToolConfig editFile) { this.editFile = editFile; }
public ToolConfig getListDirectory() { return listDirectory; }
public void setListDirectory(ToolConfig listDirectory) { this.listDirectory = listDirectory; }
public ToolConfig getShell() { return shell; }
public void setShell(ToolConfig shell) { this.shell = shell; }
}
/**
* 工具配置
*/
public static class ToolConfig {
private boolean enabled;
public ToolConfig() {}
public ToolConfig(boolean enabled) { this.enabled = enabled; }
public boolean isEnabled() { return enabled; }
public void setEnabled(boolean enabled) { this.enabled = enabled; }
}
/**
* 浏览器配置
*/
public static class Browser {
private boolean autoOpen = true;
private String url = "http://localhost:8080";
private int delaySeconds = 2;
// Getters and Setters
public boolean isAutoOpen() { return autoOpen; }
public void setAutoOpen(boolean autoOpen) { this.autoOpen = autoOpen; }
public String getUrl() { return url; }
public void setUrl(String url) { this.url = url; }
public int getDelaySeconds() { return delaySeconds; }
public void setDelaySeconds(int delaySeconds) { this.delaySeconds = delaySeconds; }
}
/**
* 审批模式
*/
public enum ApprovalMode {
DEFAULT, // 默认模式,危险操作需要确认
AUTO_EDIT, // 自动编辑模式,文件编辑不需要确认
YOLO // 完全自动模式,所有操作都不需要确认
}
}

View File

@@ -0,0 +1,104 @@
package com.example.demo.config;
import com.example.demo.service.ToolExecutionLogger;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Component;
/**
* 自定义工具执行监听器
* 提供中文日志和详细的文件操作信息记录
*
* 注意Spring AI 1.0.0使用@Tool注解来定义工具不需要ToolCallbackProvider接口
* 这个类主要用于工具执行的日志记录和监控
*/
@Component
public class CustomToolExecutionMonitor {
private static final Logger logger = LoggerFactory.getLogger(CustomToolExecutionMonitor.class);
@Autowired
private ToolExecutionLogger executionLogger;
/**
* 记录工具执行开始
*/
public long logToolStart(String toolName, String description, String parameters) {
String fileInfo = extractFileInfo(toolName, parameters);
long callId = executionLogger.logToolStart(toolName, description,
String.format("参数: %s | 文件信息: %s", parameters, fileInfo));
logger.debug("🚀 [Spring AI] 开始执行工具: {} | 文件/目录: {}", toolName, fileInfo);
return callId;
}
/**
* 记录工具执行成功
*/
public void logToolSuccess(long callId, String toolName, String result, long executionTime, String parameters) {
String fileInfo = extractFileInfo(toolName, parameters);
logger.debug("✅ [Spring AI] 工具执行成功: {} | 耗时: {}ms | 文件/目录: {}",
toolName, executionTime, fileInfo);
executionLogger.logToolSuccess(callId, toolName, result, executionTime);
}
/**
* 记录工具执行失败
*/
public void logToolError(long callId, String toolName, String errorMessage, long executionTime, String parameters) {
String fileInfo = extractFileInfo(toolName, parameters);
logger.error("❌ [Spring AI] 工具执行失败: {} | 耗时: {}ms | 文件/目录: {} | 错误: {}",
toolName, executionTime, fileInfo, errorMessage);
executionLogger.logToolError(callId, toolName, errorMessage, executionTime);
}
/**
* 提取文件信息用于日志记录
*/
private String extractFileInfo(String toolName, String arguments) {
try {
switch (toolName) {
case "readFile":
case "read_file":
return extractPathFromArgs(arguments, "absolutePath", "filePath");
case "writeFile":
case "write_file":
return extractPathFromArgs(arguments, "filePath");
case "editFile":
case "edit_file":
return extractPathFromArgs(arguments, "filePath");
case "listDirectory":
return extractPathFromArgs(arguments, "directoryPath", "path");
case "analyzeProject":
case "analyze_project":
return extractPathFromArgs(arguments, "projectPath");
case "scaffoldProject":
case "scaffold_project":
return extractPathFromArgs(arguments, "projectPath");
case "smartEdit":
case "smart_edit":
return extractPathFromArgs(arguments, "projectPath");
default:
return "未知文件路径";
}
} catch (Exception e) {
return "解析文件路径失败: " + e.getMessage();
}
}
/**
* 从参数中提取路径
*/
private String extractPathFromArgs(String arguments, String... pathKeys) {
for (String key : pathKeys) {
String pattern = "\"" + key + "\"\\s*:\\s*\"([^\"]+)\"";
java.util.regex.Pattern p = java.util.regex.Pattern.compile(pattern);
java.util.regex.Matcher m = p.matcher(arguments);
if (m.find()) {
return m.group(1);
}
}
return "未找到路径参数";
}
}

View File

@@ -0,0 +1,110 @@
package com.example.demo.config;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.ControllerAdvice;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.context.request.WebRequest;
import org.springframework.web.context.request.async.AsyncRequestTimeoutException;
import java.util.concurrent.TimeoutException;
/**
* 全局异常处理器
*/
@ControllerAdvice
public class GlobalExceptionHandler {
private static final Logger logger = LoggerFactory.getLogger(GlobalExceptionHandler.class);
/**
* 处理超时异常
*/
@ExceptionHandler({TimeoutException.class, AsyncRequestTimeoutException.class})
public ResponseEntity<ErrorResponse> handleTimeoutException(Exception e, WebRequest request) {
logger.error("Request timeout occurred", e);
ErrorResponse errorResponse = new ErrorResponse(
"TIMEOUT_ERROR",
"Request timed out. The operation took too long to complete.",
"Please try again with a simpler request or check your network connection."
);
return ResponseEntity.status(HttpStatus.REQUEST_TIMEOUT).body(errorResponse);
}
/**
* 处理AI相关异常
*/
@ExceptionHandler(RuntimeException.class)
public ResponseEntity<ErrorResponse> handleRuntimeException(RuntimeException e, WebRequest request) {
logger.error("Runtime exception occurred", e);
// 检查是否是AI调用相关的异常
String message = e.getMessage();
if (message != null && (message.contains("tool") || message.contains("function") || message.contains("AI"))) {
ErrorResponse errorResponse = new ErrorResponse(
"AI_TOOL_ERROR",
"An error occurred during AI tool execution: " + message,
"The AI encountered an issue while processing your request. Please try rephrasing your request or try again."
);
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body(errorResponse);
}
ErrorResponse errorResponse = new ErrorResponse(
"RUNTIME_ERROR",
"An unexpected error occurred: " + message,
"Please try again. If the problem persists, contact support."
);
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body(errorResponse);
}
/**
* 处理所有其他异常
*/
@ExceptionHandler(Exception.class)
public ResponseEntity<ErrorResponse> handleGenericException(Exception e, WebRequest request) {
logger.error("Unexpected exception occurred", e);
ErrorResponse errorResponse = new ErrorResponse(
"INTERNAL_ERROR",
"An internal server error occurred",
"Something went wrong on our end. Please try again later."
);
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body(errorResponse);
}
/**
* 错误响应类
*/
public static class ErrorResponse {
private String errorCode;
private String message;
private String suggestion;
private long timestamp;
public ErrorResponse(String errorCode, String message, String suggestion) {
this.errorCode = errorCode;
this.message = message;
this.suggestion = suggestion;
this.timestamp = System.currentTimeMillis();
}
// Getters and setters
public String getErrorCode() { return errorCode; }
public void setErrorCode(String errorCode) { this.errorCode = errorCode; }
public String getMessage() { return message; }
public void setMessage(String message) { this.message = message; }
public String getSuggestion() { return suggestion; }
public void setSuggestion(String suggestion) { this.suggestion = suggestion; }
public long getTimestamp() { return timestamp; }
public void setTimestamp(long timestamp) { this.timestamp = timestamp; }
}
}

View File

@@ -0,0 +1,49 @@
package com.example.demo.config;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.boot.context.event.ApplicationReadyEvent;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.event.EventListener;
import java.io.File;
import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;
/**
* 日志配置类
* 确保日志目录存在并记录应用启动信息
*/
@Configuration
public class LoggingConfiguration {
private static final Logger logger = LoggerFactory.getLogger(LoggingConfiguration.class);
@EventListener(ApplicationReadyEvent.class)
public void onApplicationReady() {
// 确保日志目录存在
File logsDir = new File("logs");
if (!logsDir.exists()) {
boolean created = logsDir.mkdirs();
if (created) {
logger.info("📁 创建日志目录: {}", logsDir.getAbsolutePath());
}
}
// 记录应用启动信息
String startTime = LocalDateTime.now().format(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss"));
logger.info("🎉 ========================================");
logger.info("🚀 (♥◠‿◠)ノ゙ AI Copilot启动成功 ლ(´ڡ`ლ)゙");
logger.info("🕐 启动时间: {}", startTime);
logger.info("📝 日志级别: DEBUG (工具调用详细日志已启用)");
logger.info("📁 日志文件: logs/copilot-file-ops.log");
logger.info("🔧 支持的工具:");
logger.info(" 📖 read_file - 读取文件内容");
logger.info(" ✏️ write_file - 写入文件内容");
logger.info(" 📝 edit_file - 编辑文件内容");
logger.info(" 🔍 analyze_project - 分析项目结构");
logger.info(" 🏗️ scaffold_project - 创建项目脚手架");
logger.info(" 🧠 smart_edit - 智能编辑项目");
logger.info("🎉 ========================================");
}
}

View File

@@ -0,0 +1,96 @@
package com.example.demo.config;
import com.example.demo.schema.SchemaValidator;
import com.example.demo.tools.*;
import org.springframework.ai.chat.client.ChatClient;
import org.springframework.ai.chat.model.ChatModel;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import java.util.List;
/**
* Spring AI 配置 - 使用Spring AI 1.0.0规范
*/
@Configuration
public class SpringAIConfiguration {
@Bean
public ChatClient chatClient(ChatModel chatModel,
FileOperationTools fileOperationTools,
SmartEditTool smartEditTool,
AnalyzeProjectTool analyzeProjectTool,
ProjectScaffoldTool projectScaffoldTool,
AppProperties appProperties) {
// 动态获取工作目录路径
String workspaceDir = appProperties.getWorkspace().getRootDirectory();
return ChatClient.builder(chatModel)
.defaultSystem("""
You are an expert software development assistant with access to file system tools.
You excel at creating complete, well-structured projects through systematic execution of multiple related tasks.
# CORE BEHAVIOR:
- When given a complex task (like "create a web project"), break it down into ALL necessary steps
- Execute MULTIPLE tool calls in sequence to complete the entire task
- Don't stop after just one file - create the complete project structure
- Always verify your work by reading files after creating them
- Continue working until the ENTIRE task is complete
# TASK EXECUTION STRATEGY:
1. **Plan First**: Mentally outline all files and directories needed
2. **Execute Systematically**: Use tools in logical sequence to build the complete solution
3. **Verify Progress**: Read files after creation to ensure correctness
4. **Continue Until Complete**: Don't stop until the entire requested project/task is finished
5. **Signal Continuation**: Use phrases like "Next, I will...", "Now I'll...", "Let me..." to indicate ongoing work
# AVAILABLE TOOLS:
- readFile: Read file contents (supports pagination)
- writeFile: Create or overwrite files
- editFile: Edit files by replacing specific text
- listDirectory: List directory contents (supports recursive)
- analyzeProject: Analyze existing projects to understand structure and dependencies
- smartEdit: Intelligently edit projects based on natural language descriptions
- scaffoldProject: Create new projects with standard structure and templates
# CRITICAL RULES:
- ALWAYS use absolute paths starting with the workspace directory: """ + workspaceDir + """
- Use proper path separators for the current operating system
- For complex requests, execute 5-15 tool calls to create a complete solution
- Use continuation phrases to signal you have more work to do
- If creating a project, make it production-ready with proper structure
- Continue working until you've delivered a complete, functional result
- Only say "completed" or "finished" when the ENTIRE task is truly done
- The tools will show both full paths and relative paths - this helps users locate files
- Always mention the full path when describing what you've created
# PATH EXAMPLES:
- Correct absolute path format:+ workspaceDir + + file separator + filename
- Always ensure paths are within the workspace directory
- Use the system's native path separators
# CONTINUATION SIGNALS:
Use these phrases when you have more work to do:
- "Next, I will create..."
- "Now I'll add..."
- "Let me now..."
- "Moving on to..."
- "I'll proceed to..."
Remember: Your goal is to deliver COMPLETE solutions through continuous execution!
""")
.defaultTools(fileOperationTools, smartEditTool, analyzeProjectTool, projectScaffoldTool)
.build();
}
/**
* 为所有工具注入Schema验证器
*/
@Autowired
public void configureTools(List<BaseTool<?>> tools, SchemaValidator schemaValidator) {
tools.forEach(tool -> tool.setSchemaValidator(schemaValidator));
}
}

View File

@@ -0,0 +1,38 @@
package com.example.demo.config;
/**
* 任务上下文持有者
* 使用ThreadLocal存储当前任务ID供AOP切面使用
*/
public class TaskContextHolder {
private static final ThreadLocal<String> taskIdHolder = new ThreadLocal<>();
/**
* 设置当前任务ID
*/
public static void setCurrentTaskId(String taskId) {
taskIdHolder.set(taskId);
}
/**
* 获取当前任务ID
*/
public static String getCurrentTaskId() {
return taskIdHolder.get();
}
/**
* 清除当前任务ID
*/
public static void clearCurrentTaskId() {
taskIdHolder.remove();
}
/**
* 检查是否有当前任务ID
*/
public static boolean hasCurrentTaskId() {
return taskIdHolder.get() != null;
}
}

View File

@@ -0,0 +1,314 @@
package com.example.demo.config;
import com.example.demo.service.ToolExecutionLogger;
import com.example.demo.service.LogStreamService;
import org.aspectj.lang.ProceedingJoinPoint;
import org.aspectj.lang.annotation.Around;
import org.aspectj.lang.annotation.Aspect;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Component;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
/**
* 工具调用日志切面
* 拦截 Spring AI 的工具调用并提供中文日志
*/
@Aspect
@Component
public class ToolCallLoggingAspect {
private static final Logger logger = LoggerFactory.getLogger(ToolCallLoggingAspect.class);
@Autowired
private ToolExecutionLogger executionLogger;
@Autowired
private LogStreamService logStreamService;
/**
* 拦截使用@Tool注解的方法执行
*/
@Around("@annotation(org.springframework.ai.tool.annotation.Tool)")
public Object interceptToolAnnotation(ProceedingJoinPoint joinPoint) throws Throwable {
Object[] args = joinPoint.getArgs();
String methodName = joinPoint.getSignature().getName();
String className = joinPoint.getSignature().getDeclaringType().getSimpleName();
// 详细的参数信息
String parametersInfo = formatMethodParameters(args);
String fileInfo = extractFileInfoFromMethodArgs(methodName, args);
logger.debug("🚀 [Spring AI @Tool] 执行工具: {}.{} | 参数: {} | 文件/目录: {}",
className, methodName, parametersInfo, fileInfo);
// 获取当前任务ID (从线程本地变量或其他方式)
String taskId = getCurrentTaskId();
// 推送工具开始执行事件
if (taskId != null) {
String startMessage = generateStartMessage(methodName, fileInfo);
logStreamService.pushToolStart(taskId, methodName, fileInfo, startMessage);
}
long startTime = System.currentTimeMillis();
try {
Object result = joinPoint.proceed();
long executionTime = System.currentTimeMillis() - startTime;
logger.debug("✅ [Spring AI @Tool] 工具执行成功: {}.{} | 耗时: {}ms | 文件/目录: {} | 参数: {}",
className, methodName, executionTime, fileInfo, parametersInfo);
// 推送工具执行成功事件
if (taskId != null) {
String successMessage = generateSuccessMessage(methodName, fileInfo, result, executionTime);
logStreamService.pushToolSuccess(taskId, methodName, fileInfo, successMessage, executionTime);
}
return result;
} catch (Throwable e) {
long executionTime = System.currentTimeMillis() - startTime;
logger.error("❌ [Spring AI @Tool] 工具执行失败: {}.{} | 耗时: {}ms | 文件/目录: {} | 参数: {} | 错误: {}",
className, methodName, executionTime, fileInfo, parametersInfo, e.getMessage());
// 推送工具执行失败事件
if (taskId != null) {
String errorMessage = generateErrorMessage(methodName, fileInfo, e.getMessage());
logStreamService.pushToolError(taskId, methodName, fileInfo, errorMessage, executionTime);
}
throw e;
}
}
/**
* 格式化方法参数为可读字符串
*/
private String formatMethodParameters(Object[] args) {
if (args == null || args.length == 0) {
return "无参数";
}
StringBuilder sb = new StringBuilder();
for (int i = 0; i < args.length; i++) {
if (i > 0) sb.append(", ");
Object arg = args[i];
if (arg == null) {
sb.append("null");
} else if (arg instanceof String) {
String str = (String) arg;
// 如果字符串太长,截断显示
if (str.length() > 100) {
sb.append("\"").append(str.substring(0, 100)).append("...\"");
} else {
sb.append("\"").append(str).append("\"");
}
} else {
sb.append(arg.toString());
}
}
return sb.toString();
}
/**
* 从方法参数中直接提取文件信息
*/
private String extractFileInfoFromMethodArgs(String methodName, Object[] args) {
if (args == null || args.length == 0) {
return "无参数";
}
try {
switch (methodName) {
case "readFile":
// readFile(String absolutePath, Integer offset, Integer limit)
return args.length > 0 && args[0] != null ? args[0].toString() : "未指定路径";
case "writeFile":
// writeFile(String filePath, String content)
return args.length > 0 && args[0] != null ? args[0].toString() : "未指定路径";
case "editFile":
// editFile(String filePath, String oldText, String newText)
return args.length > 0 && args[0] != null ? args[0].toString() : "未指定路径";
case "listDirectory":
// listDirectory(String directoryPath, Boolean recursive)
return args.length > 0 && args[0] != null ? args[0].toString() : "未指定路径";
case "analyzeProject":
// analyzeProject(String projectPath, ...)
return args.length > 0 && args[0] != null ? args[0].toString() : "未指定路径";
case "scaffoldProject":
// scaffoldProject(String projectName, String projectType, String projectPath, ...)
return args.length > 2 && args[2] != null ? args[2].toString() : "未指定路径";
case "smartEdit":
// smartEdit(String projectPath, ...)
return args.length > 0 && args[0] != null ? args[0].toString() : "未指定路径";
default:
// 对于未知方法,尝试从第一个参数中提取路径
if (args.length > 0 && args[0] != null) {
String firstArg = args[0].toString();
if (firstArg.contains("/") || firstArg.contains("\\")) {
return firstArg;
}
}
return "未识别的工具类型";
}
} catch (Exception e) {
return "解析参数失败: " + e.getMessage();
}
}
/**
* 从参数字符串中提取文件信息(备用方法)
*/
private String extractFileInfoFromArgs(String toolName, String arguments) {
try {
switch (toolName) {
case "readFile":
case "read_file":
return extractPathFromString(arguments, "absolutePath", "filePath");
case "writeFile":
case "write_file":
case "editFile":
case "edit_file":
return extractPathFromString(arguments, "filePath");
case "listDirectory":
return extractPathFromString(arguments, "directoryPath", "path");
case "analyzeProject":
case "analyze_project":
case "scaffoldProject":
case "scaffold_project":
case "smartEdit":
case "smart_edit":
return extractPathFromString(arguments, "projectPath");
default:
return "未指定文件路径";
}
} catch (Exception e) {
return "解析文件路径失败";
}
}
/**
* 从字符串中提取路径
*/
private String extractPathFromString(String text, String... pathKeys) {
for (String key : pathKeys) {
// JSON 格式
Pattern jsonPattern = Pattern.compile("\"" + key + "\"\\s*:\\s*\"([^\"]+)\"");
Matcher jsonMatcher = jsonPattern.matcher(text);
if (jsonMatcher.find()) {
return jsonMatcher.group(1);
}
// 键值对格式
Pattern kvPattern = Pattern.compile(key + "=([^,\\s\\]]+)");
Matcher kvMatcher = kvPattern.matcher(text);
if (kvMatcher.find()) {
return kvMatcher.group(1);
}
}
return "未找到路径";
}
/**
* 获取当前任务ID
* 从线程本地变量或请求上下文中获取
*/
private String getCurrentTaskId() {
// 这里需要从某个地方获取当前任务ID
// 可以从ThreadLocal、RequestAttributes或其他方式获取
try {
// 临时实现:从线程名或其他方式获取
return TaskContextHolder.getCurrentTaskId();
} catch (Exception e) {
logger.debug("无法获取当前任务ID: {}", e.getMessage());
return null;
}
}
/**
* 生成工具开始执行消息
*/
private String generateStartMessage(String toolName, String fileInfo) {
switch (toolName) {
case "readFile":
return "正在读取文件: " + getFileName(fileInfo);
case "writeFile":
return "正在写入文件: " + getFileName(fileInfo);
case "editFile":
return "正在编辑文件: " + getFileName(fileInfo);
case "listDirectory":
return "正在列出目录: " + fileInfo;
case "analyzeProject":
return "正在分析项目: " + fileInfo;
case "scaffoldProject":
return "正在创建项目脚手架: " + fileInfo;
case "smartEdit":
return "正在智能编辑项目: " + fileInfo;
default:
return "正在执行工具: " + toolName;
}
}
/**
* 生成工具执行成功消息
*/
private String generateSuccessMessage(String toolName, String fileInfo, Object result, long executionTime) {
String fileName = getFileName(fileInfo);
switch (toolName) {
case "readFile":
return String.format("已读取文件 %s (耗时 %dms)", fileName, executionTime);
case "writeFile":
return String.format("已写入文件 %s (耗时 %dms)", fileName, executionTime);
case "editFile":
return String.format("已编辑文件 %s (耗时 %dms)", fileName, executionTime);
case "listDirectory":
return String.format("已列出目录 %s (耗时 %dms)", fileInfo, executionTime);
case "analyzeProject":
return String.format("已分析项目 %s (耗时 %dms)", fileInfo, executionTime);
case "scaffoldProject":
return String.format("已创建项目脚手架 %s (耗时 %dms)", fileInfo, executionTime);
case "smartEdit":
return String.format("已智能编辑项目 %s (耗时 %dms)", fileInfo, executionTime);
default:
return String.format("工具 %s 执行成功 (耗时 %dms)", toolName, executionTime);
}
}
/**
* 生成工具执行失败消息
*/
private String generateErrorMessage(String toolName, String fileInfo, String errorMsg) {
String fileName = getFileName(fileInfo);
return String.format("工具 %s 执行失败: %s (文件: %s)", toolName, errorMsg, fileName);
}
/**
* 从文件路径中提取文件名
*/
private String getFileName(String filePath) {
if (filePath == null || filePath.isEmpty()) {
return "未知文件";
}
// 处理Windows和Unix路径
int lastSlash = Math.max(filePath.lastIndexOf('/'), filePath.lastIndexOf('\\'));
if (lastSlash >= 0 && lastSlash < filePath.length() - 1) {
return filePath.substring(lastSlash + 1);
}
return filePath;
}
}

View File

@@ -0,0 +1,258 @@
package com.example.demo.controller;
import com.example.demo.dto.ChatRequestDto;
import com.example.demo.service.ContinuousConversationService;
import com.example.demo.service.ToolExecutionLogger;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.ai.chat.client.ChatClient;
import org.springframework.ai.chat.messages.Message;
import org.springframework.ai.chat.messages.UserMessage;
import org.springframework.ai.chat.model.ChatResponse;
import org.springframework.http.MediaType;
import org.springframework.web.bind.annotation.*;
import reactor.core.publisher.Flux;
import reactor.core.publisher.Mono;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import java.util.concurrent.CompletableFuture;
/**
* 聊天控制器
* 处理与AI的对话和工具调用
*/
@RestController
@RequestMapping("/api/chat")
@CrossOrigin(origins = "*")
public class ChatController {
private static final Logger logger = LoggerFactory.getLogger(ChatController.class);
private final ChatClient chatClient;
private final ContinuousConversationService continuousConversationService;
private final ToolExecutionLogger executionLogger;
// 简单的会话存储生产环境应该使用数据库或Redis
private final List<Message> conversationHistory = new ArrayList<>();
public ChatController(ChatClient chatClient, ContinuousConversationService continuousConversationService, ToolExecutionLogger executionLogger) {
this.chatClient = chatClient;
this.continuousConversationService = continuousConversationService;
this.executionLogger = executionLogger;
}
/**
* 发送消息给AI - 支持连续工具调用
*/
// 在现有ChatController中修改sendMessage方法
@PostMapping("/message")
public Mono<ChatResponseDto> sendMessage(@RequestBody ChatRequestDto request) {
return Mono.fromCallable(() -> {
try {
logger.info("💬 ========== 新的聊天请求 ==========");
logger.info("📝 用户消息: {}", request.getMessage());
logger.info("🕐 请求时间: {}", java.time.LocalDateTime.now());
// 智能判断是否需要工具调用
boolean needsToolExecution = continuousConversationService.isLikelyToNeedTools(request.getMessage());
logger.info("🔍 工具需求分析: {}", needsToolExecution ? "可能需要工具" : "简单对话");
if (needsToolExecution) {
// 需要工具调用的复杂任务 - 使用异步模式
String taskId = continuousConversationService.startTask(request.getMessage());
logger.info("🆔 任务ID: {}", taskId);
// 记录任务开始
executionLogger.logToolStatistics(); // 显示当前统计
// 异步执行连续对话
CompletableFuture.runAsync(() -> {
try {
logger.info("🚀 开始异步执行连续对话任务: {}", taskId);
continuousConversationService.executeContinuousConversation(
taskId, request.getMessage(), conversationHistory
);
logger.info("✅ 连续对话任务完成: {}", taskId);
} catch (Exception e) {
logger.error("❌ 异步对话执行错误: {}", e.getMessage(), e);
}
});
// 返回异步任务响应
ChatResponseDto responseDto = new ChatResponseDto();
responseDto.setTaskId(taskId);
responseDto.setMessage("任务已启动,正在处理中...");
responseDto.setSuccess(true);
responseDto.setAsyncTask(true);
logger.info("📤 返回响应: taskId={}, 异步任务已启动", taskId);
return responseDto;
} else {
// 简单对话 - 使用流式模式
logger.info("🔄 执行流式对话处理");
// 返回流式响应标识,让前端建立流式连接
ChatResponseDto responseDto = new ChatResponseDto();
responseDto.setMessage("开始流式对话...");
responseDto.setSuccess(true);
responseDto.setAsyncTask(false); // 关键设置为false表示不是工具任务
responseDto.setStreamResponse(true); // 新增:标识为流式响应
responseDto.setTotalTurns(1);
logger.info("📤 返回流式响应标识");
return responseDto;
}
} catch (Exception e) {
logger.error("Error processing chat message", e);
ChatResponseDto errorResponse = new ChatResponseDto();
errorResponse.setMessage("Error: " + e.getMessage());
errorResponse.setSuccess(false);
return errorResponse;
}
});
}
/**
* 流式聊天 - 真正的流式实现
*/
@PostMapping(value = "/stream", produces = MediaType.TEXT_EVENT_STREAM_VALUE)
public Flux<String> streamMessage(@RequestBody ChatRequestDto request) {
logger.info("🌊 开始流式对话: {}", request.getMessage());
return Flux.create(sink -> {
try {
UserMessage userMessage = new UserMessage(request.getMessage());
conversationHistory.add(userMessage);
// 使用Spring AI的流式API
Flux<String> contentStream = chatClient.prompt()
.messages(conversationHistory)
.stream()
.content();
// 订阅流式内容并转发给前端
contentStream
.doOnNext(content -> {
logger.debug("📨 流式内容片段: {}", content);
// 发送SSE格式的数据
sink.next("data: " + content + "\n\n");
})
.doOnComplete(() -> {
logger.info("✅ 流式对话完成");
sink.next("data: [DONE]\n\n");
sink.complete();
})
.doOnError(error -> {
logger.error("❌ 流式对话错误: {}", error.getMessage());
sink.error(error);
})
.subscribe();
} catch (Exception e) {
logger.error("❌ 流式对话启动失败: {}", e.getMessage());
sink.error(e);
}
});
}
/**
* 清除对话历史
*/
@PostMapping("/clear")
public Mono<Map<String, String>> clearHistory() {
conversationHistory.clear();
logger.info("Conversation history cleared");
return Mono.just(Map.of("status", "success", "message", "Conversation history cleared"));
}
/**
* 获取对话历史
*/
@GetMapping("/history")
public Mono<List<MessageDto>> getHistory() {
List<MessageDto> history = conversationHistory.stream()
.map(message -> {
MessageDto dto = new MessageDto();
dto.setContent(message.getText());
dto.setRole(message instanceof UserMessage ? "user" : "assistant");
return dto;
})
.toList();
return Mono.just(history);
}
// 注意Spring AI 1.0.0 使用不同的函数调用方式
// 函数需要在配置中注册,而不是在运行时动态创建
public static class ChatResponseDto {
private String taskId;
private String message;
private boolean success;
private boolean asyncTask;
private boolean streamResponse; // 新增:标识是否为流式响应
private int totalTurns;
private boolean reachedMaxTurns;
private String stopReason;
private long totalDurationMs;
public String getTaskId() {
return taskId;
}
public void setTaskId(String taskId) {
this.taskId = taskId;
}
public String getMessage() { return message; }
public void setMessage(String message) { this.message = message; }
public boolean isSuccess() { return success; }
public void setSuccess(boolean success) { this.success = success; }
public boolean isAsyncTask() {
return asyncTask;
}
public void setAsyncTask(boolean asyncTask) {
this.asyncTask = asyncTask;
}
public boolean isStreamResponse() {
return streamResponse;
}
public void setStreamResponse(boolean streamResponse) {
this.streamResponse = streamResponse;
}
public int getTotalTurns() { return totalTurns; }
public void setTotalTurns(int totalTurns) { this.totalTurns = totalTurns; }
public boolean isReachedMaxTurns() { return reachedMaxTurns; }
public void setReachedMaxTurns(boolean reachedMaxTurns) { this.reachedMaxTurns = reachedMaxTurns; }
public String getStopReason() { return stopReason; }
public void setStopReason(String stopReason) { this.stopReason = stopReason; }
public long getTotalDurationMs() { return totalDurationMs; }
public void setTotalDurationMs(long totalDurationMs) { this.totalDurationMs = totalDurationMs; }
}
public static class MessageDto {
private String content;
private String role;
public String getContent() { return content; }
public void setContent(String content) { this.content = content; }
public String getRole() { return role; }
public void setRole(String role) { this.role = role; }
}
}

View File

@@ -0,0 +1,89 @@
package com.example.demo.controller;
import com.example.demo.service.LogStreamService;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.http.MediaType;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.servlet.mvc.method.annotation.SseEmitter;
/**
* SSE日志流控制器
* 提供SSE连接端点
*/
@RestController
@RequestMapping("/api/logs")
@CrossOrigin(origins = "*")
public class LogStreamController {
private static final Logger logger = LoggerFactory.getLogger(LogStreamController.class);
@Autowired
private LogStreamService logStreamService;
/**
* 建立SSE连接
* 前端通过此端点建立实时日志推送连接
*/
@GetMapping(value = "/stream/{taskId}", produces = MediaType.TEXT_EVENT_STREAM_VALUE)
public SseEmitter streamLogs(@PathVariable("taskId") String taskId) {
logger.info("🔗 收到SSE连接请求: taskId={}", taskId);
try {
SseEmitter emitter = logStreamService.createConnection(taskId);
logger.info("✅ SSE连接建立成功: taskId={}", taskId);
return emitter;
} catch (Exception e) {
logger.error("❌ SSE连接建立失败: taskId={}, error={}", taskId, e.getMessage());
throw new RuntimeException("Failed to create SSE connection: " + e.getMessage());
}
}
/**
* 关闭SSE连接
*/
@PostMapping("/close/{taskId}")
public void closeConnection(@PathVariable("taskId") String taskId) {
logger.info("🔚 收到关闭SSE连接请求: taskId={}", taskId);
logStreamService.closeConnection(taskId);
}
/**
* 获取连接状态
*/
@GetMapping("/status")
public ConnectionStatus getConnectionStatus() {
int activeConnections = logStreamService.getActiveConnectionCount();
logger.debug("📊 当前活跃SSE连接数: {}", activeConnections);
ConnectionStatus status = new ConnectionStatus();
status.setActiveConnections(activeConnections);
status.setStatus("OK");
return status;
}
/**
* 连接状态DTO
*/
public static class ConnectionStatus {
private int activeConnections;
private String status;
public int getActiveConnections() {
return activeConnections;
}
public void setActiveConnections(int activeConnections) {
this.activeConnections = activeConnections;
}
public String getStatus() {
return status;
}
public void setStatus(String status) {
this.status = status;
}
}
}

View File

@@ -0,0 +1,142 @@
package com.example.demo.controller;
import com.example.demo.model.TaskStatus;
import com.example.demo.service.ContinuousConversationService;
import org.springframework.web.bind.annotation.*;
import reactor.core.publisher.Mono;
@RestController
@RequestMapping("/api/task")
@CrossOrigin(origins = "*")
public class TaskStatusController {
private final ContinuousConversationService conversationService;
public TaskStatusController(ContinuousConversationService conversationService) {
this.conversationService = conversationService;
}
/**
* 获取任务状态
*/
@GetMapping("/status/{taskId}")
public Mono<TaskStatusDto> getTaskStatus(@PathVariable("taskId") String taskId) {
return Mono.fromCallable(() -> {
TaskStatus status = conversationService.getTaskStatus(taskId);
if (status == null) {
throw new RuntimeException("Task not found: " + taskId);
}
TaskStatusDto dto = new TaskStatusDto();
dto.setTaskId(status.getTaskId());
dto.setStatus(status.getStatus());
dto.setCurrentAction(status.getCurrentAction());
dto.setSummary(status.getSummary());
dto.setCurrentTurn(status.getCurrentTurn());
dto.setTotalEstimatedTurns(status.getTotalEstimatedTurns());
dto.setProgressPercentage(status.getProgressPercentage());
dto.setElapsedTime(status.getElapsedTime());
dto.setErrorMessage(status.getErrorMessage());
return dto;
});
}
/**
* 获取对话结果
*/
@GetMapping("/result/{taskId}")
public Mono<ConversationResultDto> getConversationResult(@PathVariable("taskId") String taskId) {
return Mono.fromCallable(() -> {
ContinuousConversationService.ConversationResult result = conversationService.getConversationResult(taskId);
if (result == null) {
throw new RuntimeException("Conversation result not found: " + taskId);
}
ConversationResultDto dto = new ConversationResultDto();
dto.setTaskId(taskId);
dto.setFullResponse(result.getFullResponse());
dto.setTurnResponses(result.getTurnResponses());
dto.setTotalTurns(result.getTotalTurns());
dto.setReachedMaxTurns(result.isReachedMaxTurns());
dto.setStopReason(result.getStopReason());
dto.setTotalDurationMs(result.getTotalDurationMs());
return dto;
});
}
// DTO类
public static class TaskStatusDto {
private String taskId;
private String status;
private String currentAction;
private String summary;
private int currentTurn;
private int totalEstimatedTurns;
private double progressPercentage;
private long elapsedTime;
private String errorMessage;
// Getters and Setters
public String getTaskId() { return taskId; }
public void setTaskId(String taskId) { this.taskId = taskId; }
public String getStatus() { return status; }
public void setStatus(String status) { this.status = status; }
public String getCurrentAction() { return currentAction; }
public void setCurrentAction(String currentAction) { this.currentAction = currentAction; }
public String getSummary() { return summary; }
public void setSummary(String summary) { this.summary = summary; }
public int getCurrentTurn() { return currentTurn; }
public void setCurrentTurn(int currentTurn) { this.currentTurn = currentTurn; }
public int getTotalEstimatedTurns() { return totalEstimatedTurns; }
public void setTotalEstimatedTurns(int totalEstimatedTurns) { this.totalEstimatedTurns = totalEstimatedTurns; }
public double getProgressPercentage() { return progressPercentage; }
public void setProgressPercentage(double progressPercentage) { this.progressPercentage = progressPercentage; }
public long getElapsedTime() { return elapsedTime; }
public void setElapsedTime(long elapsedTime) { this.elapsedTime = elapsedTime; }
public String getErrorMessage() { return errorMessage; }
public void setErrorMessage(String errorMessage) { this.errorMessage = errorMessage; }
}
// 对话结果DTO类
public static class ConversationResultDto {
private String taskId;
private String fullResponse;
private java.util.List<String> turnResponses;
private int totalTurns;
private boolean reachedMaxTurns;
private String stopReason;
private long totalDurationMs;
// Getters and Setters
public String getTaskId() { return taskId; }
public void setTaskId(String taskId) { this.taskId = taskId; }
public String getFullResponse() { return fullResponse; }
public void setFullResponse(String fullResponse) { this.fullResponse = fullResponse; }
public java.util.List<String> getTurnResponses() { return turnResponses; }
public void setTurnResponses(java.util.List<String> turnResponses) { this.turnResponses = turnResponses; }
public int getTotalTurns() { return totalTurns; }
public void setTotalTurns(int totalTurns) { this.totalTurns = totalTurns; }
public boolean isReachedMaxTurns() { return reachedMaxTurns; }
public void setReachedMaxTurns(boolean reachedMaxTurns) { this.reachedMaxTurns = reachedMaxTurns; }
public String getStopReason() { return stopReason; }
public void setStopReason(String stopReason) { this.stopReason = stopReason; }
public long getTotalDurationMs() { return totalDurationMs; }
public void setTotalDurationMs(long totalDurationMs) { this.totalDurationMs = totalDurationMs; }
}
}

View File

@@ -0,0 +1,26 @@
package com.example.demo.controller;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.GetMapping;
/**
* Web页面控制器
*/
@Controller
public class WebController {
@GetMapping("/")
public String index() {
return "index";
}
/**
* 处理favicon.ico请求避免404错误
*/
@GetMapping("/favicon.ico")
public ResponseEntity<Void> favicon() {
return ResponseEntity.status(HttpStatus.NO_CONTENT).build();
}
}

View File

@@ -0,0 +1,45 @@
package com.example.demo.dto;
/**
* 聊天请求数据传输对象
*/
public class ChatRequestDto {
private String message;
private String sessionId; // 可选:用于会话管理
public ChatRequestDto() {
}
public ChatRequestDto(String message) {
this.message = message;
}
public ChatRequestDto(String message, String sessionId) {
this.message = message;
this.sessionId = sessionId;
}
public String getMessage() {
return message;
}
public void setMessage(String message) {
this.message = message;
}
public String getSessionId() {
return sessionId;
}
public void setSessionId(String sessionId) {
this.sessionId = sessionId;
}
@Override
public String toString() {
return "ChatRequestDto{" +
"message='" + message + '\'' +
", sessionId='" + sessionId + '\'' +
'}';
}
}

View File

@@ -0,0 +1,261 @@
package com.example.demo.model;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
/**
* Project context information
* Contains complete project analysis results for AI understanding
*/
public class ProjectContext {
private Path projectRoot;
private ProjectType projectType;
private ProjectStructure projectStructure;
private List<DependencyInfo> dependencies;
private List<ConfigFile> configFiles;
private CodeStatistics codeStatistics;
private Map<String, Object> metadata;
private String contextSummary;
public ProjectContext() {
this.dependencies = new ArrayList<>();
this.configFiles = new ArrayList<>();
this.metadata = new HashMap<>();
}
public ProjectContext(Path projectRoot) {
this();
this.projectRoot = projectRoot;
}
/**
* Dependency information class
*/
public static class DependencyInfo {
private String name;
private String version;
private String type; // "compile", "test", "runtime", etc.
private String scope;
private boolean isDirectDependency;
public DependencyInfo(String name, String version, String type) {
this.name = name;
this.version = version;
this.type = type;
this.isDirectDependency = true;
}
// Getters and Setters
public String getName() { return name; }
public void setName(String name) { this.name = name; }
public String getVersion() { return version; }
public void setVersion(String version) { this.version = version; }
public String getType() { return type; }
public void setType(String type) { this.type = type; }
public String getScope() { return scope; }
public void setScope(String scope) { this.scope = scope; }
public boolean isDirectDependency() { return isDirectDependency; }
public void setDirectDependency(boolean directDependency) { isDirectDependency = directDependency; }
@Override
public String toString() {
return String.format("%s:%s (%s)", name, version, type);
}
}
/**
* Configuration file information class
*/
public static class ConfigFile {
private String fileName;
private String relativePath;
private String fileType; // "properties", "yaml", "json", "xml", etc.
private Map<String, Object> keySettings;
private boolean isMainConfig;
public ConfigFile(String fileName, String relativePath, String fileType) {
this.fileName = fileName;
this.relativePath = relativePath;
this.fileType = fileType;
this.keySettings = new HashMap<>();
this.isMainConfig = false;
}
// Getters and Setters
public String getFileName() { return fileName; }
public void setFileName(String fileName) { this.fileName = fileName; }
public String getRelativePath() { return relativePath; }
public void setRelativePath(String relativePath) { this.relativePath = relativePath; }
public String getFileType() { return fileType; }
public void setFileType(String fileType) { this.fileType = fileType; }
public Map<String, Object> getKeySettings() { return keySettings; }
public void setKeySettings(Map<String, Object> keySettings) { this.keySettings = keySettings; }
public boolean isMainConfig() { return isMainConfig; }
public void setMainConfig(boolean mainConfig) { isMainConfig = mainConfig; }
public void addSetting(String key, Object value) {
this.keySettings.put(key, value);
}
}
/**
* Code statistics information class
*/
public static class CodeStatistics {
private int totalLines;
private int codeLines;
private int commentLines;
private int blankLines;
private Map<String, Integer> languageLines;
private int totalClasses;
private int totalMethods;
private int totalFunctions;
public CodeStatistics() {
this.languageLines = new HashMap<>();
}
// Getters and Setters
public int getTotalLines() { return totalLines; }
public void setTotalLines(int totalLines) { this.totalLines = totalLines; }
public int getCodeLines() { return codeLines; }
public void setCodeLines(int codeLines) { this.codeLines = codeLines; }
public int getCommentLines() { return commentLines; }
public void setCommentLines(int commentLines) { this.commentLines = commentLines; }
public int getBlankLines() { return blankLines; }
public void setBlankLines(int blankLines) { this.blankLines = blankLines; }
public Map<String, Integer> getLanguageLines() { return languageLines; }
public void setLanguageLines(Map<String, Integer> languageLines) { this.languageLines = languageLines; }
public int getTotalClasses() { return totalClasses; }
public void setTotalClasses(int totalClasses) { this.totalClasses = totalClasses; }
public int getTotalMethods() { return totalMethods; }
public void setTotalMethods(int totalMethods) { this.totalMethods = totalMethods; }
public int getTotalFunctions() { return totalFunctions; }
public void setTotalFunctions(int totalFunctions) { this.totalFunctions = totalFunctions; }
public void addLanguageLines(String language, int lines) {
this.languageLines.put(language, this.languageLines.getOrDefault(language, 0) + lines);
}
}
/**
* Generate project context summary
*/
public String generateContextSummary() {
StringBuilder summary = new StringBuilder();
// Basic information
summary.append("=== PROJECT CONTEXT ===\n");
summary.append("Project: ").append(projectRoot != null ? projectRoot.getFileName() : "Unknown").append("\n");
summary.append("Type: ").append(projectType != null ? projectType.getDisplayName() : "Unknown").append("\n");
summary.append("Language: ").append(projectType != null ? projectType.getPrimaryLanguage() : "Unknown").append("\n");
summary.append("Package Manager: ").append(projectType != null ? projectType.getPackageManager() : "Unknown").append("\n\n");
// Project structure
if (projectStructure != null) {
summary.append("=== PROJECT STRUCTURE ===\n");
summary.append(projectStructure.getStructureSummary()).append("\n");
}
// Dependencies
if (!dependencies.isEmpty()) {
summary.append("=== DEPENDENCIES ===\n");
dependencies.stream()
.filter(DependencyInfo::isDirectDependency)
.limit(10)
.forEach(dep -> summary.append("- ").append(dep.toString()).append("\n"));
if (dependencies.size() > 10) {
summary.append("... and ").append(dependencies.size() - 10).append(" more dependencies\n");
}
summary.append("\n");
}
// Configuration files
if (!configFiles.isEmpty()) {
summary.append("=== CONFIGURATION FILES ===\n");
configFiles.stream()
.filter(ConfigFile::isMainConfig)
.forEach(config -> summary.append("- ").append(config.getFileName())
.append(" (").append(config.getFileType()).append(")\n"));
summary.append("\n");
}
// Code statistics
if (codeStatistics != null) {
summary.append("=== CODE STATISTICS ===\n");
summary.append("Total Lines: ").append(codeStatistics.getTotalLines()).append("\n");
summary.append("Code Lines: ").append(codeStatistics.getCodeLines()).append("\n");
if (codeStatistics.getTotalClasses() > 0) {
summary.append("Classes: ").append(codeStatistics.getTotalClasses()).append("\n");
}
if (codeStatistics.getTotalMethods() > 0) {
summary.append("Methods: ").append(codeStatistics.getTotalMethods()).append("\n");
}
summary.append("\n");
}
this.contextSummary = summary.toString();
return this.contextSummary;
}
/**
* Get dependency summary
*/
public String getDependencySummary() {
if (dependencies.isEmpty()) {
return "No dependencies found";
}
return dependencies.stream()
.filter(DependencyInfo::isDirectDependency)
.limit(5)
.map(DependencyInfo::getName)
.reduce((a, b) -> a + ", " + b)
.orElse("No direct dependencies");
}
// Getters and Setters
public Path getProjectRoot() { return projectRoot; }
public void setProjectRoot(Path projectRoot) { this.projectRoot = projectRoot; }
public ProjectType getProjectType() { return projectType; }
public void setProjectType(ProjectType projectType) { this.projectType = projectType; }
public ProjectStructure getProjectStructure() { return projectStructure; }
public void setProjectStructure(ProjectStructure projectStructure) { this.projectStructure = projectStructure; }
public List<DependencyInfo> getDependencies() { return dependencies; }
public void setDependencies(List<DependencyInfo> dependencies) { this.dependencies = dependencies; }
public List<ConfigFile> getConfigFiles() { return configFiles; }
public void setConfigFiles(List<ConfigFile> configFiles) { this.configFiles = configFiles; }
public CodeStatistics getCodeStatistics() { return codeStatistics; }
public void setCodeStatistics(CodeStatistics codeStatistics) { this.codeStatistics = codeStatistics; }
public Map<String, Object> getMetadata() { return metadata; }
public void setMetadata(Map<String, Object> metadata) { this.metadata = metadata; }
public String getContextSummary() { return contextSummary; }
public void setContextSummary(String contextSummary) { this.contextSummary = contextSummary; }
}

View File

@@ -0,0 +1,213 @@
package com.example.demo.model;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
/**
* Project structure information model
* Contains project directory structure, file statistics and other information
*/
public class ProjectStructure {
private Path projectRoot;
private ProjectType projectType;
private List<DirectoryInfo> directories;
private Map<String, Integer> fileTypeCount;
private List<String> keyFiles;
private int totalFiles;
private int totalDirectories;
private long totalSize;
public ProjectStructure() {
this.directories = new ArrayList<>();
this.fileTypeCount = new HashMap<>();
this.keyFiles = new ArrayList<>();
}
public ProjectStructure(Path projectRoot, ProjectType projectType) {
this();
this.projectRoot = projectRoot;
this.projectType = projectType;
}
/**
* Directory information inner class
*/
public static class DirectoryInfo {
private String name;
private String relativePath;
private int fileCount;
private List<String> files;
private boolean isImportant; // Whether it's an important directory (like src, test, etc.)
public DirectoryInfo(String name, String relativePath) {
this.name = name;
this.relativePath = relativePath;
this.files = new ArrayList<>();
this.isImportant = false;
}
// Getters and Setters
public String getName() { return name; }
public void setName(String name) { this.name = name; }
public String getRelativePath() { return relativePath; }
public void setRelativePath(String relativePath) { this.relativePath = relativePath; }
public int getFileCount() { return fileCount; }
public void setFileCount(int fileCount) { this.fileCount = fileCount; }
public List<String> getFiles() { return files; }
public void setFiles(List<String> files) { this.files = files; }
public boolean isImportant() { return isImportant; }
public void setImportant(boolean important) { isImportant = important; }
public void addFile(String fileName) {
this.files.add(fileName);
this.fileCount++;
}
}
/**
* Add directory information
*/
public void addDirectory(DirectoryInfo directoryInfo) {
this.directories.add(directoryInfo);
this.totalDirectories++;
}
/**
* Add file type statistics
*/
public void addFileType(String extension, int count) {
this.fileTypeCount.put(extension, this.fileTypeCount.getOrDefault(extension, 0) + count);
}
/**
* Add key file
*/
public void addKeyFile(String fileName) {
if (!this.keyFiles.contains(fileName)) {
this.keyFiles.add(fileName);
}
}
/**
* Get project structure summary
*/
public String getStructureSummary() {
StringBuilder summary = new StringBuilder();
summary.append("Project: ").append(projectRoot != null ? projectRoot.getFileName() : "Unknown").append("\n");
summary.append("Type: ").append(projectType != null ? projectType.getDisplayName() : "Unknown").append("\n");
summary.append("Directories: ").append(totalDirectories).append("\n");
summary.append("Files: ").append(totalFiles).append("\n");
if (!keyFiles.isEmpty()) {
summary.append("Key Files: ").append(String.join(", ", keyFiles)).append("\n");
}
if (!fileTypeCount.isEmpty()) {
summary.append("File Types: ");
fileTypeCount.entrySet().stream()
.sorted((e1, e2) -> e2.getValue().compareTo(e1.getValue()))
.limit(5)
.forEach(entry -> summary.append(entry.getKey()).append("(").append(entry.getValue()).append(") "));
summary.append("\n");
}
return summary.toString();
}
/**
* Get important directories list
*/
public List<DirectoryInfo> getImportantDirectories() {
return directories.stream()
.filter(DirectoryInfo::isImportant)
.toList();
}
/**
* Mark important directories based on project type
*/
public void markImportantDirectories() {
if (projectType == null) return;
for (DirectoryInfo dir : directories) {
String dirName = dir.getName().toLowerCase();
// Common important directories
if (dirName.equals("src") || dirName.equals("source") ||
dirName.equals("test") || dirName.equals("tests") ||
dirName.equals("config") || dirName.equals("conf") ||
dirName.equals("docs") || dirName.equals("doc")) {
dir.setImportant(true);
continue;
}
// Project type specific important directories
switch (projectType) {
case JAVA_MAVEN:
case JAVA_GRADLE:
case SPRING_BOOT:
if (dirName.equals("main") || dirName.equals("resources") ||
dirName.equals("webapp") || dirName.equals("target") ||
dirName.equals("build")) {
dir.setImportant(true);
}
break;
case NODE_JS:
case REACT:
case VUE:
case ANGULAR:
case NEXT_JS:
if (dirName.equals("node_modules") || dirName.equals("public") ||
dirName.equals("dist") || dirName.equals("build") ||
dirName.equals("components") || dirName.equals("pages")) {
dir.setImportant(true);
}
break;
case PYTHON:
case DJANGO:
case FLASK:
case FASTAPI:
if (dirName.equals("venv") || dirName.equals("env") ||
dirName.equals("__pycache__") || dirName.equals("migrations") ||
dirName.equals("static") || dirName.equals("templates")) {
dir.setImportant(true);
}
break;
}
}
}
// Getters and Setters
public Path getProjectRoot() { return projectRoot; }
public void setProjectRoot(Path projectRoot) { this.projectRoot = projectRoot; }
public ProjectType getProjectType() { return projectType; }
public void setProjectType(ProjectType projectType) { this.projectType = projectType; }
public List<DirectoryInfo> getDirectories() { return directories; }
public void setDirectories(List<DirectoryInfo> directories) { this.directories = directories; }
public Map<String, Integer> getFileTypeCount() { return fileTypeCount; }
public void setFileTypeCount(Map<String, Integer> fileTypeCount) { this.fileTypeCount = fileTypeCount; }
public List<String> getKeyFiles() { return keyFiles; }
public void setKeyFiles(List<String> keyFiles) { this.keyFiles = keyFiles; }
public int getTotalFiles() { return totalFiles; }
public void setTotalFiles(int totalFiles) { this.totalFiles = totalFiles; }
public int getTotalDirectories() { return totalDirectories; }
public void setTotalDirectories(int totalDirectories) { this.totalDirectories = totalDirectories; }
public long getTotalSize() { return totalSize; }
public void setTotalSize(long totalSize) { this.totalSize = totalSize; }
}

View File

@@ -0,0 +1,141 @@
package com.example.demo.model;
/**
* Project type enumeration
* Supports mainstream project type detection
*/
public enum ProjectType {
// Java projects
JAVA_MAVEN("Java Maven", "pom.xml", "Maven-based Java project"),
JAVA_GRADLE("Java Gradle", "build.gradle", "Gradle-based Java project"),
SPRING_BOOT("Spring Boot", "pom.xml", "Spring Boot application"),
// JavaScript/Node.js projects
NODE_JS("Node.js", "package.json", "Node.js project"),
REACT("React", "package.json", "React application"),
VUE("Vue.js", "package.json", "Vue.js application"),
ANGULAR("Angular", "package.json", "Angular application"),
NEXT_JS("Next.js", "package.json", "Next.js application"),
// Python projects
PYTHON("Python", "requirements.txt", "Python project"),
DJANGO("Django", "manage.py", "Django web application"),
FLASK("Flask", "app.py", "Flask web application"),
FASTAPI("FastAPI", "main.py", "FastAPI application"),
// Other project types
DOTNET("ASP.NET", "*.csproj", ".NET project"),
GO("Go", "go.mod", "Go project"),
RUST("Rust", "Cargo.toml", "Rust project"),
PHP("PHP", "composer.json", "PHP project"),
// Web frontend
HTML_STATIC("Static HTML", "index.html", "Static HTML website"),
// Unknown type
UNKNOWN("Unknown", "", "Unknown project type");
private final String displayName;
private final String keyFile;
private final String description;
ProjectType(String displayName, String keyFile, String description) {
this.displayName = displayName;
this.keyFile = keyFile;
this.description = description;
}
public String getDisplayName() {
return displayName;
}
public String getKeyFile() {
return keyFile;
}
public String getDescription() {
return description;
}
/**
* Check if it's a Java project
*/
public boolean isJavaProject() {
return this == JAVA_MAVEN || this == JAVA_GRADLE || this == SPRING_BOOT;
}
/**
* Check if it's a JavaScript project
*/
public boolean isJavaScriptProject() {
return this == NODE_JS || this == REACT || this == VUE ||
this == ANGULAR || this == NEXT_JS;
}
/**
* Check if it's a Python project
*/
public boolean isPythonProject() {
return this == PYTHON || this == DJANGO || this == FLASK || this == FASTAPI;
}
/**
* Check if it's a Web project
*/
public boolean isWebProject() {
return isJavaScriptProject() || this == HTML_STATIC ||
this == DJANGO || this == FLASK || this == FASTAPI || this == SPRING_BOOT;
}
/**
* Get the primary programming language of the project
*/
public String getPrimaryLanguage() {
if (isJavaProject()) return "Java";
if (isJavaScriptProject()) return "JavaScript";
if (isPythonProject()) return "Python";
switch (this) {
case DOTNET: return "C#";
case GO: return "Go";
case RUST: return "Rust";
case PHP: return "PHP";
case HTML_STATIC: return "HTML";
default: return "Unknown";
}
}
/**
* Get the recommended package manager
*/
public String getPackageManager() {
switch (this) {
case JAVA_MAVEN:
case SPRING_BOOT:
return "Maven";
case JAVA_GRADLE:
return "Gradle";
case NODE_JS:
case REACT:
case VUE:
case ANGULAR:
case NEXT_JS:
return "npm/yarn";
case PYTHON:
case DJANGO:
case FLASK:
case FASTAPI:
return "pip";
case DOTNET:
return "NuGet";
case GO:
return "go mod";
case RUST:
return "Cargo";
case PHP:
return "Composer";
default:
return "Unknown";
}
}
}

View File

@@ -0,0 +1,81 @@
package com.example.demo.model;
import java.util.List;
import java.util.ArrayList;
public class TaskStatus {
private String taskId;
private String status; // RUNNING, COMPLETED, ERROR
private String currentAction;
private String summary;
private int currentTurn;
private int totalEstimatedTurns;
private long startTime;
private long lastUpdateTime;
private List<String> actionHistory;
private String errorMessage;
private double progressPercentage;
public TaskStatus(String taskId) {
this.taskId = taskId;
this.status = "RUNNING";
this.startTime = System.currentTimeMillis();
this.lastUpdateTime = this.startTime;
this.actionHistory = new ArrayList<>();
this.progressPercentage = 0.0;
}
// Getters and Setters
public String getTaskId() { return taskId; }
public void setTaskId(String taskId) { this.taskId = taskId; }
public String getStatus() { return status; }
public void setStatus(String status) {
this.status = status;
this.lastUpdateTime = System.currentTimeMillis();
}
public String getCurrentAction() { return currentAction; }
public void setCurrentAction(String currentAction) {
this.currentAction = currentAction;
this.lastUpdateTime = System.currentTimeMillis();
if (currentAction != null && !currentAction.trim().isEmpty()) {
this.actionHistory.add(currentAction);
}
}
public String getSummary() { return summary; }
public void setSummary(String summary) { this.summary = summary; }
public int getCurrentTurn() { return currentTurn; }
public void setCurrentTurn(int currentTurn) {
this.currentTurn = currentTurn;
updateProgress();
}
public int getTotalEstimatedTurns() { return totalEstimatedTurns; }
public void setTotalEstimatedTurns(int totalEstimatedTurns) {
this.totalEstimatedTurns = totalEstimatedTurns;
updateProgress();
}
public long getStartTime() { return startTime; }
public long getLastUpdateTime() { return lastUpdateTime; }
public List<String> getActionHistory() { return actionHistory; }
public String getErrorMessage() { return errorMessage; }
public void setErrorMessage(String errorMessage) { this.errorMessage = errorMessage; }
public double getProgressPercentage() { return progressPercentage; }
private void updateProgress() {
if (totalEstimatedTurns > 0) {
this.progressPercentage = Math.min(100.0, (double) currentTurn / totalEstimatedTurns * 100.0);
}
}
public long getElapsedTime() {
return System.currentTimeMillis() - startTime;
}
}

View File

@@ -0,0 +1,146 @@
package com.example.demo.schema;
import com.fasterxml.jackson.annotation.JsonInclude;
import com.fasterxml.jackson.annotation.JsonProperty;
import java.util.*;
/**
* JSON Schema definition class
* Used to define tool parameter structure and validation rules
*/
@JsonInclude(JsonInclude.Include.NON_NULL)
public class JsonSchema {
private String type;
private String description;
private String pattern;
private Number minimum;
private Number maximum;
private List<Object> enumValues;
@JsonProperty("properties")
private Map<String, JsonSchema> properties;
@JsonProperty("required")
private List<String> requiredFields;
@JsonProperty("items")
private JsonSchema items;
// Constructor
public JsonSchema() {}
// Static factory methods
public static JsonSchema object() {
JsonSchema schema = new JsonSchema();
schema.type = "object";
schema.properties = new HashMap<>();
return schema;
}
public static JsonSchema string(String description) {
JsonSchema schema = new JsonSchema();
schema.type = "string";
schema.description = description;
return schema;
}
public static JsonSchema number(String description) {
JsonSchema schema = new JsonSchema();
schema.type = "number";
schema.description = description;
return schema;
}
public static JsonSchema integer(String description) {
JsonSchema schema = new JsonSchema();
schema.type = "integer";
schema.description = description;
return schema;
}
public static JsonSchema bool(String description) {
JsonSchema schema = new JsonSchema();
schema.type = "boolean";
schema.description = description;
return schema;
}
public static JsonSchema array(JsonSchema items) {
JsonSchema schema = new JsonSchema();
schema.type = "array";
schema.items = items;
return schema;
}
public static JsonSchema array(String description, JsonSchema items) {
JsonSchema schema = new JsonSchema();
schema.type = "array";
schema.description = description;
schema.items = items;
return schema;
}
// Fluent methods
public JsonSchema addProperty(String name, JsonSchema property) {
if (this.properties == null) {
this.properties = new HashMap<>();
}
this.properties.put(name, property);
return this;
}
public JsonSchema required(String... fields) {
this.requiredFields = Arrays.asList(fields);
return this;
}
public JsonSchema pattern(String pattern) {
this.pattern = pattern;
return this;
}
public JsonSchema minimum(Number minimum) {
this.minimum = minimum;
return this;
}
public JsonSchema maximum(Number maximum) {
this.maximum = maximum;
return this;
}
public JsonSchema enumValues(Object... values) {
this.enumValues = Arrays.asList(values);
return this;
}
// Getters and Setters
public String getType() { return type; }
public void setType(String type) { this.type = type; }
public String getDescription() { return description; }
public void setDescription(String description) { this.description = description; }
public String getPattern() { return pattern; }
public void setPattern(String pattern) { this.pattern = pattern; }
public Number getMinimum() { return minimum; }
public void setMinimum(Number minimum) { this.minimum = minimum; }
public Number getMaximum() { return maximum; }
public void setMaximum(Number maximum) { this.maximum = maximum; }
public List<Object> getEnumValues() { return enumValues; }
public void setEnumValues(List<Object> enumValues) { this.enumValues = enumValues; }
public Map<String, JsonSchema> getProperties() { return properties; }
public void setProperties(Map<String, JsonSchema> properties) { this.properties = properties; }
public List<String> getRequiredFields() { return requiredFields; }
public void setRequiredFields(List<String> requiredFields) { this.requiredFields = requiredFields; }
public JsonSchema getItems() { return items; }
public void setItems(JsonSchema items) { this.items = items; }
}

View File

@@ -0,0 +1,134 @@
package com.example.demo.schema;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.networknt.schema.JsonSchemaFactory;
import com.networknt.schema.SpecVersion;
import com.networknt.schema.ValidationMessage;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.stereotype.Component;
import java.util.Set;
import java.util.stream.Collectors;
/**
* JSON Schema validator
* Used to validate tool parameters against defined schema
*/
@Component
public class SchemaValidator {
private static final Logger logger = LoggerFactory.getLogger(SchemaValidator.class);
private final ObjectMapper objectMapper;
private final JsonSchemaFactory schemaFactory;
public SchemaValidator() {
this.objectMapper = new ObjectMapper();
this.schemaFactory = JsonSchemaFactory.getInstance(SpecVersion.VersionFlag.V7);
}
/**
* Validate data against schema
*
* @param schema JSON Schema definition
* @param data Data to validate
* @return Validation error message, null means validation passed
*/
public String validate(JsonSchema schema, Object data) {
try {
// Convert custom JsonSchema to standard JSON Schema string
String schemaJson = objectMapper.writeValueAsString(schema);
logger.debug("Schema JSON: {}", schemaJson);
// Create JSON Schema validator
com.networknt.schema.JsonSchema jsonSchema = schemaFactory.getSchema(schemaJson);
// Convert data to JsonNode
String dataJson = objectMapper.writeValueAsString(data);
JsonNode dataNode = objectMapper.readTree(dataJson);
logger.debug("Data JSON: {}", dataJson);
// Execute validation
Set<ValidationMessage> errors = jsonSchema.validate(dataNode);
if (errors.isEmpty()) {
logger.debug("Schema validation passed");
return null; // Validation passed
} else {
String errorMessage = errors.stream()
.map(ValidationMessage::getMessage)
.collect(Collectors.joining("; "));
logger.warn("Schema validation failed: {}", errorMessage);
return errorMessage;
}
} catch (Exception e) {
String errorMessage = "Schema validation error: " + e.getMessage();
logger.error(errorMessage, e);
return errorMessage;
}
}
/**
* Simple type validation (fallback solution)
* Used when JSON Schema validation fails
*/
public String validateSimple(JsonSchema schema, Object data) {
if (schema == null || data == null) {
return "Schema or data is null";
}
// Basic type checking
String expectedType = schema.getType();
if (expectedType != null) {
String actualType = getDataType(data);
if (!isTypeCompatible(expectedType, actualType)) {
return String.format("Type mismatch: expected %s, got %s", expectedType, actualType);
}
}
// Required field checking (only for object type)
if ("object".equals(expectedType) && schema.getRequiredFields() != null) {
if (!(data instanceof java.util.Map)) {
return "Expected object type for required field validation";
}
@SuppressWarnings("unchecked")
java.util.Map<String, Object> dataMap = (java.util.Map<String, Object>) data;
for (String requiredField : schema.getRequiredFields()) {
if (!dataMap.containsKey(requiredField) || dataMap.get(requiredField) == null) {
return "Missing required field: " + requiredField;
}
}
}
return null; // Validation passed
}
private String getDataType(Object data) {
if (data == null) return "null";
if (data instanceof String) return "string";
if (data instanceof Integer || data instanceof Long) return "integer";
if (data instanceof Number) return "number";
if (data instanceof Boolean) return "boolean";
if (data instanceof java.util.List) return "array";
if (data instanceof java.util.Map) return "object";
return "unknown";
}
private boolean isTypeCompatible(String expectedType, String actualType) {
if (expectedType.equals(actualType)) {
return true;
}
// Number type compatibility
if ("number".equals(expectedType) && "integer".equals(actualType)) {
return true;
}
return false;
}
}

View File

@@ -0,0 +1,518 @@
package com.example.demo.service;
import com.example.demo.config.TaskContextHolder;
import com.example.demo.model.TaskStatus;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.ai.chat.client.ChatClient;
import org.springframework.ai.chat.messages.AssistantMessage;
import org.springframework.ai.chat.messages.Message;
import org.springframework.ai.chat.messages.UserMessage;
import org.springframework.ai.chat.model.ChatResponse;
import org.springframework.ai.chat.model.Generation;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import java.util.UUID;
import java.util.concurrent.ConcurrentHashMap;
/**
* 连续对话服务
*/
@Service
public class ContinuousConversationService {
private static final Logger logger = LoggerFactory.getLogger(ContinuousConversationService.class);
private final ChatClient chatClient;
private final NextSpeakerService nextSpeakerService;
@Autowired
private LogStreamService logStreamService;
// 最大轮数限制,防止无限循环
private static final int MAX_TURNS = 20;
// 单轮对话超时时间(毫秒)
private static final long TURN_TIMEOUT_MS = 60_000; // 60秒
// 总对话超时时间(毫秒)
private static final long TOTAL_TIMEOUT_MS = 10 * 60_000; // 10分钟
// 继续对话的提示语
private static final String[] CONTINUE_PROMPTS = {
"Continue with the next steps to complete the task.",
"Please proceed with the remaining work.",
"What's the next step? Please continue.",
"Keep going with the task.",
"Continue the implementation."
};
// 在现有的ContinuousConversationService中添加以下改进
// 添加依赖注入
private final TaskSummaryService taskSummaryService;
private final Map<String, TaskStatus> taskStatusMap = new ConcurrentHashMap<>();
private final Map<String, ConversationResult> conversationResults = new ConcurrentHashMap<>();
// 修改构造函数
public ContinuousConversationService(ChatClient chatClient,
NextSpeakerService nextSpeakerService,
TaskSummaryService taskSummaryService) {
this.chatClient = chatClient;
this.nextSpeakerService = nextSpeakerService;
this.taskSummaryService = taskSummaryService;
}
// 添加任务状态管理方法
public TaskStatus getTaskStatus(String taskId) {
return taskStatusMap.get(taskId);
}
// 获取对话结果
public ConversationResult getConversationResult(String taskId) {
return conversationResults.get(taskId);
}
// 存储对话结果
private void storeConversationResult(String taskId, ConversationResult result) {
conversationResults.put(taskId, result);
}
public String startTask(String initialMessage) {
String taskId = UUID.randomUUID().toString();
TaskStatus status = new TaskStatus(taskId);
// 估算任务复杂度
int estimatedTurns = taskSummaryService.estimateTaskComplexity(initialMessage);
status.setTotalEstimatedTurns(estimatedTurns);
status.setCurrentAction("开始分析任务...");
taskStatusMap.put(taskId, status);
return taskId;
}
/**
* 智能判断用户消息是否可能需要工具调用
* 用于决定是否使用异步模式和显示工具执行状态
*/
public boolean isLikelyToNeedTools(String message) {
if (message == null || message.trim().isEmpty()) {
return false;
}
String lowerMessage = message.toLowerCase().trim();
// 明确的简单对话模式 - 不需要工具
String[] simplePatterns = {
"你好", "hello", "hi", "", "哈喽",
"谢谢", "thank you", "thanks", "感谢",
"再见", "goodbye", "bye", "拜拜",
"好的", "ok", "okay", "", "可以",
"不用了", "算了", "没事", "不需要",
"怎么样", "如何", "什么意思", "是什么",
"介绍一下", "解释一下", "说明一下"
};
// 检查是否是简单问候或确认
for (String pattern : simplePatterns) {
if (lowerMessage.equals(pattern) ||
(lowerMessage.length() <= 10 && lowerMessage.contains(pattern))) {
return false;
}
}
// 明确需要工具的关键词
String[] toolRequiredPatterns = {
"创建", "create", "新建", "生成", "建立",
"编辑", "edit", "修改", "更新", "改变",
"删除", "delete", "移除", "清除",
"文件", "file", "目录", "folder", "项目", "project",
"代码", "code", "程序", "script", "函数", "function",
"分析", "analyze", "检查", "查看", "读取", "read",
"写入", "write", "保存", "save",
"搜索", "search", "查找", "find",
"下载", "download", "获取", "fetch",
"安装", "install", "配置", "config",
"运行", "run", "执行", "execute",
"测试", "test", "调试", "debug"
};
// 检查是否包含工具相关关键词
for (String pattern : toolRequiredPatterns) {
if (lowerMessage.contains(pattern)) {
return true;
}
}
// 基于消息长度和复杂度的启发式判断
// 长消息更可能需要工具处理
if (message.length() > 50) {
return true;
}
// 包含路径、URL、代码片段等的消息
if (lowerMessage.contains("/") || lowerMessage.contains("\\") ||
lowerMessage.contains("http") || lowerMessage.contains("www") ||
lowerMessage.contains("{") || lowerMessage.contains("}") ||
lowerMessage.contains("<") || lowerMessage.contains(">")) {
return true;
}
// 默认情况下,对于不确定的消息,倾向于不使用工具
// 这样可以避免不必要的工具准备状态显示
return false;
}
// 修改executeContinuousConversation方法
public ConversationResult executeContinuousConversation(String taskId, String initialMessage, List<Message> conversationHistory) {
TaskStatus taskStatus = taskStatusMap.get(taskId);
if (taskStatus == null) {
throw new IllegalArgumentException("Task not found: " + taskId);
}
// 设置任务上下文供AOP切面使用
TaskContextHolder.setCurrentTaskId(taskId);
long conversationStartTime = System.currentTimeMillis();
logger.info("Starting continuous conversation with message: {}", initialMessage);
// 更新任务状态
taskStatus.setCurrentAction("开始处理对话...");
taskStatus.setCurrentTurn(0);
// 创建工作副本
List<Message> workingHistory = new ArrayList<>(conversationHistory);
StringBuilder fullResponse = new StringBuilder();
List<String> turnResponses = new ArrayList<>();
// 添加初始用户消息
UserMessage userMessage = new UserMessage(initialMessage);
workingHistory.add(userMessage);
int turnCount = 0;
boolean shouldContinue = true;
String stopReason = null;
try {
while (shouldContinue && turnCount < MAX_TURNS) {
turnCount++;
logger.debug("Executing conversation turn: {}", turnCount);
// 更新任务状态
taskStatus.setCurrentTurn(turnCount);
taskStatus.setCurrentAction(String.format("执行第 %d 轮对话...", turnCount));
// 检查总超时
long elapsedTime = System.currentTimeMillis() - conversationStartTime;
if (elapsedTime > TOTAL_TIMEOUT_MS) {
logger.warn("Conversation timed out after {}ms", elapsedTime);
stopReason = "Total conversation timeout exceeded";
break;
}
try {
// 执行单轮对话
TurnResult turnResult = executeSingleTurn(workingHistory, turnCount);
if (!turnResult.isSuccess()) {
logger.error("Turn {} failed: {}", turnCount, turnResult.getErrorMessage());
stopReason = "Turn execution failed: " + turnResult.getErrorMessage();
break;
}
// 添加响应到历史
String responseText = turnResult.getResponse();
if (responseText != null && !responseText.trim().isEmpty()) {
AssistantMessage assistantMessage = new AssistantMessage(responseText);
workingHistory.add(assistantMessage);
// 累积响应
if (fullResponse.length() > 0) {
fullResponse.append("\n\n");
}
fullResponse.append(responseText);
turnResponses.add(responseText);
// 更新任务状态 - 显示当前响应的简短摘要
String responseSummary = responseText.length() > 100 ?
responseText.substring(0, 100) + "..." : responseText;
taskStatus.setCurrentAction(String.format("第 %d 轮完成: %s", turnCount, responseSummary));
}
// 判断是否应该继续
taskStatus.setCurrentAction(String.format("分析第 %d 轮结果,判断是否继续...", turnCount));
shouldContinue = shouldContinueConversation(workingHistory, turnCount, responseText);
if (shouldContinue && turnCount < MAX_TURNS) {
// 添加继续提示
String continuePrompt = getContinuePrompt(turnCount);
UserMessage continueMessage = new UserMessage(continuePrompt);
workingHistory.add(continueMessage);
logger.debug("Added continue prompt for turn {}: {}", turnCount + 1, continuePrompt);
taskStatus.setCurrentAction(String.format("准备第 %d 轮对话...", turnCount + 1));
} else {
taskStatus.setCurrentAction("对话即将结束...");
}
} catch (Exception e) {
logger.error("Error in conversation turn {}: {}", turnCount, e.getMessage(), e);
stopReason = "Exception in turn " + turnCount + ": " + e.getMessage();
// 添加错误信息到响应中
String errorMessage = String.format("❌ Error in turn %d: %s", turnCount, e.getMessage());
if (fullResponse.length() > 0) {
fullResponse.append("\n\n");
}
fullResponse.append(errorMessage);
turnResponses.add(errorMessage);
// 更新任务状态为错误
taskStatus.setStatus("FAILED");
taskStatus.setErrorMessage(e.getMessage());
taskStatus.setCurrentAction("执行出错: " + e.getMessage());
break;
}
}
long totalDuration = System.currentTimeMillis() - conversationStartTime;
logger.info("Continuous conversation completed after {} turns in {}ms. Stop reason: {}",
turnCount, totalDuration, stopReason);
// 创建结果对象
ConversationResult result = new ConversationResult(
fullResponse.toString(),
turnResponses,
workingHistory,
turnCount,
turnCount >= MAX_TURNS,
stopReason,
totalDuration
);
// 更新任务状态为完成
taskStatus.setStatus("COMPLETED");
taskStatus.setCurrentAction("对话完成");
String summary = String.format("对话完成,共 %d 轮,耗时 %.1f 秒",
turnCount, totalDuration / 1000.0);
if (stopReason != null) {
summary += ",停止原因: " + stopReason;
}
taskStatus.setSummary(summary);
// 存储结果到任务状态中
storeConversationResult(taskId, result);
// 推送任务完成事件
logStreamService.pushTaskComplete(taskId);
return result;
} catch (Exception e) {
// 处理整个对话过程中的异常
logger.error("Fatal error in continuous conversation: {}", e.getMessage(), e);
taskStatus.setStatus("FAILED");
taskStatus.setErrorMessage("Fatal error: " + e.getMessage());
taskStatus.setCurrentAction("执行失败");
throw e;
} finally {
// 清理任务上下文
TaskContextHolder.clearCurrentTaskId();
}
}
/**
* 执行单轮对话
*/
private TurnResult executeSingleTurn(List<Message> conversationHistory, int turnNumber) {
long turnStartTime = System.currentTimeMillis();
try {
logger.debug("Executing turn {} with {} messages in history", turnNumber, conversationHistory.size());
// 调用AI这里可以添加超时控制但Spring AI目前不直接支持
ChatResponse response = chatClient.prompt()
.messages(conversationHistory)
.call()
.chatResponse();
// 处理响应
Generation generation = response.getResult();
AssistantMessage assistantMessage = generation.getOutput();
String responseText = assistantMessage.getText();
long turnDuration = System.currentTimeMillis() - turnStartTime;
logger.debug("Turn {} completed in {}ms, response length: {} characters",
turnNumber, turnDuration, responseText != null ? responseText.length() : 0);
return new TurnResult(true, responseText, null);
} catch (Exception e) {
long turnDuration = System.currentTimeMillis() - turnStartTime;
logger.error("Failed to execute turn {} after {}ms: {}", turnNumber, turnDuration, e.getMessage(), e);
return new TurnResult(false, null, e.getMessage());
}
}
/**
* 判断是否应该继续对话 - 优化版本
*/
private boolean shouldContinueConversation(List<Message> conversationHistory, int turnCount, String lastResponse) {
long startTime = System.currentTimeMillis();
// 达到最大轮数
if (turnCount >= MAX_TURNS) {
logger.debug("Reached maximum turns ({}), stopping conversation", MAX_TURNS);
return false;
}
// 响应为空
if (lastResponse == null || lastResponse.trim().isEmpty()) {
logger.debug("Empty response, stopping conversation");
return false;
}
// 优化:首先使用增强的内容分析判断
boolean contentSuggestsContinue = nextSpeakerService.shouldContinueBasedOnContent(lastResponse);
logger.debug("Content analysis result: {}", contentSuggestsContinue);
// 如果内容分析明确建议停止直接停止避免LLM调用
if (!contentSuggestsContinue) {
logger.debug("Content analysis suggests stopping, skipping LLM check");
return false;
}
// 优化:对于文件编辑等明确的工具调用场景,可以基于简单规则继续
if (isObviousToolCallScenario(lastResponse, turnCount)) {
logger.debug("Obvious tool call scenario detected, continuing without LLM check");
return true;
}
// 只有在不确定的情况下才使用智能判断服务包含LLM调用
try {
NextSpeakerService.NextSpeakerResponse nextSpeaker =
nextSpeakerService.checkNextSpeaker(conversationHistory);
long duration = System.currentTimeMillis() - startTime;
logger.debug("Next speaker check completed in {}ms, result: {}", duration, nextSpeaker);
return nextSpeaker.isModelNext();
} catch (Exception e) {
logger.warn("Failed to check next speaker, defaulting to stop: {}", e.getMessage());
return false;
}
}
/**
* 检查是否是明显的工具调用场景
*/
private boolean isObviousToolCallScenario(String lastResponse, int turnCount) {
if (lastResponse == null) return false;
String lowerResponse = lastResponse.toLowerCase();
// 如果是前几轮且包含工具调用成功的标志,很可能需要继续
if (turnCount <= 10) {
String[] toolCallIndicators = {
"successfully created",
"successfully updated",
"file created",
"file updated",
"",
"created file",
"updated file",
"next, i'll",
"now i'll",
"let me create",
"let me edit"
};
for (String indicator : toolCallIndicators) {
if (lowerResponse.contains(indicator)) {
// 但如果同时包含明确的完成信号,则不继续
String[] completionSignals = {
"all files created", "project complete", "setup complete",
"everything is ready", "task completed", "all done"
};
boolean hasCompletionSignal = false;
for (String signal : completionSignals) {
if (lowerResponse.contains(signal)) {
hasCompletionSignal = true;
break;
}
}
if (!hasCompletionSignal) {
return true;
}
}
}
}
return false;
}
/**
* 获取继续对话的提示语
*/
private String getContinuePrompt(int turnNumber) {
int index = (turnNumber - 1) % CONTINUE_PROMPTS.length;
return CONTINUE_PROMPTS[index];
}
/**
* 单轮对话结果
*/
public static class TurnResult {
private final boolean success;
private final String response;
private final String errorMessage;
public TurnResult(boolean success, String response, String errorMessage) {
this.success = success;
this.response = response;
this.errorMessage = errorMessage;
}
public boolean isSuccess() { return success; }
public String getResponse() { return response; }
public String getErrorMessage() { return errorMessage; }
}
/**
* 连续对话结果
*/
public static class ConversationResult {
private final String fullResponse;
private final List<String> turnResponses;
private final List<Message> finalHistory;
private final int totalTurns;
private final boolean reachedMaxTurns;
private final String stopReason;
private final long totalDurationMs;
public ConversationResult(String fullResponse, List<String> turnResponses,
List<Message> finalHistory, int totalTurns, boolean reachedMaxTurns,
String stopReason, long totalDurationMs) {
this.fullResponse = fullResponse;
this.turnResponses = turnResponses;
this.finalHistory = finalHistory;
this.totalTurns = totalTurns;
this.reachedMaxTurns = reachedMaxTurns;
this.stopReason = stopReason;
this.totalDurationMs = totalDurationMs;
}
public String getFullResponse() { return fullResponse; }
public List<String> getTurnResponses() { return turnResponses; }
public List<Message> getFinalHistory() { return finalHistory; }
public int getTotalTurns() { return totalTurns; }
public boolean isReachedMaxTurns() { return reachedMaxTurns; }
public String getStopReason() { return stopReason; }
public long getTotalDurationMs() { return totalDurationMs; }
}
}

View File

@@ -0,0 +1,79 @@
package com.example.demo.service;
import com.fasterxml.jackson.annotation.JsonInclude;
/**
* 日志事件基类
*/
@JsonInclude(JsonInclude.Include.NON_NULL)
public class LogEvent {
private String type;
private String taskId;
private String message;
private String timestamp;
// Constructors
public LogEvent() {}
public LogEvent(String type, String taskId, String message, String timestamp) {
this.type = type;
this.taskId = taskId;
this.message = message;
this.timestamp = timestamp;
}
// Static factory methods
public static LogEvent createConnectionEvent(String taskId) {
LogEvent event = new LogEvent();
event.setType("CONNECTION_ESTABLISHED");
event.setTaskId(taskId);
event.setMessage("SSE连接已建立");
event.setTimestamp(java.time.LocalDateTime.now().format(
java.time.format.DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss")));
return event;
}
// Getters and Setters
public String getType() {
return type;
}
public void setType(String type) {
this.type = type;
}
public String getTaskId() {
return taskId;
}
public void setTaskId(String taskId) {
this.taskId = taskId;
}
public String getMessage() {
return message;
}
public void setMessage(String message) {
this.message = message;
}
public String getTimestamp() {
return timestamp;
}
public void setTimestamp(String timestamp) {
this.timestamp = timestamp;
}
@Override
public String toString() {
return "LogEvent{" +
"type='" + type + '\'' +
", taskId='" + taskId + '\'' +
", message='" + message + '\'' +
", timestamp='" + timestamp + '\'' +
'}';
}
}

View File

@@ -0,0 +1,202 @@
package com.example.demo.service;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.stereotype.Service;
import org.springframework.web.servlet.mvc.method.annotation.SseEmitter;
import java.io.IOException;
import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
/**
* SSE日志推送服务
* 负责将AOP日志实时推送到前端
*/
@Service
public class LogStreamService {
private static final Logger logger = LoggerFactory.getLogger(LogStreamService.class);
private static final DateTimeFormatter formatter = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss");
// 活跃的SSE连接 taskId -> SseEmitter
private final Map<String, SseEmitter> activeConnections = new ConcurrentHashMap<>();
// JSON序列化器
private final ObjectMapper objectMapper = new ObjectMapper();
/**
* 建立SSE连接
*/
public SseEmitter createConnection(String taskId) {
logger.info("🔗 建立SSE连接: taskId={}", taskId);
SseEmitter emitter = new SseEmitter(0L); // 无超时
// 设置连接事件处理
emitter.onCompletion(() -> {
logger.info("✅ SSE连接完成: taskId={}", taskId);
activeConnections.remove(taskId);
});
emitter.onTimeout(() -> {
logger.warn("⏰ SSE连接超时: taskId={}", taskId);
activeConnections.remove(taskId);
});
emitter.onError((ex) -> {
logger.error("❌ SSE连接错误: taskId={}, error={}", taskId, ex.getMessage());
activeConnections.remove(taskId);
});
// 保存连接
activeConnections.put(taskId, emitter);
// 发送连接成功消息
sendLogEvent(taskId, LogEvent.createConnectionEvent(taskId));
return emitter;
}
/**
* 关闭SSE连接
*/
public void closeConnection(String taskId) {
SseEmitter emitter = activeConnections.remove(taskId);
if (emitter != null) {
try {
emitter.complete();
logger.info("🔚 关闭SSE连接: taskId={}", taskId);
} catch (Exception e) {
logger.error("关闭SSE连接失败: taskId={}, error={}", taskId, e.getMessage());
}
}
}
/**
* 推送工具开始执行事件
*/
public void pushToolStart(String taskId, String toolName, String filePath, String message) {
ToolLogEvent event = new ToolLogEvent();
event.setType("TOOL_START");
event.setTaskId(taskId);
event.setToolName(toolName);
event.setFilePath(filePath);
event.setMessage(message);
event.setTimestamp(LocalDateTime.now().format(formatter));
event.setIcon(getToolIcon(toolName));
event.setStatus("RUNNING");
sendLogEvent(taskId, event);
}
/**
* 推送工具执行成功事件
*/
public void pushToolSuccess(String taskId, String toolName, String filePath, String message, long executionTime) {
ToolLogEvent event = new ToolLogEvent();
event.setType("TOOL_SUCCESS");
event.setTaskId(taskId);
event.setToolName(toolName);
event.setFilePath(filePath);
event.setMessage(message);
event.setTimestamp(LocalDateTime.now().format(formatter));
event.setIcon(getToolIcon(toolName));
event.setStatus("SUCCESS");
event.setExecutionTime(executionTime);
sendLogEvent(taskId, event);
}
/**
* 推送工具执行失败事件
*/
public void pushToolError(String taskId, String toolName, String filePath, String message, long executionTime) {
ToolLogEvent event = new ToolLogEvent();
event.setType("TOOL_ERROR");
event.setTaskId(taskId);
event.setToolName(toolName);
event.setFilePath(filePath);
event.setMessage(message);
event.setTimestamp(LocalDateTime.now().format(formatter));
event.setIcon("");
event.setStatus("ERROR");
event.setExecutionTime(executionTime);
sendLogEvent(taskId, event);
}
/**
* 推送任务完成事件
*/
public void pushTaskComplete(String taskId) {
LogEvent event = new LogEvent();
event.setType("TASK_COMPLETE");
event.setTaskId(taskId);
event.setMessage("任务执行完成");
event.setTimestamp(LocalDateTime.now().format(formatter));
sendLogEvent(taskId, event);
// 延迟关闭连接
new Thread(() -> {
try {
Thread.sleep(2000); // 等待2秒让前端处理完成事件
closeConnection(taskId);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
}).start();
}
/**
* 发送日志事件到前端
*/
private void sendLogEvent(String taskId, Object event) {
SseEmitter emitter = activeConnections.get(taskId);
if (emitter != null) {
try {
String jsonData = objectMapper.writeValueAsString(event);
logger.info("📤 准备推送日志事件: taskId={}, type={}, data={}", taskId,
event instanceof LogEvent ? ((LogEvent) event).getType() : "unknown", jsonData);
emitter.send(SseEmitter.event()
.name("log")
.data(jsonData));
logger.info("✅ 日志事件推送成功: taskId={}", taskId);
} catch (IOException e) {
logger.error("推送日志事件失败: taskId={}, error={}", taskId, e.getMessage());
activeConnections.remove(taskId);
}
} else {
logger.warn("⚠️ 未找到SSE连接: taskId={}, 无法推送事件", taskId);
}
}
/**
* 获取工具图标
*/
private String getToolIcon(String toolName) {
switch (toolName) {
case "readFile": return "📖";
case "writeFile": return "✏️";
case "editFile": return "📝";
case "listDirectory": return "📁";
case "analyzeProject": return "🔍";
case "scaffoldProject": return "🏗️";
case "smartEdit": return "🧠";
default: return "⚙️";
}
}
/**
* 获取活跃连接数
*/
public int getActiveConnectionCount() {
return activeConnections.size();
}
}

View File

@@ -0,0 +1,511 @@
package com.example.demo.service;
import com.fasterxml.jackson.annotation.JsonProperty;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.ai.chat.client.ChatClient;
import org.springframework.ai.chat.messages.AssistantMessage;
import org.springframework.ai.chat.messages.Message;
import org.springframework.ai.chat.messages.UserMessage;
import org.springframework.ai.chat.model.ChatModel;
import org.springframework.ai.chat.model.ChatResponse;
import org.springframework.ai.converter.BeanOutputConverter;
import org.springframework.stereotype.Service;
import java.util.List;
/**
* 智能判断下一步发言者的服务
*/
@Service
public class NextSpeakerService {
private static final Logger logger = LoggerFactory.getLogger(NextSpeakerService.class);
private final ChatModel chatModel;
private final ObjectMapper objectMapper;
public NextSpeakerService(ChatModel chatModel) {
this.chatModel = chatModel;
this.objectMapper = new ObjectMapper();
}
private static final String CHECK_PROMPT = """
Analyze *only* the content and structure of your immediately preceding response (your last turn in the conversation history).
Based *strictly* on that response, determine who should logically speak next: the 'user' or the 'model' (you).
**Decision Rules (apply in order):**
1. **Model Continues:** If your last response explicitly states an immediate next action *you* intend to take
(e.g., "Next, I will...", "Now I'll process...", "Moving on to analyze...", "Let me create...", "I'll now...",
indicates an intended tool call that didn't execute), OR if the response seems clearly incomplete
(cut off mid-thought without a natural conclusion), then the **'model'** should speak next.
2. **Question to User:** If your last response ends with a direct question specifically addressed *to the user*,
then the **'user'** should speak next.
3. **Waiting for User:** If your last response completed a thought, statement, or task *and* does not meet
the criteria for Rule 1 (Model Continues) or Rule 2 (Question to User), it implies a pause expecting
user input or reaction. In this case, the **'user'** should speak next.
**Output Format:**
Respond *only* in JSON format. Do not include any text outside the JSON structure.
""";
// 简化版本的检查提示用于减少LLM调用开销
private static final String SIMPLIFIED_CHECK_PROMPT = """
Based on your last response, who should speak next: 'user' or 'model'?
Rules: If you stated a next action or response is incomplete -> 'model'.
If you asked user a question -> 'user'.
If task completed -> 'user'.
Respond in JSON: {"next_speaker": "user/model", "reasoning": "brief reason"}
""";
/**
* 判断下一步应该由谁发言 - 优化版本,添加快速路径
*/
public NextSpeakerResponse checkNextSpeaker(List<Message> conversationHistory) {
try {
// 确保有对话历史
if (conversationHistory.isEmpty()) {
return new NextSpeakerResponse("user", "No conversation history available");
}
// 获取最后一条消息
Message lastMessage = conversationHistory.get(conversationHistory.size() - 1);
// 如果最后一条不是助手消息,用户应该发言
if (!(lastMessage instanceof AssistantMessage)) {
return new NextSpeakerResponse("user", "Last message was not from assistant");
}
// 检查是否是空响应
String lastContent = lastMessage.getText();
if (lastContent == null || lastContent.trim().isEmpty()) {
return new NextSpeakerResponse("model", "Last message was empty, model should continue");
}
// 快速路径1: 使用增强的内容分析进行快速判断
NextSpeakerResponse fastPathResult = performFastPathCheck(lastContent);
if (fastPathResult != null) {
logger.debug("Fast path decision: {}", fastPathResult);
return fastPathResult;
}
// 快速路径2: 检查对话历史模式
NextSpeakerResponse patternResult = checkConversationPattern(conversationHistory);
if (patternResult != null) {
logger.debug("Pattern-based decision: {}", patternResult);
return patternResult;
}
// 只有在快速路径无法确定时才使用LLM判断
logger.debug("Fast paths inconclusive, falling back to LLM check");
return performLLMCheck(conversationHistory);
} catch (Exception e) {
logger.warn("Failed to check next speaker, defaulting to user", e);
return new NextSpeakerResponse("user", "Error occurred during check: " + e.getMessage());
}
}
/**
* 快速路径检查 - 基于内容分析的快速判断
*/
private NextSpeakerResponse performFastPathCheck(String lastContent) {
String lowerContent = lastContent.toLowerCase();
// 明确的停止信号 - 直接返回user
String[] definiteStopSignals = {
"task completed successfully",
"all files created successfully",
"project setup complete",
"website is ready",
"application is ready",
"everything is ready",
"setup is complete",
"all tasks completed",
"work is complete"
};
for (String signal : definiteStopSignals) {
if (lowerContent.contains(signal)) {
return new NextSpeakerResponse("user", "Fast path: Definite completion signal detected");
}
}
// 明确的继续信号 - 直接返回model
String[] definiteContinueSignals = {
"next, i will",
"now i will",
"let me create",
"let me edit",
"let me update",
"i'll create",
"i'll edit",
"i'll update",
"moving on to",
"proceeding to",
"next step is to"
};
for (String signal : definiteContinueSignals) {
if (lowerContent.contains(signal)) {
return new NextSpeakerResponse("model", "Fast path: Definite continue signal detected");
}
}
// 工具调用成功模式 - 通常需要继续
if (isToolCallSuccessPattern(lastContent)) {
// 但如果同时包含完成信号,则停止
if (containsCompletionSignal(lowerContent)) {
return new NextSpeakerResponse("user", "Fast path: Tool success with completion signal");
}
return new NextSpeakerResponse("model", "Fast path: Tool call success, should continue");
}
// 直接问用户问题 - 应该等待用户回答
if (lowerContent.trim().endsWith("?") && containsUserQuestion(lowerContent)) {
return new NextSpeakerResponse("user", "Fast path: Direct question to user");
}
return null; // 无法快速判断
}
/**
* 检查对话历史模式
*/
private NextSpeakerResponse checkConversationPattern(List<Message> conversationHistory) {
if (conversationHistory.size() < 2) {
return null;
}
// 检查最近几轮的模式
int recentTurns = Math.min(4, conversationHistory.size());
int modelTurns = 0;
int userTurns = 0;
for (int i = conversationHistory.size() - recentTurns; i < conversationHistory.size(); i++) {
Message msg = conversationHistory.get(i);
if (msg instanceof AssistantMessage) {
modelTurns++;
} else if (msg instanceof UserMessage) {
userTurns++;
}
}
// 如果模型连续说话太多轮,可能需要用户介入
if (modelTurns >= 3 && userTurns == 0) {
return new NextSpeakerResponse("user", "Pattern: Too many consecutive model turns");
}
return null; // 模式不明确
}
/**
* 检查是否包含完成信号
*/
private boolean containsCompletionSignal(String lowerContent) {
String[] completionSignals = {
"all done", "complete", "finished", "ready", "that's it",
"we're done", "task complete", "project complete"
};
for (String signal : completionSignals) {
if (lowerContent.contains(signal)) {
return true;
}
}
return false;
}
/**
* 检查是否包含对用户的直接问题
*/
private boolean containsUserQuestion(String lowerContent) {
String[] userQuestionPatterns = {
"what would you like",
"what do you want",
"would you like me to",
"do you want me to",
"should i",
"would you prefer",
"any preferences",
"what's next",
"what should i do next"
};
for (String pattern : userQuestionPatterns) {
if (lowerContent.contains(pattern)) {
return true;
}
}
return false;
}
private NextSpeakerResponse performLLMCheck(List<Message> conversationHistory) {
try {
long startTime = System.currentTimeMillis();
// 优化:只使用最近的对话历史,减少上下文长度
List<Message> recentHistory = getRecentHistory(conversationHistory, 6); // 最多6条消息
// 创建用于判断的对话历史 - 简化版本
List<Message> checkMessages = recentHistory.stream()
.map(msg -> {
if (msg instanceof UserMessage) {
// 截断过长的用户消息
String text = msg.getText();
if (text.length() > 500) {
text = text.substring(0, 500) + "...";
}
return new UserMessage(text);
} else if (msg instanceof AssistantMessage) {
// 截断过长的助手消息
String text = msg.getText();
if (text.length() > 500) {
text = text.substring(0, 500) + "...";
}
return new AssistantMessage(text);
}
return msg;
})
.collect(java.util.stream.Collectors.toList());
// 添加简化的检查提示
checkMessages.add(new UserMessage(SIMPLIFIED_CHECK_PROMPT));
// 使用输出转换器
BeanOutputConverter<NextSpeakerResponse> outputConverter =
new BeanOutputConverter<>(NextSpeakerResponse.class);
// 调用LLM - 这里可以考虑添加超时但Spring AI目前不直接支持
ChatResponse response = ChatClient.create(chatModel)
.prompt()
.messages(checkMessages)
.call()
.chatResponse();
long duration = System.currentTimeMillis() - startTime;
logger.debug("LLM check completed in {}ms", duration);
String responseText = response.getResult().getOutput().getText();
logger.debug("Next speaker check response: {}", responseText);
// 解析响应
try {
return outputConverter.convert(responseText);
} catch (Exception parseError) {
logger.warn("Failed to parse next speaker response, trying manual parsing", parseError);
return parseManually(responseText);
}
} catch (Exception e) {
logger.warn("LLM check failed, defaulting to user: {}", e.getMessage());
return new NextSpeakerResponse("user", "LLM check failed: " + e.getMessage());
}
}
/**
* 获取最近的对话历史
*/
private List<Message> getRecentHistory(List<Message> fullHistory, int maxMessages) {
if (fullHistory.size() <= maxMessages) {
return fullHistory;
}
return fullHistory.subList(fullHistory.size() - maxMessages, fullHistory.size());
}
private NextSpeakerResponse parseManually(String responseText) {
try {
// 简单的手动解析
if (responseText.toLowerCase().contains("\"next_speaker\"") &&
responseText.toLowerCase().contains("\"model\"")) {
return new NextSpeakerResponse("model", "Parsed manually - model should continue");
}
return new NextSpeakerResponse("user", "Parsed manually - user should speak");
} catch (Exception e) {
return new NextSpeakerResponse("user", "Manual parsing failed");
}
}
/**
* 检查响应内容是否表明需要继续 - 优化版本
*/
public boolean shouldContinueBasedOnContent(String response) {
if (response == null || response.trim().isEmpty()) {
return false;
}
String lowerResponse = response.toLowerCase();
// 优先检查明确的停止指示词 - 扩展版本
String[] stopIndicators = {
"completed", "finished", "done", "ready", "all set", "task complete",
"project complete", "successfully created all", "that's it", "we're done",
"everything is ready", "all files created", "project is ready",
"task completed successfully", "all tasks completed", "work is complete",
"implementation complete", "setup complete", "configuration complete",
"files have been created", "project has been set up", "website is ready",
"application is ready", "all necessary files", "setup is complete"
};
// 检查停止指示词
for (String indicator : stopIndicators) {
if (lowerResponse.contains(indicator)) {
logger.debug("Found stop indicator: '{}' in response", indicator);
return false;
}
}
// 检查工具调用成功的模式 - 新增:这是文件编辑场景的关键优化
if (isToolCallSuccessPattern(response)) {
logger.debug("Detected successful tool call pattern, should continue");
return true;
}
// 扩展的继续指示词
String[] continueIndicators = {
"next, i", "now i", "let me", "i'll", "i will", "moving on",
"proceeding", "continuing", "then i", "after that", "following this",
"now let's", "let's now", "i need to", "i should", "i'm going to",
"next step", "continuing with", "moving to", "proceeding to",
"now creating", "now editing", "now updating", "now modifying",
"let me create", "let me edit", "let me update", "let me modify",
"i'll create", "i'll edit", "i'll update", "i'll modify",
"creating the", "editing the", "updating the", "modifying the"
};
// 检查继续指示词
for (String indicator : continueIndicators) {
if (lowerResponse.contains(indicator)) {
logger.debug("Found continue indicator: '{}' in response", indicator);
return true;
}
}
// 检查是否包含文件操作相关内容 - 针对文件编辑场景优化
if (containsFileOperationIntent(lowerResponse)) {
logger.debug("Detected file operation intent, should continue");
return true;
}
// 如果响应很短且没有明确结束,可能需要继续
boolean shortResponseContinue = response.length() < 200 && !lowerResponse.contains("?");
if (shortResponseContinue) {
logger.debug("Short response without question mark, should continue");
}
return shortResponseContinue;
}
/**
* 检查是否是工具调用成功的模式
*/
private boolean isToolCallSuccessPattern(String response) {
String lowerResponse = response.toLowerCase();
// 工具调用成功的典型模式
String[] toolSuccessPatterns = {
"successfully created",
"successfully updated",
"successfully modified",
"successfully edited",
"file created",
"file updated",
"file modified",
"file edited",
"created file",
"updated file",
"modified file",
"edited file",
"", // 成功标记
"file has been created",
"file has been updated",
"file has been modified",
"content has been",
"successfully wrote",
"successfully saved"
};
for (String pattern : toolSuccessPatterns) {
if (lowerResponse.contains(pattern)) {
return true;
}
}
return false;
}
/**
* 检查是否包含文件操作意图
*/
private boolean containsFileOperationIntent(String lowerResponse) {
String[] fileOperationIntents = {
"create a", "create the", "creating a", "creating the",
"edit a", "edit the", "editing a", "editing the",
"update a", "update the", "updating a", "updating the",
"modify a", "modify the", "modifying a", "modifying the",
"write a", "write the", "writing a", "writing the",
"generate a", "generate the", "generating a", "generating the",
"add to", "adding to", "append to", "appending to",
"need to create", "need to edit", "need to update", "need to modify",
"will create", "will edit", "will update", "will modify",
"going to create", "going to edit", "going to update", "going to modify"
};
for (String intent : fileOperationIntents) {
if (lowerResponse.contains(intent)) {
return true;
}
}
return false;
}
/**
* 下一步发言者响应
*/
public static class NextSpeakerResponse {
@JsonProperty("next_speaker")
private String nextSpeaker;
@JsonProperty("reasoning")
private String reasoning;
public NextSpeakerResponse() {}
public NextSpeakerResponse(String nextSpeaker, String reasoning) {
this.nextSpeaker = nextSpeaker;
this.reasoning = reasoning;
}
public String getNextSpeaker() {
return nextSpeaker;
}
public void setNextSpeaker(String nextSpeaker) {
this.nextSpeaker = nextSpeaker;
}
public String getReasoning() {
return reasoning;
}
public void setReasoning(String reasoning) {
this.reasoning = reasoning;
}
public boolean isModelNext() {
return "model".equals(nextSpeaker);
}
public boolean isUserNext() {
return "user".equals(nextSpeaker);
}
@Override
public String toString() {
return String.format("NextSpeaker{speaker='%s', reasoning='%s'}", nextSpeaker, reasoning);
}
}
}

View File

@@ -0,0 +1,339 @@
package com.example.demo.service;
import com.example.demo.model.ProjectContext;
import com.example.demo.model.ProjectStructure;
import com.example.demo.model.ProjectType;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.stream.Stream;
/**
* 项目上下文分析器
* 提供完整的项目分析功能生成AI可理解的项目上下文
*/
@Service
public class ProjectContextAnalyzer {
private static final Logger logger = LoggerFactory.getLogger(ProjectContextAnalyzer.class);
@Autowired
public ProjectTypeDetector projectTypeDetector;
@Autowired
public ProjectDiscoveryService projectDiscoveryService;
/**
* 分析项目并生成完整上下文
* @param projectRoot 项目根目录
* @return 项目上下文信息
*/
public ProjectContext analyzeProject(Path projectRoot) {
logger.info("Starting comprehensive project analysis for: {}", projectRoot);
ProjectContext context = new ProjectContext(projectRoot);
try {
// 1. 检测项目类型
ProjectType projectType = projectTypeDetector.detectProjectType(projectRoot);
context.setProjectType(projectType);
logger.debug("Detected project type: {}", projectType);
// 2. 分析项目结构
ProjectStructure structure = projectDiscoveryService.analyzeProjectStructure(projectRoot);
context.setProjectStructure(structure);
logger.debug("Analyzed project structure with {} directories",
structure.getDirectories().size());
// 3. 分析依赖关系
List<ProjectContext.DependencyInfo> dependencies =
projectDiscoveryService.analyzeDependencies(projectRoot);
context.setDependencies(dependencies);
logger.debug("Found {} dependencies", dependencies.size());
// 4. 查找配置文件
List<ProjectContext.ConfigFile> configFiles =
projectDiscoveryService.findConfigurationFiles(projectRoot);
context.setConfigFiles(configFiles);
logger.debug("Found {} configuration files", configFiles.size());
// 5. 分析代码统计
ProjectContext.CodeStatistics codeStats = analyzeCodeStatistics(projectRoot, projectType);
context.setCodeStatistics(codeStats);
logger.debug("Code statistics: {} total lines", codeStats.getTotalLines());
// 6. 收集项目元数据
Map<String, Object> metadata = collectProjectMetadata(projectRoot, projectType);
context.setMetadata(metadata);
// 7. 生成上下文摘要
String summary = context.generateContextSummary();
logger.debug("Generated context summary with {} characters", summary.length());
logger.info("Project analysis completed successfully for: {}", projectRoot);
return context;
} catch (Exception e) {
logger.error("Error during project analysis for: " + projectRoot, e);
// 返回部分分析结果
return context;
}
}
/**
* 分析代码统计信息
*/
private ProjectContext.CodeStatistics analyzeCodeStatistics(Path projectRoot, ProjectType projectType) {
logger.debug("Analyzing code statistics for: {}", projectRoot);
ProjectContext.CodeStatistics stats = new ProjectContext.CodeStatistics();
try {
analyzeCodeInDirectory(projectRoot, stats, projectType, 0, 3);
} catch (Exception e) {
logger.warn("Error analyzing code statistics", e);
}
return stats;
}
/**
* 递归分析目录中的代码
*/
private void analyzeCodeInDirectory(Path directory, ProjectContext.CodeStatistics stats,
ProjectType projectType, int currentDepth, int maxDepth) {
if (currentDepth > maxDepth) {
return;
}
try (Stream<Path> paths = Files.list(directory)) {
paths.forEach(path -> {
try {
if (Files.isDirectory(path)) {
String dirName = path.getFileName().toString();
// 跳过不需要分析的目录
if (!shouldSkipDirectory(dirName)) {
analyzeCodeInDirectory(path, stats, projectType, currentDepth + 1, maxDepth);
}
} else if (Files.isRegularFile(path)) {
analyzeCodeFile(path, stats, projectType);
}
} catch (Exception e) {
logger.warn("Error processing path during code analysis: " + path, e);
}
});
} catch (IOException e) {
logger.warn("Error listing directory: " + directory, e);
}
}
/**
* 分析单个代码文件
*/
private void analyzeCodeFile(Path filePath, ProjectContext.CodeStatistics stats, ProjectType projectType) {
String fileName = filePath.getFileName().toString();
String extension = getFileExtension(fileName).toLowerCase();
// 只分析代码文件
if (!isCodeFile(extension, projectType)) {
return;
}
try {
List<String> lines = Files.readAllLines(filePath);
int totalLines = lines.size();
int codeLines = 0;
int commentLines = 0;
int blankLines = 0;
for (String line : lines) {
String trimmedLine = line.trim();
if (trimmedLine.isEmpty()) {
blankLines++;
} else if (isCommentLine(trimmedLine, extension)) {
commentLines++;
} else {
codeLines++;
}
}
// 更新统计信息
stats.setTotalLines(stats.getTotalLines() + totalLines);
stats.setCodeLines(stats.getCodeLines() + codeLines);
stats.setCommentLines(stats.getCommentLines() + commentLines);
stats.setBlankLines(stats.getBlankLines() + blankLines);
// 按语言统计
String language = getLanguageByExtension(extension);
stats.addLanguageLines(language, totalLines);
// 分析类和方法(简单实现)
if (extension.equals(".java")) {
analyzeJavaFile(lines, stats);
} else if (extension.equals(".js") || extension.equals(".ts")) {
analyzeJavaScriptFile(lines, stats);
} else if (extension.equals(".py")) {
analyzePythonFile(lines, stats);
}
} catch (IOException e) {
logger.warn("Error reading file for code analysis: " + filePath, e);
}
}
/**
* 分析Java文件
*/
private void analyzeJavaFile(List<String> lines, ProjectContext.CodeStatistics stats) {
for (String line : lines) {
String trimmedLine = line.trim();
if (trimmedLine.matches(".*\\bclass\\s+\\w+.*")) {
stats.setTotalClasses(stats.getTotalClasses() + 1);
}
if (trimmedLine.matches(".*\\b(public|private|protected)\\s+.*\\s+\\w+\\s*\\(.*\\).*")) {
stats.setTotalMethods(stats.getTotalMethods() + 1);
}
}
}
/**
* 分析JavaScript文件
*/
private void analyzeJavaScriptFile(List<String> lines, ProjectContext.CodeStatistics stats) {
for (String line : lines) {
String trimmedLine = line.trim();
if (trimmedLine.matches(".*\\bfunction\\s+\\w+.*") ||
trimmedLine.matches(".*\\w+\\s*:\\s*function.*") ||
trimmedLine.matches(".*\\w+\\s*=\\s*\\(.*\\)\\s*=>.*")) {
stats.setTotalFunctions(stats.getTotalFunctions() + 1);
}
}
}
/**
* 分析Python文件
*/
private void analyzePythonFile(List<String> lines, ProjectContext.CodeStatistics stats) {
for (String line : lines) {
String trimmedLine = line.trim();
if (trimmedLine.matches("^class\\s+\\w+.*:")) {
stats.setTotalClasses(stats.getTotalClasses() + 1);
}
if (trimmedLine.matches("^def\\s+\\w+.*:")) {
stats.setTotalFunctions(stats.getTotalFunctions() + 1);
}
}
}
/**
* 收集项目元数据
*/
private Map<String, Object> collectProjectMetadata(Path projectRoot, ProjectType projectType) {
Map<String, Object> metadata = new HashMap<>();
metadata.put("projectName", projectRoot.getFileName().toString());
metadata.put("projectType", projectType.name());
metadata.put("primaryLanguage", projectType.getPrimaryLanguage());
metadata.put("packageManager", projectType.getPackageManager());
metadata.put("analysisTimestamp", System.currentTimeMillis());
// 检查版本控制
if (Files.exists(projectRoot.resolve(".git"))) {
metadata.put("versionControl", "Git");
}
// 检查CI/CD配置
if (Files.exists(projectRoot.resolve(".github"))) {
metadata.put("cicd", "GitHub Actions");
} else if (Files.exists(projectRoot.resolve(".gitlab-ci.yml"))) {
metadata.put("cicd", "GitLab CI");
}
// 检查Docker支持
if (Files.exists(projectRoot.resolve("Dockerfile"))) {
metadata.put("containerization", "Docker");
}
return metadata;
}
/**
* 生成编辑上下文
*/
public String buildEditContext(Path projectRoot, String editDescription) {
logger.debug("Building edit context for: {}", projectRoot);
ProjectContext context = analyzeProject(projectRoot);
StringBuilder contextBuilder = new StringBuilder();
contextBuilder.append("=== EDIT CONTEXT ===\n");
contextBuilder.append("Edit Request: ").append(editDescription).append("\n\n");
contextBuilder.append(context.generateContextSummary());
return contextBuilder.toString();
}
// 辅助方法
private boolean shouldSkipDirectory(String dirName) {
return dirName.equals(".git") || dirName.equals("node_modules") ||
dirName.equals("target") || dirName.equals("build") ||
dirName.equals("dist") || dirName.equals("__pycache__") ||
dirName.startsWith(".");
}
private String getFileExtension(String fileName) {
int lastDot = fileName.lastIndexOf('.');
return lastDot > 0 ? fileName.substring(lastDot) : "";
}
private boolean isCodeFile(String extension, ProjectType projectType) {
return extension.equals(".java") || extension.equals(".js") || extension.equals(".ts") ||
extension.equals(".py") || extension.equals(".html") || extension.equals(".css") ||
extension.equals(".jsx") || extension.equals(".tsx") || extension.equals(".vue") ||
extension.equals(".go") || extension.equals(".rs") || extension.equals(".php") ||
extension.equals(".cs") || extension.equals(".cpp") || extension.equals(".c");
}
private boolean isCommentLine(String line, String extension) {
switch (extension) {
case ".java":
case ".js":
case ".ts":
case ".jsx":
case ".tsx":
case ".css":
return line.startsWith("//") || line.startsWith("/*") || line.startsWith("*");
case ".py":
return line.startsWith("#");
case ".html":
return line.startsWith("<!--");
default:
return line.startsWith("#") || line.startsWith("//");
}
}
private String getLanguageByExtension(String extension) {
switch (extension) {
case ".java": return "Java";
case ".js": case ".jsx": return "JavaScript";
case ".ts": case ".tsx": return "TypeScript";
case ".py": return "Python";
case ".html": return "HTML";
case ".css": return "CSS";
case ".vue": return "Vue";
case ".go": return "Go";
case ".rs": return "Rust";
case ".php": return "PHP";
case ".cs": return "C#";
default: return "Other";
}
}
}

View File

@@ -0,0 +1,383 @@
package com.example.demo.service;
import com.example.demo.model.ProjectContext;
import com.example.demo.model.ProjectStructure;
import com.example.demo.model.ProjectType;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.List;
import java.util.stream.Stream;
/**
* 项目发现和分析服务
* 负责分析项目结构、依赖关系和配置信息
*/
@Service
public class ProjectDiscoveryService {
private static final Logger logger = LoggerFactory.getLogger(ProjectDiscoveryService.class);
@Autowired
private ProjectTypeDetector projectTypeDetector;
private final ObjectMapper objectMapper = new ObjectMapper();
/**
* 分析项目结构
* @param projectRoot 项目根目录
* @return 项目结构信息
*/
public ProjectStructure analyzeProjectStructure(Path projectRoot) {
logger.debug("Analyzing project structure for: {}", projectRoot);
ProjectType projectType = projectTypeDetector.detectProjectType(projectRoot);
ProjectStructure structure = new ProjectStructure(projectRoot, projectType);
try {
analyzeDirectoryStructure(projectRoot, structure, 0, 3); // 最大深度3层
structure.markImportantDirectories();
logger.info("Project structure analysis completed for: {}", projectRoot);
return structure;
} catch (IOException e) {
logger.error("Error analyzing project structure for: " + projectRoot, e);
return structure; // 返回部分分析结果
}
}
/**
* 递归分析目录结构
*/
private void analyzeDirectoryStructure(Path currentPath, ProjectStructure structure,
int currentDepth, int maxDepth) throws IOException {
if (currentDepth > maxDepth) {
return;
}
try (Stream<Path> paths = Files.list(currentPath)) {
paths.forEach(path -> {
try {
if (Files.isDirectory(path)) {
String dirName = path.getFileName().toString();
String relativePath = structure.getProjectRoot().relativize(path).toString();
// 跳过常见的忽略目录
if (shouldIgnoreDirectory(dirName)) {
return;
}
ProjectStructure.DirectoryInfo dirInfo =
new ProjectStructure.DirectoryInfo(dirName, relativePath);
// 分析目录中的文件
analyzeDirectoryFiles(path, dirInfo);
structure.addDirectory(dirInfo);
// 递归分析子目录
if (currentDepth < maxDepth) {
analyzeDirectoryStructure(path, structure, currentDepth + 1, maxDepth);
}
} else if (Files.isRegularFile(path)) {
// 处理根目录下的文件
String fileName = path.getFileName().toString();
String extension = getFileExtension(fileName);
structure.addFileType(extension, 1);
structure.setTotalFiles(structure.getTotalFiles() + 1);
// 检查是否为关键文件
if (isKeyFile(fileName, structure.getProjectType())) {
structure.addKeyFile(fileName);
}
// 累计文件大小
try {
structure.setTotalSize(structure.getTotalSize() + Files.size(path));
} catch (IOException e) {
logger.warn("Could not get size for file: {}", path);
}
}
} catch (Exception e) {
logger.warn("Error processing path: " + path, e);
}
});
}
}
/**
* 分析目录中的文件
*/
private void analyzeDirectoryFiles(Path directory, ProjectStructure.DirectoryInfo dirInfo) {
try (Stream<Path> files = Files.list(directory)) {
files.filter(Files::isRegularFile)
.forEach(file -> {
String fileName = file.getFileName().toString();
dirInfo.addFile(fileName);
});
} catch (IOException e) {
logger.warn("Error analyzing files in directory: {}", directory);
}
}
/**
* 分析项目依赖
*/
public List<ProjectContext.DependencyInfo> analyzeDependencies(Path projectRoot) {
logger.debug("Analyzing dependencies for: {}", projectRoot);
List<ProjectContext.DependencyInfo> dependencies = new ArrayList<>();
ProjectType projectType = projectTypeDetector.detectProjectType(projectRoot);
try {
switch (projectType) {
case JAVA_MAVEN:
case SPRING_BOOT:
dependencies.addAll(analyzeMavenDependencies(projectRoot));
break;
case NODE_JS:
case REACT:
case VUE:
case ANGULAR:
case NEXT_JS:
dependencies.addAll(analyzeNpmDependencies(projectRoot));
break;
case PYTHON:
case DJANGO:
case FLASK:
case FASTAPI:
dependencies.addAll(analyzePythonDependencies(projectRoot));
break;
default:
logger.info("Dependency analysis not supported for project type: {}", projectType);
}
} catch (Exception e) {
logger.error("Error analyzing dependencies for: " + projectRoot, e);
}
logger.info("Found {} dependencies for project: {}", dependencies.size(), projectRoot);
return dependencies;
}
/**
* 分析Maven依赖
*/
private List<ProjectContext.DependencyInfo> analyzeMavenDependencies(Path projectRoot) {
List<ProjectContext.DependencyInfo> dependencies = new ArrayList<>();
Path pomFile = projectRoot.resolve("pom.xml");
if (!Files.exists(pomFile)) {
return dependencies;
}
try {
String pomContent = Files.readString(pomFile);
// 简单的XML解析 - 在实际项目中应该使用专门的XML解析器
if (pomContent.contains("spring-boot-starter-web")) {
dependencies.add(new ProjectContext.DependencyInfo(
"spring-boot-starter-web", "auto", "compile"));
}
if (pomContent.contains("spring-boot-starter-data-jpa")) {
dependencies.add(new ProjectContext.DependencyInfo(
"spring-boot-starter-data-jpa", "auto", "compile"));
}
if (pomContent.contains("spring-boot-starter-test")) {
dependencies.add(new ProjectContext.DependencyInfo(
"spring-boot-starter-test", "auto", "test"));
}
// 可以添加更多依赖检测逻辑
} catch (IOException e) {
logger.warn("Error reading pom.xml", e);
}
return dependencies;
}
/**
* 分析NPM依赖
*/
private List<ProjectContext.DependencyInfo> analyzeNpmDependencies(Path projectRoot) {
List<ProjectContext.DependencyInfo> dependencies = new ArrayList<>();
Path packageJsonPath = projectRoot.resolve("package.json");
if (!Files.exists(packageJsonPath)) {
return dependencies;
}
try {
String content = Files.readString(packageJsonPath);
JsonNode packageJson = objectMapper.readTree(content);
// 分析生产依赖
JsonNode deps = packageJson.get("dependencies");
if (deps != null) {
deps.fields().forEachRemaining(entry -> {
dependencies.add(new ProjectContext.DependencyInfo(
entry.getKey(), entry.getValue().asText(), "production"));
});
}
// 分析开发依赖
JsonNode devDeps = packageJson.get("devDependencies");
if (devDeps != null) {
devDeps.fields().forEachRemaining(entry -> {
ProjectContext.DependencyInfo depInfo = new ProjectContext.DependencyInfo(
entry.getKey(), entry.getValue().asText(), "development");
depInfo.setDirectDependency(true);
dependencies.add(depInfo);
});
}
} catch (IOException e) {
logger.warn("Error reading package.json", e);
}
return dependencies;
}
/**
* 分析Python依赖
*/
private List<ProjectContext.DependencyInfo> analyzePythonDependencies(Path projectRoot) {
List<ProjectContext.DependencyInfo> dependencies = new ArrayList<>();
Path requirementsFile = projectRoot.resolve("requirements.txt");
if (Files.exists(requirementsFile)) {
try {
List<String> lines = Files.readAllLines(requirementsFile);
for (String line : lines) {
line = line.trim();
if (!line.isEmpty() && !line.startsWith("#")) {
String[] parts = line.split("==|>=|<=|>|<");
String name = parts[0].trim();
String version = parts.length > 1 ? parts[1].trim() : "latest";
dependencies.add(new ProjectContext.DependencyInfo(name, version, "runtime"));
}
}
} catch (IOException e) {
logger.warn("Error reading requirements.txt", e);
}
}
return dependencies;
}
/**
* 查找配置文件
*/
public List<ProjectContext.ConfigFile> findConfigurationFiles(Path projectRoot) {
logger.debug("Finding configuration files for: {}", projectRoot);
List<ProjectContext.ConfigFile> configFiles = new ArrayList<>();
ProjectType projectType = projectTypeDetector.detectProjectType(projectRoot);
try {
// 通用配置文件
addConfigFileIfExists(configFiles, projectRoot, "application.properties", "properties");
addConfigFileIfExists(configFiles, projectRoot, "application.yml", "yaml");
addConfigFileIfExists(configFiles, projectRoot, "application.yaml", "yaml");
addConfigFileIfExists(configFiles, projectRoot, "config.json", "json");
// 项目类型特定的配置文件
switch (projectType) {
case JAVA_MAVEN:
case SPRING_BOOT:
addConfigFileIfExists(configFiles, projectRoot, "pom.xml", "xml");
break;
case NODE_JS:
case REACT:
case VUE:
case ANGULAR:
case NEXT_JS:
addConfigFileIfExists(configFiles, projectRoot, "package.json", "json");
addConfigFileIfExists(configFiles, projectRoot, "webpack.config.js", "javascript");
break;
case PYTHON:
case DJANGO:
case FLASK:
case FASTAPI:
addConfigFileIfExists(configFiles, projectRoot, "requirements.txt", "text");
addConfigFileIfExists(configFiles, projectRoot, "setup.py", "python");
break;
}
} catch (Exception e) {
logger.error("Error finding configuration files for: " + projectRoot, e);
}
logger.info("Found {} configuration files for project: {}", configFiles.size(), projectRoot);
return configFiles;
}
/**
* 添加配置文件(如果存在)
*/
private void addConfigFileIfExists(List<ProjectContext.ConfigFile> configFiles,
Path projectRoot, String fileName, String fileType) {
Path configPath = projectRoot.resolve(fileName);
if (Files.exists(configPath)) {
String relativePath = projectRoot.relativize(configPath).toString();
ProjectContext.ConfigFile configFile =
new ProjectContext.ConfigFile(fileName, relativePath, fileType);
// 标记主要配置文件
if (fileName.equals("pom.xml") || fileName.equals("package.json") ||
fileName.startsWith("application.")) {
configFile.setMainConfig(true);
}
configFiles.add(configFile);
}
}
/**
* 检查是否应该忽略目录
*/
private boolean shouldIgnoreDirectory(String dirName) {
return dirName.equals(".git") || dirName.equals(".svn") ||
dirName.equals("node_modules") || dirName.equals("target") ||
dirName.equals("build") || dirName.equals("dist") ||
dirName.equals("__pycache__") || dirName.equals(".idea") ||
dirName.equals(".vscode") || dirName.startsWith(".");
}
/**
* 获取文件扩展名
*/
private String getFileExtension(String fileName) {
int lastDot = fileName.lastIndexOf('.');
return lastDot > 0 ? fileName.substring(lastDot) : "";
}
/**
* 检查是否为关键文件
*/
private boolean isKeyFile(String fileName, ProjectType projectType) {
// 通用关键文件
if (fileName.equals("README.md") || fileName.equals("LICENSE") ||
fileName.equals("Dockerfile") || fileName.equals(".gitignore")) {
return true;
}
// 项目类型特定的关键文件
if (projectType != null) {
String keyFile = projectType.getKeyFile();
if (keyFile != null && !keyFile.isEmpty()) {
return fileName.equals(keyFile) || fileName.matches(keyFile);
}
}
return false;
}
}

View File

@@ -0,0 +1,453 @@
package com.example.demo.service;
import com.example.demo.model.ProjectType;
import org.springframework.stereotype.Service;
import java.util.Map;
/**
* 项目模板扩展服务
* 生成README、gitignore等通用文件模板
*/
@Service
public class ProjectTemplateExtensions {
/**
* 生成README.md内容
*/
public String generateReadmeContent(Map<String, String> variables) {
return String.format("""
# %s
%s
## 🚀 Getting Started
### Prerequisites
- Java 17 or higher (for Java projects)
- Node.js 16+ (for JavaScript projects)
- Python 3.8+ (for Python projects)
### Installation
1. Clone the repository:
```bash
git clone <repository-url>
cd %s
```
2. Install dependencies:
```bash
# For Java Maven projects
mvn clean install
# For Node.js projects
npm install
# For Python projects
pip install -r requirements.txt
```
3. Run the application:
```bash
# For Java Maven projects
mvn spring-boot:run
# For Node.js projects
npm start
# For Python projects
python main.py
```
## 📁 Project Structure
```
%s/
├── src/ # Source code
├── test/ # Test files
├── docs/ # Documentation
├── README.md # This file
└── ...
```
## 🛠️ Development
### Running Tests
```bash
# For Java projects
mvn test
# For Node.js projects
npm test
# For Python projects
python -m pytest
```
### Building
```bash
# For Java projects
mvn clean package
# For Node.js projects
npm run build
# For Python projects
python setup.py build
```
## 📝 Features
- Feature 1: Description
- Feature 2: Description
- Feature 3: Description
## 🤝 Contributing
1. Fork the project
2. Create your feature branch (`git checkout -b feature/AmazingFeature`)
3. Commit your changes (`git commit -m 'Add some AmazingFeature'`)
4. Push to the branch (`git push origin feature/AmazingFeature`)
5. Open a Pull Request
## 📄 License
This project is licensed under the MIT License - see the [LICENSE](LICENSE) file for details.
## 👥 Authors
- **%s** - *Initial work* - [%s](mailto:%s)
## 🙏 Acknowledgments
- Hat tip to anyone whose code was used
- Inspiration
- etc
---
Created with ❤️ by %s
""",
variables.get("PROJECT_NAME_PASCAL"),
variables.get("DESCRIPTION"),
variables.get("PROJECT_NAME"),
variables.get("PROJECT_NAME"),
variables.get("AUTHOR"),
variables.get("AUTHOR"),
variables.get("EMAIL"),
variables.get("AUTHOR")
);
}
/**
* 生成.gitignore内容
*/
public String generateGitignoreContent(ProjectType projectType) {
StringBuilder gitignore = new StringBuilder();
// 通用忽略规则
gitignore.append("""
# General
.DS_Store
.DS_Store?
._*
.Spotlight-V100
.Trashes
ehthumbs.db
Thumbs.db
# IDE
.idea/
.vscode/
*.swp
*.swo
*~
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
# Runtime data
pids
*.pid
*.seed
*.pid.lock
# Coverage directory used by tools like istanbul
coverage/
# Dependency directories
node_modules/
""");
// 项目类型特定的忽略规则
switch (projectType) {
case JAVA_MAVEN:
case JAVA_GRADLE:
case SPRING_BOOT:
gitignore.append("""
# Java
*.class
*.jar
*.war
*.ear
*.nar
hs_err_pid*
# Maven
target/
pom.xml.tag
pom.xml.releaseBackup
pom.xml.versionsBackup
pom.xml.next
release.properties
dependency-reduced-pom.xml
buildNumber.properties
.mvn/timing.properties
.mvn/wrapper/maven-wrapper.jar
# Gradle
.gradle
build/
!gradle/wrapper/gradle-wrapper.jar
!**/src/main/**/build/
!**/src/test/**/build/
# Spring Boot
spring-boot-*.log
""");
break;
case NODE_JS:
case REACT:
case VUE:
case ANGULAR:
case NEXT_JS:
gitignore.append("""
# Node.js
node_modules/
npm-debug.log*
yarn-debug.log*
yarn-error.log*
lerna-debug.log*
# Runtime data
pids
*.pid
*.seed
*.pid.lock
# Coverage directory used by tools like istanbul
coverage/
*.lcov
# nyc test coverage
.nyc_output
# Grunt intermediate storage
.grunt
# Bower dependency directory
bower_components
# node-waf configuration
.lock-wscript
# Compiled binary addons
build/Release
# Dependency directories
node_modules/
jspm_packages/
# Optional npm cache directory
.npm
# Optional eslint cache
.eslintcache
# Microbundle cache
.rpt2_cache/
.rts2_cache_cjs/
.rts2_cache_es/
.rts2_cache_umd/
# Optional REPL history
.node_repl_history
# Output of 'npm pack'
*.tgz
# Yarn Integrity file
.yarn-integrity
# dotenv environment variables file
.env
.env.test
.env.local
.env.development.local
.env.test.local
.env.production.local
# parcel-bundler cache
.cache
.parcel-cache
# Next.js build output
.next
out
# Nuxt.js build / generate output
.nuxt
dist
# Gatsby files
.cache/
public
# Storybook build outputs
.out
.storybook-out
# Temporary folders
tmp/
temp/
""");
break;
case PYTHON:
case DJANGO:
case FLASK:
case FASTAPI:
gitignore.append("""
# Python
__pycache__/
*.py[cod]
*$py.class
# C extensions
*.so
# Distribution / packaging
.Python
build/
develop-eggs/
dist/
downloads/
eggs/
.eggs/
lib/
lib64/
parts/
sdist/
var/
wheels/
*.egg-info/
.installed.cfg
*.egg
MANIFEST
# PyInstaller
*.manifest
*.spec
# Installer logs
pip-log.txt
pip-delete-this-directory.txt
# Unit test / coverage reports
htmlcov/
.tox/
.nox/
.coverage
.coverage.*
.cache
nosetests.xml
coverage.xml
*.cover
.hypothesis/
.pytest_cache/
# Translations
*.mo
*.pot
# Django stuff:
*.log
local_settings.py
db.sqlite3
# Flask stuff:
instance/
.webassets-cache
# Scrapy stuff:
.scrapy
# Sphinx documentation
docs/_build/
# PyBuilder
target/
# Jupyter Notebook
.ipynb_checkpoints
# IPython
profile_default/
ipython_config.py
# pyenv
.python-version
# celery beat schedule file
celerybeat-schedule
# SageMath parsed files
*.sage.py
# Environments
.env
.venv
env/
venv/
ENV/
env.bak/
venv.bak/
# Spyder project settings
.spyderproject
.spyproject
# Rope project settings
.ropeproject
# mkdocs documentation
/site
# mypy
.mypy_cache/
.dmypy.json
dmypy.json
""");
break;
default:
// 基本忽略规则已经添加
break;
}
return gitignore.toString();
}
}

View File

@@ -0,0 +1,682 @@
package com.example.demo.service;
import com.example.demo.model.ProjectType;
import org.springframework.stereotype.Service;
import java.util.Map;
/**
* 项目模板服务
* 生成各种项目类型的模板文件内容
*/
@Service
public class ProjectTemplateService {
/**
* 生成Maven pom.xml
*/
public String generatePomXml(Map<String, String> variables) {
return String.format("""
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0
http://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<groupId>com.example</groupId>
<artifactId>%s</artifactId>
<version>%s</version>
<packaging>jar</packaging>
<name>%s</name>
<description>%s</description>
<properties>
<maven.compiler.source>17</maven.compiler.source>
<maven.compiler.target>17</maven.compiler.target>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
</properties>
<dependencies>
<dependency>
<groupId>junit</groupId>
<artifactId>junit</artifactId>
<version>4.13.2</version>
<scope>test</scope>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-compiler-plugin</artifactId>
<version>3.11.0</version>
<configuration>
<source>17</source>
<target>17</target>
</configuration>
</plugin>
</plugins>
</build>
</project>
""",
variables.get("PROJECT_NAME"),
variables.get("VERSION"),
variables.get("PROJECT_NAME_PASCAL"),
variables.get("DESCRIPTION")
);
}
/**
* 生成Spring Boot pom.xml
*/
public String generateSpringBootPomXml(Map<String, String> variables) {
return String.format("""
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0
http://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>3.2.1</version>
<relativePath/>
</parent>
<groupId>com.example</groupId>
<artifactId>%s</artifactId>
<version>%s</version>
<packaging>jar</packaging>
<name>%s</name>
<description>%s</description>
<properties>
<java.version>17</java.version>
</properties>
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
</plugin>
</plugins>
</build>
</project>
""",
variables.get("PROJECT_NAME"),
variables.get("VERSION"),
variables.get("PROJECT_NAME_PASCAL"),
variables.get("DESCRIPTION")
);
}
/**
* 生成Java主类
*/
public String generateJavaMainClass(Map<String, String> variables) {
return String.format("""
package com.example.%s;
/**
* Main application class for %s
*
* @author %s
*/
public class Application {
public static void main(String[] args) {
System.out.println("Hello from %s!");
System.out.println("Application started successfully.");
}
/**
* Get application name
* @return application name
*/
public String getApplicationName() {
return "%s";
}
}
""",
variables.get("PROJECT_NAME").toLowerCase(),
variables.get("PROJECT_NAME_PASCAL"),
variables.get("AUTHOR"),
variables.get("PROJECT_NAME_PASCAL"),
variables.get("PROJECT_NAME_PASCAL")
);
}
/**
* 生成Spring Boot主类
*/
public String generateSpringBootMainClass(Map<String, String> variables) {
return String.format("""
package com.example.%s;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
/**
* Spring Boot main application class for %s
*
* @author %s
*/
@SpringBootApplication
public class Application {
public static void main(String[] args) {
SpringApplication.run(Application.class, args);
}
}
""",
variables.get("PROJECT_NAME").toLowerCase(),
variables.get("PROJECT_NAME_PASCAL"),
variables.get("AUTHOR")
);
}
/**
* 生成Spring Boot Controller
*/
public String generateSpringBootController(Map<String, String> variables) {
return String.format("""
package com.example.%s.controller;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;
/**
* Hello controller for %s
*
* @author %s
*/
@RestController
public class HelloController {
@GetMapping("/")
public String hello() {
return "Hello from %s!";
}
@GetMapping("/health")
public String health() {
return "OK";
}
}
""",
variables.get("PROJECT_NAME").toLowerCase(),
variables.get("PROJECT_NAME_PASCAL"),
variables.get("AUTHOR"),
variables.get("PROJECT_NAME_PASCAL")
);
}
/**
* 生成Java测试类
*/
public String generateJavaTestClass(Map<String, String> variables) {
return String.format("""
package com.example.%s;
import org.junit.Test;
import static org.junit.Assert.*;
/**
* Test class for %s Application
*
* @author %s
*/
public class ApplicationTest {
@Test
public void testApplicationName() {
Application app = new Application();
assertEquals("%s", app.getApplicationName());
}
@Test
public void testApplicationCreation() {
Application app = new Application();
assertNotNull(app);
}
}
""",
variables.get("PROJECT_NAME").toLowerCase(),
variables.get("PROJECT_NAME_PASCAL"),
variables.get("AUTHOR"),
variables.get("PROJECT_NAME_PASCAL")
);
}
/**
* 生成application.yml
*/
public String generateApplicationYml(Map<String, String> variables) {
return String.format("""
# Application configuration for %s
server:
port: 8080
servlet:
context-path: /
spring:
application:
name: %s
profiles:
active: dev
# Logging configuration
logging:
level:
com.example.%s: DEBUG
org.springframework: INFO
pattern:
console: "%%d{yyyy-MM-dd HH:mm:ss} - %%msg%%n"
# Management endpoints
management:
endpoints:
web:
exposure:
include: health,info
endpoint:
health:
show-details: when-authorized
""",
variables.get("PROJECT_NAME_PASCAL"),
variables.get("PROJECT_NAME"),
variables.get("PROJECT_NAME").toLowerCase()
);
}
/**
* 生成package.json
*/
public String generatePackageJson(Map<String, String> variables) {
return String.format("""
{
"name": "%s",
"version": "%s",
"description": "%s",
"main": "index.js",
"scripts": {
"start": "node index.js",
"test": "echo \\"Error: no test specified\\" && exit 1",
"dev": "node index.js"
},
"keywords": [
"nodejs",
"%s"
],
"author": "%s <%s>",
"license": "MIT",
"dependencies": {},
"devDependencies": {}
}
""",
variables.get("PROJECT_NAME"),
variables.get("VERSION"),
variables.get("DESCRIPTION"),
variables.get("PROJECT_NAME"),
variables.get("AUTHOR"),
variables.get("EMAIL")
);
}
/**
* 生成Node.js主文件
*/
public String generateNodeJsMainFile(Map<String, String> variables) {
return String.format("""
/**
* Main application file for %s
*
* @author %s
*/
console.log('Hello from %s!');
console.log('Node.js application started successfully.');
// Simple HTTP server example
const http = require('http');
const server = http.createServer((req, res) => {
res.writeHead(200, { 'Content-Type': 'text/plain' });
res.end('Hello from %s!\\n');
});
const PORT = process.env.PORT || 3000;
server.listen(PORT, () => {
console.log(`Server running on port ${PORT}`);
});
""",
variables.get("PROJECT_NAME_PASCAL"),
variables.get("AUTHOR"),
variables.get("PROJECT_NAME_PASCAL"),
variables.get("PROJECT_NAME_PASCAL")
);
}
/**
* 生成React App.js
*/
public String generateReactAppJs(Map<String, String> variables) {
return String.format("""
import React from 'react';
import './App.css';
/**
* Main App component for %s
*
* @author %s
*/
function App() {
return (
<div className="App">
<header className="App-header">
<h1>Welcome to %s</h1>
<p>%s</p>
<p>
Edit <code>src/App.js</code> and save to reload.
</p>
</header>
</div>
);
}
export default App;
""",
variables.get("PROJECT_NAME_PASCAL"),
variables.get("AUTHOR"),
variables.get("PROJECT_NAME_PASCAL"),
variables.get("DESCRIPTION")
);
}
/**
* 生成React index.html
*/
public String generateReactIndexHtml(Map<String, String> variables) {
return String.format("""
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>%s</title>
<meta name="description" content="%s">
<meta name="author" content="%s">
</head>
<body>
<noscript>You need to enable JavaScript to run this app.</noscript>
<div id="root"></div>
</body>
</html>
""",
variables.get("PROJECT_NAME_PASCAL"),
variables.get("DESCRIPTION"),
variables.get("AUTHOR")
);
}
/**
* 生成Python主文件
*/
public String generatePythonMainFile(Map<String, String> variables) {
return String.format("""
#!/usr/bin/env python3
\"\"\"
Main application file for %s
Author: %s
\"\"\"
def main():
\"\"\"Main function\"\"\"
print("Hello from %s!")
print("Python application started successfully.")
def get_application_name():
\"\"\"Get application name\"\"\"
return "%s"
if __name__ == "__main__":
main()
""",
variables.get("PROJECT_NAME_PASCAL"),
variables.get("AUTHOR"),
variables.get("PROJECT_NAME_PASCAL"),
variables.get("PROJECT_NAME_PASCAL")
);
}
/**
* 生成requirements.txt
*/
public String generateRequirementsTxt(Map<String, String> variables) {
return """
# Python dependencies for """ + variables.get("PROJECT_NAME") + """
# Add your dependencies here
# Example dependencies:
# requests>=2.28.0
# flask>=2.3.0
# pytest>=7.0.0
""";
}
/**
* 生成静态HTML index.html
*/
public String generateStaticIndexHtml(Map<String, String> variables) {
return String.format("""
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>%s</title>
<meta name="description" content="%s">
<meta name="author" content="%s">
<link rel="stylesheet" href="css/style.css">
</head>
<body>
<header>
<h1>Welcome to %s</h1>
</header>
<main>
<section>
<h2>About</h2>
<p>%s</p>
</section>
<section>
<h2>Features</h2>
<ul>
<li>Modern HTML5 structure</li>
<li>Responsive design</li>
<li>Clean CSS styling</li>
<li>JavaScript functionality</li>
</ul>
</section>
</main>
<footer>
<p>&copy; %s %s. All rights reserved.</p>
</footer>
<script src="js/script.js"></script>
</body>
</html>
""",
variables.get("PROJECT_NAME_PASCAL"),
variables.get("DESCRIPTION"),
variables.get("AUTHOR"),
variables.get("PROJECT_NAME_PASCAL"),
variables.get("DESCRIPTION"),
variables.get("CURRENT_YEAR"),
variables.get("AUTHOR")
);
}
/**
* 生成基本CSS
*/
public String generateBasicCss(Map<String, String> variables) {
return String.format("""
/* CSS styles for %s */
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
line-height: 1.6;
color: #333;
background-color: #f4f4f4;
}
header {
background: #35424a;
color: white;
padding: 1rem 0;
text-align: center;
}
header h1 {
margin: 0;
}
main {
max-width: 800px;
margin: 2rem auto;
padding: 0 1rem;
background: white;
border-radius: 8px;
box-shadow: 0 2px 5px rgba(0,0,0,0.1);
}
section {
padding: 2rem;
}
h2 {
color: #35424a;
margin-bottom: 1rem;
}
ul {
margin-left: 2rem;
}
li {
margin-bottom: 0.5rem;
}
footer {
text-align: center;
padding: 1rem;
background: #35424a;
color: white;
margin-top: 2rem;
}
@media (max-width: 768px) {
main {
margin: 1rem;
}
section {
padding: 1rem;
}
}
""",
variables.get("PROJECT_NAME_PASCAL")
);
}
/**
* 生成基本JavaScript
*/
public String generateBasicJs(Map<String, String> variables) {
return String.format("""
/**
* JavaScript functionality for %s
*
* @author %s
*/
// Wait for DOM to be fully loaded
document.addEventListener('DOMContentLoaded', function() {
console.log('%s application loaded successfully!');
// Add click event to header
const header = document.querySelector('header h1');
if (header) {
header.addEventListener('click', function() {
alert('Welcome to %s!');
});
}
// Add smooth scrolling for anchor links
const links = document.querySelectorAll('a[href^="#"]');
links.forEach(link => {
link.addEventListener('click', function(e) {
e.preventDefault();
const target = document.querySelector(this.getAttribute('href'));
if (target) {
target.scrollIntoView({
behavior: 'smooth'
});
}
});
});
});
/**
* Utility function to get application name
*/
function getApplicationName() {
return '%s';
}
/**
* Utility function to show notification
*/
function showNotification(message) {
console.log('Notification:', message);
// You can implement a proper notification system here
}
""",
variables.get("PROJECT_NAME_PASCAL"),
variables.get("AUTHOR"),
variables.get("PROJECT_NAME_PASCAL"),
variables.get("PROJECT_NAME_PASCAL"),
variables.get("PROJECT_NAME_PASCAL")
);
}
}

View File

@@ -0,0 +1,284 @@
package com.example.demo.service;
import com.example.demo.model.ProjectType;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.stereotype.Component;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.List;
import java.util.stream.Stream;
/**
* 项目类型检测器
* 基于文件特征自动识别项目类型
*/
@Component
public class ProjectTypeDetector {
private static final Logger logger = LoggerFactory.getLogger(ProjectTypeDetector.class);
private final ObjectMapper objectMapper = new ObjectMapper();
/**
* 检测项目类型
* @param projectRoot 项目根目录
* @return 检测到的项目类型
*/
public ProjectType detectProjectType(Path projectRoot) {
if (!Files.exists(projectRoot) || !Files.isDirectory(projectRoot)) {
logger.warn("Project root does not exist or is not a directory: {}", projectRoot);
return ProjectType.UNKNOWN;
}
try {
logger.debug("Detecting project type for: {}", projectRoot);
// 按优先级检测项目类型
ProjectType detectedType = detectByKeyFiles(projectRoot);
if (detectedType != ProjectType.UNKNOWN) {
logger.info("Detected project type: {} for {}", detectedType, projectRoot);
return detectedType;
}
// 如果关键文件检测失败,尝试基于目录结构检测
detectedType = detectByDirectoryStructure(projectRoot);
if (detectedType != ProjectType.UNKNOWN) {
logger.info("Detected project type by structure: {} for {}", detectedType, projectRoot);
return detectedType;
}
logger.info("Could not determine project type for: {}", projectRoot);
return ProjectType.UNKNOWN;
} catch (Exception e) {
logger.error("Error detecting project type for: " + projectRoot, e);
return ProjectType.UNKNOWN;
}
}
/**
* 基于关键文件检测项目类型
*/
private ProjectType detectByKeyFiles(Path projectRoot) throws IOException {
// Java Maven项目
if (Files.exists(projectRoot.resolve("pom.xml"))) {
// 检查是否为Spring Boot项目
if (isSpringBootProject(projectRoot)) {
return ProjectType.SPRING_BOOT;
}
return ProjectType.JAVA_MAVEN;
}
// Java Gradle项目
if (Files.exists(projectRoot.resolve("build.gradle")) ||
Files.exists(projectRoot.resolve("build.gradle.kts"))) {
return ProjectType.JAVA_GRADLE;
}
// Node.js项目
if (Files.exists(projectRoot.resolve("package.json"))) {
return analyzeNodeJsProject(projectRoot);
}
// Python项目
if (Files.exists(projectRoot.resolve("requirements.txt")) ||
Files.exists(projectRoot.resolve("setup.py")) ||
Files.exists(projectRoot.resolve("pyproject.toml"))) {
return analyzePythonProject(projectRoot);
}
// .NET项目
try (Stream<Path> files = Files.list(projectRoot)) {
if (files.anyMatch(path -> path.toString().endsWith(".csproj") ||
path.toString().endsWith(".sln"))) {
return ProjectType.DOTNET;
}
}
// Go项目
if (Files.exists(projectRoot.resolve("go.mod"))) {
return ProjectType.GO;
}
// Rust项目
if (Files.exists(projectRoot.resolve("Cargo.toml"))) {
return ProjectType.RUST;
}
// PHP项目
if (Files.exists(projectRoot.resolve("composer.json"))) {
return ProjectType.PHP;
}
// 静态HTML项目
if (Files.exists(projectRoot.resolve("index.html"))) {
return ProjectType.HTML_STATIC;
}
return ProjectType.UNKNOWN;
}
/**
* 检查是否为Spring Boot项目
*/
private boolean isSpringBootProject(Path projectRoot) {
try {
Path pomFile = projectRoot.resolve("pom.xml");
if (!Files.exists(pomFile)) {
return false;
}
String pomContent = Files.readString(pomFile);
return pomContent.contains("spring-boot-starter") ||
pomContent.contains("org.springframework.boot");
} catch (IOException e) {
logger.warn("Error reading pom.xml for Spring Boot detection", e);
return false;
}
}
/**
* 分析Node.js项目类型
*/
private ProjectType analyzeNodeJsProject(Path projectRoot) {
try {
Path packageJsonPath = projectRoot.resolve("package.json");
String content = Files.readString(packageJsonPath);
JsonNode packageJson = objectMapper.readTree(content);
// 检查依赖来确定具体的框架类型
JsonNode dependencies = packageJson.get("dependencies");
JsonNode devDependencies = packageJson.get("devDependencies");
if (hasDependency(dependencies, "react") || hasDependency(devDependencies, "react")) {
return ProjectType.REACT;
}
if (hasDependency(dependencies, "vue") || hasDependency(devDependencies, "vue")) {
return ProjectType.VUE;
}
if (hasDependency(dependencies, "@angular/core") ||
hasDependency(devDependencies, "@angular/cli")) {
return ProjectType.ANGULAR;
}
if (hasDependency(dependencies, "next") || hasDependency(devDependencies, "next")) {
return ProjectType.NEXT_JS;
}
return ProjectType.NODE_JS;
} catch (IOException e) {
logger.warn("Error analyzing package.json", e);
return ProjectType.NODE_JS;
}
}
/**
* 分析Python项目类型
*/
private ProjectType analyzePythonProject(Path projectRoot) {
// 检查Django项目
if (Files.exists(projectRoot.resolve("manage.py"))) {
return ProjectType.DJANGO;
}
// 检查Flask项目
if (Files.exists(projectRoot.resolve("app.py")) ||
Files.exists(projectRoot.resolve("application.py"))) {
return ProjectType.FLASK;
}
// 检查FastAPI项目
if (Files.exists(projectRoot.resolve("main.py"))) {
try {
String content = Files.readString(projectRoot.resolve("main.py"));
if (content.contains("from fastapi import") || content.contains("import fastapi")) {
return ProjectType.FASTAPI;
}
} catch (IOException e) {
logger.warn("Error reading main.py for FastAPI detection", e);
}
}
return ProjectType.PYTHON;
}
/**
* 基于目录结构检测项目类型
*/
private ProjectType detectByDirectoryStructure(Path projectRoot) {
try {
List<String> directories = Files.list(projectRoot)
.filter(Files::isDirectory)
.map(path -> path.getFileName().toString().toLowerCase())
.toList();
// Java项目特征目录
if (directories.contains("src") &&
(directories.contains("target") || directories.contains("build"))) {
return ProjectType.JAVA_MAVEN; // 默认为Maven
}
// Node.js项目特征目录
if (directories.contains("node_modules") ||
directories.contains("public") ||
directories.contains("dist")) {
return ProjectType.NODE_JS;
}
// Python项目特征目录
if (directories.contains("venv") ||
directories.contains("env") ||
directories.contains("__pycache__")) {
return ProjectType.PYTHON;
}
} catch (IOException e) {
logger.warn("Error analyzing directory structure", e);
}
return ProjectType.UNKNOWN;
}
/**
* 检查是否存在特定依赖
*/
private boolean hasDependency(JsonNode dependencies, String dependencyName) {
return dependencies != null && dependencies.has(dependencyName);
}
/**
* 获取项目类型的详细信息
*/
public String getProjectTypeDetails(Path projectRoot, ProjectType projectType) {
StringBuilder details = new StringBuilder();
details.append("Project Type: ").append(projectType.getDisplayName()).append("\n");
details.append("Primary Language: ").append(projectType.getPrimaryLanguage()).append("\n");
details.append("Package Manager: ").append(projectType.getPackageManager()).append("\n");
// 添加特定项目类型的详细信息
switch (projectType) {
case SPRING_BOOT:
details.append("Framework: Spring Boot\n");
details.append("Build Tool: Maven\n");
break;
case REACT:
details.append("Framework: React\n");
details.append("Runtime: Node.js\n");
break;
case DJANGO:
details.append("Framework: Django\n");
details.append("Language: Python\n");
break;
// 可以添加更多项目类型的详细信息
}
return details.toString();
}
}

View File

@@ -0,0 +1,143 @@
package com.example.demo.service;
import org.springframework.stereotype.Service;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.util.regex.Pattern;
import java.util.regex.Matcher;
@Service
public class TaskSummaryService {
private static final Logger logger = LoggerFactory.getLogger(TaskSummaryService.class);
private static final Pattern[] ACTION_PATTERNS = {
Pattern.compile("(?i)creating?\\s+(?:a\\s+)?(?:new\\s+)?(.{1,50}?)(?:\\s+file|\\s+directory|\\s+project)?", Pattern.CASE_INSENSITIVE),
Pattern.compile("(?i)writing?\\s+(?:to\\s+)?(.{1,50}?)(?:\\s+file)?", Pattern.CASE_INSENSITIVE),
Pattern.compile("(?i)reading?\\s+(?:from\\s+)?(.{1,50}?)(?:\\s+file)?", Pattern.CASE_INSENSITIVE),
Pattern.compile("(?i)editing?\\s+(.{1,50}?)(?:\\s+file)?", Pattern.CASE_INSENSITIVE),
Pattern.compile("(?i)listing?\\s+(?:the\\s+)?(.{1,50}?)(?:\\s+directory)?", Pattern.CASE_INSENSITIVE),
Pattern.compile("(?i)analyzing?\\s+(.{1,50}?)", Pattern.CASE_INSENSITIVE),
Pattern.compile("(?i)generating?\\s+(.{1,50}?)", Pattern.CASE_INSENSITIVE),
Pattern.compile("(?i)building?\\s+(.{1,50}?)", Pattern.CASE_INSENSITIVE)
};
private static final String[] ACTION_VERBS = {
"创建", "写入", "读取", "编辑", "列出", "分析", "生成", "构建",
"creating", "writing", "reading", "editing", "listing", "analyzing", "generating", "building"
};
/**
* 从AI响应中提取任务摘要
*/
public String extractTaskSummary(String aiResponse) {
if (aiResponse == null || aiResponse.trim().isEmpty()) {
return "处理中...";
}
// 清理响应文本
String cleanResponse = aiResponse.replaceAll("```[\\s\\S]*?```", "").trim();
// 尝试匹配具体操作
for (Pattern pattern : ACTION_PATTERNS) {
Matcher matcher = pattern.matcher(cleanResponse);
if (matcher.find()) {
String action = matcher.group(0).trim();
if (action.length() > 50) {
action = action.substring(0, 47) + "...";
}
return action;
}
}
// 查找动作词汇
String lowerResponse = cleanResponse.toLowerCase();
for (String verb : ACTION_VERBS) {
if (lowerResponse.contains(verb.toLowerCase())) {
// 提取包含动作词的句子
String[] sentences = cleanResponse.split("[.!?\\n]");
for (String sentence : sentences) {
if (sentence.toLowerCase().contains(verb.toLowerCase())) {
String summary = sentence.trim();
if (summary.length() > 60) {
summary = summary.substring(0, 57) + "...";
}
return summary;
}
}
}
}
// 如果没有找到具体操作,返回通用描述
if (cleanResponse.length() > 60) {
return cleanResponse.substring(0, 57) + "...";
}
return cleanResponse.isEmpty() ? "处理中..." : cleanResponse;
}
/**
* 估算任务复杂度和预期轮数
*/
public int estimateTaskComplexity(String initialMessage) {
if (initialMessage == null) return 1;
String lowerMessage = initialMessage.toLowerCase();
int complexity = 1;
// 基于关键词估算复杂度
if (lowerMessage.contains("project") || lowerMessage.contains("项目")) complexity += 3;
if (lowerMessage.contains("complete") || lowerMessage.contains("完整")) complexity += 2;
if (lowerMessage.contains("multiple") || lowerMessage.contains("多个")) complexity += 2;
if (lowerMessage.contains("full-stack") || lowerMessage.contains("全栈")) complexity += 4;
if (lowerMessage.contains("website") || lowerMessage.contains("网站")) complexity += 2;
if (lowerMessage.contains("api") || lowerMessage.contains("接口")) complexity += 2;
// 基于文件操作数量估算
long fileOperations = lowerMessage.chars()
.mapToObj(c -> String.valueOf((char) c))
.filter(s -> s.matches(".*(?:create|write|edit|file|directory).*"))
.count();
complexity += (int) Math.min(fileOperations / 2, 5);
return Math.min(complexity, 15); // 最大15轮
}
/**
* 生成当前状态的用户友好描述
*/
public String generateStatusDescription(String status, String currentAction, int currentTurn, int totalTurns) {
StringBuilder desc = new StringBuilder();
switch (status) {
case "RUNNING":
if (currentAction != null && !currentAction.trim().isEmpty()) {
desc.append("🔄 ").append(currentAction);
} else {
desc.append("🤔 AI正在思考...");
}
if (totalTurns > 1) {
desc.append(String.format(" (第%d/%d轮)", currentTurn, totalTurns));
}
break;
case "COMPLETED":
desc.append("✅ 任务完成");
if (totalTurns > 1) {
desc.append(String.format(" (共%d轮)", currentTurn));
}
break;
case "ERROR":
desc.append("❌ 执行出错");
break;
default:
desc.append("⏳ 处理中...");
}
return desc.toString();
}
}

View File

@@ -0,0 +1,168 @@
package com.example.demo.service;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.stereotype.Service;
import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.atomic.AtomicLong;
/**
* 工具执行日志记录服务
* 记录所有工具调用的详细信息,使用中文日志
*/
@Service
public class ToolExecutionLogger {
private static final Logger logger = LoggerFactory.getLogger(ToolExecutionLogger.class);
private static final DateTimeFormatter formatter = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss");
// 工具调用计数器
private final AtomicLong callCounter = new AtomicLong(0);
// 工具执行统计
private final Map<String, ToolStats> toolStats = new ConcurrentHashMap<>();
/**
* 记录工具调用开始
*/
public long logToolStart(String toolName, String description, Object parameters) {
long callId = callCounter.incrementAndGet();
String timestamp = LocalDateTime.now().format(formatter);
logger.info("🚀 [工具调用-{}] 开始执行工具: {}", callId, toolName);
logger.info("📝 [工具调用-{}] 工具描述: {}", callId, description);
logger.info("⚙️ [工具调用-{}] 调用参数: {}", callId, formatParameters(parameters));
logger.info("🕐 [工具调用-{}] 开始时间: {}", callId, timestamp);
// 更新统计信息
toolStats.computeIfAbsent(toolName, k -> new ToolStats()).incrementCalls();
return callId;
}
/**
* 记录工具调用成功
*/
public void logToolSuccess(long callId, String toolName, String result, long executionTimeMs) {
String timestamp = LocalDateTime.now().format(formatter);
logger.info("✅ [工具调用-{}] 工具执行成功: {}", callId, toolName);
logger.info("📊 [工具调用-{}] 执行结果: {}", callId, truncateResult(result));
logger.info("⏱️ [工具调用-{}] 执行耗时: {}ms", callId, executionTimeMs);
logger.info("🕐 [工具调用-{}] 完成时间: {}", callId, timestamp);
// 更新统计信息
ToolStats stats = toolStats.get(toolName);
if (stats != null) {
stats.incrementSuccess();
stats.addExecutionTime(executionTimeMs);
}
}
/**
* 记录工具调用失败
*/
public void logToolError(long callId, String toolName, String error, long executionTimeMs) {
String timestamp = LocalDateTime.now().format(formatter);
logger.error("❌ [工具调用-{}] 工具执行失败: {}", callId, toolName);
logger.error("🚨 [工具调用-{}] 错误信息: {}", callId, error);
logger.error("⏱️ [工具调用-{}] 执行耗时: {}ms", callId, executionTimeMs);
logger.error("🕐 [工具调用-{}] 失败时间: {}", callId, timestamp);
// 更新统计信息
ToolStats stats = toolStats.get(toolName);
if (stats != null) {
stats.incrementError();
stats.addExecutionTime(executionTimeMs);
}
}
/**
* 记录工具调用的详细步骤
*/
public void logToolStep(long callId, String toolName, String step, String details) {
logger.debug("🔄 [工具调用-{}] [{}] 执行步骤: {} - {}", callId, toolName, step, details);
}
/**
* 记录文件操作
*/
public void logFileOperation(long callId, String operation, String filePath, String details) {
logger.info("📁 [工具调用-{}] 文件操作: {} - 文件: {} - 详情: {}", callId, operation, filePath, details);
}
/**
* 记录项目分析
*/
public void logProjectAnalysis(long callId, String projectPath, String projectType, String details) {
logger.info("🔍 [工具调用-{}] 项目分析: 路径={}, 类型={}, 详情={}", callId, projectPath, projectType, details);
}
/**
* 记录项目创建
*/
public void logProjectCreation(long callId, String projectName, String projectType, String projectPath) {
logger.info("🏗️ [工具调用-{}] 项目创建: 名称={}, 类型={}, 路径={}", callId, projectName, projectType, projectPath);
}
/**
* 获取工具执行统计
*/
public void logToolStatistics() {
logger.info("📈 ========== 工具执行统计 ==========");
toolStats.forEach((toolName, stats) -> {
logger.info("🔧 工具: {} | 调用次数: {} | 成功: {} | 失败: {} | 平均耗时: {}ms",
toolName, stats.getTotalCalls(), stats.getSuccessCount(),
stats.getErrorCount(), stats.getAverageExecutionTime());
});
logger.info("📈 ================================");
}
/**
* 格式化参数显示
*/
private String formatParameters(Object parameters) {
if (parameters == null) {
return "无参数";
}
String paramStr = parameters.toString();
return paramStr.length() > 200 ? paramStr.substring(0, 200) + "..." : paramStr;
}
/**
* 截断结果显示
*/
private String truncateResult(String result) {
if (result == null) {
return "无结果";
}
return result.length() > 300 ? result.substring(0, 300) + "..." : result;
}
/**
* 工具统计信息内部类
*/
private static class ToolStats {
private long totalCalls = 0;
private long successCount = 0;
private long errorCount = 0;
private long totalExecutionTime = 0;
public void incrementCalls() { totalCalls++; }
public void incrementSuccess() { successCount++; }
public void incrementError() { errorCount++; }
public void addExecutionTime(long time) { totalExecutionTime += time; }
public long getTotalCalls() { return totalCalls; }
public long getSuccessCount() { return successCount; }
public long getErrorCount() { return errorCount; }
public long getAverageExecutionTime() {
return totalCalls > 0 ? totalExecutionTime / totalCalls : 0;
}
}
}

View File

@@ -0,0 +1,93 @@
package com.example.demo.service;
import com.fasterxml.jackson.annotation.JsonInclude;
/**
* 工具日志事件类
* 继承自LogEvent添加工具相关的字段
*/
@JsonInclude(JsonInclude.Include.NON_NULL)
public class ToolLogEvent extends LogEvent {
private String toolName;
private String filePath;
private String icon;
private String status; // RUNNING, SUCCESS, ERROR
private Long executionTime; // 执行时间(毫秒)
private String summary; // 操作摘要
// Constructors
public ToolLogEvent() {
super();
}
public ToolLogEvent(String type, String taskId, String toolName, String filePath,
String message, String timestamp, String icon, String status) {
super(type, taskId, message, timestamp);
this.toolName = toolName;
this.filePath = filePath;
this.icon = icon;
this.status = status;
}
// Getters and Setters
public String getToolName() {
return toolName;
}
public void setToolName(String toolName) {
this.toolName = toolName;
}
public String getFilePath() {
return filePath;
}
public void setFilePath(String filePath) {
this.filePath = filePath;
}
public String getIcon() {
return icon;
}
public void setIcon(String icon) {
this.icon = icon;
}
public String getStatus() {
return status;
}
public void setStatus(String status) {
this.status = status;
}
public Long getExecutionTime() {
return executionTime;
}
public void setExecutionTime(Long executionTime) {
this.executionTime = executionTime;
}
public String getSummary() {
return summary;
}
public void setSummary(String summary) {
this.summary = summary;
}
@Override
public String toString() {
return "ToolLogEvent{" +
"toolName='" + toolName + '\'' +
", filePath='" + filePath + '\'' +
", icon='" + icon + '\'' +
", status='" + status + '\'' +
", executionTime=" + executionTime +
", summary='" + summary + '\'' +
"} " + super.toString();
}
}

View File

@@ -0,0 +1,495 @@
package com.example.demo.tools;
import com.example.demo.config.AppProperties;
import com.example.demo.model.ProjectContext;
import com.example.demo.model.ProjectStructure;
import com.example.demo.schema.JsonSchema;
import com.example.demo.service.ProjectContextAnalyzer;
import com.fasterxml.jackson.annotation.JsonProperty;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.ai.tool.annotation.Tool;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Component;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.util.concurrent.CompletableFuture;
/**
* 项目分析工具
* 分析现有项目的结构、类型、依赖等信息
*/
@Component
public class AnalyzeProjectTool extends BaseTool<AnalyzeProjectTool.AnalyzeProjectParams> {
private static final Logger logger = LoggerFactory.getLogger(AnalyzeProjectTool.class);
@Autowired
private ProjectContextAnalyzer projectContextAnalyzer;
private final String rootDirectory;
private final AppProperties appProperties;
public AnalyzeProjectTool(AppProperties appProperties) {
super(
"analyze_project",
"AnalyzeProject",
"Analyze an existing project to understand its structure, type, dependencies, and configuration. " +
"Provides comprehensive project information that can be used for intelligent editing and refactoring.",
createSchema()
);
this.appProperties = appProperties;
this.rootDirectory = appProperties.getWorkspace().getRootDirectory();
}
private static JsonSchema createSchema() {
return JsonSchema.object()
.addProperty("project_path", JsonSchema.string(
"Absolute path to the project root directory to analyze. " +
"Must be within the workspace directory."
))
.addProperty("analysis_depth", JsonSchema.string(
"Analysis depth: 'basic', 'detailed', or 'comprehensive'. " +
"Default: 'detailed'. " +
"- basic: Project type and structure only\n" +
"- detailed: Includes dependencies and configuration\n" +
"- comprehensive: Full analysis including code statistics"
))
.addProperty("include_code_stats", JsonSchema.bool(
"Whether to include detailed code statistics (lines of code, classes, methods, etc.). " +
"Default: true for detailed/comprehensive analysis"
))
.addProperty("output_format", JsonSchema.string(
"Output format: 'summary', 'detailed', or 'json'. Default: 'detailed'"
))
.required("project_path");
}
public enum AnalysisDepth {
BASIC("basic", "Basic project type and structure analysis"),
DETAILED("detailed", "Detailed analysis including dependencies and configuration"),
COMPREHENSIVE("comprehensive", "Comprehensive analysis with full code statistics");
private final String value;
private final String description;
AnalysisDepth(String value, String description) {
this.value = value;
this.description = description;
}
public static AnalysisDepth fromString(String value) {
for (AnalysisDepth depth : values()) {
if (depth.value.equals(value)) {
return depth;
}
}
return DETAILED; // default
}
public String getValue() { return value; }
public String getDescription() { return description; }
}
public enum OutputFormat {
SUMMARY("summary", "Brief summary of key project information"),
DETAILED("detailed", "Detailed human-readable analysis report"),
JSON("json", "Structured JSON output for programmatic use");
private final String value;
private final String description;
OutputFormat(String value, String description) {
this.value = value;
this.description = description;
}
public static OutputFormat fromString(String value) {
for (OutputFormat format : values()) {
if (format.value.equals(value)) {
return format;
}
}
return DETAILED; // default
}
public String getValue() { return value; }
public String getDescription() { return description; }
}
@Override
public String validateToolParams(AnalyzeProjectParams params) {
String baseValidation = super.validateToolParams(params);
if (baseValidation != null) {
return baseValidation;
}
if (params.projectPath == null || params.projectPath.trim().isEmpty()) {
return "Project path cannot be empty";
}
Path projectPath = Paths.get(params.projectPath);
if (!projectPath.isAbsolute()) {
return "Project path must be absolute: " + params.projectPath;
}
if (!Files.exists(projectPath)) {
return "Project path does not exist: " + params.projectPath;
}
if (!Files.isDirectory(projectPath)) {
return "Project path must be a directory: " + params.projectPath;
}
if (!isWithinWorkspace(projectPath)) {
return "Project path must be within the workspace directory: " + params.projectPath;
}
return null;
}
/**
* Analyze project tool method for Spring AI integration
*/
@Tool(name = "analyze_project", description = "Analyzes project structure, type, dependencies and other information")
public String analyzeProject(String projectPath, String analysisDepth, String outputFormat, Boolean includeCodeStats) {
try {
AnalyzeProjectParams params = new AnalyzeProjectParams();
params.setProjectPath(projectPath);
params.setAnalysisDepth(analysisDepth != null ? analysisDepth : "basic");
params.setOutputFormat(outputFormat != null ? outputFormat : "detailed");
params.setIncludeCodeStats(includeCodeStats != null ? includeCodeStats : false);
// Validate parameters
String validation = validateToolParams(params);
if (validation != null) {
return "Error: " + validation;
}
// Execute the tool
ToolResult result = execute(params).join();
if (result.isSuccess()) {
return result.getLlmContent();
} else {
return "Error: " + result.getErrorMessage();
}
} catch (Exception e) {
logger.error("Error in analyze project tool", e);
return "Error: " + e.getMessage();
}
}
@Override
public CompletableFuture<ToolResult> execute(AnalyzeProjectParams params) {
return CompletableFuture.supplyAsync(() -> {
try {
logger.info("Starting project analysis for: {}", params.projectPath);
Path projectPath = Paths.get(params.projectPath);
AnalysisDepth depth = AnalysisDepth.fromString(params.analysisDepth);
OutputFormat format = OutputFormat.fromString(params.outputFormat);
// 执行项目分析
ProjectContext context = analyzeProject(projectPath, depth, params);
// 生成输出
String output = generateOutput(context, format, depth);
String summary = generateSummary(context);
logger.info("Project analysis completed for: {}", params.projectPath);
return ToolResult.success(summary, output);
} catch (Exception e) {
logger.error("Error during project analysis", e);
return ToolResult.error("Project analysis failed: " + e.getMessage());
}
});
}
/**
* 执行项目分析
*/
private ProjectContext analyzeProject(Path projectPath, AnalysisDepth depth, AnalyzeProjectParams params) {
logger.debug("Analyzing project with depth: {}", depth);
switch (depth) {
case BASIC:
return analyzeBasic(projectPath);
case DETAILED:
return analyzeDetailed(projectPath, params);
case COMPREHENSIVE:
return analyzeComprehensive(projectPath, params);
default:
return projectContextAnalyzer.analyzeProject(projectPath);
}
}
/**
* 基础分析
*/
private ProjectContext analyzeBasic(Path projectPath) {
// 只分析项目类型和基本结构
ProjectContext context = new ProjectContext(projectPath);
context.setProjectType(projectContextAnalyzer.projectTypeDetector.detectProjectType(projectPath));
context.setProjectStructure(projectContextAnalyzer.projectDiscoveryService.analyzeProjectStructure(projectPath));
return context;
}
/**
* 详细分析
*/
private ProjectContext analyzeDetailed(Path projectPath, AnalyzeProjectParams params) {
ProjectContext context = analyzeBasic(projectPath);
// 添加依赖和配置文件分析
context.setDependencies(projectContextAnalyzer.projectDiscoveryService.analyzeDependencies(projectPath));
context.setConfigFiles(projectContextAnalyzer.projectDiscoveryService.findConfigurationFiles(projectPath));
// 如果需要代码统计
if (params.includeCodeStats == null || params.includeCodeStats) {
// 简化的代码统计,避免性能问题
ProjectContext.CodeStatistics stats = new ProjectContext.CodeStatistics();
// 这里可以添加基本的代码统计逻辑
context.setCodeStatistics(stats);
}
return context;
}
/**
* 全面分析
*/
private ProjectContext analyzeComprehensive(Path projectPath, AnalyzeProjectParams params) {
// 使用完整的项目分析
return projectContextAnalyzer.analyzeProject(projectPath);
}
/**
* 生成输出
*/
private String generateOutput(ProjectContext context, OutputFormat format, AnalysisDepth depth) {
switch (format) {
case SUMMARY:
return generateSummaryOutput(context);
case DETAILED:
return generateDetailedOutput(context, depth);
case JSON:
return generateJsonOutput(context);
default:
return generateDetailedOutput(context, depth);
}
}
/**
* 生成摘要输出
*/
private String generateSummaryOutput(ProjectContext context) {
StringBuilder output = new StringBuilder();
output.append("📊 PROJECT ANALYSIS SUMMARY\n");
output.append("=" .repeat(50)).append("\n\n");
// 基本信息
output.append("🏗️ Project: ").append(context.getProjectRoot().getFileName()).append("\n");
output.append("🔧 Type: ").append(context.getProjectType().getDisplayName()).append("\n");
output.append("💻 Language: ").append(context.getProjectType().getPrimaryLanguage()).append("\n");
output.append("📦 Package Manager: ").append(context.getProjectType().getPackageManager()).append("\n\n");
// 结构信息
if (context.getProjectStructure() != null) {
ProjectStructure structure = context.getProjectStructure();
output.append("📁 Structure:\n");
output.append(" - Directories: ").append(structure.getTotalDirectories()).append("\n");
output.append(" - Files: ").append(structure.getTotalFiles()).append("\n");
output.append(" - Size: ").append(formatFileSize(structure.getTotalSize())).append("\n\n");
}
// 依赖信息
if (context.getDependencies() != null && !context.getDependencies().isEmpty()) {
output.append("📚 Dependencies: ").append(context.getDependencies().size()).append(" found\n");
output.append(" - Key dependencies: ").append(context.getDependencySummary()).append("\n\n");
}
// 配置文件
if (context.getConfigFiles() != null && !context.getConfigFiles().isEmpty()) {
output.append("⚙️ Configuration Files: ").append(context.getConfigFiles().size()).append(" found\n");
context.getConfigFiles().stream()
.filter(ProjectContext.ConfigFile::isMainConfig)
.forEach(config -> output.append(" - ").append(config.getFileName()).append("\n"));
}
return output.toString();
}
/**
* 生成详细输出
*/
private String generateDetailedOutput(ProjectContext context, AnalysisDepth depth) {
StringBuilder output = new StringBuilder();
output.append("📊 COMPREHENSIVE PROJECT ANALYSIS\n");
output.append("=" .repeat(60)).append("\n\n");
// 使用项目上下文的摘要生成功能
output.append(context.generateContextSummary());
// 添加分析深度特定的信息
if (depth == AnalysisDepth.COMPREHENSIVE) {
output.append("\n=== DETAILED INSIGHTS ===\n");
output.append(generateProjectInsights(context));
}
return output.toString();
}
/**
* 生成JSON输出
*/
private String generateJsonOutput(ProjectContext context) {
// 简化的JSON输出实现
// 在实际项目中应该使用Jackson等JSON库
StringBuilder json = new StringBuilder();
json.append("{\n");
json.append(" \"projectName\": \"").append(context.getProjectRoot().getFileName()).append("\",\n");
json.append(" \"projectType\": \"").append(context.getProjectType().name()).append("\",\n");
json.append(" \"primaryLanguage\": \"").append(context.getProjectType().getPrimaryLanguage()).append("\",\n");
if (context.getProjectStructure() != null) {
ProjectStructure structure = context.getProjectStructure();
json.append(" \"structure\": {\n");
json.append(" \"directories\": ").append(structure.getTotalDirectories()).append(",\n");
json.append(" \"files\": ").append(structure.getTotalFiles()).append(",\n");
json.append(" \"totalSize\": ").append(structure.getTotalSize()).append("\n");
json.append(" },\n");
}
json.append(" \"dependencyCount\": ").append(
context.getDependencies() != null ? context.getDependencies().size() : 0).append(",\n");
json.append(" \"configFileCount\": ").append(
context.getConfigFiles() != null ? context.getConfigFiles().size() : 0).append("\n");
json.append("}");
return json.toString();
}
/**
* 生成项目洞察
*/
private String generateProjectInsights(ProjectContext context) {
StringBuilder insights = new StringBuilder();
// 项目健康度评估
insights.append("Project Health Assessment:\n");
// 检查是否有版本控制
if (context.getMetadata().containsKey("versionControl")) {
insights.append("✅ Version control detected: ").append(context.getMetadata().get("versionControl")).append("\n");
} else {
insights.append("⚠️ No version control detected\n");
}
// 检查是否有CI/CD
if (context.getMetadata().containsKey("cicd")) {
insights.append("✅ CI/CD configured: ").append(context.getMetadata().get("cicd")).append("\n");
} else {
insights.append("💡 Consider setting up CI/CD\n");
}
// 检查是否有容器化
if (context.getMetadata().containsKey("containerization")) {
insights.append("✅ Containerization: ").append(context.getMetadata().get("containerization")).append("\n");
}
// 代码质量建议
insights.append("\nRecommendations:\n");
if (context.getProjectType().isJavaProject()) {
insights.append("- Consider using static analysis tools like SpotBugs or PMD\n");
insights.append("- Ensure proper test coverage with JUnit\n");
} else if (context.getProjectType().isJavaScriptProject()) {
insights.append("- Consider using ESLint for code quality\n");
insights.append("- Add TypeScript for better type safety\n");
} else if (context.getProjectType().isPythonProject()) {
insights.append("- Consider using pylint or flake8 for code quality\n");
insights.append("- Add type hints for better code documentation\n");
}
return insights.toString();
}
/**
* 生成摘要
*/
private String generateSummary(ProjectContext context) {
return String.format("Analyzed %s project: %s (%s) with %d dependencies and %d config files",
context.getProjectType().getDisplayName(),
context.getProjectRoot().getFileName(),
context.getProjectType().getPrimaryLanguage(),
context.getDependencies() != null ? context.getDependencies().size() : 0,
context.getConfigFiles() != null ? context.getConfigFiles().size() : 0
);
}
/**
* 格式化文件大小
*/
private String formatFileSize(long bytes) {
if (bytes < 1024) return bytes + " B";
if (bytes < 1024 * 1024) return String.format("%.1f KB", bytes / 1024.0);
if (bytes < 1024 * 1024 * 1024) return String.format("%.1f MB", bytes / (1024.0 * 1024));
return String.format("%.1f GB", bytes / (1024.0 * 1024 * 1024));
}
/**
* 检查路径是否在工作空间内
*/
private boolean isWithinWorkspace(Path filePath) {
try {
Path workspaceRoot = Paths.get(rootDirectory).toRealPath();
Path normalizedPath = filePath.toRealPath();
return normalizedPath.startsWith(workspaceRoot);
} catch (Exception e) {
logger.warn("Could not resolve workspace path", e);
return false;
}
}
/**
* 分析项目参数
*/
public static class AnalyzeProjectParams {
@JsonProperty("project_path")
private String projectPath;
@JsonProperty("analysis_depth")
private String analysisDepth = "detailed";
@JsonProperty("include_code_stats")
private Boolean includeCodeStats;
@JsonProperty("output_format")
private String outputFormat = "detailed";
// Getters and Setters
public String getProjectPath() { return projectPath; }
public void setProjectPath(String projectPath) { this.projectPath = projectPath; }
public String getAnalysisDepth() { return analysisDepth; }
public void setAnalysisDepth(String analysisDepth) { this.analysisDepth = analysisDepth; }
public Boolean getIncludeCodeStats() { return includeCodeStats; }
public void setIncludeCodeStats(Boolean includeCodeStats) { this.includeCodeStats = includeCodeStats; }
public String getOutputFormat() { return outputFormat; }
public void setOutputFormat(String outputFormat) { this.outputFormat = outputFormat; }
@Override
public String toString() {
return String.format("AnalyzeProjectParams{path='%s', depth='%s', format='%s'}",
projectPath, analysisDepth, outputFormat);
}
}
}

View File

@@ -0,0 +1,109 @@
package com.example.demo.tools;
import com.example.demo.schema.JsonSchema;
import com.example.demo.schema.SchemaValidator;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.ai.chat.model.ToolContext;
import java.util.concurrent.CompletableFuture;
/**
* Base abstract class for tools
* All tools should inherit from this class
*/
public abstract class BaseTool<P> {
protected final Logger logger = LoggerFactory.getLogger(getClass());
protected final String name;
protected final String displayName;
protected final String description;
protected final JsonSchema parameterSchema;
protected final boolean isOutputMarkdown;
protected final boolean canUpdateOutput;
protected SchemaValidator schemaValidator;
public BaseTool(String name, String displayName, String description, JsonSchema parameterSchema) {
this(name, displayName, description, parameterSchema, true, false);
}
public BaseTool(String name, String displayName, String description, JsonSchema parameterSchema,
boolean isOutputMarkdown, boolean canUpdateOutput) {
this.name = name;
this.displayName = displayName;
this.description = description;
this.parameterSchema = parameterSchema;
this.isOutputMarkdown = isOutputMarkdown;
this.canUpdateOutput = canUpdateOutput;
}
/**
* Set Schema validator (through dependency injection)
*/
public void setSchemaValidator(SchemaValidator schemaValidator) {
this.schemaValidator = schemaValidator;
}
/**
* Validate tool parameters
*
* @param params Parameter object
* @return Validation error message, null means validation passed
*/
public String validateToolParams(P params) {
if (schemaValidator == null || parameterSchema == null) {
logger.warn("Schema validator or parameter schema is null, skipping validation");
return null;
}
try {
return schemaValidator.validate(parameterSchema, params);
} catch (Exception e) {
logger.error("Parameter validation failed", e);
return "Parameter validation error: " + e.getMessage();
}
}
/**
* Confirm whether user approval is needed for execution
*
* @param params Parameter object
* @return Confirmation details, null means no confirmation needed
*/
public CompletableFuture<ToolConfirmationDetails> shouldConfirmExecute(P params) {
return CompletableFuture.completedFuture(null); // Default no confirmation needed
}
/**
* Execute tool
*
* @param params Parameter object
* @return Execution result
*/
public abstract CompletableFuture<ToolResult> execute(P params);
/**
* Get tool description (for AI understanding)
*
* @param params Parameter object
* @return Description information
*/
public String getDescription(P params) {
return description;
}
// Getters
public String getName() { return name; }
public String getDisplayName() { return displayName; }
public String getDescription() { return description; }
public JsonSchema getParameterSchema() { return parameterSchema; }
public boolean isOutputMarkdown() { return isOutputMarkdown; }
public boolean canUpdateOutput() { return canUpdateOutput; }
@Override
public String toString() {
return String.format("Tool{name='%s', displayName='%s'}", name, displayName);
}
}

View File

@@ -0,0 +1,440 @@
package com.example.demo.tools;
import com.example.demo.config.AppProperties;
import com.example.demo.schema.JsonSchema;
import com.example.demo.service.ToolExecutionLogger;
import com.fasterxml.jackson.annotation.JsonProperty;
import com.github.difflib.DiffUtils;
import com.github.difflib.UnifiedDiffUtils;
import com.github.difflib.patch.Patch;
import org.springframework.ai.tool.annotation.Tool;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Component;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;
import java.util.Arrays;
import java.util.List;
import java.util.concurrent.CompletableFuture;
/**
* File editing tool
* Supports file editing based on string replacement, automatically shows differences
*/
@Component
public class EditFileTool extends BaseTool<EditFileTool.EditFileParams> {
private final String rootDirectory;
private final AppProperties appProperties;
@Autowired
private ToolExecutionLogger executionLogger;
public EditFileTool(AppProperties appProperties) {
super(
"edit_file",
"EditFile",
"Edits a file by replacing specified text with new text. " +
"Shows a diff of the changes before applying them. " +
"Supports both exact string matching and line-based editing. " +
"Use absolute paths within the workspace directory.",
createSchema()
);
this.appProperties = appProperties;
this.rootDirectory = appProperties.getWorkspace().getRootDirectory();
}
private static String getWorkspaceBasePath() {
return Paths.get(System.getProperty("user.dir"), "workspace").toString();
}
private static String getPathExample(String subPath) {
return "Example: \"" + Paths.get(getWorkspaceBasePath(), subPath).toString() + "\"";
}
private static JsonSchema createSchema() {
return JsonSchema.object()
.addProperty("file_path", JsonSchema.string(
"MUST be an absolute path to the file to edit. Path must be within the workspace directory (" +
getWorkspaceBasePath() + "). " +
getPathExample("project/src/main.java") + ". " +
"Relative paths are NOT allowed."
))
.addProperty("old_str", JsonSchema.string(
"The exact string to find and replace. Must match exactly including whitespace and newlines."
))
.addProperty("new_str", JsonSchema.string(
"The new string to replace the old string with. Can be empty to delete the old string."
))
.addProperty("start_line", JsonSchema.integer(
"Optional: 1-based line number where the old_str starts. Helps with disambiguation."
).minimum(1))
.addProperty("end_line", JsonSchema.integer(
"Optional: 1-based line number where the old_str ends. Must be >= start_line."
).minimum(1))
.required("file_path", "old_str", "new_str");
}
@Override
public String validateToolParams(EditFileParams params) {
String baseValidation = super.validateToolParams(params);
if (baseValidation != null) {
return baseValidation;
}
// 验证路径
if (params.filePath == null || params.filePath.trim().isEmpty()) {
return "File path cannot be empty";
}
if (params.oldStr == null) {
return "Old string cannot be null";
}
if (params.newStr == null) {
return "New string cannot be null";
}
Path filePath = Paths.get(params.filePath);
// Validate if it's an absolute path
if (!filePath.isAbsolute()) {
return "File path must be absolute: " + params.filePath;
}
// 验证是否在工作目录内
if (!isWithinWorkspace(filePath)) {
return "File path must be within the workspace directory (" + rootDirectory + "): " + params.filePath;
}
// 验证行号
if (params.startLine != null && params.endLine != null) {
if (params.endLine < params.startLine) {
return "End line must be >= start line";
}
}
return null;
}
@Override
public CompletableFuture<ToolConfirmationDetails> shouldConfirmExecute(EditFileParams params) {
// Decide whether confirmation is needed based on configuration
if (appProperties.getSecurity().getApprovalMode() == AppProperties.ApprovalMode.AUTO_EDIT ||
appProperties.getSecurity().getApprovalMode() == AppProperties.ApprovalMode.YOLO) {
return CompletableFuture.completedFuture(null);
}
return CompletableFuture.supplyAsync(() -> {
try {
Path filePath = Paths.get(params.filePath);
if (!Files.exists(filePath)) {
return null; // 文件不存在,无法预览差异
}
String currentContent = Files.readString(filePath, StandardCharsets.UTF_8);
String newContent = performEdit(currentContent, params);
if (newContent == null) {
return null; // Edit failed, cannot preview differences
}
// 生成差异显示
String diff = generateDiff(filePath.getFileName().toString(), currentContent, newContent);
String title = "Confirm Edit: " + getRelativePath(filePath);
return ToolConfirmationDetails.edit(title, filePath.getFileName().toString(), diff);
} catch (IOException e) {
logger.warn("Could not read file for edit preview: " + params.filePath, e);
return null;
}
});
}
/**
* Edit file tool method for Spring AI integration
*/
@Tool(name = "edit_file", description = "Edits a file by replacing specified text with new text")
public String editFile(String filePath, String oldStr, String newStr, Integer startLine, Integer endLine) {
long callId = executionLogger.logToolStart("edit_file", "编辑文件内容",
String.format("文件=%s, 替换文本长度=%d->%d, 行号范围=%s-%s",
filePath, oldStr != null ? oldStr.length() : 0,
newStr != null ? newStr.length() : 0, startLine, endLine));
long startTime = System.currentTimeMillis();
try {
EditFileParams params = new EditFileParams();
params.setFilePath(filePath);
params.setOldStr(oldStr);
params.setNewStr(newStr);
params.setStartLine(startLine);
params.setEndLine(endLine);
executionLogger.logToolStep(callId, "edit_file", "参数验证", "验证文件路径和替换内容");
// Validate parameters
String validation = validateToolParams(params);
if (validation != null) {
long executionTime = System.currentTimeMillis() - startTime;
executionLogger.logToolError(callId, "edit_file", "参数验证失败: " + validation, executionTime);
return "Error: " + validation;
}
String editDetails = startLine != null && endLine != null ?
String.format("行号范围编辑: %d-%d行", startLine, endLine) : "字符串替换编辑";
executionLogger.logFileOperation(callId, "编辑文件", filePath, editDetails);
// Execute the tool
ToolResult result = execute(params).join();
long executionTime = System.currentTimeMillis() - startTime;
if (result.isSuccess()) {
executionLogger.logToolSuccess(callId, "edit_file", "文件编辑成功", executionTime);
return result.getLlmContent();
} else {
executionLogger.logToolError(callId, "edit_file", result.getErrorMessage(), executionTime);
return "Error: " + result.getErrorMessage();
}
} catch (Exception e) {
long executionTime = System.currentTimeMillis() - startTime;
executionLogger.logToolError(callId, "edit_file", "工具执行异常: " + e.getMessage(), executionTime);
logger.error("Error in edit file tool", e);
return "Error: " + e.getMessage();
}
}
@Override
public CompletableFuture<ToolResult> execute(EditFileParams params) {
return CompletableFuture.supplyAsync(() -> {
try {
Path filePath = Paths.get(params.filePath);
// Check if file exists
if (!Files.exists(filePath)) {
return ToolResult.error("File not found: " + params.filePath);
}
// Check if it's a file
if (!Files.isRegularFile(filePath)) {
return ToolResult.error("Path is not a regular file: " + params.filePath);
}
// 读取原始内容
String originalContent = Files.readString(filePath, StandardCharsets.UTF_8);
// 执行编辑
String newContent = performEdit(originalContent, params);
if (newContent == null) {
return ToolResult.error("Could not find the specified text to replace in file: " + params.filePath);
}
// 创建备份
if (shouldCreateBackup()) {
createBackup(filePath, originalContent);
}
// Write new content
Files.writeString(filePath, newContent, StandardCharsets.UTF_8);
// Generate differences and results
String diff = generateDiff(filePath.getFileName().toString(), originalContent, newContent);
String relativePath = getRelativePath(filePath);
String successMessage = String.format("Successfully edited file: %s", params.filePath);
return ToolResult.success(successMessage, new FileDiff(diff, filePath.getFileName().toString()));
} catch (IOException e) {
logger.error("Error editing file: " + params.filePath, e);
return ToolResult.error("Error editing file: " + e.getMessage());
} catch (Exception e) {
logger.error("Unexpected error editing file: " + params.filePath, e);
return ToolResult.error("Unexpected error: " + e.getMessage());
}
});
}
private String performEdit(String content, EditFileParams params) {
// If line numbers are specified, use line numbers to assist in finding
if (params.startLine != null && params.endLine != null) {
return performEditWithLineNumbers(content, params);
} else {
return performSimpleEdit(content, params);
}
}
private String performSimpleEdit(String content, EditFileParams params) {
// Simple string replacement
if (!content.contains(params.oldStr)) {
return null; // Cannot find string to replace
}
// Only replace the first match to avoid unexpected multiple replacements
int index = content.indexOf(params.oldStr);
if (index == -1) {
return null;
}
return content.substring(0, index) + params.newStr + content.substring(index + params.oldStr.length());
}
private String performEditWithLineNumbers(String content, EditFileParams params) {
String[] lines = content.split("\n", -1); // -1 preserve trailing empty lines
// Validate line number range
if (params.startLine > lines.length || params.endLine > lines.length) {
return null; // Line number out of range
}
// Extract content from specified line range
StringBuilder targetContent = new StringBuilder();
for (int i = params.startLine - 1; i < params.endLine; i++) {
if (i > params.startLine - 1) {
targetContent.append("\n");
}
targetContent.append(lines[i]);
}
// 检查是否匹配
if (!targetContent.toString().equals(params.oldStr)) {
return null; // 指定行范围的内容与old_str不匹配
}
// 执行替换
StringBuilder result = new StringBuilder();
// 添加前面的行
for (int i = 0; i < params.startLine - 1; i++) {
if (i > 0) result.append("\n");
result.append(lines[i]);
}
// 添加新内容
if (params.startLine > 1) result.append("\n");
result.append(params.newStr);
// 添加后面的行
for (int i = params.endLine; i < lines.length; i++) {
result.append("\n");
result.append(lines[i]);
}
return result.toString();
}
private String generateDiff(String fileName, String oldContent, String newContent) {
try {
List<String> oldLines = Arrays.asList(oldContent.split("\n"));
List<String> newLines = Arrays.asList(newContent.split("\n"));
Patch<String> patch = DiffUtils.diff(oldLines, newLines);
List<String> unifiedDiff = UnifiedDiffUtils.generateUnifiedDiff(
fileName + " (Original)",
fileName + " (Edited)",
oldLines,
patch,
3 // context lines
);
return String.join("\n", unifiedDiff);
} catch (Exception e) {
logger.warn("Could not generate diff", e);
return "Diff generation failed: " + e.getMessage();
}
}
private void createBackup(Path filePath, String content) throws IOException {
String timestamp = LocalDateTime.now().format(DateTimeFormatter.ofPattern("yyyyMMdd_HHmmss"));
String backupFileName = filePath.getFileName().toString() + ".backup." + timestamp;
Path backupPath = filePath.getParent().resolve(backupFileName);
Files.writeString(backupPath, content, StandardCharsets.UTF_8);
logger.info("Created backup: {}", backupPath);
}
private boolean shouldCreateBackup() {
return true; // 总是创建备份
}
private boolean isWithinWorkspace(Path filePath) {
try {
Path workspaceRoot = Paths.get(rootDirectory).toRealPath();
Path normalizedPath = filePath.normalize();
return normalizedPath.startsWith(workspaceRoot.normalize());
} catch (IOException e) {
logger.warn("Could not resolve workspace path", e);
return false;
}
}
private String getRelativePath(Path filePath) {
try {
Path workspaceRoot = Paths.get(rootDirectory);
return workspaceRoot.relativize(filePath).toString();
} catch (Exception e) {
return filePath.toString();
}
}
/**
* 编辑文件参数
*/
public static class EditFileParams {
@JsonProperty("file_path")
private String filePath;
@JsonProperty("old_str")
private String oldStr;
@JsonProperty("new_str")
private String newStr;
@JsonProperty("start_line")
private Integer startLine;
@JsonProperty("end_line")
private Integer endLine;
// 构造器
public EditFileParams() {}
public EditFileParams(String filePath, String oldStr, String newStr) {
this.filePath = filePath;
this.oldStr = oldStr;
this.newStr = newStr;
}
// Getters and Setters
public String getFilePath() { return filePath; }
public void setFilePath(String filePath) { this.filePath = filePath; }
public String getOldStr() { return oldStr; }
public void setOldStr(String oldStr) { this.oldStr = oldStr; }
public String getNewStr() { return newStr; }
public void setNewStr(String newStr) { this.newStr = newStr; }
public Integer getStartLine() { return startLine; }
public void setStartLine(Integer startLine) { this.startLine = startLine; }
public Integer getEndLine() { return endLine; }
public void setEndLine(Integer endLine) { this.endLine = endLine; }
@Override
public String toString() {
return String.format("EditFileParams{path='%s', oldStrLength=%d, newStrLength=%d, lines=%s-%s}",
filePath,
oldStr != null ? oldStr.length() : 0,
newStr != null ? newStr.length() : 0,
startLine, endLine);
}
}
}

View File

@@ -0,0 +1,410 @@
package com.example.demo.tools;
import com.example.demo.config.AppProperties;
import com.example.demo.utils.PathUtils;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.ai.tool.annotation.Tool;
import org.springframework.ai.tool.annotation.ToolParam;
import org.springframework.stereotype.Component;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.nio.file.StandardOpenOption;
import java.util.List;
import java.util.stream.Collectors;
import java.util.stream.Stream;
/**
* 文件操作工具类 - 使用Spring AI 1.0.0 @Tool注解
*/
@Component
public class FileOperationTools {
private static final Logger logger = LoggerFactory.getLogger(FileOperationTools.class);
private final String rootDirectory;
private final AppProperties appProperties;
// 在构造函数中
public FileOperationTools(AppProperties appProperties) {
this.appProperties = appProperties;
// 使用规范化的路径
this.rootDirectory = PathUtils.normalizePath(appProperties.getWorkspace().getRootDirectory());
}
@Tool(description = "Read the content of a file from the local filesystem. Supports pagination for large files.")
public String readFile(
@ToolParam(description = "The absolute path to the file to read. Must be within the workspace directory.")
String absolutePath,
@ToolParam(description = "Optional: For text files, the 0-based line number to start reading from.", required = false)
Integer offset,
@ToolParam(description = "Optional: For text files, the number of lines to read from the offset.", required = false)
Integer limit) {
long startTime = System.currentTimeMillis();
try {
logger.debug("Starting readFile operation for: {}", absolutePath);
// 验证路径
String validationError = validatePath(absolutePath);
if (validationError != null) {
return "Error: " + validationError;
}
Path filePath = Paths.get(absolutePath);
// 检查文件是否存在
if (!Files.exists(filePath)) {
return "Error: File not found: " + absolutePath;
}
// 检查是否为文件
if (!Files.isRegularFile(filePath)) {
return "Error: Path is not a regular file: " + absolutePath;
}
// 检查文件大小
long fileSize = Files.size(filePath);
if (fileSize > appProperties.getWorkspace().getMaxFileSize()) {
return "Error: File too large: " + fileSize + " bytes. Maximum allowed: " +
appProperties.getWorkspace().getMaxFileSize() + " bytes";
}
// 检查文件扩展名
String fileName = filePath.getFileName().toString();
if (!isAllowedFileType(fileName)) {
return "Error: File type not allowed: " + fileName +
". Allowed extensions: " + appProperties.getWorkspace().getAllowedExtensions();
}
// 读取文件
if (offset != null && limit != null) {
return readFileWithPagination(filePath, offset, limit);
} else {
return readFullFile(filePath);
}
} catch (IOException e) {
long duration = System.currentTimeMillis() - startTime;
logger.error("Error reading file: {} (duration: {}ms)", absolutePath, duration, e);
return String.format("❌ Error reading file: %s\n⏱ Duration: %dms\n🔍 Details: %s",
absolutePath, duration, e.getMessage());
} catch (Exception e) {
long duration = System.currentTimeMillis() - startTime;
logger.error("Unexpected error reading file: {} (duration: {}ms)", absolutePath, duration, e);
return String.format("❌ Unexpected error reading file: %s\n⏱ Duration: %dms\n🔍 Details: %s",
absolutePath, duration, e.getMessage());
} finally {
long duration = System.currentTimeMillis() - startTime;
logger.debug("Completed readFile operation for: {} (duration: {}ms)", absolutePath, duration);
}
}
@Tool(description = "Write content to a file. Creates new file or overwrites existing file.")
public String writeFile(
@ToolParam(description = "The absolute path to the file to write. Must be within the workspace directory.")
String filePath,
@ToolParam(description = "The content to write to the file")
String content) {
long startTime = System.currentTimeMillis();
try {
logger.debug("Starting writeFile operation for: {}", filePath);
// 验证路径
String validationError = validatePath(filePath);
if (validationError != null) {
return "Error: " + validationError;
}
// 验证内容大小
byte[] contentBytes = content.getBytes(StandardCharsets.UTF_8);
if (contentBytes.length > appProperties.getWorkspace().getMaxFileSize()) {
return "Error: Content too large: " + contentBytes.length + " bytes. Maximum allowed: " +
appProperties.getWorkspace().getMaxFileSize() + " bytes";
}
Path path = Paths.get(filePath);
boolean isNewFile = !Files.exists(path);
// 确保父目录存在
Files.createDirectories(path.getParent());
// 写入文件
Files.writeString(path, content, StandardCharsets.UTF_8,
StandardOpenOption.CREATE, StandardOpenOption.TRUNCATE_EXISTING);
long lineCount = content.lines().count();
String absolutePath = path.toAbsolutePath().toString();
String relativePath = getRelativePath(path);
if (isNewFile) {
return String.format("Successfully created file:\n📁 Full path: %s\n📂 Relative path: %s\n📊 Stats: %d lines, %d bytes",
absolutePath, relativePath, lineCount, contentBytes.length);
} else {
return String.format("Successfully wrote to file:\n📁 Full path: %s\n📂 Relative path: %s\n📊 Stats: %d lines, %d bytes",
absolutePath, relativePath, lineCount, contentBytes.length);
}
} catch (IOException e) {
long duration = System.currentTimeMillis() - startTime;
logger.error("Error writing file: {} (duration: {}ms)", filePath, duration, e);
return String.format("❌ Error writing file: %s\n⏱ Duration: %dms\n🔍 Details: %s",
filePath, duration, e.getMessage());
} catch (Exception e) {
long duration = System.currentTimeMillis() - startTime;
logger.error("Unexpected error writing file: {} (duration: {}ms)", filePath, duration, e);
return String.format("❌ Unexpected error writing file: %s\n⏱ Duration: %dms\n🔍 Details: %s",
filePath, duration, e.getMessage());
} finally {
long duration = System.currentTimeMillis() - startTime;
logger.debug("Completed writeFile operation for: {} (duration: {}ms)", filePath, duration);
}
}
@Tool(description = "Edit a file by replacing specific text content.")
public String editFile(
@ToolParam(description = "The absolute path to the file to edit. Must be within the workspace directory.")
String filePath,
@ToolParam(description = "The text to find and replace in the file")
String oldText,
@ToolParam(description = "The new text to replace the old text with")
String newText) {
try {
// 验证路径
String validationError = validatePath(filePath);
if (validationError != null) {
return "Error: " + validationError;
}
Path path = Paths.get(filePath);
// 检查文件是否存在
if (!Files.exists(path)) {
return "Error: File not found: " + filePath;
}
// 检查是否为文件
if (!Files.isRegularFile(path)) {
return "Error: Path is not a regular file: " + filePath;
}
// 读取原始内容
String originalContent = Files.readString(path, StandardCharsets.UTF_8);
// 执行替换
if (!originalContent.contains(oldText)) {
return "Error: Could not find the specified text to replace in file: " + filePath;
}
String newContent = originalContent.replace(oldText, newText);
// 写入新内容
Files.writeString(path, newContent, StandardCharsets.UTF_8);
String absolutePath = path.toAbsolutePath().toString();
String relativePath = getRelativePath(path);
return String.format("Successfully edited file:\n📁 Full path: %s\n📂 Relative path: %s\n✏ Replaced text successfully",
absolutePath, relativePath);
} catch (IOException e) {
logger.error("Error editing file: " + filePath, e);
return "Error editing file: " + e.getMessage();
} catch (Exception e) {
logger.error("Unexpected error editing file: " + filePath, e);
return "Unexpected error: " + e.getMessage();
}
}
@Tool(description = "List the contents of a directory.")
public String listDirectory(
@ToolParam(description = "The absolute path to the directory to list. Must be within the workspace directory.")
String directoryPath,
@ToolParam(description = "Whether to list contents recursively", required = false)
Boolean recursive) {
try {
// 验证路径
String validationError = validatePath(directoryPath);
if (validationError != null) {
return "Error: " + validationError;
}
Path path = Paths.get(directoryPath);
// 检查目录是否存在
if (!Files.exists(path)) {
return "Error: Directory not found: " + directoryPath;
}
// 检查是否为目录
if (!Files.isDirectory(path)) {
return "Error: Path is not a directory: " + directoryPath;
}
boolean isRecursive = recursive != null && recursive;
String absolutePath = path.toAbsolutePath().toString();
String relativePath = getRelativePath(path);
if (isRecursive) {
return listDirectoryRecursive(path, absolutePath, relativePath);
} else {
return listDirectorySimple(path, absolutePath, relativePath);
}
} catch (IOException e) {
logger.error("Error listing directory: " + directoryPath, e);
return "Error listing directory: " + e.getMessage();
} catch (Exception e) {
logger.error("Unexpected error listing directory: " + directoryPath, e);
return "Unexpected error: " + e.getMessage();
}
}
// 辅助方法
private String validatePath(String path) {
if (path == null || path.trim().isEmpty()) {
return "Path cannot be empty";
}
Path filePath = Paths.get(path);
// 验证是否为绝对路径
if (!filePath.isAbsolute()) {
return "Path must be absolute: " + path;
}
// 验证是否在工作目录内
if (!isWithinWorkspace(filePath)) {
return "Path must be within the workspace directory (" + rootDirectory + "): " + path;
}
return null;
}
private boolean isWithinWorkspace(Path path) {
try {
Path workspacePath = Paths.get(rootDirectory).toRealPath();
Path targetPath = path.toRealPath();
return targetPath.startsWith(workspacePath);
} catch (IOException e) {
// 如果路径不存在,检查其父目录
try {
Path workspacePath = Paths.get(rootDirectory).toRealPath();
Path normalizedPath = path.normalize();
return normalizedPath.startsWith(workspacePath.normalize());
} catch (IOException ex) {
return false;
}
}
}
private boolean isAllowedFileType(String fileName) {
List<String> allowedExtensions = appProperties.getWorkspace().getAllowedExtensions();
return allowedExtensions.stream()
.anyMatch(ext -> fileName.toLowerCase().endsWith(ext.toLowerCase()));
}
private String getRelativePath(Path path) {
try {
Path workspacePath = Paths.get(rootDirectory);
return workspacePath.relativize(path).toString();
} catch (Exception e) {
return path.toString();
}
}
private String readFullFile(Path filePath) throws IOException {
String content = Files.readString(filePath, StandardCharsets.UTF_8);
String absolutePath = filePath.toAbsolutePath().toString();
String relativePath = getRelativePath(filePath);
long lineCount = content.lines().count();
return String.format("📁 Full path: %s\n📂 Relative path: %s\n📊 Stats: %d lines, %d bytes\n\n📄 Content:\n%s",
absolutePath, relativePath, lineCount, content.getBytes(StandardCharsets.UTF_8).length, content);
}
private String readFileWithPagination(Path filePath, int offset, int limit) throws IOException {
List<String> allLines = Files.readAllLines(filePath, StandardCharsets.UTF_8);
if (offset >= allLines.size()) {
return "Error: Offset " + offset + " is beyond file length (" + allLines.size() + " lines)";
}
int endIndex = Math.min(offset + limit, allLines.size());
List<String> selectedLines = allLines.subList(offset, endIndex);
String content = String.join("\n", selectedLines);
String absolutePath = filePath.toAbsolutePath().toString();
String relativePath = getRelativePath(filePath);
return String.format("📁 Full path: %s\n📂 Relative path: %s\n📊 Showing lines %d-%d of %d total\n\n📄 Content:\n%s",
absolutePath, relativePath, offset + 1, endIndex, allLines.size(), content);
}
private String listDirectorySimple(Path path, String absolutePath, String relativePath) throws IOException {
StringBuilder result = new StringBuilder();
result.append("📁 Full path: ").append(absolutePath).append("\n");
result.append("📂 Relative path: ").append(relativePath).append("\n\n");
result.append("📋 Directory contents:\n");
try (Stream<Path> entries = Files.list(path)) {
List<Path> sortedEntries = entries.sorted().collect(Collectors.toList());
for (Path entry : sortedEntries) {
String name = entry.getFileName().toString();
String entryAbsolutePath = entry.toAbsolutePath().toString();
if (Files.isDirectory(entry)) {
result.append("📁 [DIR] ").append(name).append("/\n");
result.append(" └─ ").append(entryAbsolutePath).append("\n");
} else {
long size = Files.size(entry);
result.append("📄 [FILE] ").append(name).append(" (").append(size).append(" bytes)\n");
result.append(" └─ ").append(entryAbsolutePath).append("\n");
}
}
}
return result.toString();
}
private String listDirectoryRecursive(Path path, String absolutePath, String relativePath) throws IOException {
StringBuilder result = new StringBuilder();
result.append("📁 Full path: ").append(absolutePath).append("\n");
result.append("📂 Relative path: ").append(relativePath).append("\n\n");
result.append("🌳 Directory tree (recursive):\n");
try (Stream<Path> entries = Files.walk(path)) {
entries.sorted()
.forEach(entry -> {
if (!entry.equals(path)) {
String entryAbsolutePath = entry.toAbsolutePath().toString();
String entryRelativePath = getRelativePath(entry);
// 计算缩进级别
int depth = entry.getNameCount() - path.getNameCount();
String indent = " ".repeat(depth);
if (Files.isDirectory(entry)) {
result.append(indent).append("📁 ").append(entryRelativePath).append("/\n");
result.append(indent).append(" └─ ").append(entryAbsolutePath).append("\n");
} else {
try {
long size = Files.size(entry);
result.append(indent).append("📄 ").append(entryRelativePath).append(" (").append(size).append(" bytes)\n");
result.append(indent).append(" └─ ").append(entryAbsolutePath).append("\n");
} catch (IOException e) {
result.append(indent).append("📄 ").append(entryRelativePath).append(" (size unknown)\n");
result.append(indent).append(" └─ ").append(entryAbsolutePath).append("\n");
}
}
}
});
}
return result.toString();
}
}

View File

@@ -0,0 +1,359 @@
package com.example.demo.tools;
import com.example.demo.config.AppProperties;
import com.example.demo.schema.JsonSchema;
import com.fasterxml.jackson.annotation.JsonProperty;
import org.springframework.stereotype.Component;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.time.LocalDateTime;
import java.time.ZoneId;
import java.time.format.DateTimeFormatter;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.List;
import java.util.concurrent.CompletableFuture;
import java.util.stream.Collectors;
import java.util.stream.Stream;
/**
* 目录列表工具
* 列出指定目录的文件和子目录,支持递归列表
*/
@Component
public class ListDirectoryTool extends BaseTool<ListDirectoryTool.ListDirectoryParams> {
private final String rootDirectory;
private final AppProperties appProperties;
public ListDirectoryTool(AppProperties appProperties) {
super(
"list_directory",
"ListDirectory",
"Lists files and directories in the specified path. " +
"Supports recursive listing and filtering. " +
"Shows file sizes, modification times, and types. " +
"Use absolute paths within the workspace directory.",
createSchema()
);
this.appProperties = appProperties;
this.rootDirectory = appProperties.getWorkspace().getRootDirectory();
}
private static String getWorkspaceBasePath() {
return Paths.get(System.getProperty("user.dir"), "workspace").toString();
}
private static String getPathExample(String subPath) {
return "Example: \"" + Paths.get(getWorkspaceBasePath(), subPath).toString() + "\"";
}
private static JsonSchema createSchema() {
return JsonSchema.object()
.addProperty("path", JsonSchema.string(
"MUST be an absolute path to the directory to list. Path must be within the workspace directory (" +
getWorkspaceBasePath() + "). " +
getPathExample("project/src") + ". " +
"Relative paths are NOT allowed."
))
.addProperty("recursive", JsonSchema.bool(
"Optional: Whether to list files recursively in subdirectories. Default: false"
))
.addProperty("max_depth", JsonSchema.integer(
"Optional: Maximum depth for recursive listing. Default: 3, Maximum: 10"
).minimum(1).maximum(10))
.addProperty("show_hidden", JsonSchema.bool(
"Optional: Whether to show hidden files (starting with '.'). Default: false"
))
.required("path");
}
@Override
public String validateToolParams(ListDirectoryParams params) {
String baseValidation = super.validateToolParams(params);
if (baseValidation != null) {
return baseValidation;
}
// 验证路径
if (params.path == null || params.path.trim().isEmpty()) {
return "Directory path cannot be empty";
}
Path dirPath = Paths.get(params.path);
// 验证是否为绝对路径
if (!dirPath.isAbsolute()) {
return "Directory path must be absolute: " + params.path;
}
// 验证是否在工作目录内
if (!isWithinWorkspace(dirPath)) {
return "Directory path must be within the workspace directory (" + rootDirectory + "): " + params.path;
}
// 验证最大深度
if (params.maxDepth != null && (params.maxDepth < 1 || params.maxDepth > 10)) {
return "Max depth must be between 1 and 10";
}
return null;
}
@Override
public CompletableFuture<ToolResult> execute(ListDirectoryParams params) {
return CompletableFuture.supplyAsync(() -> {
try {
Path dirPath = Paths.get(params.path);
// 检查目录是否存在
if (!Files.exists(dirPath)) {
return ToolResult.error("Directory not found: " + params.path);
}
// 检查是否为目录
if (!Files.isDirectory(dirPath)) {
return ToolResult.error("Path is not a directory: " + params.path);
}
// 列出文件和目录
List<FileInfo> fileInfos = listFiles(dirPath, params);
// 生成输出
String content = formatFileList(fileInfos, params);
String relativePath = getRelativePath(dirPath);
String displayMessage = String.format("Listed directory: %s (%d items)",
relativePath, fileInfos.size());
return ToolResult.success(content, displayMessage);
} catch (IOException e) {
logger.error("Error listing directory: " + params.path, e);
return ToolResult.error("Error listing directory: " + e.getMessage());
} catch (Exception e) {
logger.error("Unexpected error listing directory: " + params.path, e);
return ToolResult.error("Unexpected error: " + e.getMessage());
}
});
}
private List<FileInfo> listFiles(Path dirPath, ListDirectoryParams params) throws IOException {
List<FileInfo> fileInfos = new ArrayList<>();
if (params.recursive != null && params.recursive) {
int maxDepth = params.maxDepth != null ? params.maxDepth : 3;
listFilesRecursive(dirPath, fileInfos, 0, maxDepth, params);
} else {
listFilesInDirectory(dirPath, fileInfos, params);
}
// 排序:目录在前,然后按名称排序
fileInfos.sort(Comparator
.comparing((FileInfo f) -> !f.isDirectory())
.thenComparing(FileInfo::getName));
return fileInfos;
}
private void listFilesInDirectory(Path dirPath, List<FileInfo> fileInfos, ListDirectoryParams params) throws IOException {
try (Stream<Path> stream = Files.list(dirPath)) {
stream.forEach(path -> {
try {
String fileName = path.getFileName().toString();
// 跳过隐藏文件(除非明确要求显示)
if (!params.showHidden && fileName.startsWith(".")) {
return;
}
FileInfo fileInfo = createFileInfo(path, dirPath);
fileInfos.add(fileInfo);
} catch (IOException e) {
logger.warn("Could not get info for file: " + path, e);
}
});
}
}
private void listFilesRecursive(Path dirPath, List<FileInfo> fileInfos, int currentDepth, int maxDepth, ListDirectoryParams params) throws IOException {
if (currentDepth >= maxDepth) {
return;
}
try (Stream<Path> stream = Files.list(dirPath)) {
List<Path> paths = stream.collect(Collectors.toList());
for (Path path : paths) {
String fileName = path.getFileName().toString();
// 跳过隐藏文件(除非明确要求显示)
if (!params.showHidden && fileName.startsWith(".")) {
continue;
}
try {
FileInfo fileInfo = createFileInfo(path, Paths.get(params.path));
fileInfos.add(fileInfo);
// 如果是目录,递归列出
if (Files.isDirectory(path)) {
listFilesRecursive(path, fileInfos, currentDepth + 1, maxDepth, params);
}
} catch (IOException e) {
logger.warn("Could not get info for file: " + path, e);
}
}
}
}
private FileInfo createFileInfo(Path path, Path basePath) throws IOException {
String name = path.getFileName().toString();
boolean isDirectory = Files.isDirectory(path);
long size = isDirectory ? 0 : Files.size(path);
LocalDateTime lastModified = LocalDateTime.ofInstant(
Files.getLastModifiedTime(path).toInstant(),
ZoneId.systemDefault()
);
String relativePath = basePath.relativize(path).toString();
return new FileInfo(name, relativePath, isDirectory, size, lastModified);
}
private String formatFileList(List<FileInfo> fileInfos, ListDirectoryParams params) {
if (fileInfos.isEmpty()) {
return "Directory is empty.";
}
StringBuilder sb = new StringBuilder();
sb.append(String.format("Directory listing for: %s\n", getRelativePath(Paths.get(params.path))));
sb.append(String.format("Total items: %d\n\n", fileInfos.size()));
// 表头
sb.append(String.format("%-4s %-40s %-12s %-20s %s\n",
"Type", "Name", "Size", "Modified", "Path"));
sb.append("-".repeat(80)).append("\n");
DateTimeFormatter formatter = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss");
for (FileInfo fileInfo : fileInfos) {
String type = fileInfo.isDirectory() ? "DIR" : "FILE";
String sizeStr = fileInfo.isDirectory() ? "-" : formatFileSize(fileInfo.getSize());
String modifiedStr = fileInfo.getLastModified().format(formatter);
sb.append(String.format("%-4s %-40s %-12s %-20s %s\n",
type,
truncate(fileInfo.getName(), 40),
sizeStr,
modifiedStr,
fileInfo.getRelativePath()
));
}
return sb.toString();
}
private String formatFileSize(long bytes) {
if (bytes < 1024) return bytes + " B";
if (bytes < 1024 * 1024) return String.format("%.1f KB", bytes / 1024.0);
if (bytes < 1024 * 1024 * 1024) return String.format("%.1f MB", bytes / (1024.0 * 1024));
return String.format("%.1f GB", bytes / (1024.0 * 1024 * 1024));
}
private String truncate(String str, int maxLength) {
if (str.length() <= maxLength) {
return str;
}
return str.substring(0, maxLength - 3) + "...";
}
private boolean isWithinWorkspace(Path dirPath) {
try {
Path workspaceRoot = Paths.get(rootDirectory).toRealPath();
Path normalizedPath = dirPath.normalize();
return normalizedPath.startsWith(workspaceRoot.normalize());
} catch (IOException e) {
logger.warn("Could not resolve workspace path", e);
return false;
}
}
private String getRelativePath(Path dirPath) {
try {
Path workspaceRoot = Paths.get(rootDirectory);
return workspaceRoot.relativize(dirPath).toString();
} catch (Exception e) {
return dirPath.toString();
}
}
/**
* 文件信息
*/
public static class FileInfo {
private final String name;
private final String relativePath;
private final boolean isDirectory;
private final long size;
private final LocalDateTime lastModified;
public FileInfo(String name, String relativePath, boolean isDirectory, long size, LocalDateTime lastModified) {
this.name = name;
this.relativePath = relativePath;
this.isDirectory = isDirectory;
this.size = size;
this.lastModified = lastModified;
}
// Getters
public String getName() { return name; }
public String getRelativePath() { return relativePath; }
public boolean isDirectory() { return isDirectory; }
public long getSize() { return size; }
public LocalDateTime getLastModified() { return lastModified; }
}
/**
* 列表目录参数
*/
public static class ListDirectoryParams {
private String path;
private Boolean recursive;
@JsonProperty("max_depth")
private Integer maxDepth;
@JsonProperty("show_hidden")
private Boolean showHidden;
// 构造器
public ListDirectoryParams() {}
public ListDirectoryParams(String path) {
this.path = path;
}
// Getters and Setters
public String getPath() { return path; }
public void setPath(String path) { this.path = path; }
public Boolean getRecursive() { return recursive; }
public void setRecursive(Boolean recursive) { this.recursive = recursive; }
public Integer getMaxDepth() { return maxDepth; }
public void setMaxDepth(Integer maxDepth) { this.maxDepth = maxDepth; }
public Boolean getShowHidden() { return showHidden; }
public void setShowHidden(Boolean showHidden) { this.showHidden = showHidden; }
@Override
public String toString() {
return String.format("ListDirectoryParams{path='%s', recursive=%s, maxDepth=%d}",
path, recursive, maxDepth);
}
}
}

View File

@@ -0,0 +1,590 @@
package com.example.demo.tools;
import com.example.demo.config.AppProperties;
import com.example.demo.model.ProjectType;
import com.example.demo.schema.JsonSchema;
import com.fasterxml.jackson.annotation.JsonProperty;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.ai.tool.annotation.Tool;
import org.springframework.stereotype.Component;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.util.HashMap;
import java.util.Map;
import java.util.concurrent.CompletableFuture;
/**
* Project scaffolding tool
* Quickly create standard project structure and template files
*/
@Component
public class ProjectScaffoldTool extends BaseTool<ProjectScaffoldTool.ScaffoldParams> {
private static final Logger logger = LoggerFactory.getLogger(ProjectScaffoldTool.class);
private final String rootDirectory;
private final AppProperties appProperties;
public ProjectScaffoldTool(AppProperties appProperties) {
super(
"scaffold_project",
"ScaffoldProject",
"Create a new project with standard structure and template files. " +
"Supports various project types including Java, Node.js, Python, and more.",
createSchema()
);
this.appProperties = appProperties;
this.rootDirectory = appProperties.getWorkspace().getRootDirectory();
}
private static JsonSchema createSchema() {
return JsonSchema.object()
.addProperty("project_name", JsonSchema.string(
"Name of the project to create. Will be used as directory name and in templates."
))
.addProperty("project_type", JsonSchema.string(
"Type of project to create. Options: " +
"java_maven, java_gradle, spring_boot, " +
"node_js, react, vue, angular, " +
"python, django, flask, " +
"html_static"
))
.addProperty("project_path", JsonSchema.string(
"Optional: Custom path where to create the project. " +
"If not provided, will create in workspace root."
))
.addProperty("template_variables", JsonSchema.object(
))
.addProperty("include_git", JsonSchema.bool(
"Whether to initialize Git repository. Default: true"
))
.addProperty("include_readme", JsonSchema.bool(
"Whether to create README.md file. Default: true"
))
.addProperty("include_gitignore", JsonSchema.bool(
"Whether to create .gitignore file. Default: true"
))
.required("project_name", "project_type");
}
@Override
public String validateToolParams(ScaffoldParams params) {
String baseValidation = super.validateToolParams(params);
if (baseValidation != null) {
return baseValidation;
}
if (params.projectName == null || params.projectName.trim().isEmpty()) {
return "Project name cannot be empty";
}
if (params.projectType == null || params.projectType.trim().isEmpty()) {
return "Project type cannot be empty";
}
// 验证项目名称格式
if (!params.projectName.matches("[a-zA-Z0-9_-]+")) {
return "Project name can only contain letters, numbers, underscores, and hyphens";
}
// 验证项目类型
try {
ProjectType.valueOf(params.projectType.toUpperCase());
} catch (IllegalArgumentException e) {
return "Invalid project type: " + params.projectType;
}
// 验证项目路径
if (params.projectPath != null) {
Path projectPath = Paths.get(params.projectPath);
if (!isWithinWorkspace(projectPath)) {
return "Project path must be within workspace: " + params.projectPath;
}
}
return null;
}
/**
* Project scaffold tool method for Spring AI integration
*/
@Tool(name = "scaffold_project", description = "Create a new project with standard structure and template files")
public String scaffoldProject(String projectName, String projectType, String projectPath, Boolean includeGit, Boolean includeReadme, Boolean includeGitignore) {
try {
ScaffoldParams params = new ScaffoldParams();
params.setProjectName(projectName);
params.setProjectType(projectType);
params.setProjectPath(projectPath);
params.setIncludeGit(includeGit != null ? includeGit : true);
params.setIncludeReadme(includeReadme != null ? includeReadme : true);
params.setIncludeGitignore(includeGitignore != null ? includeGitignore : true);
// Validate parameters
String validation = validateToolParams(params);
if (validation != null) {
return "Error: " + validation;
}
// Execute the tool
ToolResult result = execute(params).join();
if (result.isSuccess()) {
return result.getLlmContent();
} else {
return "Error: " + result.getErrorMessage();
}
} catch (Exception e) {
logger.error("Error in scaffold project tool", e);
return "Error: " + e.getMessage();
}
}
@Override
public CompletableFuture<ToolResult> execute(ScaffoldParams params) {
return CompletableFuture.supplyAsync(() -> {
try {
logger.info("Creating project scaffold: {} ({})", params.projectName, params.projectType);
// 1. 确定项目路径
Path projectPath = determineProjectPath(params);
// 2. 检查项目是否已存在
if (Files.exists(projectPath)) {
return ToolResult.error("Project directory already exists: " + projectPath);
}
// 3. 创建项目目录
Files.createDirectories(projectPath);
// 4. 获取项目类型
ProjectType projectType = ProjectType.valueOf(params.projectType.toUpperCase());
// 5. 创建项目结构
ScaffoldResult result = createProjectStructure(projectPath, projectType, params);
logger.info("Project scaffold created successfully: {}", projectPath);
return ToolResult.success(result.getSummary(), result.getDetails());
} catch (Exception e) {
logger.error("Error creating project scaffold", e);
return ToolResult.error("Failed to create project: " + e.getMessage());
}
});
}
/**
* 确定项目路径
*/
private Path determineProjectPath(ScaffoldParams params) {
if (params.projectPath != null && !params.projectPath.trim().isEmpty()) {
return Paths.get(params.projectPath, params.projectName);
} else {
return Paths.get(rootDirectory, params.projectName);
}
}
/**
* 创建项目结构
*/
private ScaffoldResult createProjectStructure(Path projectPath, ProjectType projectType, ScaffoldParams params) throws IOException {
ScaffoldResult result = new ScaffoldResult();
// 准备模板变量
Map<String, String> variables = prepareTemplateVariables(params);
// 根据项目类型创建结构
switch (projectType) {
case JAVA_MAVEN:
createJavaMavenProject(projectPath, variables, result);
break;
case SPRING_BOOT:
createSpringBootProject(projectPath, variables, result);
break;
case NODE_JS:
createNodeJsProject(projectPath, variables, result);
break;
case REACT:
createReactProject(projectPath, variables, result);
break;
case PYTHON:
createPythonProject(projectPath, variables, result);
break;
case HTML_STATIC:
createHtmlStaticProject(projectPath, variables, result);
break;
default:
createBasicProject(projectPath, variables, result);
}
// 创建通用文件
if (params.includeReadme == null || params.includeReadme) {
createReadmeFile(projectPath, variables, result);
}
if (params.includeGitignore == null || params.includeGitignore) {
createGitignoreFile(projectPath, projectType, result);
}
if (params.includeGit == null || params.includeGit) {
initializeGitRepository(projectPath, result);
}
result.generateSummary(projectPath, projectType);
return result;
}
/**
* 准备模板变量
*/
private Map<String, String> prepareTemplateVariables(ScaffoldParams params) {
Map<String, String> variables = new HashMap<>();
// Default variables
variables.put("PROJECT_NAME", params.projectName);
variables.put("PROJECT_NAME_CAMEL", toCamelCase(params.projectName));
variables.put("PROJECT_NAME_PASCAL", toPascalCase(params.projectName));
variables.put("CURRENT_YEAR", String.valueOf(java.time.Year.now().getValue()));
variables.put("AUTHOR", "Developer");
variables.put("EMAIL", "developer@example.com");
variables.put("VERSION", "1.0.0");
variables.put("DESCRIPTION", "A new " + params.projectType + " project");
// User-provided variables
if (params.templateVariables != null) {
params.templateVariables.forEach((key, value) ->
variables.put(key.toUpperCase(), String.valueOf(value)));
}
return variables;
}
/**
* Create Java Maven project
*/
private void createJavaMavenProject(Path projectPath, Map<String, String> variables, ScaffoldResult result) throws IOException {
// Create directory structure
createDirectory(projectPath.resolve("src/main/java"), result);
createDirectory(projectPath.resolve("src/main/resources"), result);
createDirectory(projectPath.resolve("src/test/java"), result);
createDirectory(projectPath.resolve("src/test/resources"), result);
// Create pom.xml
String pomContent = generatePomXml(variables);
createFile(projectPath.resolve("pom.xml"), pomContent, result);
// Create main class
String packagePath = "src/main/java/com/example/" + variables.get("PROJECT_NAME").toLowerCase();
createDirectory(projectPath.resolve(packagePath), result);
String mainClassContent = generateJavaMainClass(variables);
createFile(projectPath.resolve(packagePath + "/Application.java"), mainClassContent, result);
// Create test class
String testPackagePath = "src/test/java/com/example/" + variables.get("PROJECT_NAME").toLowerCase();
createDirectory(projectPath.resolve(testPackagePath), result);
String testClassContent = generateJavaTestClass(variables);
createFile(projectPath.resolve(testPackagePath + "/ApplicationTest.java"), testClassContent, result);
}
/**
* 创建Spring Boot项目
*/
private void createSpringBootProject(Path projectPath, Map<String, String> variables, ScaffoldResult result) throws IOException {
// 先创建基本的Maven结构
createJavaMavenProject(projectPath, variables, result);
// 覆盖pom.xml为Spring Boot版本
String springBootPomContent = generateSpringBootPomXml(variables);
createFile(projectPath.resolve("pom.xml"), springBootPomContent, result);
// 创建Spring Boot主类
String packagePath = "src/main/java/com/example/" + variables.get("PROJECT_NAME").toLowerCase();
String springBootMainClass = generateSpringBootMainClass(variables);
createFile(projectPath.resolve(packagePath + "/Application.java"), springBootMainClass, result);
// 创建application.yml
String applicationYml = generateApplicationYml(variables);
createFile(projectPath.resolve("src/main/resources/application.yml"), applicationYml, result);
// 创建简单的Controller
String controllerContent = generateSpringBootController(variables);
createFile(projectPath.resolve(packagePath + "/controller/HelloController.java"), controllerContent, result);
}
// 其他项目类型的创建方法将在后续实现
private void createNodeJsProject(Path projectPath, Map<String, String> variables, ScaffoldResult result) throws IOException {
// Node.js项目结构实现
createFile(projectPath.resolve("package.json"), generatePackageJson(variables), result);
createFile(projectPath.resolve("index.js"), generateNodeJsMainFile(variables), result);
createDirectory(projectPath.resolve("src"), result);
createDirectory(projectPath.resolve("test"), result);
}
private void createReactProject(Path projectPath, Map<String, String> variables, ScaffoldResult result) throws IOException {
// React项目结构实现
createNodeJsProject(projectPath, variables, result);
createDirectory(projectPath.resolve("public"), result);
createDirectory(projectPath.resolve("src/components"), result);
createFile(projectPath.resolve("public/index.html"), generateReactIndexHtml(variables), result);
createFile(projectPath.resolve("src/App.js"), generateReactAppJs(variables), result);
}
private void createPythonProject(Path projectPath, Map<String, String> variables, ScaffoldResult result) throws IOException {
// Python项目结构实现
createFile(projectPath.resolve("requirements.txt"), generateRequirementsTxt(variables), result);
createFile(projectPath.resolve("main.py"), generatePythonMainFile(variables), result);
createDirectory(projectPath.resolve("src"), result);
createDirectory(projectPath.resolve("tests"), result);
}
private void createHtmlStaticProject(Path projectPath, Map<String, String> variables, ScaffoldResult result) throws IOException {
// 静态HTML项目结构实现
createFile(projectPath.resolve("index.html"), generateStaticIndexHtml(variables), result);
createDirectory(projectPath.resolve("css"), result);
createDirectory(projectPath.resolve("js"), result);
createDirectory(projectPath.resolve("images"), result);
createFile(projectPath.resolve("css/style.css"), generateBasicCss(variables), result);
createFile(projectPath.resolve("js/script.js"), generateBasicJs(variables), result);
}
private void createBasicProject(Path projectPath, Map<String, String> variables, ScaffoldResult result) throws IOException {
// 基本项目结构
createDirectory(projectPath.resolve("src"), result);
createDirectory(projectPath.resolve("docs"), result);
createFile(projectPath.resolve("src/main.txt"), "Main file for " + variables.get("PROJECT_NAME"), result);
}
// 辅助方法
private void createDirectory(Path path, ScaffoldResult result) throws IOException {
Files.createDirectories(path);
result.addCreatedItem("Directory: " + path.getFileName());
}
private void createFile(Path path, String content, ScaffoldResult result) throws IOException {
Files.createDirectories(path.getParent());
Files.writeString(path, content, StandardCharsets.UTF_8);
result.addCreatedItem("File: " + path.getFileName());
}
private void createReadmeFile(Path projectPath, Map<String, String> variables, ScaffoldResult result) throws IOException {
String readmeContent = generateReadmeContent(variables);
createFile(projectPath.resolve("README.md"), readmeContent, result);
}
private void createGitignoreFile(Path projectPath, ProjectType projectType, ScaffoldResult result) throws IOException {
String gitignoreContent = generateGitignoreContent(projectType);
createFile(projectPath.resolve(".gitignore"), gitignoreContent, result);
}
private void initializeGitRepository(Path projectPath, ScaffoldResult result) {
try {
// 简单的Git初始化 - 在实际项目中应该使用JGit库
result.addCreatedItem("Git repository initialized");
} catch (Exception e) {
logger.warn("Failed to initialize Git repository", e);
}
}
private boolean isWithinWorkspace(Path filePath) {
try {
Path workspaceRoot = Paths.get(rootDirectory).toRealPath();
Path normalizedPath = filePath.toRealPath();
return normalizedPath.startsWith(workspaceRoot);
} catch (Exception e) {
return false;
}
}
// String utility methods
private String toCamelCase(String str) {
if (str == null || str.isEmpty()) {
return str;
}
StringBuilder result = new StringBuilder();
boolean capitalizeNext = false;
for (int i = 0; i < str.length(); i++) {
char c = str.charAt(i);
if (c == '-' || c == '_') {
capitalizeNext = true;
} else if (capitalizeNext) {
result.append(Character.toUpperCase(c));
capitalizeNext = false;
} else if (i == 0) {
result.append(Character.toLowerCase(c));
} else {
result.append(c);
}
}
return result.toString();
}
private String toPascalCase(String str) {
if (str == null || str.isEmpty()) {
return str;
}
String camelCase = toCamelCase(str);
return Character.toUpperCase(camelCase.charAt(0)) + camelCase.substring(1);
}
// 模板生成方法 - 委托给模板服务
private String generatePomXml(Map<String, String> variables) {
// 这些方法需要注入ProjectTemplateService
return "<!-- Maven POM.xml content -->";
}
private String generateSpringBootPomXml(Map<String, String> variables) {
return "<!-- Spring Boot POM.xml content -->";
}
private String generateJavaMainClass(Map<String, String> variables) {
return "// Java main class content";
}
private String generateSpringBootMainClass(Map<String, String> variables) {
return "// Spring Boot main class content";
}
private String generateSpringBootController(Map<String, String> variables) {
return "// Spring Boot controller content";
}
private String generateJavaTestClass(Map<String, String> variables) {
return "// Java test class content";
}
private String generateApplicationYml(Map<String, String> variables) {
return "# Application YAML content";
}
private String generatePackageJson(Map<String, String> variables) {
return "{}";
}
private String generateNodeJsMainFile(Map<String, String> variables) {
return "// Node.js main file content";
}
private String generateReactIndexHtml(Map<String, String> variables) {
return "<!-- React index.html content -->";
}
private String generateReactAppJs(Map<String, String> variables) {
return "// React App.js content";
}
private String generateRequirementsTxt(Map<String, String> variables) {
return "# Python requirements";
}
private String generatePythonMainFile(Map<String, String> variables) {
return "# Python main file content";
}
private String generateStaticIndexHtml(Map<String, String> variables) {
return "<!-- Static HTML content -->";
}
private String generateBasicCss(Map<String, String> variables) {
return "/* CSS content */";
}
private String generateBasicJs(Map<String, String> variables) {
return "// JavaScript content";
}
private String generateReadmeContent(Map<String, String> variables) {
return "# README content";
}
private String generateGitignoreContent(ProjectType projectType) {
return "# Gitignore content";
}
/**
* 脚手架参数类
*/
public static class ScaffoldParams {
@JsonProperty("project_name")
private String projectName;
@JsonProperty("project_type")
private String projectType;
@JsonProperty("project_path")
private String projectPath;
@JsonProperty("template_variables")
private Map<String, Object> templateVariables;
@JsonProperty("include_git")
private Boolean includeGit = true;
@JsonProperty("include_readme")
private Boolean includeReadme = true;
@JsonProperty("include_gitignore")
private Boolean includeGitignore = true;
// Getters and Setters
public String getProjectName() { return projectName; }
public void setProjectName(String projectName) { this.projectName = projectName; }
public String getProjectType() { return projectType; }
public void setProjectType(String projectType) { this.projectType = projectType; }
public String getProjectPath() { return projectPath; }
public void setProjectPath(String projectPath) { this.projectPath = projectPath; }
public Map<String, Object> getTemplateVariables() { return templateVariables; }
public void setTemplateVariables(Map<String, Object> templateVariables) { this.templateVariables = templateVariables; }
public Boolean getIncludeGit() { return includeGit; }
public void setIncludeGit(Boolean includeGit) { this.includeGit = includeGit; }
public Boolean getIncludeReadme() { return includeReadme; }
public void setIncludeReadme(Boolean includeReadme) { this.includeReadme = includeReadme; }
public Boolean getIncludeGitignore() { return includeGitignore; }
public void setIncludeGitignore(Boolean includeGitignore) { this.includeGitignore = includeGitignore; }
}
/**
* 脚手架结果类
*/
private static class ScaffoldResult {
private java.util.List<String> createdItems = new java.util.ArrayList<>();
private String summary;
private String details;
public void addCreatedItem(String item) {
createdItems.add(item);
}
public void generateSummary(Path projectPath, ProjectType projectType) {
this.summary = String.format("Created %s project '%s' with %d files/directories",
projectType.getDisplayName(),
projectPath.getFileName(),
createdItems.size());
StringBuilder detailsBuilder = new StringBuilder();
detailsBuilder.append("Created project structure:\n");
for (String item : createdItems) {
detailsBuilder.append("").append(item).append("\n");
}
this.details = detailsBuilder.toString();
}
public String getSummary() { return summary; }
public String getDetails() { return details; }
}
}

View File

@@ -0,0 +1,309 @@
package com.example.demo.tools;
import com.example.demo.config.AppProperties;
import com.example.demo.schema.JsonSchema;
import com.example.demo.service.ToolExecutionLogger;
import com.fasterxml.jackson.annotation.JsonProperty;
import org.springframework.ai.tool.annotation.Tool;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Component;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.util.List;
import java.util.concurrent.CompletableFuture;
/**
* 文件读取工具
* 支持读取文本文件,可以分页读取大文件
*/
@Component
public class ReadFileTool extends BaseTool<ReadFileTool.ReadFileParams> {
private final String rootDirectory;
private final AppProperties appProperties;
@Autowired
private ToolExecutionLogger executionLogger;
public ReadFileTool(AppProperties appProperties) {
super(
"read_file",
"ReadFile",
"Reads and returns the content of a specified file from the local filesystem. " +
"Handles text files and supports pagination for large files. " +
"Always use absolute paths within the workspace directory.",
createSchema()
);
this.appProperties = appProperties;
this.rootDirectory = appProperties.getWorkspace().getRootDirectory();
}
private static String getWorkspaceBasePath() {
return Paths.get(System.getProperty("user.dir"), "workspace").toString();
}
private static String getPathExample(String subPath) {
return "Example: \"" + Paths.get(getWorkspaceBasePath(), subPath).toString() + "\"";
}
private static JsonSchema createSchema() {
return JsonSchema.object()
.addProperty("absolute_path", JsonSchema.string(
"MUST be an absolute path to the file to read. Path must be within the workspace directory (" +
getWorkspaceBasePath() + "). " +
getPathExample("project/src/main.java") + ". " +
"Relative paths are NOT allowed."
))
.addProperty("offset", JsonSchema.integer(
"Optional: For text files, the 0-based line number to start reading from. " +
"Requires 'limit' to be set. Use for paginating through large files."
).minimum(0))
.addProperty("limit", JsonSchema.integer(
"Optional: For text files, the number of lines to read from the offset. " +
"Use for paginating through large files."
).minimum(1))
.required("absolute_path");
}
@Override
public String validateToolParams(ReadFileParams params) {
String baseValidation = super.validateToolParams(params);
if (baseValidation != null) {
return baseValidation;
}
// 验证路径
if (params.absolutePath == null || params.absolutePath.trim().isEmpty()) {
return "File path cannot be empty";
}
Path filePath = Paths.get(params.absolutePath);
// 验证是否为绝对路径
if (!filePath.isAbsolute()) {
return "File path must be absolute: " + params.absolutePath;
}
// 验证是否在工作目录内
if (!isWithinWorkspace(filePath)) {
return "File path must be within the workspace directory (" + rootDirectory + "): " + params.absolutePath;
}
// 验证分页参数
if (params.offset != null && params.limit == null) {
return "When 'offset' is specified, 'limit' must also be specified";
}
if (params.offset != null && params.offset < 0) {
return "Offset must be non-negative";
}
if (params.limit != null && params.limit <= 0) {
return "Limit must be positive";
}
return null;
}
/**
* Read file tool method for Spring AI integration
*/
@Tool(name = "read_file", description = "Reads and returns the content of a specified file from the local filesystem")
public String readFile(String absolutePath, Integer offset, Integer limit) {
long callId = executionLogger.logToolStart("read_file", "读取文件内容",
String.format("文件路径=%s, 偏移量=%s, 限制行数=%s", absolutePath, offset, limit));
long startTime = System.currentTimeMillis();
try {
ReadFileParams params = new ReadFileParams();
params.setAbsolutePath(absolutePath);
params.setOffset(offset);
params.setLimit(limit);
executionLogger.logToolStep(callId, "read_file", "参数验证", "验证文件路径和分页参数");
// Validate parameters
String validation = validateToolParams(params);
if (validation != null) {
long executionTime = System.currentTimeMillis() - startTime;
executionLogger.logToolError(callId, "read_file", "参数验证失败: " + validation, executionTime);
return "Error: " + validation;
}
executionLogger.logFileOperation(callId, "读取文件", absolutePath,
offset != null ? String.format("分页读取: 偏移=%d, 限制=%d", offset, limit) : "完整读取");
// Execute the tool
ToolResult result = execute(params).join();
long executionTime = System.currentTimeMillis() - startTime;
if (result.isSuccess()) {
executionLogger.logToolSuccess(callId, "read_file", "文件读取成功", executionTime);
return result.getLlmContent();
} else {
executionLogger.logToolError(callId, "read_file", result.getErrorMessage(), executionTime);
return "Error: " + result.getErrorMessage();
}
} catch (Exception e) {
long executionTime = System.currentTimeMillis() - startTime;
executionLogger.logToolError(callId, "read_file", "工具执行异常: " + e.getMessage(), executionTime);
logger.error("Error in read file tool", e);
return "Error: " + e.getMessage();
}
}
@Override
public CompletableFuture<ToolResult> execute(ReadFileParams params) {
return CompletableFuture.supplyAsync(() -> {
try {
Path filePath = Paths.get(params.absolutePath);
// 检查文件是否存在
if (!Files.exists(filePath)) {
return ToolResult.error("File not found: " + params.absolutePath);
}
// 检查是否为文件
if (!Files.isRegularFile(filePath)) {
return ToolResult.error("Path is not a regular file: " + params.absolutePath);
}
// 检查文件大小
long fileSize = Files.size(filePath);
if (fileSize > appProperties.getWorkspace().getMaxFileSize()) {
return ToolResult.error("File too large: " + fileSize + " bytes. Maximum allowed: " +
appProperties.getWorkspace().getMaxFileSize() + " bytes");
}
// 检查文件扩展名
String fileName = filePath.getFileName().toString();
if (!isAllowedFileType(fileName)) {
return ToolResult.error("File type not allowed: " + fileName +
". Allowed extensions: " + appProperties.getWorkspace().getAllowedExtensions());
}
// 读取文件
if (params.offset != null && params.limit != null) {
return readFileWithPagination(filePath, params.offset, params.limit);
} else {
return readFullFile(filePath);
}
} catch (IOException e) {
logger.error("Error reading file: " + params.absolutePath, e);
return ToolResult.error("Error reading file: " + e.getMessage());
} catch (Exception e) {
logger.error("Unexpected error reading file: " + params.absolutePath, e);
return ToolResult.error("Unexpected error: " + e.getMessage());
}
});
}
private ToolResult readFullFile(Path filePath) throws IOException {
String content = Files.readString(filePath, StandardCharsets.UTF_8);
String relativePath = getRelativePath(filePath);
long lineCount = content.lines().count();
String displayMessage = String.format("Read file: %s (%d lines, %d bytes)",
relativePath, lineCount, content.getBytes(StandardCharsets.UTF_8).length);
return ToolResult.success(content, displayMessage);
}
private ToolResult readFileWithPagination(Path filePath, int offset, int limit) throws IOException {
List<String> allLines = Files.readAllLines(filePath, StandardCharsets.UTF_8);
if (offset >= allLines.size()) {
return ToolResult.error("Offset " + offset + " is beyond file length (" + allLines.size() + " lines)");
}
int endIndex = Math.min(offset + limit, allLines.size());
List<String> selectedLines = allLines.subList(offset, endIndex);
String content = String.join("\n", selectedLines);
String relativePath = getRelativePath(filePath);
String displayMessage = String.format("Read file: %s (lines %d-%d of %d total)",
relativePath, offset + 1, endIndex, allLines.size());
return ToolResult.success(content, displayMessage);
}
private boolean isWithinWorkspace(Path filePath) {
try {
Path workspaceRoot = Paths.get(rootDirectory).toRealPath();
Path resolvedPath = filePath.toRealPath();
return resolvedPath.startsWith(workspaceRoot);
} catch (IOException e) {
// 如果路径不存在,检查其父目录
try {
Path workspaceRoot = Paths.get(rootDirectory).toRealPath();
Path normalizedPath = filePath.normalize();
return normalizedPath.startsWith(workspaceRoot.normalize());
} catch (IOException ex) {
logger.warn("Could not resolve workspace path", ex);
return false;
}
}
}
private boolean isAllowedFileType(String fileName) {
List<String> allowedExtensions = appProperties.getWorkspace().getAllowedExtensions();
return allowedExtensions.stream()
.anyMatch(ext -> fileName.toLowerCase().endsWith(ext.toLowerCase()));
}
private String getRelativePath(Path filePath) {
try {
Path workspaceRoot = Paths.get(rootDirectory);
return workspaceRoot.relativize(filePath).toString();
} catch (Exception e) {
return filePath.toString();
}
}
/**
* 读取文件参数
*/
public static class ReadFileParams {
@JsonProperty("absolute_path")
private String absolutePath;
private Integer offset;
private Integer limit;
// 构造器
public ReadFileParams() {}
public ReadFileParams(String absolutePath) {
this.absolutePath = absolutePath;
}
public ReadFileParams(String absolutePath, Integer offset, Integer limit) {
this.absolutePath = absolutePath;
this.offset = offset;
this.limit = limit;
}
// Getters and Setters
public String getAbsolutePath() { return absolutePath; }
public void setAbsolutePath(String absolutePath) { this.absolutePath = absolutePath; }
public Integer getOffset() { return offset; }
public void setOffset(Integer offset) { this.offset = offset; }
public Integer getLimit() { return limit; }
public void setLimit(Integer limit) { this.limit = limit; }
@Override
public String toString() {
return String.format("ReadFileParams{path='%s', offset=%d, limit=%d}",
absolutePath, offset, limit);
}
}
}

View File

@@ -0,0 +1,626 @@
package com.example.demo.tools;
import com.example.demo.config.AppProperties;
import com.example.demo.model.ProjectContext;
import com.example.demo.schema.JsonSchema;
import com.example.demo.service.ProjectContextAnalyzer;
import com.fasterxml.jackson.annotation.JsonProperty;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.ai.chat.client.ChatClient;
import org.springframework.ai.chat.model.ChatModel;
import org.springframework.ai.chat.model.ChatResponse;
import org.springframework.ai.chat.messages.Message;
import org.springframework.ai.chat.messages.UserMessage;
import org.springframework.ai.tool.annotation.Tool;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Component;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.CompletableFuture;
/**
* Smart editing tool
* Provides intelligent multi-file editing capabilities based on project context understanding
*/
@Component
public class SmartEditTool extends BaseTool<SmartEditTool.SmartEditParams> {
private static final Logger logger = LoggerFactory.getLogger(SmartEditTool.class);
@Autowired
private ProjectContextAnalyzer projectContextAnalyzer;
@Autowired
private EditFileTool editFileTool;
@Autowired
private ReadFileTool readFileTool;
@Autowired
private WriteFileTool writeFileTool;
@Autowired
private ChatModel chatModel;
private final String rootDirectory;
private final AppProperties appProperties;
public SmartEditTool(AppProperties appProperties) {
super(
"smart_edit",
"SmartEdit",
"Intelligently edit projects based on natural language descriptions. " +
"Analyzes project context and performs multi-file edits when necessary. " +
"Can handle complex refactoring, feature additions, and project-wide changes.",
createSchema()
);
this.appProperties = appProperties;
this.rootDirectory = appProperties.getWorkspace().getRootDirectory();
}
private static JsonSchema createSchema() {
return JsonSchema.object()
.addProperty("project_path", JsonSchema.string(
"Absolute path to the project root directory to analyze and edit"
))
.addProperty("edit_description", JsonSchema.string(
"Natural language description of the desired changes. " +
"Examples: 'Add a new REST endpoint for user management', " +
"'Refactor the authentication logic', 'Update dependencies to latest versions'"
))
.addProperty("target_files", JsonSchema.array(JsonSchema.string(
"Optional: Specific files to focus on. If not provided, the tool will determine which files to edit based on the description"
)))
.addProperty("scope", JsonSchema.string(
"Edit scope: 'single_file', 'related_files', or 'project_wide'. Default: 'related_files'"
))
.addProperty("dry_run", JsonSchema.bool(
"If true, only analyze and show what would be changed without making actual changes. Default: false"
))
.required("project_path", "edit_description");
}
public enum EditScope {
SINGLE_FILE("single_file", "Edit only one file"),
RELATED_FILES("related_files", "Edit related files that are affected by the change"),
PROJECT_WIDE("project_wide", "Make project-wide changes including configuration files");
private final String value;
private final String description;
EditScope(String value, String description) {
this.value = value;
this.description = description;
}
public static EditScope fromString(String value) {
for (EditScope scope : values()) {
if (scope.value.equals(value)) {
return scope;
}
}
return RELATED_FILES; // default
}
public String getValue() { return value; }
public String getDescription() { return description; }
}
/**
* Smart edit tool method for Spring AI integration
*/
@Tool(name = "smart_edit", description = "Intelligently edit projects based on natural language descriptions")
public String smartEdit(
String projectPath,
String editDescription,
String scope,
Boolean dryRun) {
try {
SmartEditParams params = new SmartEditParams();
params.setProjectPath(projectPath);
params.setEditDescription(editDescription);
params.setScope(scope != null ? scope : "related_files");
params.setDryRun(dryRun != null ? dryRun : false);
// Validate parameters
String validation = validateToolParams(params);
if (validation != null) {
return "Error: " + validation;
}
// Execute the tool
ToolResult result = execute(params).join();
if (result.isSuccess()) {
return result.getLlmContent();
} else {
return "Error: " + result.getErrorMessage();
}
} catch (Exception e) {
logger.error("Error in smart edit tool", e);
return "Error: " + e.getMessage();
}
}
@Override
public String validateToolParams(SmartEditParams params) {
String baseValidation = super.validateToolParams(params);
if (baseValidation != null) {
return baseValidation;
}
if (params.projectPath == null || params.projectPath.trim().isEmpty()) {
return "Project path cannot be empty";
}
if (params.editDescription == null || params.editDescription.trim().isEmpty()) {
return "Edit description cannot be empty";
}
Path projectPath = Paths.get(params.projectPath);
if (!projectPath.isAbsolute()) {
return "Project path must be absolute: " + params.projectPath;
}
if (!Files.exists(projectPath)) {
return "Project path does not exist: " + params.projectPath;
}
if (!Files.isDirectory(projectPath)) {
return "Project path must be a directory: " + params.projectPath;
}
if (!isWithinWorkspace(projectPath)) {
return "Project path must be within the workspace directory: " + params.projectPath;
}
return null;
}
@Override
public CompletableFuture<ToolConfirmationDetails> shouldConfirmExecute(SmartEditParams params) {
if (params.dryRun != null && params.dryRun) {
return CompletableFuture.completedFuture(null); // No confirmation needed for dry run
}
if (appProperties.getSecurity().getApprovalMode() == AppProperties.ApprovalMode.AUTO_EDIT ||
appProperties.getSecurity().getApprovalMode() == AppProperties.ApprovalMode.YOLO) {
return CompletableFuture.completedFuture(null);
}
return CompletableFuture.supplyAsync(() -> {
try {
EditPlan plan = analyzeAndPlanEdit(params);
String confirmationMessage = buildConfirmationMessage(plan);
return new ToolConfirmationDetails(
"smart_edit",
"Confirm Smart Edit: " + params.editDescription,
"Smart edit operation confirmation",
confirmationMessage
);
} catch (Exception e) {
logger.warn("Could not generate edit plan for confirmation", e);
return null;
}
});
}
@Override
public CompletableFuture<ToolResult> execute(SmartEditParams params) {
return CompletableFuture.supplyAsync(() -> {
try {
logger.info("Starting smart edit for project: {}", params.projectPath);
logger.info("Edit description: {}", params.editDescription);
// 1. Analyze project context
Path projectPath = Paths.get(params.projectPath);
ProjectContext context = projectContextAnalyzer.analyzeProject(projectPath);
// 2. Generate edit plan
EditPlan plan = generateEditPlan(params, context);
if (params.dryRun != null && params.dryRun) {
return ToolResult.success(
"Dry run completed. Edit plan generated successfully.",
plan.toString()
);
}
// 3. Execute edit plan
EditResult result = executeEditPlan(plan);
logger.info("Smart edit completed for project: {}", params.projectPath);
return ToolResult.success(
result.getSummary(),
result.getDetails()
);
} catch (Exception e) {
logger.error("Error during smart edit execution", e);
return ToolResult.error("Smart edit failed: " + e.getMessage());
}
});
}
/**
* Analyze and generate edit plan
*/
private EditPlan analyzeAndPlanEdit(SmartEditParams params) {
Path projectPath = Paths.get(params.projectPath);
ProjectContext context = projectContextAnalyzer.analyzeProject(projectPath);
return generateEditPlan(params, context);
}
/**
* Generate edit plan
*/
private EditPlan generateEditPlan(SmartEditParams params, ProjectContext context) {
logger.debug("Generating edit plan for: {}", params.editDescription);
EditPlan plan = new EditPlan();
plan.setDescription(params.editDescription);
plan.setScope(EditScope.fromString(params.scope));
plan.setProjectContext(context);
// Use AI to analyze edit intent and generate specific edit steps
String editContext = buildEditContext(context, params.editDescription);
List<EditStep> steps = generateEditSteps(editContext, params);
plan.setSteps(steps);
return plan;
}
/**
* Build edit context from project context and description
*/
private String buildEditContext(ProjectContext context, String editDescription) {
StringBuilder contextBuilder = new StringBuilder();
contextBuilder.append("PROJECT CONTEXT:\n");
contextBuilder.append("Type: ").append(context.getProjectType().getDisplayName()).append("\n");
contextBuilder.append("Language: ").append(context.getProjectType().getPrimaryLanguage()).append("\n");
if (context.getProjectStructure() != null) {
contextBuilder.append("Structure: ").append(context.getProjectStructure().getStructureSummary()).append("\n");
}
if (context.getDependencies() != null && !context.getDependencies().isEmpty()) {
contextBuilder.append("Dependencies: ").append(context.getDependencySummary()).append("\n");
}
contextBuilder.append("\nEDIT REQUEST: ").append(editDescription);
return contextBuilder.toString();
}
/**
* Use AI to generate edit steps
*/
private List<EditStep> generateEditSteps(String editContext, SmartEditParams params) {
List<EditStep> steps = new ArrayList<>();
try {
String prompt = buildEditPlanPrompt(editContext, params);
List<Message> messages = List.of(new UserMessage(prompt));
ChatResponse response = ChatClient.create(chatModel)
.prompt()
.messages(messages)
.call()
.chatResponse();
String aiResponse = response.getResult().getOutput().getText();
steps = parseEditStepsFromAI(aiResponse, params);
} catch (Exception e) {
logger.warn("Failed to generate AI-based edit steps, using fallback", e);
steps = generateFallbackEditSteps(params);
}
return steps;
}
/**
* Build edit plan prompt
*/
private String buildEditPlanPrompt(String editContext, SmartEditParams params) {
return String.format("""
You are an expert software developer. Based on the project context below,
create a detailed plan to implement the requested changes.
%s
TASK: %s
Please provide a step-by-step plan in the following format:
STEP 1: [Action] - [File] - [Description]
STEP 2: [Action] - [File] - [Description]
...
Actions can be: CREATE, EDIT, DELETE, RENAME
Be specific about which files need to be modified and what changes are needed.
Consider dependencies between files and the overall project structure.
""", editContext, params.editDescription);
}
/**
* Parse edit steps from AI response
*/
private List<EditStep> parseEditStepsFromAI(String aiResponse, SmartEditParams params) {
List<EditStep> steps = new ArrayList<>();
String[] lines = aiResponse.split("\n");
for (String line : lines) {
line = line.trim();
if (line.startsWith("STEP") && line.contains(":")) {
try {
EditStep step = parseEditStepLine(line, params);
if (step != null) {
steps.add(step);
}
} catch (Exception e) {
logger.warn("Failed to parse edit step: {}", line);
}
}
}
return steps;
}
/**
* Parse single edit step line
*/
private EditStep parseEditStepLine(String line, SmartEditParams params) {
// Simple parsing implementation
// In actual projects, more complex parsing logic should be used
String[] parts = line.split(" - ");
if (parts.length >= 3) {
String actionPart = parts[0].substring(parts[0].indexOf(":") + 1).trim();
String filePart = parts[1].trim();
String descriptionPart = parts[2].trim();
EditStep step = new EditStep();
step.setAction(actionPart);
step.setTargetFile(filePart);
step.setDescription(descriptionPart);
return step;
}
return null;
}
/**
* Generate fallback edit steps
*/
private List<EditStep> generateFallbackEditSteps(SmartEditParams params) {
List<EditStep> steps = new ArrayList<>();
// Simple fallback logic
if (params.targetFiles != null && !params.targetFiles.isEmpty()) {
for (String file : params.targetFiles) {
EditStep step = new EditStep();
step.setAction("EDIT");
step.setTargetFile(file);
step.setDescription("Edit " + file + " according to: " + params.editDescription);
steps.add(step);
}
} else {
// Default edit step
EditStep step = new EditStep();
step.setAction("ANALYZE");
step.setTargetFile("*");
step.setDescription("Analyze project and apply changes: " + params.editDescription);
steps.add(step);
}
return steps;
}
/**
* Execute edit plan
*/
private EditResult executeEditPlan(EditPlan plan) {
EditResult result = new EditResult();
List<String> executedSteps = new ArrayList<>();
List<String> errors = new ArrayList<>();
for (EditStep step : plan.getSteps()) {
try {
String stepResult = executeEditStep(step, plan.getProjectContext());
executedSteps.add(stepResult);
logger.debug("Executed step: {}", step.getDescription());
} catch (Exception e) {
String error = "Failed to execute step: " + step.getDescription() + " - " + e.getMessage();
errors.add(error);
logger.warn(error, e);
}
}
result.setExecutedSteps(executedSteps);
result.setErrors(errors);
result.generateSummary();
return result;
}
/**
* Execute single edit step
*/
private String executeEditStep(EditStep step, ProjectContext context) throws Exception {
switch (step.getAction().toUpperCase()) {
case "CREATE":
return executeCreateStep(step, context);
case "EDIT":
return executeFileEditStep(step, context);
case "DELETE":
return executeDeleteStep(step, context);
default:
return "Skipped unsupported action: " + step.getAction();
}
}
private String executeCreateStep(EditStep step, ProjectContext context) throws Exception {
// Implement file creation logic
return "Created file: " + step.getTargetFile();
}
private String executeFileEditStep(EditStep step, ProjectContext context) throws Exception {
// Implement file editing logic
return "Edited file: " + step.getTargetFile();
}
private String executeDeleteStep(EditStep step, ProjectContext context) throws Exception {
// Implement file deletion logic
return "Deleted file: " + step.getTargetFile();
}
private String buildConfirmationMessage(EditPlan plan) {
StringBuilder message = new StringBuilder();
message.append("Smart Edit Plan:\n");
message.append("Description: ").append(plan.getDescription()).append("\n");
message.append("Scope: ").append(plan.getScope().getDescription()).append("\n");
message.append("Steps to execute:\n");
for (int i = 0; i < plan.getSteps().size(); i++) {
EditStep step = plan.getSteps().get(i);
message.append(String.format("%d. %s - %s\n",
i + 1, step.getAction(), step.getDescription()));
}
return message.toString();
}
private boolean isWithinWorkspace(Path filePath) {
try {
Path workspaceRoot = Paths.get(rootDirectory).toRealPath();
Path normalizedPath = filePath.toRealPath();
return normalizedPath.startsWith(workspaceRoot);
} catch (IOException e) {
logger.warn("Could not resolve workspace path", e);
return false;
}
}
// Inner class definitions
public static class SmartEditParams {
@JsonProperty("project_path")
private String projectPath;
@JsonProperty("edit_description")
private String editDescription;
@JsonProperty("target_files")
private List<String> targetFiles;
@JsonProperty("scope")
private String scope = "related_files";
@JsonProperty("dry_run")
private Boolean dryRun = false;
// Getters and Setters
public String getProjectPath() { return projectPath; }
public void setProjectPath(String projectPath) { this.projectPath = projectPath; }
public String getEditDescription() { return editDescription; }
public void setEditDescription(String editDescription) { this.editDescription = editDescription; }
public List<String> getTargetFiles() { return targetFiles; }
public void setTargetFiles(List<String> targetFiles) { this.targetFiles = targetFiles; }
public String getScope() { return scope; }
public void setScope(String scope) { this.scope = scope; }
public Boolean getDryRun() { return dryRun; }
public void setDryRun(Boolean dryRun) { this.dryRun = dryRun; }
}
private static class EditPlan {
private String description;
private EditScope scope;
private ProjectContext projectContext;
private List<EditStep> steps;
// Getters and Setters
public String getDescription() { return description; }
public void setDescription(String description) { this.description = description; }
public EditScope getScope() { return scope; }
public void setScope(EditScope scope) { this.scope = scope; }
public ProjectContext getProjectContext() { return projectContext; }
public void setProjectContext(ProjectContext projectContext) { this.projectContext = projectContext; }
public List<EditStep> getSteps() { return steps; }
public void setSteps(List<EditStep> steps) { this.steps = steps; }
}
private static class EditStep {
private String action;
private String targetFile;
private String description;
// Getters and Setters
public String getAction() { return action; }
public void setAction(String action) { this.action = action; }
public String getTargetFile() { return targetFile; }
public void setTargetFile(String targetFile) { this.targetFile = targetFile; }
public String getDescription() { return description; }
public void setDescription(String description) { this.description = description; }
}
private static class EditResult {
private List<String> executedSteps;
private List<String> errors;
private String summary;
private String details;
public void generateSummary() {
int successCount = executedSteps.size();
int errorCount = errors.size();
this.summary = String.format("Smart edit completed: %d steps executed, %d errors",
successCount, errorCount);
StringBuilder detailsBuilder = new StringBuilder();
detailsBuilder.append("Executed Steps:\n");
for (String step : executedSteps) {
detailsBuilder.append("").append(step).append("\n");
}
if (!errors.isEmpty()) {
detailsBuilder.append("\nErrors:\n");
for (String error : errors) {
detailsBuilder.append("").append(error).append("\n");
}
}
this.details = detailsBuilder.toString();
}
// Getters and Setters
public List<String> getExecutedSteps() { return executedSteps; }
public void setExecutedSteps(List<String> executedSteps) { this.executedSteps = executedSteps; }
public List<String> getErrors() { return errors; }
public void setErrors(List<String> errors) { this.errors = errors; }
public String getSummary() { return summary; }
public String getDetails() { return details; }
}
}

View File

@@ -0,0 +1,105 @@
package com.example.demo.tools;
import com.fasterxml.jackson.annotation.JsonInclude;
/**
* 工具执行结果
*/
@JsonInclude(JsonInclude.Include.NON_NULL)
public class ToolResult {
private final boolean success;
private final String llmContent;
private final Object returnDisplay;
private final String errorMessage;
private ToolResult(boolean success, String llmContent, Object returnDisplay, String errorMessage) {
this.success = success;
this.llmContent = llmContent;
this.returnDisplay = returnDisplay;
this.errorMessage = errorMessage;
}
// 静态工厂方法
public static ToolResult success(String llmContent) {
return new ToolResult(true, llmContent, llmContent, null);
}
public static ToolResult success(String llmContent, Object returnDisplay) {
return new ToolResult(true, llmContent, returnDisplay, null);
}
public static ToolResult error(String errorMessage) {
return new ToolResult(false, "Error: " + errorMessage, "Error: " + errorMessage, errorMessage);
}
// Getters
public boolean isSuccess() { return success; }
public String getLlmContent() { return llmContent; }
public Object getReturnDisplay() { return returnDisplay; }
public String getErrorMessage() { return errorMessage; }
@Override
public String toString() {
if (success) {
return "ToolResult{success=true, content='" + llmContent + "'}";
} else {
return "ToolResult{success=false, error='" + errorMessage + "'}";
}
}
}
/**
* 文件差异结果
*/
@JsonInclude(JsonInclude.Include.NON_NULL)
class FileDiff {
private final String fileDiff;
private final String fileName;
public FileDiff(String fileDiff, String fileName) {
this.fileDiff = fileDiff;
this.fileName = fileName;
}
public String getFileDiff() { return fileDiff; }
public String getFileName() { return fileName; }
@Override
public String toString() {
return "FileDiff{fileName='" + fileName + "'}";
}
}
/**
* 工具确认详情
*/
@JsonInclude(JsonInclude.Include.NON_NULL)
class ToolConfirmationDetails {
private final String type;
private final String title;
private final String description;
private final Object details;
public ToolConfirmationDetails(String type, String title, String description, Object details) {
this.type = type;
this.title = title;
this.description = description;
this.details = details;
}
public static ToolConfirmationDetails edit(String title, String fileName, String fileDiff) {
return new ToolConfirmationDetails("edit", title, "File edit confirmation",
new FileDiff(fileDiff, fileName));
}
public static ToolConfirmationDetails exec(String title, String command) {
return new ToolConfirmationDetails("exec", title, "Command execution confirmation", command);
}
// Getters
public String getType() { return type; }
public String getTitle() { return title; }
public String getDescription() { return description; }
public Object getDetails() { return details; }
}

View File

@@ -0,0 +1,348 @@
package com.example.demo.tools;
import com.example.demo.config.AppProperties;
import com.example.demo.schema.JsonSchema;
import com.example.demo.service.ToolExecutionLogger;
import com.fasterxml.jackson.annotation.JsonProperty;
import com.github.difflib.DiffUtils;
import com.github.difflib.UnifiedDiffUtils;
import com.github.difflib.patch.Patch;
import org.springframework.ai.tool.annotation.Tool;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Component;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.nio.file.StandardOpenOption;
import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;
import java.util.Arrays;
import java.util.List;
import java.util.concurrent.CompletableFuture;
/**
* 文件写入工具
* 支持创建新文件或覆盖现有文件,自动显示差异
*/
@Component
public class WriteFileTool extends BaseTool<WriteFileTool.WriteFileParams> {
private final String rootDirectory;
private final AppProperties appProperties;
@Autowired
private ToolExecutionLogger executionLogger;
public WriteFileTool(AppProperties appProperties) {
super(
"write_file",
"WriteFile",
"Writes content to a file. Creates new files or overwrites existing ones. " +
"Always shows a diff before writing. Automatically creates parent directories if needed. " +
"Use absolute paths within the workspace directory.",
createSchema()
);
this.appProperties = appProperties;
this.rootDirectory = appProperties.getWorkspace().getRootDirectory();
}
private static String getWorkspaceBasePath() {
return Paths.get(System.getProperty("user.dir"), "workspace").toString();
}
private static String getPathExample(String subPath) {
return "Example: \"" + Paths.get(getWorkspaceBasePath(), subPath).toString() + "\"";
}
private static JsonSchema createSchema() {
return JsonSchema.object()
.addProperty("file_path", JsonSchema.string(
"MUST be an absolute path to the file to write to. Path must be within the workspace directory (" +
getWorkspaceBasePath() + "). " +
getPathExample("project/src/main.java") + ". " +
"Relative paths are NOT allowed."
))
.addProperty("content", JsonSchema.string(
"The content to write to the file. Will completely replace existing content if file exists."
))
.required("file_path", "content");
}
@Override
public String validateToolParams(WriteFileParams params) {
String baseValidation = super.validateToolParams(params);
if (baseValidation != null) {
return baseValidation;
}
// 验证路径
if (params.filePath == null || params.filePath.trim().isEmpty()) {
return "File path cannot be empty";
}
if (params.content == null) {
return "Content cannot be null";
}
Path filePath = Paths.get(params.filePath);
// 验证是否为绝对路径
if (!filePath.isAbsolute()) {
return "File path must be absolute: " + params.filePath;
}
// 验证是否在工作目录内
if (!isWithinWorkspace(filePath)) {
return "File path must be within the workspace directory (" + rootDirectory + "): " + params.filePath;
}
// 验证文件扩展名
String fileName = filePath.getFileName().toString();
if (!isAllowedFileType(fileName)) {
return "File type not allowed: " + fileName +
". Allowed extensions: " + appProperties.getWorkspace().getAllowedExtensions();
}
// 验证内容大小
byte[] contentBytes = params.content.getBytes(StandardCharsets.UTF_8);
if (contentBytes.length > appProperties.getWorkspace().getMaxFileSize()) {
return "Content too large: " + contentBytes.length + " bytes. Maximum allowed: " +
appProperties.getWorkspace().getMaxFileSize() + " bytes";
}
return null;
}
@Override
public CompletableFuture<ToolConfirmationDetails> shouldConfirmExecute(WriteFileParams params) {
// 根据配置决定是否需要确认
if (appProperties.getSecurity().getApprovalMode() == AppProperties.ApprovalMode.AUTO_EDIT ||
appProperties.getSecurity().getApprovalMode() == AppProperties.ApprovalMode.YOLO) {
return CompletableFuture.completedFuture(null);
}
return CompletableFuture.supplyAsync(() -> {
try {
Path filePath = Paths.get(params.filePath);
String currentContent = "";
boolean isNewFile = !Files.exists(filePath);
if (!isNewFile) {
currentContent = Files.readString(filePath, StandardCharsets.UTF_8);
}
// 生成差异显示
String diff = generateDiff(
filePath.getFileName().toString(),
currentContent,
params.content
);
String title = isNewFile ?
"Confirm Create: " + getRelativePath(filePath) :
"Confirm Write: " + getRelativePath(filePath);
return ToolConfirmationDetails.edit(title, filePath.getFileName().toString(), diff);
} catch (IOException e) {
logger.warn("Could not read existing file for diff: " + params.filePath, e);
return null; // 如果无法读取文件,直接执行
}
});
}
/**
* Write file tool method for Spring AI integration
*/
@Tool(name = "write_file", description = "Creates a new file or overwrites an existing file with the specified content")
public String writeFile(String filePath, String content) {
long callId = executionLogger.logToolStart("write_file", "写入文件内容",
String.format("文件路径=%s, 内容长度=%d字符", filePath, content != null ? content.length() : 0));
long startTime = System.currentTimeMillis();
try {
WriteFileParams params = new WriteFileParams();
params.setFilePath(filePath);
params.setContent(content);
executionLogger.logToolStep(callId, "write_file", "参数验证", "验证文件路径和内容");
// Validate parameters
String validation = validateToolParams(params);
if (validation != null) {
long executionTime = System.currentTimeMillis() - startTime;
executionLogger.logToolError(callId, "write_file", "参数验证失败: " + validation, executionTime);
return "Error: " + validation;
}
executionLogger.logFileOperation(callId, "写入文件", filePath,
String.format("内容长度: %d字符", content != null ? content.length() : 0));
// Execute the tool
ToolResult result = execute(params).join();
long executionTime = System.currentTimeMillis() - startTime;
if (result.isSuccess()) {
executionLogger.logToolSuccess(callId, "write_file", "文件写入成功", executionTime);
return result.getLlmContent();
} else {
executionLogger.logToolError(callId, "write_file", result.getErrorMessage(), executionTime);
return "Error: " + result.getErrorMessage();
}
} catch (Exception e) {
long executionTime = System.currentTimeMillis() - startTime;
executionLogger.logToolError(callId, "write_file", "工具执行异常: " + e.getMessage(), executionTime);
logger.error("Error in write file tool", e);
return "Error: " + e.getMessage();
}
}
@Override
public CompletableFuture<ToolResult> execute(WriteFileParams params) {
return CompletableFuture.supplyAsync(() -> {
try {
Path filePath = Paths.get(params.filePath);
boolean isNewFile = !Files.exists(filePath);
String originalContent = "";
// 读取原始内容(用于备份和差异显示)
if (!isNewFile) {
originalContent = Files.readString(filePath, StandardCharsets.UTF_8);
}
// 创建备份(如果启用)
if (!isNewFile && shouldCreateBackup()) {
createBackup(filePath, originalContent);
}
// 确保父目录存在
Files.createDirectories(filePath.getParent());
// 写入文件
Files.writeString(filePath, params.content, StandardCharsets.UTF_8,
StandardOpenOption.CREATE, StandardOpenOption.TRUNCATE_EXISTING);
// 生成结果
String relativePath = getRelativePath(filePath);
long lineCount = params.content.lines().count();
long byteCount = params.content.getBytes(StandardCharsets.UTF_8).length;
if (isNewFile) {
String successMessage = String.format("Successfully created file: %s (%d lines, %d bytes)",
params.filePath, lineCount, byteCount);
String displayMessage = String.format("Created %s (%d lines)", relativePath, lineCount);
return ToolResult.success(successMessage, displayMessage);
} else {
String diff = generateDiff(filePath.getFileName().toString(), originalContent, params.content);
String successMessage = String.format("Successfully wrote to file: %s (%d lines, %d bytes)",
params.filePath, lineCount, byteCount);
return ToolResult.success(successMessage, new FileDiff(diff, filePath.getFileName().toString()));
}
} catch (IOException e) {
logger.error("Error writing file: " + params.filePath, e);
return ToolResult.error("Error writing file: " + e.getMessage());
} catch (Exception e) {
logger.error("Unexpected error writing file: " + params.filePath, e);
return ToolResult.error("Unexpected error: " + e.getMessage());
}
});
}
private String generateDiff(String fileName, String oldContent, String newContent) {
try {
List<String> oldLines = Arrays.asList(oldContent.split("\n"));
List<String> newLines = Arrays.asList(newContent.split("\n"));
Patch<String> patch = DiffUtils.diff(oldLines, newLines);
List<String> unifiedDiff = UnifiedDiffUtils.generateUnifiedDiff(
fileName + " (Original)",
fileName + " (New)",
oldLines,
patch,
3 // context lines
);
return String.join("\n", unifiedDiff);
} catch (Exception e) {
logger.warn("Could not generate diff", e);
return "Diff generation failed: " + e.getMessage();
}
}
private void createBackup(Path filePath, String content) throws IOException {
String timestamp = LocalDateTime.now().format(DateTimeFormatter.ofPattern("yyyyMMdd_HHmmss"));
String backupFileName = filePath.getFileName().toString() + ".backup." + timestamp;
Path backupPath = filePath.getParent().resolve(backupFileName);
Files.writeString(backupPath, content, StandardCharsets.UTF_8);
logger.info("Created backup: {}", backupPath);
}
private boolean shouldCreateBackup() {
// 可以从配置中读取,这里简化为总是创建备份
return true;
}
private boolean isWithinWorkspace(Path filePath) {
try {
Path workspaceRoot = Paths.get(rootDirectory).toRealPath();
Path normalizedPath = filePath.normalize();
return normalizedPath.startsWith(workspaceRoot.normalize());
} catch (IOException e) {
logger.warn("Could not resolve workspace path", e);
return false;
}
}
private boolean isAllowedFileType(String fileName) {
List<String> allowedExtensions = appProperties.getWorkspace().getAllowedExtensions();
return allowedExtensions.stream()
.anyMatch(ext -> fileName.toLowerCase().endsWith(ext.toLowerCase()));
}
private String getRelativePath(Path filePath) {
try {
Path workspaceRoot = Paths.get(rootDirectory);
return workspaceRoot.relativize(filePath).toString();
} catch (Exception e) {
return filePath.toString();
}
}
/**
* 写入文件参数
*/
public static class WriteFileParams {
@JsonProperty("file_path")
private String filePath;
private String content;
// 构造器
public WriteFileParams() {}
public WriteFileParams(String filePath, String content) {
this.filePath = filePath;
this.content = content;
}
// Getters and Setters
public String getFilePath() { return filePath; }
public void setFilePath(String filePath) { this.filePath = filePath; }
public String getContent() { return content; }
public void setContent(String content) { this.content = content; }
@Override
public String toString() {
return String.format("WriteFileParams{path='%s', contentLength=%d}",
filePath, content != null ? content.length() : 0);
}
}
}

View File

@@ -0,0 +1,106 @@
package com.example.demo.util;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.awt.Desktop;
import java.io.IOException;
import java.net.URI;
import java.net.URISyntaxException;
/**
* 浏览器工具类
* 用于跨平台打开默认浏览器
*/
public class BrowserUtil {
private static final Logger logger = LoggerFactory.getLogger(BrowserUtil.class);
/**
* 打开默认浏览器访问指定URL
*
* @param url 要访问的URL
* @return 是否成功打开
*/
public static boolean openBrowser(String url) {
if (url == null || url.trim().isEmpty()) {
logger.warn("URL is null or empty, cannot open browser");
return false;
}
try {
// 方法1: 使用Desktop API (推荐)
if (Desktop.isDesktopSupported()) {
Desktop desktop = Desktop.getDesktop();
if (desktop.isSupported(Desktop.Action.BROWSE)) {
desktop.browse(new URI(url));
logger.info("Successfully opened browser with URL: {}", url);
return true;
}
}
// 方法2: 使用系统命令 (备用方案)
return openBrowserWithCommand(url);
} catch (IOException | URISyntaxException e) {
logger.error("Failed to open browser with Desktop API for URL: {}", url, e);
// 尝试备用方案
return openBrowserWithCommand(url);
}
}
/**
* 使用系统命令打开浏览器 (备用方案)
*
* @param url 要访问的URL
* @return 是否成功打开
*/
private static boolean openBrowserWithCommand(String url) {
try {
String os = System.getProperty("os.name").toLowerCase();
ProcessBuilder processBuilder;
if (os.contains("win")) {
// Windows
processBuilder = new ProcessBuilder("rundll32", "url.dll,FileProtocolHandler", url);
} else if (os.contains("mac")) {
// macOS
processBuilder = new ProcessBuilder("open", url);
} else {
// Linux/Unix
processBuilder = new ProcessBuilder("xdg-open", url);
}
Process process = processBuilder.start();
// 等待一小段时间确保命令执行
Thread.sleep(500);
logger.info("Successfully opened browser using system command for URL: {}", url);
return true;
} catch (IOException | InterruptedException e) {
logger.error("Failed to open browser using system command for URL: {}", url, e);
return false;
}
}
/**
* 检查URL是否有效
*
* @param url 要检查的URL
* @return 是否有效
*/
public static boolean isValidUrl(String url) {
if (url == null || url.trim().isEmpty()) {
return false;
}
try {
new URI(url);
return url.startsWith("http://") || url.startsWith("https://");
} catch (URISyntaxException e) {
return false;
}
}
}

View File

@@ -0,0 +1,53 @@
package com.example.demo.utils;
import java.nio.file.Paths;
/**
* 跨平台路径处理工具类
*/
public class PathUtils {
/**
* 构建跨平台兼容的绝对路径
* @param basePath 基础路径
* @param relativePath 相对路径部分
* @return 跨平台兼容的绝对路径
*/
public static String buildPath(String basePath, String... relativePath) {
return Paths.get(basePath, relativePath).toString();
}
/**
* 规范化路径,确保跨平台兼容
* @param path 原始路径
* @return 规范化后的路径
*/
public static String normalizePath(String path) {
return Paths.get(path).normalize().toString();
}
/**
* 检查路径是否为绝对路径
* @param path 要检查的路径
* @return 是否为绝对路径
*/
public static boolean isAbsolute(String path) {
return Paths.get(path).isAbsolute();
}
/**
* 获取当前工作目录
* @return 当前工作目录的绝对路径
*/
public static String getCurrentWorkingDirectory() {
return System.getProperty("user.dir");
}
/**
* 构建工作空间路径
* @return 工作空间的绝对路径
*/
public static String buildWorkspacePath() {
return buildPath(getCurrentWorkingDirectory(), "workspace");
}
}

View File

@@ -0,0 +1,87 @@
spring:
application:
name: springAI-alibaba-copilot
ai:
openai:
base-url: https://dashscope.aliyuncs.com/compatible-mode
api-key: xx
chat:
options:
model: qwen-plus
server:
port: 8080
app:
# 工作目录配置
workspace:
# 使用 ${file.separator} 或让 Java 代码处理路径拼接
root-directory: ${user.dir}/workspace # 改为使用正斜杠Java会自动转换
max-file-size: 10485760 # 10MB
allowed-extensions:
- .txt
- .md
- .java
- .js
- .ts
- .json
- .xml
- .yml
- .yaml
- .properties
- .html
- .css
- .sql
# 浏览器自动打开配置
browser:
# 是否启用项目启动后自动打开浏览器
auto-open: true
# 要打开的URL默认为项目首页
url: http://localhost:${server.port:8080}
# 启动后延迟打开时间(秒)
delay-seconds: 2
# 安全配置
security:
approval-mode: DEFAULT # DEFAULT, AUTO_EDIT, YOLO
dangerous-commands:
- rm
- del
- format
- fdisk
- mkfs
# 工具配置
tools:
read-file:
enabled: true
max-lines-per-read: 1000
write-file:
enabled: true
backup-enabled: true
edit-file:
enabled: true
diff-context-lines: 3
list-directory:
enabled: true
max-depth: 5
shell:
enabled: true
timeout-seconds: 30
logging:
level:
com.example.demo: DEBUG
com.example.demo.tools: INFO
com.example.demo.controller: INFO
com.example.demo.service: INFO
com.example.demo.config: DEBUG
# 禁用 Spring AI 默认工具调用日志,使用我们的自定义日志
org.springframework.ai.model.tool.DefaultToolCallingManager: WARN
org.springframework.ai.tool.method.MethodToolCallback: WARN
org.springframework.ai: INFO
pattern:
console: "%d{yyyy-MM-dd HH:mm:ss.SSS} [%thread] %-5level %logger{50} - %msg%n"
file: "%d{yyyy-MM-dd HH:mm:ss.SSS} [%thread] %-5level %logger{50} - %msg%n"
file:
name: logs/copilot-file-ops.log

View File

@@ -0,0 +1,591 @@
/* SpringAI Alibaba Copilot - 主样式文件 */
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
min-height: 100vh;
display: flex;
justify-content: center;
align-items: center;
}
.container {
background: white;
border-radius: 15px;
box-shadow: 0 20px 40px rgba(0,0,0,0.1);
width: 90%;
max-width: 1200px;
height: 80vh;
display: flex;
flex-direction: column;
overflow: hidden;
}
.header {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
padding: 20px;
text-align: center;
}
.header h1 {
margin-bottom: 10px;
font-size: 24px;
}
.header p {
opacity: 0.9;
font-size: 14px;
}
.main-content {
display: flex;
flex: 1;
overflow: hidden;
}
.chat-area {
flex: 2;
display: flex;
flex-direction: column;
border-right: 1px solid #eee;
}
.messages {
flex: 1;
overflow-y: auto;
padding: 20px;
background: #f8f9fa;
}
.message {
margin-bottom: 20px;
display: flex;
align-items: flex-start;
}
.message.user {
justify-content: flex-end;
}
.message.user > div {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
border-radius: 18px 18px 4px 18px;
}
.message.assistant > div {
background: white;
border: 1px solid #e0e0e0;
border-radius: 18px 18px 18px 4px;
}
.message > div {
max-width: 70%;
padding: 12px 16px;
box-shadow: 0 2px 8px rgba(0,0,0,0.1);
}
.message-role {
font-size: 12px;
font-weight: bold;
margin-bottom: 5px;
opacity: 0.8;
}
.message-content {
line-height: 1.5;
word-wrap: break-word;
}
.message-content pre {
background: #f4f4f4;
padding: 10px;
border-radius: 5px;
margin: 10px 0;
overflow-x: auto;
font-size: 13px;
}
.message-content code {
background: #f0f0f0;
padding: 2px 4px;
border-radius: 3px;
font-family: 'Courier New', monospace;
font-size: 13px;
}
.loading {
display: none;
text-align: center;
padding: 20px;
color: #666;
font-style: italic;
}
.input-area {
padding: 20px;
background: white;
border-top: 1px solid #eee;
display: flex;
gap: 10px;
}
.input-area input {
flex: 1;
padding: 12px 16px;
border: 1px solid #ddd;
border-radius: 25px;
outline: none;
font-size: 14px;
}
.input-area input:focus {
border-color: #667eea;
box-shadow: 0 0 0 2px rgba(102, 126, 234, 0.2);
}
.input-area button {
padding: 12px 24px;
border: none;
border-radius: 25px;
cursor: pointer;
font-size: 14px;
font-weight: 500;
transition: all 0.3s ease;
}
.input-area button:first-of-type {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
}
.input-area button:first-of-type:hover {
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(102, 126, 234, 0.3);
}
.clear-btn {
background: #f8f9fa;
color: #666;
border: 1px solid #ddd;
}
.clear-btn:hover {
background: #e9ecef;
color: #333;
}
.input-area button:disabled {
opacity: 0.6;
cursor: not-allowed;
transform: none;
}
.sidebar {
flex: 1;
padding: 20px;
background: #f8f9fa;
overflow-y: auto;
}
.sidebar h3 {
margin-bottom: 15px;
color: #333;
font-size: 16px;
}
.quick-actions {
display: flex;
flex-direction: column;
gap: 8px;
margin-bottom: 30px;
}
.quick-action {
padding: 12px 16px;
background: white;
border: 1px solid #e0e0e0;
border-radius: 8px;
cursor: pointer;
transition: all 0.3s ease;
font-size: 13px;
color: #333;
}
.quick-action:hover {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(102, 126, 234, 0.2);
}
.status {
position: fixed;
top: 20px;
right: 20px;
padding: 12px 20px;
border-radius: 8px;
color: white;
font-size: 14px;
z-index: 1000;
animation: slideIn 0.3s ease;
}
.status.success {
background: #28a745;
}
.status.error {
background: #dc3545;
}
@keyframes slideIn {
from {
transform: translateX(100%);
opacity: 0;
}
to {
transform: translateX(0);
opacity: 1;
}
}
/* 工具日志容器样式 */
.tool-log-container {
background: white;
border: 1px solid #e0e0e0;
border-radius: 12px;
margin: 15px 0;
box-shadow: 0 2px 8px rgba(0,0,0,0.1);
overflow: hidden;
transition: all 0.3s ease;
}
.tool-log-header {
background: linear-gradient(135deg, #f8f9fa 0%, #e9ecef 100%);
padding: 12px 16px;
border-bottom: 1px solid #e0e0e0;
display: flex;
justify-content: space-between;
align-items: center;
}
.tool-log-title {
font-weight: 600;
color: #333;
font-size: 14px;
}
.connection-status {
font-size: 12px;
padding: 4px 8px;
border-radius: 12px;
background: #e9ecef;
color: #666;
}
.connection-status.connected {
background: #d4edda;
color: #155724;
}
.connection-status.error {
background: #f8d7da;
color: #721c24;
}
.connection-status.completed {
background: #d1ecf1;
color: #0c5460;
}
.tool-log-content {
padding: 16px;
}
.tool-card {
background: #f8f9fa;
border: 1px solid #e9ecef;
border-radius: 8px;
padding: 12px;
margin-bottom: 12px;
transition: all 0.3s ease;
}
.tool-card.running {
border-left: 4px solid #ffc107;
background: #fff8e1;
}
.tool-card.success {
border-left: 4px solid #28a745;
background: #f8fff9;
}
.tool-card.error {
border-left: 4px solid #dc3545;
background: #fff5f5;
}
.tool-header {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 8px;
}
.tool-icon {
font-size: 16px;
}
.tool-name {
font-weight: 600;
color: #333;
font-size: 14px;
}
.tool-status {
margin-left: auto;
font-size: 12px;
padding: 2px 8px;
border-radius: 12px;
background: #e9ecef;
color: #666;
}
.tool-status.success {
background: #d4edda;
color: #155724;
}
.tool-status.error {
background: #f8d7da;
color: #721c24;
}
.tool-file {
font-size: 12px;
color: #666;
margin-bottom: 4px;
}
.tool-message {
font-size: 13px;
color: #333;
margin-bottom: 4px;
}
.tool-time {
font-size: 11px;
color: #888;
}
/* 等待状态卡片样式 */
.tool-log-container.waiting {
background: linear-gradient(135deg, #f8f9ff 0%, #e8f2ff 100%);
border: 2px dashed #4285f4;
animation: waitingPulse 2s ease-in-out infinite;
}
.waiting-message {
text-align: center;
padding: 20px;
color: #666;
}
.loading-spinner {
width: 24px;
height: 24px;
border: 3px solid #e3e3e3;
border-top: 3px solid #4285f4;
border-radius: 50%;
animation: spin 1s linear infinite;
margin: 0 auto 12px;
}
.waiting-text {
font-size: 14px;
font-weight: 500;
margin-bottom: 8px;
color: #4285f4;
}
.waiting-hint {
font-size: 12px;
color: #888;
line-height: 1.4;
}
.connection-status.connecting {
color: #4285f4;
animation: blink 1.5s ease-in-out infinite;
}
/* 改进加载状态样式 */
.loading.show {
display: block;
animation: fadeIn 0.3s ease;
}
/* 动画定义 */
@keyframes waitingPulse {
0%, 100% {
border-color: #4285f4;
box-shadow: 0 0 0 0 rgba(66, 133, 244, 0.3);
}
50% {
border-color: #34a853;
box-shadow: 0 0 0 8px rgba(66, 133, 244, 0);
}
}
@keyframes spin {
0% { transform: rotate(0deg); }
100% { transform: rotate(360deg); }
}
@keyframes blink {
0%, 50% { opacity: 1; }
51%, 100% { opacity: 0.5; }
}
@keyframes fadeIn {
from { opacity: 0; transform: translateY(-10px); }
to { opacity: 1; transform: translateY(0); }
}
/* 响应式设计 */
@media (max-width: 768px) {
.container {
width: 95%;
height: 90vh;
}
.main-content {
flex-direction: column;
}
.chat-area {
border-right: none;
border-bottom: 1px solid #eee;
}
.sidebar {
max-height: 200px;
}
.message > div {
max-width: 85%;
}
.header h1 {
font-size: 20px;
}
.header p {
font-size: 12px;
}
.input-area {
padding: 15px;
}
.input-area input {
font-size: 16px; /* 防止iOS缩放 */
}
.quick-action {
padding: 10px 12px;
font-size: 12px;
}
.waiting-message {
padding: 15px;
}
.waiting-text {
font-size: 13px;
}
.waiting-hint {
font-size: 11px;
}
.tool-log-container {
margin: 10px 0;
}
.tool-log-content {
padding: 12px;
}
.tool-card {
padding: 10px;
margin-bottom: 10px;
}
}
/* 流式消息样式 */
.message.streaming {
position: relative;
}
.stream-content {
white-space: pre-wrap;
word-wrap: break-word;
line-height: 1.5;
}
.stream-indicator {
display: flex;
align-items: center;
margin-top: 8px;
padding-top: 8px;
border-top: 1px solid #e0e0e0;
}
.typing-indicator {
display: flex;
align-items: center;
gap: 4px;
}
.typing-indicator span {
width: 6px;
height: 6px;
border-radius: 50%;
background-color: #007bff;
animation: typing 1.4s infinite ease-in-out;
}
.typing-indicator span:nth-child(1) {
animation-delay: -0.32s;
}
.typing-indicator span:nth-child(2) {
animation-delay: -0.16s;
}
@keyframes typing {
0%, 80%, 100% {
transform: scale(0.8);
opacity: 0.5;
}
40% {
transform: scale(1);
opacity: 1;
}
}
.stream-indicator .error {
color: #dc3545;
font-size: 12px;
font-style: italic;
}

View File

@@ -0,0 +1,332 @@
/**
* SpringAI Alibaba Copilot - 主JavaScript文件
* 处理聊天界面交互、SSE连接和工具日志显示
*/
// 全局变量
const messagesContainer = document.getElementById('messages');
const messageInput = document.getElementById('messageInput');
const sendBtn = document.getElementById('sendBtn');
const clearBtn = document.getElementById('clearBtn');
const loading = document.getElementById('loading');
const status = document.getElementById('status');
// 全局错误处理
window.addEventListener('error', function(event) {
console.error('Global JavaScript error:', event.error);
if (event.error && event.error.message && event.error.message.includes('userMessage')) {
console.error('Detected userMessage error, this might be a variable scope issue');
}
});
// 函数声明会被提升,但为了安全起见,我们在页面加载后再设置全局引用
// 发送消息
async function sendMessage() {
const message = messageInput.value.trim();
if (!message) return;
// 添加用户消息
addMessage('user', message);
messageInput.value = '';
// 显示加载状态
showLoading(true);
setButtonsEnabled(false);
try {
const response = await fetch('/api/chat/message', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ message: message })
});
const data = await response.json();
if (data.success) {
// 如果是异步任务工具调用建立SSE连接
if (data.taskId && data.asyncTask) {
// 先显示等待状态的工具卡片
showWaitingToolCard();
logStreamManager.startLogStream(data.taskId);
showStatus('任务已启动,正在建立实时连接...', 'success');
} else if (data.streamResponse) {
// 流式对话响应
handleStreamResponse(message);
showStatus('开始流式对话...', 'success');
} else {
// 同步任务,直接显示结果
addMessage('assistant', data.message);
// 显示连续对话统计信息
let statusMessage = 'Message sent successfully';
if (data.totalTurns && data.totalTurns > 1) {
statusMessage += ` (${data.totalTurns} turns`;
if (data.totalDurationMs) {
statusMessage += `, ${(data.totalDurationMs / 1000).toFixed(1)}s`;
}
statusMessage += ')';
if (data.reachedMaxTurns) {
statusMessage += ' - Reached max turns limit';
}
if (data.stopReason) {
statusMessage += ` - ${data.stopReason}`;
}
}
showStatus(statusMessage, 'success');
}
} else {
addMessage('assistant', data.message);
showStatus('Error: ' + data.message, 'error');
}
} catch (error) {
console.error('Error:', error);
// 更安全的错误处理
const errorMessage = error && error.message ? error.message : 'Unknown error occurred';
addMessage('assistant', 'Sorry, there was an error processing your request: ' + errorMessage);
showStatus('Network error: ' + errorMessage, 'error');
} finally {
showLoading(false);
setButtonsEnabled(true);
messageInput.focus();
}
}
// 快速操作
function quickAction(message) {
messageInput.value = message;
sendMessage();
}
// 清除历史
async function clearHistory() {
try {
await fetch('/api/chat/clear', { method: 'POST' });
messagesContainer.innerHTML = '';
showStatus('History cleared', 'success');
} catch (error) {
showStatus('Error clearing history', 'error');
}
}
// 添加消息到界面
function addMessage(role, content) {
const messageDiv = document.createElement('div');
messageDiv.className = `message ${role}`;
// 处理代码块和格式化
const formattedContent = formatMessage(content);
messageDiv.innerHTML = `
<div>
<div class="message-role">${role === 'user' ? 'You' : 'Assistant'}</div>
<div class="message-content">${formattedContent}</div>
</div>
`;
messagesContainer.appendChild(messageDiv);
messagesContainer.scrollTop = messagesContainer.scrollHeight;
}
// 格式化消息内容
function formatMessage(content) {
// 简单的代码块处理
content = content.replace(/```(\w+)?\n([\s\S]*?)```/g, '<pre><code>$2</code></pre>');
// 处理行内代码
content = content.replace(/`([^`]+)`/g, '<code style="background: #f0f0f0; padding: 2px 4px; border-radius: 3px;">$1</code>');
// 处理换行
content = content.replace(/\n/g, '<br>');
return content;
}
// 显示/隐藏加载状态
function showLoading(show) {
loading.classList.toggle('show', show);
}
// 启用/禁用按钮
function setButtonsEnabled(enabled) {
sendBtn.disabled = !enabled;
clearBtn.disabled = !enabled;
}
// 显示状态消息
function showStatus(message, type) {
status.textContent = message;
status.className = `status ${type}`;
status.style.display = 'block';
setTimeout(() => {
status.style.display = 'none';
}, 3000);
}
// 显示等待状态的工具卡片
function showWaitingToolCard() {
const waitingCard = document.createElement('div');
waitingCard.className = 'tool-log-container waiting';
waitingCard.id = 'waiting-tool-card';
waitingCard.innerHTML = `
<div class="tool-log-header">
<span class="tool-log-title">🔧 工具执行准备中</span>
<span class="connection-status connecting">连接中...</span>
</div>
<div class="tool-log-content">
<div class="waiting-message">
<div class="loading-spinner"></div>
<div class="waiting-text">正在等待工具执行推送...</div>
<div class="waiting-hint">AI正在分析您的请求并准备执行相应的工具操作</div>
</div>
</div>
`;
messagesContainer.appendChild(waitingCard);
messagesContainer.scrollTop = messagesContainer.scrollHeight;
}
// 处理流式响应
function handleStreamResponse(userMessage) {
console.log('🌊 开始处理流式响应,消息:', userMessage);
// 参数验证
if (!userMessage) {
console.error('handleStreamResponse: userMessage is undefined or empty');
showStatus('流式响应参数错误', 'error');
return;
}
// 创建流式消息容器
const streamMessageId = 'stream-message-' + Date.now();
const streamContainer = document.createElement('div');
streamContainer.className = 'message assistant streaming';
streamContainer.id = streamMessageId;
streamContainer.innerHTML = `
<div class="message-content">
<div class="stream-content"></div>
<div class="stream-indicator">
<div class="typing-indicator">
<span></span>
<span></span>
<span></span>
</div>
</div>
</div>
`;
messagesContainer.appendChild(streamContainer);
messagesContainer.scrollTop = messagesContainer.scrollHeight;
// 使用fetch API处理流式响应
const streamContent = streamContainer.querySelector('.stream-content');
const streamIndicator = streamContainer.querySelector('.stream-indicator');
let fullContent = '';
fetch('/api/chat/stream', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ message: userMessage })
})
.then(response => {
if (!response.ok) {
throw new Error('Network response was not ok');
}
const reader = response.body.getReader();
const decoder = new TextDecoder();
function readStream() {
return reader.read().then(({ done, value }) => {
if (done) {
console.log('✅ 流式响应完成');
streamIndicator.style.display = 'none';
streamContainer.classList.remove('streaming');
showStatus('流式对话完成', 'success');
return;
}
const chunk = decoder.decode(value, { stream: true });
console.log('📨 收到流式数据块:', chunk);
// 处理SSE格式的数据
const lines = chunk.split('\n');
for (const line of lines) {
if (line.startsWith('data: ')) {
const data = line.substring(6);
if (data === '[DONE]') {
console.log('✅ 流式响应完成');
streamIndicator.style.display = 'none';
streamContainer.classList.remove('streaming');
showStatus('流式对话完成', 'success');
return;
}
// 追加内容
fullContent += data;
streamContent.textContent = fullContent;
messagesContainer.scrollTop = messagesContainer.scrollHeight;
}
}
return readStream();
});
}
return readStream();
})
.catch(error => {
console.error('❌ 流式响应错误:', error);
const errorMessage = error && error.message ? error.message : 'Unknown stream error';
streamIndicator.innerHTML = '<span class="error">连接错误: ' + errorMessage + '</span>';
showStatus('流式对话连接错误: ' + errorMessage, 'error');
});
}
// 移除等待状态卡片
function removeWaitingToolCard() {
const waitingCard = document.getElementById('waiting-tool-card');
if (waitingCard) {
waitingCard.remove();
}
}
// 事件监听器
messageInput.addEventListener('keypress', function(e) {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
sendMessage();
}
});
// 调试函数
function debugVariables() {
console.log('=== Debug Variables ===');
console.log('messagesContainer:', messagesContainer);
console.log('messageInput:', messageInput);
console.log('sendBtn:', sendBtn);
console.log('clearBtn:', clearBtn);
console.log('loading:', loading);
console.log('status:', status);
console.log('addMessage function:', typeof addMessage);
console.log('showStatus function:', typeof showStatus);
console.log('logStreamManager:', typeof logStreamManager);
}
// 页面加载完成后聚焦输入框
window.addEventListener('load', function() {
messageInput.focus();
// 确保函数在全局作用域中可用
window.addMessage = addMessage;
window.showStatus = showStatus;
// 调试信息
debugVariables();
});

View File

@@ -0,0 +1,190 @@
/**
* SSE日志流管理器
* 负责管理Server-Sent Events连接和工具日志显示
*/
// SSE实时日志管理器
class LogStreamManager {
constructor() {
this.activeConnections = new Map(); // taskId -> EventSource
this.toolLogDisplays = new Map(); // taskId -> ToolLogDisplay
}
// 建立SSE连接
startLogStream(taskId) {
if (this.activeConnections.has(taskId)) {
console.log('SSE连接已存在:', taskId);
return;
}
console.log('🔗 建立SSE连接:', taskId);
// 创建工具日志显示组件
const toolLogDisplay = new ToolLogDisplay(taskId);
this.toolLogDisplays.set(taskId, toolLogDisplay);
// 建立EventSource连接
const eventSource = new EventSource(`/api/logs/stream/${taskId}`);
eventSource.onopen = () => {
console.log('✅ SSE连接建立成功:', taskId);
toolLogDisplay.showConnectionStatus('已连接');
};
eventSource.onmessage = (event) => {
try {
const logEvent = JSON.parse(event.data);
console.log('📨 收到日志事件:', logEvent);
this.handleLogEvent(taskId, logEvent);
} catch (error) {
console.error('解析日志事件失败:', error);
}
};
// 监听特定的 "log" 事件
eventSource.addEventListener('log', (event) => {
try {
const logEvent = JSON.parse(event.data);
console.log('📨 收到log事件:', logEvent);
this.handleLogEvent(taskId, logEvent);
} catch (error) {
console.error('解析log事件失败:', error);
}
});
eventSource.onerror = (error) => {
console.error('❌ SSE连接错误:', error);
toolLogDisplay.showConnectionStatus('连接错误');
this.handleConnectionError(taskId);
};
this.activeConnections.set(taskId, eventSource);
}
// 处理日志事件
handleLogEvent(taskId, logEvent) {
const toolLogDisplay = this.toolLogDisplays.get(taskId);
if (!toolLogDisplay) {
console.warn('找不到工具日志显示组件:', taskId);
return;
}
switch (logEvent.type) {
case 'CONNECTION_ESTABLISHED':
toolLogDisplay.showConnectionStatus('已连接');
// 连接建立后如果5秒内没有工具事件显示提示
setTimeout(() => {
const waitingCard = document.getElementById('waiting-tool-card');
if (waitingCard) {
const waitingText = waitingCard.querySelector('.waiting-text');
if (waitingText) {
waitingText.textContent = '连接已建立等待AI开始执行工具...';
}
}
}, 5000);
break;
case 'TOOL_START':
toolLogDisplay.addToolStart(logEvent);
break;
case 'TOOL_SUCCESS':
toolLogDisplay.updateToolSuccess(logEvent);
break;
case 'TOOL_ERROR':
toolLogDisplay.updateToolError(logEvent);
break;
case 'TASK_COMPLETE':
toolLogDisplay.showTaskComplete();
this.handleTaskComplete(taskId);
this.closeConnection(taskId);
break;
default:
console.log('未知日志事件类型:', logEvent.type);
}
}
// 关闭SSE连接
closeConnection(taskId) {
const eventSource = this.activeConnections.get(taskId);
if (eventSource) {
eventSource.close();
this.activeConnections.delete(taskId);
console.log('🔚 关闭SSE连接:', taskId);
}
// 延迟移除显示组件
setTimeout(() => {
const toolLogDisplay = this.toolLogDisplays.get(taskId);
if (toolLogDisplay) {
toolLogDisplay.fadeOut();
this.toolLogDisplays.delete(taskId);
}
}, 5000);
}
// 处理任务完成
async handleTaskComplete(taskId) {
try {
// 获取对话结果
const response = await fetch(`/api/task/result/${taskId}`);
const resultData = await response.json();
// 安全地显示最终结果
if (typeof addMessage === 'function' && resultData && resultData.fullResponse) {
addMessage('assistant', resultData.fullResponse);
} else {
console.error('addMessage function not available or invalid result data');
}
// 显示统计信息
let statusMessage = '对话完成';
if (resultData.totalTurns > 1) {
statusMessage += ` (${resultData.totalTurns}`;
if (resultData.totalDurationMs) {
statusMessage += `, ${(resultData.totalDurationMs / 1000).toFixed(1)}`;
}
statusMessage += ')';
if (resultData.reachedMaxTurns) {
statusMessage += ' - 达到最大轮次限制';
}
if (resultData.stopReason) {
statusMessage += ` - ${resultData.stopReason}`;
}
}
// 安全地调用showStatus函数
if (typeof showStatus === 'function') {
showStatus(statusMessage, 'success');
} else {
console.log(statusMessage);
}
} catch (error) {
console.error('获取对话结果失败:', error);
// 安全地调用showStatus函数
if (typeof showStatus === 'function') {
showStatus('获取对话结果失败', 'error');
} else {
console.error('获取对话结果失败');
}
}
}
// 处理连接错误
handleConnectionError(taskId) {
// 可以实现重连逻辑
console.log('处理连接错误:', taskId);
setTimeout(() => {
if (!this.activeConnections.has(taskId)) {
console.log('尝试重连:', taskId);
this.startLogStream(taskId);
}
}, 3000);
}
}
// 创建SSE日志流管理器实例
const logStreamManager = new LogStreamManager();
// 确保在全局作用域中可用
window.logStreamManager = logStreamManager;

View File

@@ -0,0 +1,149 @@
/**
* 工具日志显示组件
* 负责显示工具执行的实时状态和结果
*/
class ToolLogDisplay {
constructor(taskId) {
this.taskId = taskId;
this.toolCards = new Map(); // toolName -> DOM element
this.container = this.createContainer();
this.appendToPage();
}
// 创建容器
createContainer() {
const container = document.createElement('div');
container.className = 'tool-log-container';
container.id = `tool-log-${this.taskId}`;
container.innerHTML = `
<div class="tool-log-header">
<span class="tool-log-title">🔧 工具执行日志</span>
<span class="connection-status">连接中...</span>
</div>
<div class="tool-log-content">
<!-- 工具卡片将在这里动态添加 -->
</div>
`;
return container;
}
// 添加到页面
appendToPage() {
messagesContainer.appendChild(this.container);
messagesContainer.scrollTop = messagesContainer.scrollHeight;
}
// 显示连接状态
showConnectionStatus(status) {
const statusElement = this.container.querySelector('.connection-status');
if (statusElement) {
statusElement.textContent = status;
statusElement.className = `connection-status ${status === '已连接' ? 'connected' : 'error'}`;
}
}
// 添加工具开始执行
addToolStart(logEvent) {
// 移除等待状态卡片(如果存在)
removeWaitingToolCard();
const toolCard = this.createToolCard(logEvent);
const content = this.container.querySelector('.tool-log-content');
content.appendChild(toolCard);
this.toolCards.set(logEvent.toolName, toolCard);
this.scrollToBottom();
}
// 更新工具执行成功
updateToolSuccess(logEvent) {
const toolCard = this.toolCards.get(logEvent.toolName);
if (toolCard) {
this.updateToolCard(toolCard, logEvent, 'success');
}
}
// 更新工具执行失败
updateToolError(logEvent) {
const toolCard = this.toolCards.get(logEvent.toolName);
if (toolCard) {
this.updateToolCard(toolCard, logEvent, 'error');
}
}
// 创建工具卡片
createToolCard(logEvent) {
const card = document.createElement('div');
card.className = 'tool-card running';
card.innerHTML = `
<div class="tool-header">
<span class="tool-icon">${logEvent.icon}</span>
<span class="tool-name">${logEvent.toolName}</span>
<span class="tool-status">⏳ 执行中</span>
</div>
<div class="tool-file">📁 ${this.getFileName(logEvent.filePath)}</div>
<div class="tool-message">${logEvent.message}</div>
<div class="tool-time">开始时间: ${logEvent.timestamp}</div>
`;
return card;
}
// 更新工具卡片
updateToolCard(toolCard, logEvent, status) {
toolCard.className = `tool-card ${status}`;
const statusElement = toolCard.querySelector('.tool-status');
const messageElement = toolCard.querySelector('.tool-message');
const timeElement = toolCard.querySelector('.tool-time');
if (status === 'success') {
statusElement.innerHTML = '✅ 完成';
statusElement.className = 'tool-status success';
} else if (status === 'error') {
statusElement.innerHTML = '❌ 失败';
statusElement.className = 'tool-status error';
}
messageElement.textContent = logEvent.message;
if (logEvent.executionTime) {
timeElement.textContent = `完成时间: ${logEvent.timestamp} (耗时: ${logEvent.executionTime}ms)`;
}
this.scrollToBottom();
}
// 显示任务完成
showTaskComplete() {
const header = this.container.querySelector('.tool-log-header');
header.innerHTML = `
<span class="tool-log-title">🎉 任务执行完成</span>
<span class="connection-status completed">已完成</span>
`;
}
// 淡出效果
fadeOut() {
this.container.style.transition = 'opacity 1s ease-out';
this.container.style.opacity = '0.5';
setTimeout(() => {
if (this.container.parentNode) {
this.container.parentNode.removeChild(this.container);
}
}, 10000); // 10秒后移除
}
// 滚动到底部
scrollToBottom() {
messagesContainer.scrollTop = messagesContainer.scrollHeight;
}
// 获取文件名
getFileName(filePath) {
if (!filePath) return '未知文件';
const parts = filePath.split(/[/\\]/);
return parts[parts.length - 1] || filePath;
}
}

View File

@@ -0,0 +1,109 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>SpringAI Alibaba Copilot</title>
<link rel="stylesheet" href="/css/main.css">
</head>
<body>
<div class="container">
<div class="header">
<h1>🤖 SpringAI Alibaba 编码助手</h1>
<p>AI助手将分析您的需求制定执行计划并逐步完成任务</p>
</div>
<div class="main-content">
<div class="chat-area">
<div class="messages" id="messages">
<div class="message assistant">
<div>
<div class="message-role">Assistant</div>
<div class="message-content">
👋 Hello! I'm your AI file operations assistant. I can help you:
<br><br>
📁 <strong>Read files</strong> - View file contents with pagination support
<br>✏️ <strong>Write files</strong> - Create new files or overwrite existing ones
<br>🔧 <strong>Edit files</strong> - Make precise edits with diff preview
<br>📋 <strong>List directories</strong> - Browse directory structure
<br><br>
Try asking me to create a simple project, read a file, or explore the workspace!
<br><br>
<em>Workspace: /workspace</em>
</div>
</div>
</div>
</div>
<div class="loading" id="loading">
<div>🤔 AI is thinking...</div>
</div>
<div class="input-area">
<input type="text" id="messageInput" placeholder="Ask me to create files, read content, or manage your project..." />
<button onclick="sendMessage()" id="sendBtn">Send</button>
<button onclick="clearHistory()" class="clear-btn" id="clearBtn">Clear</button>
</div>
</div>
<div class="sidebar">
<h3>🚀 Quick Actions</h3>
<div class="quick-actions">
<div class="quick-action" onclick="quickAction('List the workspace directory')">
📁 List workspace directory
</div>
<div class="quick-action" onclick="quickAction('Create a simple Java Hello World project')">
☕ Create Java Hello World
</div>
<div class="quick-action" onclick="quickAction('Create a simple web project with HTML, CSS and JS')">
🌐 Create web project
</div>
<div class="quick-action" onclick="quickAction('Create a README.md file for this project')">
📝 Create README.md
</div>
<div class="quick-action" onclick="quickAction('Show me the structure of the current directory recursively')">
🌳 Show directory tree
</div>
<div class="quick-action" onclick="quickAction('Create a simple Python script that prints hello world')">
🐍 Create Python script
</div>
</div>
<h3>🔄 Continuous Task Tests</h3>
<div class="quick-actions">
<div class="quick-action" onclick="quickAction('Create a complete React project with components, styles, and package.json')">
⚛️ Create React project
</div>
<div class="quick-action" onclick="quickAction('Create a full-stack todo app with HTML, CSS, JavaScript frontend and Node.js backend')">
📋 Create Todo App
</div>
<div class="quick-action" onclick="quickAction('Create a Spring Boot REST API project with controller, service, and model classes')">
🍃 Create Spring Boot API
</div>
<div class="quick-action" onclick="quickAction('Create a complete blog website with multiple HTML pages, CSS styles, and JavaScript functionality')">
📰 Create Blog Website
</div>
</div>
<div id="status" class="status" style="display: none;"></div>
<div style="margin-top: 30px;">
<h3>💡 Tips</h3>
<div style="font-size: 12px; color: #666; line-height: 1.4;">
• Ask for step-by-step project creation<br>
• Request file content before editing<br>
• Use specific file paths<br>
• Ask for directory structure first<br>
• Try continuous operations
</div>
</div>
</div>
</div>
</div>
<!-- JavaScript文件引用 -->
<script src="/js/tool-log-display.js"></script>
<script src="/js/sse-manager.js"></script>
<script src="/js/main.js"></script>
</body>
</html>

View File

@@ -28,7 +28,7 @@
</scm>
<properties>
<java.version>17</java.version>
<spring-ai.version>1.0.0-M7</spring-ai.version>
<spring-ai.version>1.0.0</spring-ai.version>
</properties>
<dependencies>
<dependency>

View File

@@ -16,7 +16,7 @@
<maven.compiler.source>17</maven.compiler.source>
<maven.compiler.target>17</maven.compiler.target>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
<spring-ai.version>1.0.0-M7</spring-ai.version>
<spring-ai.version>1.0.0</spring-ai.version>
</properties>
<dependencyManagement>

Some files were not shown because too many files have changed in this diff Show More