15 Commits

Author SHA1 Message Date
ageerle
410cb0b6f2 feat: 集成通用 Coding Agent 与 AI 报表 Agent
- 新增 Coding Agent 子代理并接入 Supervisor
- 创建 file 工具(路径校验、父目录创建、覆盖控制)
- run_command 受控命令工具(白名单、子命令白名单、超时、审批令牌闭环)
- 升级 edit_file 为 unified diff 解析应用(上下文校验)
- 新增 task_planner 任务规划工具(风险分级、审批令牌生成)
- ApprovalTokenStore 审批令牌存储(TTL、scope 绑定)
- 新增 AI 报表 Agent:自然语言生成 SQL 预览与 shell 查询执行
- 报表前端页面(需求描述 -> SQL 确认 -> 报表展示 -> 动态编辑)
Co-authored-by: monkeycode-ai <monkeycode-ai@chaitin.com>
2026-05-03 16:03:47 +00:00
ageerle@163.com
ec092a11c3 docs: 添加 web coding 快速部署说明
在 README 中新增使用 web coding 急速部署的章节,包含在线体验链接和部署截图展示

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-30 17:45:16 +08:00
ageerle
9a7b727413 Merge pull request #293 from RobustH/main
升级RAG模块
2026-04-23 09:15:02 +08:00
RobustH
b8d16b7669 feat(rag): 对接用户端用户知识库对话,集成知识库配置应用 2026-04-23 00:52:53 +08:00
RobustH
058a4aee2a feat(rag): 新增测试配置应用的功能 2026-04-21 22:54:11 +08:00
RobustH
1b50c7f9f1 fix(rag): 修复合并重复,重排模型新增硅基流动供应商 2026-04-21 22:41:00 +08:00
RobustH
e7f53fd55f Merge remote-tracking branch 'origin/main'
# Conflicts:
#	ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/domain/bo/knowledge/KnowledgeInfoBo.java
#	ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/domain/entity/knowledge/KnowledgeInfo.java
#	ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/domain/vo/knowledge/KnowledgeInfoVo.java
#	ruoyi-modules/ruoyi-chat/src/main/java/org/ruoyi/service/chat/impl/ChatServiceFacade.java
2026-04-21 21:41:51 +08:00
ageerle
07bdc5e585 Merge pull request #292 from yangzhen233/feature/rerank-model
Feature/rerank model
2026-04-20 21:06:27 +08:00
yangzhen
e1b8a5f011 新增千问3重排序模型,并附带新增sql文件 2026-04-20 16:07:02 +08:00
杨振
80ca76ea37 添加重排序功能 2026-04-20 01:02:09 +08:00
RobustH
ccbf5c9520 feat(rag): 知识库检索测试新增混合检索 2026-04-14 23:18:29 +08:00
RobustH
1208c46cca feat(rag): 集成硅基流动、阿里百炼重排模型并全方位增强检索测试体验 2026-04-14 01:40:28 +08:00
RobustH
06a63c377e feat: 新增检索测试相关接口
- 实现向量 L2 归一化,统一 Milvus/Qdrant/Weaviate 检索评分为 [0, 1] 空间
2026-04-13 23:33:56 +08:00
RobustH
0fa25032a3 feat(knowledge): 优化知识库文件状态枚举为"未解析,解析中,解析成功,解析失败",支持异步线程池解析文档 2026-04-13 00:15:01 +08:00
RobustH
28ad29d6ed feat(knowledge): 完善知识库及附件统计功能并修复分块数统计问题 2026-04-12 18:38:32 +08:00
67 changed files with 4143 additions and 171 deletions

63
.monkeycode/MEMORY.md Normal file
View File

@@ -0,0 +1,63 @@
# 用户指令记忆
本文件记录了用户的指令、偏好和教导,用于在未来的交互中提供参考。
## 格式
### 用户指令条目
用户指令条目应遵循以下格式:
[用户指令摘要]
- Date: [YYYY-MM-DD]
- Context: [提及的场景或时间]
- Instructions:
- [用户教导或指示的内容,逐行描述]
### 项目知识条目
Agent 在任务执行过程中发现的条目应遵循以下格式:
[项目知识摘要]
- Date: [YYYY-MM-DD]
- Context: Agent 在执行 [具体任务描述] 时发现
- Category: [代码结构|代码模式|代码生成|构建方法|测试方法|依赖关系|环境配置]
- Instructions:
- [具体的知识点,逐行描述]
## 去重策略
- 添加新条目前,检查是否存在相似或相同的指令
- 若发现重复,跳过新条目或与已有条目合并
- 合并时,更新上下文或日期信息
- 这有助于避免冗余条目,保持记忆文件整洁
## 条目
[项目技术栈偏好LangChain4j]
- Date: 2026-05-03
- Context: 用户在讨论“集成 AI 编程能力”方案时说明
- Category: 依赖关系
- Instructions:
- 项目基于 LangChain4j 设计仓库级理解与自动化动作能力
[需求澄清:构建通用 Coding Agent]
- Date: 2026-05-03
- Context: 用户纠正方案范围时说明
- Category: 代码结构
- Instructions:
- 目标是基于 LangChain4j 构建通用 coding agent而非仅项目内问答助手
- Agent 需要可操作文件、调用工具、跨前后端完成任务(如新建前端页面并对接现有后端)
[内置工具自动注册机制]
- Date: 2026-05-03
- Context: Agent 在执行 coding agent 工具扩展时发现
- Category: 代码结构
- Instructions:
- ruoyi-chat 模块通过 BuiltinToolProvider + @Component 自动发现并注册内置工具
- 新增工具无需手工改注册表BuiltinToolRegistry 会在启动时扫描并创建可供 Agent 调用的实例
[新增能力方向AI 报表 Agent]
- Date: 2026-05-03
- Context: 用户提出新的产品化需求
- Category: 代码模式
- Instructions:
- 用户希望通过自然语言生成报表,包含数据库查询和 HTML 报表生成
- 用户希望在报表页面内通过提示词继续动态编辑页面

View File

@@ -72,6 +72,29 @@
- **实时通信**WebSocket 实时通信SSE 流式响应
- **系统监控**:完善的日志体系、性能监控、服务健康检查
## 使用web coding急速部署
#### 在线体验: https://monkeycode-ai.com/?ic=019d9e9f-edc3-7a4b-8987-11b028751a1e
<table>
<tr>
<td align="center" style="padding: 20px;">
<img src="docs/image/01.png" alt="web code" width="660" height="400"><br>
</td>
</tr>
<tr>
<td align="center" style="padding: 20px;">
<img src="docs/image/02.png" alt="web code" width="660" height="400"><br>
</td>
</tr>
<tr>
<td align="center" style="padding: 20px;">
<img src="docs/image/03.png" alt="web code" width="660" height="400"><br>
</td>
</tr>
</table>
#### 等待10分钟左右即可完成
## 🐳 Docker 部署
本项目提供两种 Docker 部署方式:

BIN
docs/image/01.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 298 KiB

BIN
docs/image/02.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 185 KiB

BIN
docs/image/03.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 316 KiB

View File

@@ -0,0 +1,46 @@
/*
Navicat Premium Dump SQL
Source Server : localhost-mysql
Source Server Type : MySQL
Source Server Version : 80045 (8.0.45)
Source Host : localhost:3306
Source Schema : ruoyi-ai
Target Server Type : MySQL
Target Server Version : 80045 (8.0.45)
File Encoding : 65001
Date: 20/04/2026 15:30:00
*/
SET NAMES utf8mb4;
SET FOREIGN_KEY_CHECKS = 0;
-- ----------------------------
-- 新增重排序模型chat_model
-- ----------------------------
INSERT INTO `chat_model`
(id, category, model_name, provider_code, model_describe, model_dimension, model_show, api_host, api_key, create_dept, create_by, create_time, update_by, update_time, remark, tenant_id)
VALUES(2045071617578237953, 'rerank', 'rerank', 'zhipu', '智谱重排序', NULL, 'Y', 'https://open.bigmodel.cn', 'e9xx', 103, 1, '2026-04-17 17:27:24', 1, '2026-04-20 15:21:48', '智谱重排序', 0);
INSERT INTO `chat_model`
(id, category, model_name, provider_code, model_describe, model_dimension, model_show, api_host, api_key, create_dept, create_by, create_time, update_by, update_time, remark, tenant_id)
VALUES(2046119803482902530, 'rerank', 'qwen3-rerank', 'qianwen', '千问3重排序', NULL, NULL, 'https://dashscope.aliyuncs.com', 'sk-xx', 103, 1, '2026-04-20 14:52:31', 1, '2026-04-20 15:03:13', '千问3文本重排序', 0);
-- ----------------------------
-- 新增:字典类型 - 重排序模型分类
-- ----------------------------
INSERT INTO `sys_dict_data`
(dict_code, tenant_id, dict_sort, dict_label, dict_value, dict_type, css_class, list_class, is_default, create_dept, create_by, create_time, update_by, update_time, remark)
VALUES(2045070879435259905, '000000', 4, '重排序', 'rerank', 'chat_model_category', NULL, '#000000', 'N', 103, 1, '2026-04-17 17:24:28', 1, '2026-04-19 01:02:20', '重排序模型');
-- ----------------------------
-- 修改表knowledge_info 增加重排序相关字段
-- ----------------------------
ALTER TABLE `knowledge_info` ADD COLUMN `enable_rerank` tinyint DEFAULT 0 NULL COMMENT '是否启用重排序0否 1是';
ALTER TABLE `knowledge_info` ADD COLUMN `rerank_score_threshold` double NULL COMMENT '重排序相关性分数阈值';
ALTER TABLE `knowledge_info` ADD COLUMN `rerank_top_n` int NULL COMMENT '重排序后返回的文档数量';
ALTER TABLE `knowledge_info` ADD COLUMN `rerank_model` varchar(100) NULL COMMENT '重排序模型名称';
SET FOREIGN_KEY_CHECKS = 1;

View File

@@ -0,0 +1,14 @@
-- 为知识库信息表新增检索配置字段 (剔除了已存在的重排字段)
ALTER TABLE knowledge_info
ADD COLUMN similarity_threshold DOUBLE DEFAULT 0.5 COMMENT '相似度阈值'
AFTER retrieve_limit;
ALTER TABLE knowledge_info ADD COLUMN enable_hybrid tinyint(1) DEFAULT 0 COMMENT '是否启用混合检索';
ALTER TABLE knowledge_info ADD COLUMN hybrid_alpha double DEFAULT 0.5 COMMENT '混合检索权重比例 (0.0=纯向量, 1.0=纯关键词)';
-- 为知识片段表增加全文索引及关联ID
ALTER TABLE knowledge_fragment ADD COLUMN knowledge_id bigint COMMENT '知识库ID';
ALTER TABLE knowledge_fragment ADD FULLTEXT INDEX ft_content (content) WITH PARSER ngram;
-- 为知识库附件表增加解析状态字段
ALTER TABLE `knowledge_attach` ADD COLUMN `status` TINYINT DEFAULT 0 COMMENT '解析状态: 0待解析, 1解析中, 2已解析, 3解析失败';

View File

@@ -0,0 +1,320 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>AI Report Agent</title>
<style>
*{margin:0;padding:0;box-sizing:border-box}
:root{--primary:#6366f1;--primary-hover:#4f46e5;--bg:#f8f9fa;--card:#fff;--border:#e2e8f0;--text:#1e293b;--text-muted:#64748b;--success:#22c55e;--warning:#f59e0b;--error:#ef4444;--shadow:0 1px 3px rgba(0,0,0,0.1);--radius:8px}
body{font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;background:var(--bg);color:var(--text);line-height:1.6;min-height:100vh}
.app{max-width:1200px;margin:0 auto;padding:20px}
.header{text-align:center;margin-bottom:32px}
.header h1{font-size:28px;background:linear-gradient(135deg,var(--primary),#a855f7);-webkit-background-clip:text;-webkit-text-fill-color:transparent}
.header p{color:var(--text-muted);margin-top:4px}
.steps{display:flex;gap:8px;margin-bottom:24px;align-items:center}
.step{flex:1;padding:12px 16px;background:var(--card);border:2px solid var(--border);border-radius:var(--radius);text-align:center;transition:all 0.3s}
.step.active{border-color:var(--primary);background:#eef2ff}
.step.done{border-color:var(--success);background:#f0fdf4}
.step h3{font-size:14px;color:var(--text-muted);margin-bottom:2px}
.step .num{font-size:24px;font-weight:700}
.card{background:var(--card);border-radius:var(--radius);box-shadow:var(--shadow);padding:24px;margin-bottom:20px}
.card h2{font-size:18px;margin-bottom:16px;padding-bottom:8px;border-bottom:1px solid var(--border)}
textarea{width:100%;padding:12px;border:1px solid var(--border);border-radius:var(--radius);resize:vertical;font-size:14px;line-height:1.5;font-family:inherit}
textarea:focus{outline:none;border-color:var(--primary);box-shadow:0 0 0 3px rgba(99,102,241,0.1)}
.model-select{display:flex;align-items:center;gap:12px;margin-bottom:16px}
.model-select label{font-weight:500}
.model-select select{padding:8px 12px;border:1px solid var(--border);border-radius:var(--radius);font-size:14px}
.model-select input[type=number]{width:80px}
.btn{padding:10px 24px;border:none;border-radius:var(--radius);font-size:14px;font-weight:600;cursor:pointer;transition:all 0.2s;display:inline-flex;align-items:center;gap:8px}
.btn-primary{background:var(--primary);color:#fff}
.btn-primary:hover{background:var(--primary-hover)}
.btn-success{background:var(--success);color:#fff}
.btn-success:hover{background:#16a34a}
.btn-outline{background:transparent;border:1px solid var(--border);color:var(--text)}
.btn-outline:hover{border-color:var(--primary);color:var(--primary)}
.btn:disabled{opacity:0.6;cursor:not-allowed}
.btn-group{display:flex;gap:8px;margin-top:16px}
.sql-preview{background:#1e293b;color:#e2e8f0;padding:16px;border-radius:var(--radius);font-family:monospace;white-space:pre-wrap;overflow:auto;max-height:300px;line-height:1.5}
.sql-block{background:#1e293b;color:#e2e8f0;padding:12px 16px;border-radius:var(--radius);margin:12px 0;font-family:monospace;overflow-x:auto}
.sql-block .label{color:#94a3b8;font-size:12px;text-transform:uppercase;letter-spacing:1px;margin-bottom:4px}
.status-msg{padding:12px 16px;border-radius:var(--radius);margin:12px 0;font-size:14px;display:flex;align-items:center;gap:8px}
.status-info{background:#e0f2fe;color:#0369a1}
.status-success{background:#dcfce7;color:#15803d}
.status-error{background:#fee2e2;color:#dc2626}
.loader{display:inline-block;width:16px;height:16px;border:2px solid currentColor;border-right-color:transparent;border-radius:50%;animation:spin 0.6s linear infinite}
@keyframes spin{to{transform:rotate(360deg)}}
#report-container{display:none}
#report-container iframe{width:100%;height:70vh;border:1px solid var(--border);border-radius:var(--radius)}
.section{display:none}
.section.active{display:block}
.edit-bar{display:flex;gap:8px;margin-top:16px;align-items:center}
.edit-bar input{flex:1;padding:10px 12px;border:1px solid var(--border);border-radius:var(--radius);font-size:14px}
.edit-bar input:focus{outline:none;border-color:var(--primary)}
.fade-in{animation:fadeIn 0.3s ease-in}
@keyframes fadeIn{from{opacity:0;transform:translateY(8px)}to{opacity:1;transform:none}}
</style>
</head>
<body>
<div class="app">
<div class="header">
<h1>AI Report Agent</h1>
<p>自然语言描述需求,自动生成数据报表</p>
</div>
<div class="steps">
<div class="step active" id="step-1"><h3>Step 1</h3><div class="num" data-step="1">描述需求</div></div>
<div class="step" id="step-2"><h3>Step 2</h3><div class="num" data-step="2">确认SQL</div></div>
<div class="step" id="step-3"><h3>Step 3</h3><div class="num" data-step="3">查看报表</div></div>
</div>
<div class="section active fade-in" id="section-prompt">
<div class="card">
<h2>输入报表需求</h2>
<div class="model-select">
<label>模型:</label>
<select id="model-name" required>
<option value="">-- 选择模型 --</option>
</select>
<label>最大行数:</label>
<input type="number" id="max-rows" value="100" min="1" max="1000">
</div>
<textarea id="user-prompt" rows="4" placeholder="例如查询最近30天注册的用户数量按日期分组统计"></textarea>
<div class="btn-group">
<button class="btn btn-primary" id="btn-generate"></button>
</div>
</div>
</div>
<div class="section fade-in" id="section-preview">
<div class="card">
<h2>SQL 预览与确认</h2>
<div id="preview-info"></div>
<div class="sql-preview" id="sql-code"></div>
<div class="btn-group">
<button class="btn btn-success" id="btn-execute" disabled></button>
<button class="btn btn-outline" id="btn-modify" disabled></button>
<button class="btn btn-outline" id="btn-cancel"></button>
</div>
</div>
</div>
<div class="section fade-in" id="section-report">
<div id="report-container">
<iframe id="report-frame" sandbox="allow-scripts allow-same-origin allow-forms"></iframe>
</div>
<div class="edit-bar">
<input type="text" id="edit-prompt" placeholder="输入修改要求,例如:把表格改成柱状图展示">
<button class="btn btn-primary" id="btn-refine"></button>
</div>
<div class="btn-group">
<button class="btn btn-outline" id="btn-back"></button>
</div>
</div>
<div id="status-area"></div>
</div>
<script>
const API_BASE = '';
let currentReport = {
model: '',
title: '',
summary: '',
sql: '',
html: '',
queryResult: ''
};
async function loadModels() {
try {
const res = await fetch(API_BASE + '/chat/model/list');
const data = await res.json();
const select = document.getElementById('model-name');
if (data.code === 200 && data.rows) {
data.rows.forEach(m => {
const opt = document.createElement('option');
opt.value = m.modelName || m.name;
opt.textContent = m.modelName || m.name;
select.appendChild(opt);
});
}
} catch (e) {
console.warn('无法加载模型列表,请手动填写', e);
}
}
function showStatus(msg, type) {
const area = document.getElementById('status-area');
area.innerHTML = '<div class="status-msg status-' + (type || 'info') + '">' + msg + '</div>';
setTimeout(() => area.innerHTML = '', 5000);
}
function setLoading(btn, loading) {
if (loading) {
btn.disabled = true;
btn._orig = btn.innerHTML;
btn.innerHTML = '<span class="loader"></span> 处理中...';
} else {
btn.disabled = false;
btn.innerHTML = btn._orig || '';
}
}
function setStep(s) {
document.querySelectorAll('.step').forEach(el => {
const n = parseInt(el.dataset.step || el.querySelector('.num').dataset.step);
el.classList.remove('active', 'done');
if (n === s) el.classList.add('active');
else if (n < s) el.classList.add('done');
});
document.querySelectorAll('.section').forEach(el => el.classList.remove('active'));
document.getElementById(['section-prompt','section-preview','section-report'][s - 1]).classList.add('active');
}
// Step 1: Generate SQL
document.getElementById('btn-generate').addEventListener('click', async () => {
const btn = document.getElementById('btn-generate');
const model = document.getElementById('model-name').value;
const prompt = document.getElementById('user-prompt').value.trim();
const maxRows = parseInt(document.getElementById('max-rows').value) || 100;
if (!model) { showStatus('请选择或填写模型名称', 'error'); return; }
if (!prompt) { showStatus('请输入报表需求', 'error'); return; }
setLoading(btn, true);
showStatus('正在生成 SQL 预览...', 'info');
try {
const res = await fetch(API_BASE + '/chat/report/generate', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ model, prompt, maxRows })
});
const data = await res.json();
if (data.code !== 200 || !data.data) throw new Error(data.msg || '生成失败');
currentReport.model = model;
currentReport.title = data.data.title;
currentReport.summary = data.data.summary;
currentReport.sql = data.data.sql;
currentReport.html = data.data.html || '';
currentReport.queryResult = data.data.queryResult || '';
document.getElementById('preview-info').innerHTML =
'<p><strong>报表标题:</strong> ' + escapeHtml(currentReport.title) + '</p>' +
'<p><strong>摘要:</strong> ' + escapeHtml(currentReport.summary) + '</p>';
document.getElementById('sql-code').textContent = currentReport.sql;
document.getElementById('btn-execute').disabled = false;
document.getElementById('btn-modify').disabled = false;
setStep(2);
showStatus('SQL 生成成功,请确认后执行', 'success');
} catch (e) {
showStatus('生成失败: ' + e.message, 'error');
} finally {
setLoading(btn, false);
}
});
// Step 2: Execute
document.getElementById('btn-execute').addEventListener('click', async () => {
const btn = document.getElementById('btn-execute');
setLoading(btn, true);
showStatus('正在执行查询并生成报表...', 'info');
try {
const res = await fetch(API_BASE + '/chat/report/execute', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
model: currentReport.model,
title: currentReport.title,
summary: currentReport.summary,
sql: currentReport.sql
})
});
const data = await res.json();
if (data.code !== 200 || !data.data) throw new Error(data.msg || '执行失败');
currentReport.html = data.data.html;
currentReport.queryResult = data.data.queryResult;
const container = document.getElementById('report-container');
const frame = document.getElementById('report-frame');
container.style.display = 'block';
frame.contentWindow.document.open();
frame.contentWindow.document.write(currentReport.html);
frame.contentWindow.document.close();
setStep(3);
showStatus('报表生成成功', 'success');
} catch (e) {
showStatus('执行失败: ' + e.message, 'error');
} finally {
setLoading(btn, false);
}
});
// Step 3: Refine
document.getElementById('btn-refine').addEventListener('click', async () => {
const btn = document.getElementById('btn-refine');
const prompt = document.getElementById('edit-prompt').value.trim();
if (!prompt) { showStatus('请输入修改要求', 'error'); return; }
setLoading(btn, true);
showStatus('正在优化报表...', 'info');
try {
const res = await fetch(API_BASE + '/chat/report/refine', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
model: currentReport.model,
prompt,
html: currentReport.html,
dataContext: currentReport.queryResult
})
});
const data = await res.json();
if (data.code !== 200 || !data.data) throw new Error(data.msg || '修改失败');
currentReport.html = data.data.html;
currentReport.summary = data.data.summary;
const frame = document.getElementById('report-frame');
frame.contentWindow.document.open();
frame.contentWindow.document.write(currentReport.html);
frame.contentWindow.document.close();
document.getElementById('edit-prompt').value = '';
showStatus('报表已更新', 'success');
} catch (e) {
showStatus('修改失败: ' + e.message, 'error');
} finally {
setLoading(btn, false);
}
});
// Back button
document.getElementById('btn-cancel').addEventListener('click', () => { setStep(1); showStatus('已取消', 'info'); });
document.getElementById('btn-back').addEventListener('click', () => { setStep(2); });
document.getElementById('btn-modify').addEventListener('click', () => { setStep(1); document.getElementById('user-prompt').value = document.getElementById('edit-prompt').value || ''; });
function escapeHtml(s) {
if (!s) return '';
const d = document.createElement('div');
d.textContent = s;
return d.innerHTML;
}
// Init
loadModels();
document.getElementById('btn-generate').textContent = '生成 SQL 预览';
document.getElementById('btn-execute').textContent = '确认执行并生成报表';
document.getElementById('btn-cancel').textContent = '返回修改';
document.getElementById('btn-modify').textContent = '重新编辑需求';
document.getElementById('btn-refine').textContent = '执行修改';
document.getElementById('btn-back').textContent = '返回查看 SQL';
</script>
</body>
</html>

View File

@@ -10,6 +10,7 @@ import org.springframework.boot.context.properties.EnableConfigurationProperties
import org.springframework.context.annotation.Bean;
import org.springframework.core.task.VirtualThreadTaskExecutor;
import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor;
import java.util.concurrent.*;
/**
@@ -22,6 +23,12 @@ import java.util.concurrent.*;
@EnableConfigurationProperties(ThreadPoolProperties.class)
public class ThreadPoolConfig {
private final ThreadPoolProperties properties;
public ThreadPoolConfig(ThreadPoolProperties properties) {
this.properties = properties;
}
/**
* 核心线程数 = cpu 核心数 + 1
*/
@@ -54,6 +61,22 @@ public class ThreadPoolConfig {
return scheduledThreadPoolExecutor;
}
/**
* 知识库解析专用异步线程池
*/
@Bean(name = "knowledgeParseExecutor")
public ThreadPoolTaskExecutor knowledgeParseExecutor() {
ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
executor.setCorePoolSize(core);
executor.setMaxPoolSize(core * 2);
executor.setQueueCapacity(properties.getQueueCapacity());
executor.setKeepAliveSeconds(properties.getKeepAliveSeconds());
executor.setThreadNamePrefix("knowledge-parse-pool-");
executor.setRejectedExecutionHandler(new ThreadPoolExecutor.CallerRunsPolicy());
executor.initialize();
return executor;
}
/**
* 销毁事件
* 停止线程池

View File

@@ -0,0 +1,27 @@
package org.ruoyi.agent;
import dev.langchain4j.agentic.Agent;
import dev.langchain4j.service.SystemMessage;
import dev.langchain4j.service.UserMessage;
import dev.langchain4j.service.V;
/**
* 通用 Coding Agent
* 使用任务规划、文件操作与受控命令执行能力完成开发任务
*/
public interface CodingAgent {
@SystemMessage("""
你是一个通用 coding agent负责把用户的软件开发请求落地为可执行改动。
必须遵循:
1. 先调用 task_planner 输出结构化计划与 approval token
2. 获得用户确认后再执行 create_file、edit_file、run_command
3. run_command 必须使用 task_planner 返回的 approval token 和 approval scope
4. 优先最小改动,保持可验证、可回滚
5. 输出最终变更摘要、验证结果与下一步建议
""")
@UserMessage("{{query}}")
@Agent("通用 Coding Agent支持任务规划、文件操作与受控命令执行")
String execute(@V("query") String query);
}

View File

@@ -0,0 +1,37 @@
package org.ruoyi.controller.chat;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
import org.ruoyi.common.core.domain.R;
import org.ruoyi.domain.dto.request.AiReportExecuteRequest;
import org.ruoyi.domain.dto.request.AiReportGenerateRequest;
import org.ruoyi.domain.dto.request.AiReportRefineRequest;
import org.ruoyi.domain.dto.response.AiReportResponse;
import org.ruoyi.service.report.IAiReportService;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
@RestController
@RequiredArgsConstructor
@RequestMapping("/chat/report")
public class AiReportController {
private final IAiReportService aiReportService;
@PostMapping("/generate")
public R<AiReportResponse> generate(@RequestBody @Valid AiReportGenerateRequest request) {
return R.ok(aiReportService.generate(request));
}
@PostMapping("/execute")
public R<AiReportResponse> execute(@RequestBody @Valid AiReportExecuteRequest request) {
return R.ok(aiReportService.execute(request));
}
@PostMapping("/refine")
public R<AiReportResponse> refine(@RequestBody @Valid AiReportRefineRequest request) {
return R.ok(aiReportService.refine(request));
}
}

View File

@@ -110,6 +110,17 @@ public class KnowledgeAttachController extends BaseController {
@PostMapping(value = "/upload")
public R<String> upload(KnowledgeInfoUploadBo bo){
knowledgeAttachService.upload(bo);
return R.ok("上传知识库附件成功!");
return R.ok("上传成功!");
}
/**
* 手动解析附件内容
*
* @param id 附件ID
*/
@PostMapping("/parse/{id}")
public R<Void> parse(@PathVariable Long id) {
knowledgeAttachService.parse(id);
return R.ok();
}
}

View File

@@ -8,6 +8,7 @@ import jakarta.validation.constraints.*;
import cn.dev33.satoken.annotation.SaCheckPermission;
import org.ruoyi.domain.bo.knowledge.KnowledgeFragmentBo;
import org.ruoyi.domain.vo.knowledge.KnowledgeFragmentVo;
import org.ruoyi.domain.vo.knowledge.KnowledgeRetrievalVo;
import org.ruoyi.service.knowledge.IKnowledgeFragmentService;
import org.springframework.web.bind.annotation.*;
import org.springframework.validation.annotation.Validated;
@@ -102,4 +103,12 @@ public class KnowledgeFragmentController extends BaseController {
@PathVariable Long[] ids) {
return toAjax(knowledgeFragmentService.deleteWithValidByIds(List.of(ids), true));
}
/**
* 检索测试
*/
@PostMapping("/retrieval")
public R<List<KnowledgeRetrievalVo>> retrieval(@RequestBody KnowledgeFragmentBo bo) {
return R.ok(knowledgeFragmentService.retrieval(bo));
}
}

View File

@@ -49,5 +49,44 @@ public class KnowledgeFragmentBo extends BaseEntity {
*/
private String remark;
/**
* 知识库ID
*/
private Long knowledgeId;
/**
* 检索内容
*/
private String query;
/**
* 返回条数
*/
private Integer topK;
/**
* 相似度阈值
*/
private Double threshold;
/**
* 是否启用重排
*/
private Boolean enableRerank;
/**
* 重排模型名称
*/
private String rerankModel;
/**
* 是否启用混合检索
*/
private Boolean enableHybrid;
/**
* 混合检索权重 (0.0-1.0)
*/
private Double hybridAlpha;
}

View File

@@ -62,6 +62,11 @@ public class KnowledgeInfoBo extends BaseEntity {
*/
private Long retrieveLimit;
/**
* 相似度阈值
*/
private Double similarityThreshold;
/**
* 文本块大小
*/
@@ -77,10 +82,40 @@ public class KnowledgeInfoBo extends BaseEntity {
*/
private String embeddingModel;
/**
* 是否启用重排序0 否 1是
*/
private Integer enableRerank;
/**
* 重排序模型名称
*/
private String rerankModel;
/**
* 重排序后返回的文档数量
*/
private Integer rerankTopN;
/**
* 重排序相关性分数阈值
*/
private Double rerankScoreThreshold;
/**
* 是否启用混合检索0 否 1是
*/
private Integer enableHybrid;
/**
* 混合检索权重 (0.0-1.0)
*/
private Double hybridAlpha;
/**
* 备注
*/
private String remark;
}

View File

@@ -16,6 +16,11 @@ public class KnowledgeInfoUploadBo {
private MultipartFile file;
/**
* 是否自动解析 (true: 立即解析, false: 仅上传)
*/
private Boolean autoParse;
/**
* 生效时间, 为空则立即生效
*/

View File

@@ -0,0 +1,44 @@
package org.ruoyi.domain.bo.rerank;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.util.List;
/**
* 重排序请求参数
*
* @author yang
* @date 2026-04-19
*/
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class RerankRequest {
/**
* 查询文本
*/
private String query;
/**
* 候选文档列表
*/
private List<String> documents;
/**
* 返回的文档数量topN
* 如果不指定,默认返回所有文档
*/
private Integer topN;
/**
* 是否返回原始文档内容
* 默认为 true
*/
@Builder.Default
private Boolean returnDocuments = true;
}

View File

@@ -0,0 +1,72 @@
package org.ruoyi.domain.bo.rerank;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.util.List;
/**
* 重排序结果
*
* @author yang
* @date 2026-04-19
*/
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class RerankResult {
/**
* 重排序后的文档结果列表
*/
private List<RerankDocument> documents;
/**
* 原始请求中的文档总数
*/
private Integer totalDocuments;
/**
* 重排序耗时(毫秒)
*/
private Long durationMs;
/**
* 单个重排序文档结果
*/
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public static class RerankDocument {
/**
* 文档在原始列表中的索引位置
*/
private Integer index;
/**
* 相关性分数(通常 0-1 之间,越高越相关)
*/
private Double relevanceScore;
/**
* 文档内容
*/
private String document;
}
/**
* 创建空结果
*/
public static RerankResult empty() {
return RerankResult.builder()
.documents(List.of())
.totalDocuments(0)
.durationMs(0L)
.build();
}
}

View File

@@ -51,4 +51,48 @@ public class QueryVectorBo {
*/
private String baseUrl;
// ========== 重排序相关参数 ==========
/**
* 是否启用重排序
* 默认为 false
*/
private Boolean enableRerank = false;
/**
* 重排序模型名称
*/
private String rerankModelName;
/**
* 重排序后返回的文档数量topN
* 如果不指定,默认与 maxResults 相同
*/
private Integer rerankTopN;
/**
* 重排序相关性分数阈值
* 低于此阈值的文档将被过滤
*/
private Double rerankScoreThreshold;
// ========== 混合检索与阈值相关参数 ==========
/**
* 相似度阈值 (0.0-1.0)
* 应用于向量搜索阶段
*/
private Double similarityThreshold;
/**
* 是否启用混合检索
*/
private Boolean enableHybrid = false;
/**
* 混合检索权重 (0.0-1.0)
*/
private Double hybridAlpha;
}

View File

@@ -0,0 +1,20 @@
package org.ruoyi.domain.dto.request;
import jakarta.validation.constraints.NotBlank;
import lombok.Data;
@Data
public class AiReportExecuteRequest {
@NotBlank(message = "模型不能为空")
private String model;
@NotBlank(message = "报表标题不能为空")
private String title;
@NotBlank(message = "报表摘要不能为空")
private String summary;
@NotBlank(message = "SQL 不能为空")
private String sql;
}

View File

@@ -0,0 +1,16 @@
package org.ruoyi.domain.dto.request;
import jakarta.validation.constraints.NotBlank;
import lombok.Data;
@Data
public class AiReportGenerateRequest {
@NotBlank(message = "模型不能为空")
private String model;
@NotBlank(message = "报表需求不能为空")
private String prompt;
private Integer maxRows;
}

View File

@@ -0,0 +1,19 @@
package org.ruoyi.domain.dto.request;
import jakarta.validation.constraints.NotBlank;
import lombok.Data;
@Data
public class AiReportRefineRequest {
@NotBlank(message = "模型不能为空")
private String model;
@NotBlank(message = "编辑提示词不能为空")
private String prompt;
@NotBlank(message = "当前报表 HTML 不能为空")
private String html;
private String dataContext;
}

View File

@@ -0,0 +1,55 @@
package org.ruoyi.domain.dto.request;
import com.fasterxml.jackson.annotation.JsonInclude;
import com.fasterxml.jackson.annotation.JsonProperty;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper;
import java.util.List;
/**
* 阿里百炼重排序请求DTOOpenAI兼容格式
*
* @author yang
* @date 2026-04-20
*/
@JsonInclude(JsonInclude.Include.NON_NULL)
public record AliBaiLianRerankRequest(
String model,
List<String> documents,
String query,
@JsonProperty("top_n")
Integer topN,
String instruct,
@JsonProperty("return_documents")
Boolean returnDocuments
) {
private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper();
/**
* 创建文本重排序请求
*/
public static AliBaiLianRerankRequest create(String modelName, String query,
List<String> documents, Integer topN,
Boolean returnDocuments) {
return new AliBaiLianRerankRequest(
modelName,
documents,
query,
topN != null ? topN : documents.size(),
null,
returnDocuments != null ? returnDocuments : true
);
}
/**
* 转换为JSON字符串
*/
public String toJson() {
try {
return OBJECT_MAPPER.writeValueAsString(this);
} catch (JsonProcessingException e) {
throw new IllegalArgumentException("序列化阿里百炼重排序请求失败", e);
}
}
}

View File

@@ -0,0 +1,48 @@
package org.ruoyi.domain.dto.request;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper;
import java.util.List;
/**
* 智谱AI重排序请求DTO
*
* @author yang
* @date 2026-04-19
*/
public record ZhipuRerankRequest(
String model,
String query,
List<String> documents,
Integer top_n,
Boolean return_documents
) {
private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper();
/**
* 创建智谱重排序请求
*/
public static ZhipuRerankRequest create(String modelName, String query,
List<String> documents, Integer topN,
Boolean returnDocuments) {
return new ZhipuRerankRequest(
modelName,
query,
documents,
topN != null ? topN : documents.size(),
returnDocuments != null ? returnDocuments : true
);
}
/**
* 转换为JSON字符串
*/
public String toJson() {
try {
return OBJECT_MAPPER.writeValueAsString(this);
} catch (JsonProcessingException e) {
throw new IllegalArgumentException("序列化智谱重排序请求失败", e);
}
}
}

View File

@@ -0,0 +1,19 @@
package org.ruoyi.domain.dto.response;
import lombok.Builder;
import lombok.Data;
@Data
@Builder
public class AiReportResponse {
private String sql;
private String title;
private String summary;
private String queryResult;
private String html;
}

View File

@@ -0,0 +1,81 @@
package org.ruoyi.domain.dto.response;
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
import com.fasterxml.jackson.annotation.JsonProperty;
import org.ruoyi.domain.bo.rerank.RerankResult;
import java.util.List;
import java.util.Map;
import java.util.stream.Collectors;
/**
* 阿里百炼重排序响应DTOOpenAI兼容格式
*
* @author yang
* @date 2026-04-20
*/
@JsonIgnoreProperties(ignoreUnknown = true)
public record AliBaiLianRerankResponse(
String id,
String object,
List<ResultItem> results,
UsageInfo usage
) {
/**
* 单个重排序结果项
*/
@JsonIgnoreProperties(ignoreUnknown = true)
public record ResultItem(
Integer index,
@JsonProperty("relevance_score")
Double relevanceScore,
Object document
) {
/**
* 获取文档文本内容
*/
public String getDocumentText() {
if (document == null) return null;
if (document instanceof String) return (String) document;
if (document instanceof Map) {
Object text = ((Map<?, ?>) document).get("text");
return text != null ? text.toString() : null;
}
return document.toString();
}
}
/**
* Token使用信息
*/
@JsonIgnoreProperties(ignoreUnknown = true)
public record UsageInfo(
@JsonProperty("total_tokens")
Integer totalTokens,
@JsonProperty("prompt_tokens")
Integer promptTokens
) {}
/**
* 转换为通用RerankResult
*/
public RerankResult toRerankResult(int totalDocs, long durationMs) {
if (results == null || results.isEmpty()) {
return RerankResult.empty();
}
List<RerankResult.RerankDocument> documents = results.stream()
.map(item -> RerankResult.RerankDocument.builder()
.index(item.index())
.relevanceScore(item.relevanceScore())
.document(item.getDocumentText())
.build())
.collect(Collectors.toList());
return RerankResult.builder()
.documents(documents)
.totalDocuments(totalDocs)
.durationMs(durationMs)
.build();
}
}

View File

@@ -0,0 +1,68 @@
package org.ruoyi.domain.dto.response;
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
import com.fasterxml.jackson.annotation.JsonProperty;
import org.ruoyi.domain.bo.rerank.RerankResult;
import java.util.List;
import java.util.stream.Collectors;
/**
* 智谱AI重排序响应DTO
*
* @author yang
* @date 2026-04-19
*/
@JsonIgnoreProperties(ignoreUnknown = true)
public record ZhipuRerankResponse(
String model,
String object,
List<ResultItem> results,
UsageInfo usage
) {
/**
* 单个重排序结果项
*/
public record ResultItem(
Integer index,
@JsonProperty("relevance_score")
Double relevanceScore,
String document
) {}
/**
* Token使用信息
*/
@JsonIgnoreProperties(ignoreUnknown = true)
public record UsageInfo(
@JsonProperty("total_tokens")
Integer totalTokens,
@JsonProperty("input_tokens")
Integer inputTokens,
@JsonProperty("output_tokens")
Integer outputTokens
) {}
/**
* 转换为通用RerankResult
*/
public RerankResult toRerankResult(int totalDocs, long durationMs) {
if (results == null || results.isEmpty()) {
return RerankResult.empty();
}
List<RerankResult.RerankDocument> documents = results.stream()
.map(item -> RerankResult.RerankDocument.builder()
.index(item.index())
.relevanceScore(item.relevanceScore())
.document(item.document())
.build())
.collect(Collectors.toList());
return RerankResult.builder()
.documents(documents)
.totalDocuments(totalDocs)
.durationMs(durationMs)
.build();
}
}

View File

@@ -57,5 +57,10 @@ public class KnowledgeAttach extends BaseEntity {
*/
private String remark;
/**
* 解析状态: 0待解析, 1解析中, 2已解析, 3解析失败
*/
private Integer status;
}

View File

@@ -47,5 +47,10 @@ public class KnowledgeFragment extends BaseEntity {
*/
private String remark;
/**
* 知识库ID
*/
private Long knowledgeId;
}

View File

@@ -63,6 +63,11 @@ public class KnowledgeInfo extends BaseEntity {
*/
private Long retrieveLimit;
/**
* 相似度阈值
*/
private Double similarityThreshold;
/**
* 文本块大小
*/
@@ -78,6 +83,36 @@ public class KnowledgeInfo extends BaseEntity {
*/
private String embeddingModel;
/**
* 是否启用重排序0 否 1是
*/
private Integer enableRerank;
/**
* 重排序模型名称
*/
private String rerankModel;
/**
* 重排序后返回的文档数量
*/
private Integer rerankTopN;
/**
* 重排序相关性分数阈值
*/
private Double rerankScoreThreshold;
/**
* 是否启用混合检索0 否 1是
*/
private Integer enableHybrid;
/**
* 混合检索权重 (0.0-1.0)
*/
private Double hybridAlpha;
/**
* 备注
*/

View File

@@ -0,0 +1,20 @@
package org.ruoyi.domain.vo.knowledge;
import lombok.Data;
/**
* 文档分块数统计 VO用于 GROUP BY 查询结果接收)
*/
@Data
public class DocFragmentCountVo {
/**
* 文档ID关联 knowledge_attach.doc_id
*/
private String docId;
/**
* 该文档下的分块数量
*/
private Integer fragmentCount;
}

View File

@@ -8,6 +8,7 @@ import org.ruoyi.domain.entity.knowledge.KnowledgeAttach;
import java.io.Serial;
import java.io.Serializable;
import java.util.Date;
@@ -68,5 +69,22 @@ public class KnowledgeAttachVo implements Serializable {
@ExcelProperty(value = "备注")
private String remark;
/**
* 上传时间(来自 BaseEntity.createTime
*/
@ExcelProperty(value = "上传时间")
private Date createTime;
/**
* 解析状态: 0待解析, 1解析中, 2已解析, 3解析失败
*/
@ExcelProperty(value = "解析状态")
private Integer status;
/**
* 分块数(统计字段,非数据库列)
*/
private Integer fragmentCount;
}

View File

@@ -39,7 +39,7 @@ public class KnowledgeFragmentVo implements Serializable {
* 片段索引下标
*/
@ExcelProperty(value = "片段索引下标")
private Long idx;
private Integer idx;
/**
* 文档内容
@@ -53,5 +53,10 @@ public class KnowledgeFragmentVo implements Serializable {
@ExcelProperty(value = "备注")
private String remark;
/**
* 知识库ID
*/
private Long knowledgeId;
}

View File

@@ -76,6 +76,12 @@ public class KnowledgeInfoVo implements Serializable {
@ExcelProperty(value = "知识库中检索的条数")
private Integer retrieveLimit;
/**
* 相似度阈值
*/
@ExcelProperty(value = "相似度阈值")
private Double similarityThreshold;
/**
* 文本块大小
*/
@@ -94,6 +100,48 @@ public class KnowledgeInfoVo implements Serializable {
@ExcelProperty(value = "向量模型")
private String embeddingModel;
/**
* 是否启用重排序0 否 1是
*/
@ExcelProperty(value = "是否启用重排序")
private Integer enableRerank;
/**
* 重排序模型名称
*/
@ExcelProperty(value = "重排序模型")
private String rerankModel;
/**
* 重排序后返回的文档数量
*/
@ExcelProperty(value = "重排序返回数量")
private Integer rerankTopN;
/**
* 重排序相关性分数阈值
*/
@ExcelProperty(value = "重排序分数阈值")
private Double rerankScoreThreshold;
/**
* 是否启用混合检索0 否 1是
*/
@ExcelProperty(value = "是否启用混合检索")
private Integer enableHybrid;
/**
* 混合检索权重 (0.0-1.0)
*/
@ExcelProperty(value = "混合检索权重")
private Double hybridAlpha;
/**
* 文档数量
*/
@ExcelProperty(value = "文档数量")
private Integer documentCount;
/**
* 备注
*/

View File

@@ -0,0 +1,69 @@
package org.ruoyi.domain.vo.knowledge;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.io.Serial;
import java.io.Serializable;
/**
* 知识检索测试结果视图对象
*
* @author RobustH
*/
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class KnowledgeRetrievalVo implements Serializable {
@Serial
private static final long serialVersionUID = 1L;
/**
* 片段ID
*/
private String id;
/**
* 文档ID
*/
private String docId;
/**
* 知识库ID
*/
private Long knowledgeId;
/**
* 分片索引
*/
private Integer idx;
/**
* 片段内容
*/
private String content;
/**
* 相似度得分
*/
private Double score;
/**
* 原始检索排名 (重排前)
*/
private Integer originalIndex;
/**
* 原始检索得分 (重排前)
*/
private Double rawScore;
/**
* 来源文档名称
*/
private String sourceName;
}

View File

@@ -0,0 +1,38 @@
package org.ruoyi.enums;
import lombok.AllArgsConstructor;
import lombok.Getter;
/**
* 知识库附件解析状态枚举
*
* @author RobustH
*/
@Getter
@AllArgsConstructor
public enum KnowledgeAttachStatus {
/**
* 待解析
*/
WAITING(0, "待解析"),
/**
* 解析中
*/
PARSING(1, "解析中"),
/**
* 已解析
*/
COMPLETED(2, "已解析"),
/**
* 解析失败
*/
FAILED(3, "解析失败");
private final Integer code;
private final String info;
}

View File

@@ -0,0 +1,106 @@
package org.ruoyi.factory;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.ruoyi.common.chat.domain.vo.chat.ChatModelVo;
import org.ruoyi.common.chat.service.chat.IChatModelService;
import org.ruoyi.service.rerank.RerankModelService;
import org.springframework.beans.factory.NoSuchBeanDefinitionException;
import org.springframework.context.ApplicationContext;
import org.springframework.stereotype.Service;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
/**
* 重排序模型工厂服务类
* 参考设计模式EmbeddingModelFactory
* 负责创建和管理重排序模型实例
*
* @author yang
* @date 2026-04-19
*/
@Service
@RequiredArgsConstructor
@Slf4j
public class RerankModelFactory {
private final ApplicationContext applicationContext;
private final IChatModelService chatModelService;
/**
* 模型缓存使用ConcurrentHashMap保证线程安全
*/
private final Map<String, RerankModelService> modelCache = new ConcurrentHashMap<>();
/**
* 创建重排序模型实例
* 如果模型已存在于缓存中,则直接返回;否则创建新的实例
*
* @param rerankModelName 重排序模型名称
*/
public RerankModelService createModel(String rerankModelName) {
return modelCache.computeIfAbsent(rerankModelName, name -> {
ChatModelVo modelConfig = chatModelService.selectModelByName(rerankModelName);
if (modelConfig == null) {
throw new IllegalArgumentException("未找到重排序模型配置name=" + name);
}
return createModelInstance(modelConfig.getProviderCode(), modelConfig);
});
}
/**
* 刷新模型缓存
* 根据给定的模型ID从缓存中移除对应的模型
*
* @param modelId 模型的唯一标识ID
*/
public void refreshModel(Long modelId) {
modelCache.remove(modelId);
}
/**
* 获取所有支持模型工厂的列表
*
* @return 支持的模型工厂名称列表
*/
public List<String> getSupportedFactories() {
return new ArrayList<>(applicationContext.getBeansOfType(RerankModelService.class)
.keySet());
}
/**
* 创建具体的模型实例
* 根据提供的工厂名称和配置信息创建并配置模型实例
*
* @param factory 工厂名称用于标识模型类型providerCode
* @param config 模型配置信息
* @return RerankModelService 配置好的模型实例
* @throws IllegalArgumentException 当无法获取指定的模型实例时抛出
*/
private RerankModelService createModelInstance(String factory, ChatModelVo config) {
try {
// 优先尝试使用 providerCode + "Rerank" 作为 Bean 名称
// 例如zhipu -> zhipuRerankjina -> jinaRerank
String rerankBeanName = factory + "Rerank";
RerankModelService model = applicationContext.getBean(rerankBeanName, RerankModelService.class);
model.configure(config);
log.info("成功创建重排序模型: factory={}, modelName={}", rerankBeanName, config.getModelName());
return model;
} catch (NoSuchBeanDefinitionException e) {
// 如果找不到,尝试使用原始的 providerCode
try {
RerankModelService model = applicationContext.getBean(factory, RerankModelService.class);
model.configure(config);
log.info("成功创建重排序模型: factory={}, modelName={}", factory, config.getModelName());
return model;
} catch (NoSuchBeanDefinitionException ex) {
throw new IllegalArgumentException("获取不到重排序模型: " + factory + "" + factory + "Rerank", ex);
}
}
}
}

View File

@@ -1,5 +1,8 @@
package org.ruoyi.mapper.knowledge;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import org.apache.ibatis.annotations.Select;
import org.ruoyi.domain.entity.knowledge.KnowledgeAttach;
import org.ruoyi.domain.vo.knowledge.KnowledgeAttachVo;
import org.ruoyi.common.mybatis.core.mapper.BaseMapperPlus;
@@ -10,6 +13,12 @@ import org.ruoyi.common.mybatis.core.mapper.BaseMapperPlus;
* @author ageerle
* @date 2025-12-17
*/
@Mapper
public interface KnowledgeAttachMapper extends BaseMapperPlus<KnowledgeAttach, KnowledgeAttachVo> {
/**
* 统计指定知识库下的文档数量
*/
@Select("SELECT COUNT(*) FROM knowledge_attach WHERE knowledge_id = #{knowledgeId}")
int countByKnowledgeId(@Param("knowledgeId") Long knowledgeId);
}

View File

@@ -1,15 +1,45 @@
package org.ruoyi.mapper.knowledge;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import org.apache.ibatis.annotations.Select;
import org.ruoyi.domain.entity.knowledge.KnowledgeFragment;
import org.ruoyi.domain.vo.knowledge.DocFragmentCountVo;
import org.ruoyi.domain.vo.knowledge.KnowledgeFragmentVo;
import org.ruoyi.common.mybatis.core.mapper.BaseMapperPlus;
import java.util.List;
/**
* 知识片段Mapper接口
*
* @author ageerle
* @date 2025-12-17
*/
@Mapper
public interface KnowledgeFragmentMapper extends BaseMapperPlus<KnowledgeFragment, KnowledgeFragmentVo> {
/**
* 批量统计各文档的分块数(强类型接收,避免 Map key 大小写问题)
*
* @param docIds 文档 ID 列表
* @return 每个 docId 对应的分块数列表
*/
@Select("<script>" +
"SELECT doc_id AS docId, COUNT(*) AS fragmentCount " +
"FROM knowledge_fragment " +
"WHERE doc_id IN " +
"<foreach collection='docIds' item='id' open='(' separator=',' close=')'>#{id}</foreach> " +
"GROUP BY doc_id" +
"</script>")
List<DocFragmentCountVo> selectFragmentCountByDocIds(@Param("docIds") List<String> docIds);
@Select("<script>" +
"SELECT id, doc_id AS docId, content, idx, knowledge_id AS knowledgeId " +
"FROM knowledge_fragment " +
"WHERE knowledge_id = #{knowledgeId} " +
"AND MATCH (content) AGAINST (#{query} IN NATURAL LANGUAGE MODE) " +
"ORDER BY MATCH (content) AGAINST (#{query} IN NATURAL LANGUAGE MODE) DESC " +
"LIMIT #{limit}" +
"</script>")
List<KnowledgeFragmentVo> searchByKeyword(@Param("knowledgeId") Long knowledgeId, @Param("query") String query, @Param("limit") Integer limit);
}

View File

@@ -0,0 +1,49 @@
package org.ruoyi.mcp.tools;
import java.time.Instant;
import java.util.Map;
import java.util.UUID;
import java.util.concurrent.ConcurrentHashMap;
/**
* 执行审批令牌存储
* 使用内存存储短期有效令牌,用于“先计划后执行”闭环
*/
public final class ApprovalTokenStore {
private static final Map<String, TokenRecord> TOKENS = new ConcurrentHashMap<>();
private ApprovalTokenStore() {
}
public static String issue(String goal, long ttlSeconds) {
cleanupExpired();
String token = UUID.randomUUID().toString();
long expireAt = Instant.now().getEpochSecond() + Math.max(60, ttlSeconds);
TOKENS.put(token, new TokenRecord(goal == null ? "" : goal, expireAt));
return token;
}
public static boolean consume(String token, String scope) {
if (token == null || token.isBlank()) {
return false;
}
TokenRecord record = TOKENS.remove(token.trim());
if (record == null) {
return false;
}
if (record.expireAtEpochSeconds() < Instant.now().getEpochSecond()) {
return false;
}
String normalizedScope = scope == null ? "" : scope.trim();
return record.goal().equals(normalizedScope);
}
private static void cleanupExpired() {
long now = Instant.now().getEpochSecond();
TOKENS.entrySet().removeIf(entry -> entry.getValue().expireAtEpochSeconds() < now);
}
private record TokenRecord(String goal, long expireAtEpochSeconds) {
}
}

View File

@@ -0,0 +1,116 @@
package org.ruoyi.mcp.tools;
import dev.langchain4j.agent.tool.Tool;
import org.ruoyi.mcp.service.core.BuiltinToolProvider;
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;
/**
* 创建文件工具
* 在工作区内创建新文件,可选择是否覆盖已有文件
*/
@Component
public class CreateFileTool implements BuiltinToolProvider {
public static final String DESCRIPTION = "Creates a new file with provided content. " +
"Supports creating parent directories automatically. " +
"Use absolute paths within the workspace directory. " +
"Set overwrite to true to replace existing file content.";
private final String rootDirectory;
private final org.slf4j.Logger logger = org.slf4j.LoggerFactory.getLogger(getClass());
public CreateFileTool() {
this.rootDirectory = Paths.get(System.getProperty("user.dir"), "workspace").toString();
}
@Tool(DESCRIPTION)
public String createFile(String filePath, String content, Boolean overwrite) {
try {
if (filePath == null || filePath.trim().isEmpty()) {
return "Error: File path cannot be empty";
}
Path path = Paths.get(filePath);
if (!path.isAbsolute()) {
return "Error: File path must be absolute: " + filePath;
}
if (!isWithinWorkspace(path)) {
return "Error: File path must be within the workspace directory (" + rootDirectory + "): " + filePath;
}
if (Files.exists(path) && Files.isDirectory(path)) {
return "Error: Path is a directory, not a file: " + filePath;
}
boolean allowOverwrite = overwrite != null && overwrite;
if (Files.exists(path) && !allowOverwrite) {
return "Error: File already exists. Set overwrite=true to replace content: " + filePath;
}
Path parent = path.getParent();
if (parent != null && !Files.exists(parent)) {
Files.createDirectories(parent);
}
String safeContent = content == null ? "" : content;
if (allowOverwrite) {
Files.writeString(path, safeContent, StandardCharsets.UTF_8,
StandardOpenOption.CREATE, StandardOpenOption.TRUNCATE_EXISTING, StandardOpenOption.WRITE);
} else {
Files.writeString(path, safeContent, StandardCharsets.UTF_8,
StandardOpenOption.CREATE_NEW, StandardOpenOption.WRITE);
}
return "Successfully created file: " + getRelativePath(path);
} catch (IOException e) {
logger.error("Error creating file: {}", filePath, e);
return "Error: " + e.getMessage();
} catch (Exception e) {
logger.error("Unexpected error creating file: {}", filePath, e);
return "Error: Unexpected error: " + e.getMessage();
}
}
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();
}
}
@Override
public String getToolName() {
return "create_file";
}
@Override
public String getDisplayName() {
return "创建文件";
}
@Override
public String getDescription() {
return DESCRIPTION;
}
}

View File

@@ -11,7 +11,10 @@ import java.nio.file.Path;
import java.nio.file.Paths;
import java.nio.file.StandardOpenOption;
import java.util.Arrays;
import java.util.ArrayList;
import java.util.List;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
/**
* 编辑文件工具
@@ -75,12 +78,9 @@ public class EditFileTool implements BuiltinToolProvider {
// 读取原始内容
String originalContent = Files.readString(path, StandardCharsets.UTF_8);
List<String> originalLines = Arrays.asList(originalContent.split("\n"));
// 应用diff
try {
// 这里简化处理,直接用新内容替换
// 在实际应用中可能需要更复杂的diff解析
String newContent = applyDiff(originalContent, diff);
// 写入文件
@@ -104,14 +104,100 @@ public class EditFileTool implements BuiltinToolProvider {
}
/**
* 简化的diff应用逻辑
* 实际应用中可能需要使用更复杂的diff解析器
* 仅支持 unified diff包含 @@ hunk 头)
*/
private String applyDiff(String originalContent, String diff) {
// 这里简化处理实际应用中需要解析diff格式
// 目前将diff作为新内容直接替换
// 可以考虑使用jgit等库来解析 unified diff 格式
return diff;
List<String> originalLines = new ArrayList<>(Arrays.asList(originalContent.split("\n", -1)));
List<String> diffLines = Arrays.asList(diff.split("\n", -1));
int i = 0;
while (i < diffLines.size()) {
String line = diffLines.get(i);
if (line.startsWith("---") || line.startsWith("+++")) {
i++;
continue;
}
if (!line.startsWith("@@")) {
i++;
continue;
}
HunkHeader header = parseHunkHeader(line);
int targetIndex = Math.max(0, header.oldStart - 1);
i++;
while (i < diffLines.size()) {
String hunkLine = diffLines.get(i);
if (hunkLine.startsWith("@@") || hunkLine.startsWith("---") || hunkLine.startsWith("+++")) {
break;
}
if (hunkLine.startsWith("\\ No newline at end of file")) {
i++;
continue;
}
if (hunkLine.isEmpty()) {
// unified diff 中空内容上下文行会表现为空字符串,视为上下文行
ensureExpectedLine(originalLines, targetIndex, "");
targetIndex++;
i++;
continue;
}
char op = hunkLine.charAt(0);
String content = hunkLine.length() > 1 ? hunkLine.substring(1) : "";
switch (op) {
case ' ':
ensureExpectedLine(originalLines, targetIndex, content);
targetIndex++;
break;
case '-':
ensureExpectedLine(originalLines, targetIndex, content);
originalLines.remove(targetIndex);
break;
case '+':
originalLines.add(targetIndex, content);
targetIndex++;
break;
default:
throw new IllegalArgumentException("Unsupported diff line: " + hunkLine);
}
i++;
}
}
return String.join("\n", originalLines);
}
private void ensureExpectedLine(List<String> lines, int index, String expected) {
if (index < 0 || index >= lines.size()) {
throw new IllegalArgumentException("Diff out of range at line index: " + index);
}
String actual = lines.get(index);
if (!actual.equals(expected)) {
throw new IllegalArgumentException("Diff context mismatch at line " + (index + 1)
+ ", expected: [" + expected + "], actual: [" + actual + "]");
}
}
private HunkHeader parseHunkHeader(String headerLine) {
Pattern p = Pattern.compile("@@ -(\\d+)(?:,(\\d+))? \\+(\\d+)(?:,(\\d+))? @@.*");
Matcher m = p.matcher(headerLine);
if (!m.matches()) {
throw new IllegalArgumentException("Invalid unified diff hunk header: " + headerLine);
}
int oldStart = Integer.parseInt(m.group(1));
int oldCount = m.group(2) == null ? 1 : Integer.parseInt(m.group(2));
int newStart = Integer.parseInt(m.group(3));
int newCount = m.group(4) == null ? 1 : Integer.parseInt(m.group(4));
return new HunkHeader(oldStart, oldCount, newStart, newCount);
}
private record HunkHeader(int oldStart, int oldCount, int newStart, int newCount) {
}
private boolean isWithinWorkspace(Path filePath) {

View File

@@ -0,0 +1,260 @@
package org.ruoyi.mcp.tools;
import dev.langchain4j.agent.tool.Tool;
import org.ruoyi.mcp.service.core.BuiltinToolProvider;
import org.springframework.stereotype.Component;
import java.io.BufferedReader;
import java.io.IOException;
import java.io.InputStreamReader;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.time.Duration;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.concurrent.ExecutionException;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.Future;
import java.util.concurrent.TimeUnit;
import java.util.stream.Collectors;
/**
* 受控命令执行工具
* 仅允许执行白名单命令,禁止高风险和破坏性命令
*/
@Component
public class RunCommandTool implements BuiltinToolProvider {
public static final String DESCRIPTION = "Runs a safe whitelisted command in workspace. " +
"Only supports non-interactive commands for build/test/git status workflows. " +
"Blocks destructive and shell-chaining commands. " +
"Use absolute working directory inside workspace. " +
"Requires an approval token from task_planner before execution.";
private static final int DEFAULT_TIMEOUT_SECONDS = 60;
private static final int MAX_TIMEOUT_SECONDS = 120;
private static final int MAX_OUTPUT_CHARS = 20_000;
private static final Set<String> ALLOWED_COMMANDS = Set.of(
"mvn", "./mvnw", "npm", "pnpm", "yarn", "go", "gradle", "./gradlew", "git"
);
private static final Map<String, Set<String>> ALLOWED_SUBCOMMANDS = Map.of(
"git", Set.of("status", "diff", "log", "branch", "checkout", "switch", "add", "commit", "restore"),
"mvn", Set.of("compile", "test", "package", "verify"),
"./mvnw", Set.of("compile", "test", "package", "verify"),
"npm", Set.of("run", "test", "install", "ci"),
"pnpm", Set.of("run", "test", "install"),
"yarn", Set.of("run", "test", "install"),
"go", Set.of("test", "build"),
"gradle", Set.of("test", "build"),
"./gradlew", Set.of("test", "build")
);
private static final Set<String> BLOCKED_EXACT_TOKENS = Set.of(
"rm", "rmdir", "unlink", "shutdown", "reboot", "poweroff", "sudo", "su", "mkfs", "fdisk",
"mount", "umount", "iptables", "nft", "useradd", "userdel"
);
private static final List<String> BLOCKED_PHRASES = List.of(
"git reset --hard", "git clean -f", "git clean -fd", "git clean -xdf"
);
private final String rootDirectory;
private final org.slf4j.Logger logger = org.slf4j.LoggerFactory.getLogger(getClass());
public RunCommandTool() {
this.rootDirectory = Paths.get(System.getProperty("user.dir"), "workspace").toString();
}
@Tool(DESCRIPTION)
public String runCommand(String command, String workingDirectory, Integer timeoutSeconds, String approvalToken,
String approvalScope) {
try {
if (!ApprovalTokenStore.consume(approvalToken, approvalScope)) {
return "Error: Invalid or expired approval token. Please generate a new plan token via task_planner and confirm before execution.";
}
if (command == null || command.trim().isEmpty()) {
return "Error: Command cannot be empty";
}
String normalized = command.trim();
if (containsShellChaining(normalized)) {
return "Error: Shell chaining operators are not allowed";
}
String lower = normalized.toLowerCase();
for (String blocked : BLOCKED_PHRASES) {
if (lower.contains(blocked)) {
return "Error: Command contains blocked token: " + blocked;
}
}
List<String> parts = List.of(normalized.split("\\s+"));
List<String> lowerParts = parts.stream().map(String::toLowerCase).collect(Collectors.toList());
for (String token : lowerParts) {
if (BLOCKED_EXACT_TOKENS.contains(token)) {
return "Error: Command contains blocked token: " + token;
}
}
String binary = parts.get(0);
if (!ALLOWED_COMMANDS.contains(binary)) {
return "Error: Command is not in allowed list: " + binary;
}
String subcommandError = validateSubcommand(binary, parts);
if (subcommandError != null) {
return "Error: " + subcommandError;
}
Path workdir = resolveWorkdir(workingDirectory);
if (!Files.exists(workdir) || !Files.isDirectory(workdir)) {
return "Error: Working directory not found: " + workdir;
}
int timeout = timeoutSeconds == null ? DEFAULT_TIMEOUT_SECONDS : timeoutSeconds;
if (timeout < 1 || timeout > MAX_TIMEOUT_SECONDS) {
return "Error: timeoutSeconds must be between 1 and " + MAX_TIMEOUT_SECONDS;
}
ProcessBuilder pb = new ProcessBuilder(parts);
pb.directory(workdir.toFile());
pb.redirectErrorStream(true);
Process process = pb.start();
ExecutorService executor = Executors.newSingleThreadExecutor();
Future<String> outputFuture = executor.submit(() -> readProcessOutput(process, MAX_OUTPUT_CHARS));
boolean finished = process.waitFor(timeout, TimeUnit.SECONDS);
if (!finished) {
process.destroyForcibly();
process.waitFor(5, TimeUnit.SECONDS);
executor.shutdownNow();
return "Error: Command timeout after " + timeout + " seconds";
}
String output = getOutputSafely(outputFuture, executor);
int code = process.exitValue();
String relativeWd = getRelativePath(workdir);
String summary = "Command: " + normalized + "\nWorkingDirectory: " + relativeWd + "\nExitCode: " + code + "\n\n";
return summary + output;
} catch (IOException e) {
logger.error("Error running command: {}", command, e);
return "Error: " + e.getMessage();
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
return "Error: Command execution interrupted";
} catch (Exception e) {
logger.error("Unexpected error running command: {}", command, e);
return "Error: Unexpected error: " + e.getMessage();
}
}
private Path resolveWorkdir(String workingDirectory) throws IOException {
Path workspaceRoot = Paths.get(rootDirectory).toRealPath();
if (workingDirectory == null || workingDirectory.isBlank()) {
return workspaceRoot;
}
Path path = Paths.get(workingDirectory);
Path resolved;
if (!path.isAbsolute()) {
resolved = workspaceRoot.resolve(path).normalize();
} else {
resolved = path.normalize();
}
if (!resolved.startsWith(workspaceRoot)) {
throw new IOException("Working directory must be within workspace: " + workingDirectory);
}
return resolved;
}
private boolean containsShellChaining(String command) {
return command.contains("&&") || command.contains("||") || command.contains(";") || command.contains("|");
}
private String validateSubcommand(String binary, List<String> parts) {
Set<String> allowedSubs = ALLOWED_SUBCOMMANDS.get(binary);
if (allowedSubs == null) {
return null;
}
if (parts.size() < 2) {
return "Missing subcommand for " + binary;
}
String sub = parts.get(1);
if (sub.startsWith("-")) {
return "Flag-only command is not allowed for " + binary;
}
if (!allowedSubs.contains(sub)) {
return "Subcommand is not allowed for " + binary + ": " + sub;
}
if (("npm".equals(binary) || "pnpm".equals(binary) || "yarn".equals(binary))
&& "install".equals(sub) && !parts.contains("-g") && !parts.contains("--global")) {
return "Package install must be global (-g/--global)";
}
return null;
}
private String readProcessOutput(Process process, int maxChars) throws IOException {
try (BufferedReader reader = new BufferedReader(
new InputStreamReader(process.getInputStream(), StandardCharsets.UTF_8))) {
StringBuilder sb = new StringBuilder();
String line;
while ((line = reader.readLine()) != null) {
if (sb.length() + line.length() + 1 <= maxChars) {
sb.append(line).append('\n');
}
}
return sb.toString();
}
}
private String getOutputSafely(Future<String> outputFuture, ExecutorService executor)
throws InterruptedException, ExecutionException {
try {
return outputFuture.get(5, TimeUnit.SECONDS);
} catch (java.util.concurrent.TimeoutException e) {
return "";
} finally {
executor.shutdown();
executor.awaitTermination(Duration.ofSeconds(2).toSeconds(), TimeUnit.SECONDS);
}
}
private String getRelativePath(Path filePath) {
try {
Path workspaceRoot = Paths.get(rootDirectory).toRealPath();
return workspaceRoot.relativize(filePath.toRealPath()).toString();
} catch (Exception e) {
return filePath.toString();
}
}
@Override
public String getToolName() {
return "run_command";
}
@Override
public String getDisplayName() {
return "执行命令";
}
@Override
public String getDescription() {
return DESCRIPTION;
}
}

View File

@@ -0,0 +1,158 @@
package org.ruoyi.mcp.tools;
import dev.langchain4j.agent.tool.Tool;
import org.ruoyi.mcp.service.core.BuiltinToolProvider;
import org.springframework.stereotype.Component;
import java.util.ArrayList;
import java.util.List;
/**
* 任务规划工具
* 将自然语言开发任务转换为可执行的结构化计划
*/
@Component
public class TaskPlannerTool implements BuiltinToolProvider {
private static final long DEFAULT_APPROVAL_TTL_SECONDS = 30 * 60;
public static final String DESCRIPTION = "Creates a structured coding task plan from a natural language request. " +
"Returns objective, constraints, steps with acceptance criteria, and risk level. " +
"Use this tool before executing file or command operations.";
@Tool(DESCRIPTION)
public String planTask(String goal, String constraints, String executionMode) {
if (goal == null || goal.trim().isEmpty()) {
return "Error: goal cannot be empty";
}
String normalizedGoal = goal.trim();
String normalizedConstraints = normalize(constraints);
String mode = normalizeMode(executionMode);
RiskLevel risk = detectRisk(normalizedGoal, normalizedConstraints);
List<String> steps = buildSteps(normalizedGoal, mode, risk);
List<String> acceptance = buildAcceptance(mode, risk);
StringBuilder sb = new StringBuilder();
sb.append("# Task Plan\n");
sb.append("Objective: ").append(normalizedGoal).append("\n");
sb.append("ExecutionMode: ").append(mode).append("\n");
sb.append("RiskLevel: ").append(risk.name()).append("\n");
if (!normalizedConstraints.isEmpty()) {
sb.append("Constraints: ").append(normalizedConstraints).append("\n");
}
sb.append("\nSteps:\n");
for (int i = 0; i < steps.size(); i++) {
sb.append(i + 1).append(". ").append(steps.get(i)).append("\n");
}
sb.append("\nAcceptanceCriteria:\n");
for (int i = 0; i < acceptance.size(); i++) {
sb.append(i + 1).append(". ").append(acceptance.get(i)).append("\n");
}
String approvalToken = ApprovalTokenStore.issue(normalizedGoal, DEFAULT_APPROVAL_TTL_SECONDS);
sb.append("\nExecutionApproval:\n");
sb.append("1. ApprovalToken: ").append(approvalToken).append("\n");
sb.append("2. ApprovalScope: ").append(normalizedGoal).append("\n");
sb.append("3. TokenTTLSeconds: ").append(DEFAULT_APPROVAL_TTL_SECONDS).append("\n");
sb.append("4. Run command tools only after explicit user confirmation\n");
sb.append("\nSafetyGates:\n");
sb.append("1. Only workspace-scoped file operations are allowed\n");
sb.append("2. Only whitelisted commands are allowed\n");
sb.append("3. Risky actions require explicit user confirmation\n");
return sb.toString();
}
private String normalize(String value) {
return value == null ? "" : value.trim();
}
private String normalizeMode(String executionMode) {
String mode = normalize(executionMode).toUpperCase();
if (!"PLAN_ONLY".equals(mode) && !"PLAN_AND_EXECUTE".equals(mode)) {
return "PLAN_ONLY";
}
return mode;
}
private RiskLevel detectRisk(String goal, String constraints) {
String text = (goal + " " + constraints).toLowerCase();
if (containsAny(text, "delete", "drop", "remove", "reset --hard", "force", "rewrite history")) {
return RiskLevel.HIGH;
}
if (containsAny(text, "refactor", "multi-module", "database", "migration", "deploy", "production")) {
return RiskLevel.MEDIUM;
}
return RiskLevel.LOW;
}
private boolean containsAny(String text, String... words) {
for (String word : words) {
if (text.contains(word)) {
return true;
}
}
return false;
}
private List<String> buildSteps(String goal, String mode, RiskLevel risk) {
List<String> steps = new ArrayList<>();
steps.add("Clarify target scope and impacted modules for: " + goal);
steps.add("Discover relevant files and APIs using list/read/search tools");
steps.add("Design minimal change set and draft patch plan");
steps.add("Apply file changes incrementally and keep each step reversible");
steps.add("Run whitelisted validation commands (build/test/lint) for impacted modules only");
if ("PLAN_AND_EXECUTE".equals(mode)) {
steps.add("Prepare summary of changes and execution logs for user review");
} else {
steps.add("Return executable checklist and wait for execution approval");
}
if (risk != RiskLevel.LOW) {
steps.add("Request explicit confirmation before any medium/high-risk operation");
}
return steps;
}
private List<String> buildAcceptance(String mode, RiskLevel risk) {
List<String> criteria = new ArrayList<>();
criteria.add("Planned steps are concrete, ordered, and testable");
criteria.add("Every file/command action is bounded within workspace and policy constraints");
criteria.add("Validation commands and expected outputs are specified");
if ("PLAN_AND_EXECUTE".equals(mode)) {
criteria.add("Executed changes produce a verifiable diff and command results");
} else {
criteria.add("Plan is ready for immediate execution after user approval");
}
if (risk == RiskLevel.HIGH) {
criteria.add("High-risk actions are isolated and require explicit user confirmation");
}
return criteria;
}
@Override
public String getToolName() {
return "task_planner";
}
@Override
public String getDisplayName() {
return "任务规划";
}
@Override
public String getDescription() {
return DESCRIPTION;
}
private enum RiskLevel {
LOW,
MEDIUM,
HIGH
}
}

View File

@@ -20,10 +20,16 @@ import dev.langchain4j.model.chat.response.StreamingChatResponseHandler;
import dev.langchain4j.model.openai.OpenAiChatModel;
import dev.langchain4j.service.tool.ToolProvider;
import dev.langchain4j.skills.shell.ShellSkills;
import dev.langchain4j.rag.AugmentationRequest;
import dev.langchain4j.rag.AugmentationResult;
import dev.langchain4j.rag.DefaultRetrievalAugmentor;
import dev.langchain4j.rag.RetrievalAugmentor;
import dev.langchain4j.rag.query.Metadata;
import lombok.RequiredArgsConstructor;
import lombok.SneakyThrows;
import lombok.extern.slf4j.Slf4j;
import org.ruoyi.agent.ChartGenerationAgent;
import org.ruoyi.agent.CodingAgent;
import org.ruoyi.agent.EchartsAgent;
import org.ruoyi.agent.SkillsAgent;
import org.ruoyi.agent.SqlAgent;
@@ -49,11 +55,19 @@ import org.ruoyi.domain.bo.vector.QueryVectorBo;
import org.ruoyi.domain.vo.knowledge.KnowledgeInfoVo;
import org.ruoyi.factory.ChatServiceFactory;
import org.ruoyi.mcp.service.core.ToolProviderFactory;
import org.ruoyi.mcp.tools.CreateFileTool;
import org.ruoyi.mcp.tools.EditFileTool;
import org.ruoyi.mcp.tools.ListDirectoryTool;
import org.ruoyi.mcp.tools.ReadFileTool;
import org.ruoyi.mcp.tools.RunCommandTool;
import org.ruoyi.mcp.tools.TaskPlannerTool;
import org.ruoyi.observability.*;
import org.ruoyi.service.chat.AbstractChatService;
import org.ruoyi.service.chat.IChatMessageService;
import org.ruoyi.service.chat.impl.memory.PersistentChatMemoryStore;
import org.ruoyi.service.knowledge.IKnowledgeInfoService;
import org.ruoyi.service.retrieval.KnowledgeRetrievalService;
import org.ruoyi.service.knowledge.retriever.CustomVectorRetriever;
import org.ruoyi.service.vector.VectorStoreService;
import org.springframework.stereotype.Service;
import org.springframework.web.servlet.mvc.method.annotation.SseEmitter;
@@ -89,6 +103,8 @@ public class ChatServiceFacade implements IChatService {
private final VectorStoreService vectorStoreService;
private final KnowledgeRetrievalService knowledgeRetrievalService;
private final SseEmitterManager sseEmitterManager;
private final IChatMessageService chatMessageService;
@@ -307,11 +323,25 @@ public class ChatServiceFacade implements IChatService {
.listener(new MyAgentListener())
.build();
// 构建子 Agent 6: CodingAgent - 负责通用开发任务落地
CodingAgent codingAgent = AgenticServices.agentBuilder(CodingAgent.class)
.chatModel(plannerModel)
.tools(
new TaskPlannerTool(),
new ListDirectoryTool(),
new ReadFileTool(),
new CreateFileTool(),
new EditFileTool(),
new RunCommandTool()
)
.listener(new MyAgentListener())
.build();
// 构建监督者 Agent - 管理多个子 Agent
SupervisorAgent supervisor = AgenticServices.supervisorBuilder()
.chatModel(plannerModel)
//.listener(new SupervisorStreamListener(null))
.subAgents(skillsAgent,searchAgent, sqlAgent, chartGenerationAgent, echartsAgent)
.subAgents(skillsAgent,searchAgent, sqlAgent, chartGenerationAgent, echartsAgent, codingAgent)
// 加入历史上下文 - 使用 ChatMemoryProvider 提供持久化的聊天内存
//.chatMemoryProvider(memoryId -> createChatMemory(chatRequest.getSessionId()))
.responseStrategy(SupervisorResponseStrategy.LAST)
@@ -409,7 +439,6 @@ public class ChatServiceFacade implements IChatService {
/**
* 构建上下文消息列表
* 消息顺序:历史消息 → 当前用户消息(确保 AI 正确理解对话上下文)
*
* @param chatRequest 聊天请求
@@ -418,7 +447,41 @@ public class ChatServiceFacade implements IChatService {
private List<ChatMessage> buildContextMessages(ChatRequest chatRequest) {
List<ChatMessage> messages = new ArrayList<>();
// 从数据库查询历史对话消息(放在前面)
// 1. 初始化当前用户消息
UserMessage userMessage = UserMessage.userMessage(chatRequest.getContent());
// 2. 知识库检索增强 (RAG)
if (chatRequest.getKnowledgeId() != null) {
KnowledgeInfoVo knowledgeInfoVo = knowledgeInfoService.queryById(Long.valueOf(chatRequest.getKnowledgeId()));
if (knowledgeInfoVo != null) {
ChatModelVo chatModel = chatModelService.selectModelByName(knowledgeInfoVo.getEmbeddingModel());
if (chatModel != null) {
log.info("执行高级 RAG 流程: kid={}", chatRequest.getKnowledgeId());
// 构建自定义检索器
CustomVectorRetriever retriever = new CustomVectorRetriever(
knowledgeRetrievalService, knowledgeInfoVo, chatModel);
// 构建增强流水线
RetrievalAugmentor augmentor = DefaultRetrievalAugmentor.builder()
.contentRetriever(retriever)
.build();
// 执行增强:编织上下文到 UserMessage
Metadata metadata = Metadata.from(userMessage, chatRequest.getSessionId(), new ArrayList<>());
AugmentationRequest augmentationRequest = new AugmentationRequest(userMessage, metadata);
AugmentationResult result = augmentor.augment(augmentationRequest);
ChatMessage augmented = result.chatMessage();
if (augmented instanceof UserMessage) {
userMessage = (UserMessage) augmented;
log.debug("RAG 增强完成UserMessage 已注入背景知识");
}
}
}
}
// 3. 从数据库查询历史对话消息(放在前面)
if (chatRequest.getSessionId() != null) {
MessageWindowChatMemory memory = createChatMemory(chatRequest.getSessionId());
if (memory != null) {
@@ -430,38 +493,7 @@ public class ChatServiceFacade implements IChatService {
}
}
// 从向量库查询相关历史消息(知识库内容作为上下文
if (chatRequest.getKnowledgeId() != null) {
// 查询知识库信息
KnowledgeInfoVo knowledgeInfoVo = knowledgeInfoService.queryById(Long.valueOf(chatRequest.getKnowledgeId()));
if (knowledgeInfoVo == null) {
log.warn("知识库信息不存在kid: {}", chatRequest.getKnowledgeId());
// 继续添加当前用户消息
messages.add(UserMessage.userMessage(chatRequest.getContent()));
return messages;
}
// 查询向量模型配置信息
ChatModelVo chatModel = chatModelService.selectModelByName(knowledgeInfoVo.getEmbeddingModel());
if (chatModel == null) {
log.warn("向量模型配置不存在,模型名称: {}", knowledgeInfoVo.getEmbeddingModel());
messages.add(UserMessage.userMessage(chatRequest.getContent()));
return messages;
}
// 构建向量查询参数
QueryVectorBo queryVectorBo = buildQueryVectorBo(chatRequest, knowledgeInfoVo, chatModel);
// 获取向量查询结果(知识库内容作为系统上下文,放在历史消息之后)
List<String> nearestList = vectorStoreService.getQueryVector(queryVectorBo);
for (String prompt : nearestList) {
// 知识库内容作为系统上下文添加
messages.add(new AiMessage(prompt));
}
}
// 构建当前用户消息(放在最后)
UserMessage userMessage = UserMessage.userMessage(chatRequest.getContent());
// 4. 添加经过增强的用户消息(放在最后
messages.add(userMessage);
return messages;
@@ -480,6 +512,13 @@ public class ChatServiceFacade implements IChatService {
queryVectorBo.setVectorModelName(knowledgeInfoVo.getVectorModel());
queryVectorBo.setEmbeddingModelName(knowledgeInfoVo.getEmbeddingModel());
queryVectorBo.setMaxResults(knowledgeInfoVo.getRetrieveLimit());
// 设置重排序参数
queryVectorBo.setEnableRerank(knowledgeInfoVo.getEnableRerank() != null && knowledgeInfoVo.getEnableRerank() == 1);
queryVectorBo.setRerankModelName(knowledgeInfoVo.getRerankModel());
queryVectorBo.setRerankTopN(knowledgeInfoVo.getRerankTopN());
queryVectorBo.setRerankScoreThreshold(knowledgeInfoVo.getRerankScoreThreshold());
return queryVectorBo;
}
@@ -600,4 +639,3 @@ public class ChatServiceFacade implements IChatService {
};
}
}

View File

@@ -72,4 +72,11 @@ public interface IKnowledgeAttachService {
* 上传附件
*/
void upload(KnowledgeInfoUploadBo bo);
/**
* 解析附件知识片段
*
* @param id 附件ID
*/
void parse(Long id);
}

View File

@@ -4,6 +4,7 @@ import org.ruoyi.common.mybatis.core.page.TableDataInfo;
import org.ruoyi.common.mybatis.core.page.PageQuery;
import org.ruoyi.domain.bo.knowledge.KnowledgeFragmentBo;
import org.ruoyi.domain.vo.knowledge.KnowledgeFragmentVo;
import org.ruoyi.domain.vo.knowledge.KnowledgeRetrievalVo;
import java.util.Collection;
import java.util.List;
@@ -65,4 +66,12 @@ public interface IKnowledgeFragmentService {
* @return 是否删除成功
*/
Boolean deleteWithValidByIds(Collection<Long> ids, Boolean isValid);
/**
* 检索测试
*
* @param bo 检索参数
* @return 检索结果
*/
List<KnowledgeRetrievalVo> retrieval(KnowledgeFragmentBo bo);
}

View File

@@ -2,24 +2,27 @@ package org.ruoyi.service.knowledge.impl;
import cn.hutool.core.collection.CollUtil;
import cn.hutool.core.util.RandomUtil;
import org.ruoyi.common.chat.service.chat.IChatModelService;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.core.toolkit.Wrappers;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.ruoyi.common.chat.domain.vo.chat.ChatModelVo;
import org.ruoyi.common.chat.service.chat.IChatModelService;
import org.ruoyi.enums.KnowledgeAttachStatus;
import org.ruoyi.common.core.domain.dto.OssDTO;
import org.ruoyi.common.core.service.OssService;
import org.ruoyi.common.core.utils.MapstructUtils;
import org.ruoyi.common.core.utils.SpringUtils;
import org.ruoyi.common.core.utils.StringUtils;
import org.ruoyi.common.mybatis.core.page.TableDataInfo;
import org.ruoyi.common.mybatis.core.page.PageQuery;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.core.toolkit.Wrappers;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.ruoyi.common.mybatis.core.page.TableDataInfo;
import org.ruoyi.domain.bo.knowledge.KnowledgeAttachBo;
import org.ruoyi.domain.bo.knowledge.KnowledgeInfoUploadBo;
import org.ruoyi.domain.bo.vector.StoreEmbeddingBo;
import org.ruoyi.domain.entity.knowledge.KnowledgeAttach;
import org.ruoyi.domain.entity.knowledge.KnowledgeFragment;
import org.ruoyi.domain.vo.knowledge.DocFragmentCountVo;
import org.ruoyi.domain.vo.knowledge.KnowledgeAttachVo;
import org.ruoyi.domain.vo.knowledge.KnowledgeInfoVo;
import org.ruoyi.factory.ResourceLoaderFactory;
@@ -29,11 +32,15 @@ import org.ruoyi.service.knowledge.IKnowledgeAttachService;
import org.ruoyi.service.knowledge.IKnowledgeInfoService;
import org.ruoyi.service.knowledge.ResourceLoader;
import org.ruoyi.service.vector.VectorStoreService;
import org.springframework.scheduling.annotation.Async;
import org.springframework.stereotype.Service;
import org.springframework.web.multipart.MultipartFile;
import java.io.IOException;
import java.io.InputStream;
import java.net.URL;
import java.util.*;
import java.util.stream.Collectors;
/**
* 知识库附件Service业务层处理
@@ -47,57 +54,51 @@ import java.util.*;
public class KnowledgeAttachServiceImpl implements IKnowledgeAttachService {
private final KnowledgeAttachMapper baseMapper;
private final IKnowledgeInfoService knowledgeInfoService;
private final KnowledgeFragmentMapper knowledgeFragmentMapper;
private final IChatModelService chatModelService;
private final ResourceLoaderFactory resourceLoaderFactory;
private final VectorStoreService vectorStoreService;
private final OssService ossService;
/**
* 查询知识库附件
*
* @param id 主键
* @return 知识库附件
*/
@Override
public KnowledgeAttachVo queryById(Long id) {
return baseMapper.selectVoById(id);
}
/**
* 分页查询知识库附件列表
*
* @param bo 查询条件
* @param pageQuery 分页参数
* @return 知识库附件分页列表
*/
@Override
public TableDataInfo<KnowledgeAttachVo> queryPageList(KnowledgeAttachBo bo, PageQuery pageQuery) {
LambdaQueryWrapper<KnowledgeAttach> lqw = buildQueryWrapper(bo);
Page<KnowledgeAttachVo> result = baseMapper.selectVoPage(pageQuery.build(), lqw);
fillFragmentCount(result.getRecords());
return TableDataInfo.build(result);
}
/**
* 查询符合条件的知识库附件列表
*
* @param bo 查询条件
* @return 知识库附件列表
*/
@Override
public List<KnowledgeAttachVo> queryList(KnowledgeAttachBo bo) {
LambdaQueryWrapper<KnowledgeAttach> lqw = buildQueryWrapper(bo);
return baseMapper.selectVoList(lqw);
List<KnowledgeAttachVo> list = baseMapper.selectVoList(lqw);
fillFragmentCount(list);
return list;
}
private void fillFragmentCount(List<KnowledgeAttachVo> records) {
if (records == null || records.isEmpty()) return;
List<String> docIds = records.stream()
.map(KnowledgeAttachVo::getDocId)
.filter(StringUtils::isNotBlank)
.distinct()
.collect(Collectors.toList());
if (docIds.isEmpty()) return;
List<DocFragmentCountVo> countList = knowledgeFragmentMapper.selectFragmentCountByDocIds(docIds);
Map<String, Integer> countMap = countList.stream()
.collect(Collectors.toMap(DocFragmentCountVo::getDocId, DocFragmentCountVo::getFragmentCount, (k1, k2) -> k1));
for (KnowledgeAttachVo vo : records) {
vo.setFragmentCount(countMap.getOrDefault(vo.getDocId(), 0));
}
}
private LambdaQueryWrapper<KnowledgeAttach> buildQueryWrapper(KnowledgeAttachBo bo) {
Map<String, Object> params = bo.getParams();
LambdaQueryWrapper<KnowledgeAttach> lqw = Wrappers.lambdaQuery();
lqw.orderByAsc(KnowledgeAttach::getId);
lqw.eq(bo.getKnowledgeId() != null, KnowledgeAttach::getKnowledgeId, bo.getKnowledgeId());
@@ -107,16 +108,9 @@ public class KnowledgeAttachServiceImpl implements IKnowledgeAttachService {
return lqw;
}
/**
* 新增知识库附件
*
* @param bo 知识库附件
* @return 是否新增成功
*/
@Override
public Boolean insertByBo(KnowledgeAttachBo bo) {
KnowledgeAttach add = MapstructUtils.convert(bo, KnowledgeAttach.class);
validEntityBeforeSave(add);
boolean flag = baseMapper.insert(add) > 0;
if (flag) {
bo.setId(add.getId());
@@ -124,86 +118,88 @@ public class KnowledgeAttachServiceImpl implements IKnowledgeAttachService {
return flag;
}
/**
* 修改知识库附件
*
* @param bo 知识库附件
* @return 是否修改成功
*/
@Override
public Boolean updateByBo(KnowledgeAttachBo bo) {
KnowledgeAttach update = MapstructUtils.convert(bo, KnowledgeAttach.class);
validEntityBeforeSave(update);
return baseMapper.updateById(update) > 0;
}
/**
* 保存前的数据校验
*/
private void validEntityBeforeSave(KnowledgeAttach entity){
//TODO 做一些数据校验,如唯一约束
}
/**
* 校验并批量删除知识库附件信息
*
* @param ids 待删除的主键集合
* @param isValid 是否进行有效性校验
* @return 是否删除成功
*/
@Override
public Boolean deleteWithValidByIds(Collection<Long> ids, Boolean isValid) {
if(isValid){
//TODO 做一些业务上的校验,判断是否需要校验
}
return baseMapper.deleteByIds(ids) > 0;
}
@Override
public void upload(KnowledgeInfoUploadBo bo) {
MultipartFile file = bo.getFile();
// 保存文件信息
OssDTO ossDTO = ossService.uploadFile(file);
Long knowledgeId = bo.getKnowledgeId();
List<String> chunkList = new ArrayList<>();
KnowledgeAttach knowledgeAttach = new KnowledgeAttach();
knowledgeAttach.setKnowledgeId(bo.getKnowledgeId());
String docId = RandomUtil.randomString(10);
knowledgeAttach.setOssId(ossDTO.getOssId());
knowledgeAttach.setDocId(docId);
knowledgeAttach.setDocId(RandomUtil.randomString(10));
knowledgeAttach.setName(ossDTO.getOriginalName());
knowledgeAttach.setType(ossDTO.getFileSuffix());
String content = "";
ResourceLoader resourceLoader = resourceLoaderFactory.getLoaderByFileType(knowledgeAttach.getType());
// 文档分段入库
List<String> fids = new ArrayList<>();
knowledgeAttach.setStatus(KnowledgeAttachStatus.WAITING.getCode()); // 待解析
baseMapper.insert(knowledgeAttach);
if (Boolean.TRUE.equals(bo.getAutoParse())) {
// 通过 SpringUtils 获取代理对象,确保 @Async 生效
SpringUtils.getBean(IKnowledgeAttachService.class).parse(knowledgeAttach.getId());
}
}
@Async("knowledgeParseExecutor")
@Override
public void parse(Long id) {
KnowledgeAttach attach = baseMapper.selectById(id);
if (attach == null || (!KnowledgeAttachStatus.WAITING.getCode().equals(attach.getStatus()) && !KnowledgeAttachStatus.FAILED.getCode().equals(attach.getStatus()))) {
return;
}
try {
content = resourceLoader.getContent(file.getInputStream());
chunkList = resourceLoader.getChunkList(content, String.valueOf(knowledgeId));
attach.setStatus(KnowledgeAttachStatus.PARSING.getCode()); // 解析中
baseMapper.updateById(attach);
log.info("开始解析知识库文档... id: {}, docId: {}", id, attach.getDocId());
Long knowledgeId = attach.getKnowledgeId();
String docId = attach.getDocId();
// 获取文件信息并下载
List<OssDTO> ossDTOs = ossService.selectByIds(String.valueOf(attach.getOssId()));
if (ossDTOs == null || ossDTOs.isEmpty()) {
throw new RuntimeException("未找到对应的 OSS 文件信息");
}
OssDTO ossDTO = ossDTOs.get(0);
String content;
ResourceLoader resourceLoader = resourceLoaderFactory.getLoaderByFileType(attach.getType());
try (InputStream inputStream = new URL(ossDTO.getUrl()).openStream()) {
content = resourceLoader.getContent(inputStream);
}
List<String> chunkList = resourceLoader.getChunkList(content, String.valueOf(knowledgeId));
List<String> fids = new ArrayList<>();
List<KnowledgeFragment> knowledgeFragmentList = new ArrayList<>();
if (CollUtil.isNotEmpty(chunkList)) {
for (int i = 0; i < chunkList.size(); i++) {
// 生成知识片段ID
String fid = RandomUtil.randomString(10);
fids.add(fid);
KnowledgeFragment knowledgeFragment = new KnowledgeFragment();
knowledgeFragment.setKnowledgeId(knowledgeId);
knowledgeFragment.setDocId(docId);
knowledgeFragment.setIdx(i);
knowledgeFragment.setContent(chunkList.get(i));
knowledgeFragment.setCreateTime(new Date());
knowledgeFragmentList.add(knowledgeFragment);
}
}
knowledgeFragmentMapper.delete(Wrappers.<KnowledgeFragment>lambdaQuery().eq(KnowledgeFragment::getDocId, docId));
knowledgeFragmentMapper.insertBatch(knowledgeFragmentList);
} catch (IOException e) {
log.error("保存知识库信息失败!{}", e.getMessage());
log.info("文档切片并入库完成,共计 {} 个片段。id: {}", chunkList.size(), id);
}
baseMapper.insert(knowledgeAttach);
// 查询知识库信息
KnowledgeInfoVo knowledgeInfoVo = knowledgeInfoService.queryById(knowledgeId);
// 查询向量模信息
ChatModelVo chatModelVo = chatModelService.selectModelByName(knowledgeInfoVo.getEmbeddingModel());
StoreEmbeddingBo storeEmbeddingBo = new StoreEmbeddingBo();
@@ -216,6 +212,15 @@ public class KnowledgeAttachServiceImpl implements IKnowledgeAttachService {
storeEmbeddingBo.setApiKey(chatModelVo.getApiKey());
storeEmbeddingBo.setBaseUrl(chatModelVo.getApiHost());
vectorStoreService.storeEmbeddings(storeEmbeddingBo);
}
attach.setStatus(KnowledgeAttachStatus.COMPLETED.getCode()); // 已完成
baseMapper.updateById(attach);
log.info("知识库文档解析、向量化并入库成功id: {}", id);
} catch (Exception e) {
log.error("解析文档失败id: {}, error: {}", id, e.getMessage(), e);
attach.setStatus(KnowledgeAttachStatus.FAILED.getCode()); // 失败
attach.setRemark(StringUtils.substring(e.getMessage(), 0, 255)); // 保存错误原因,截取防止溢出
baseMapper.updateById(attach);
}
}
}

View File

@@ -1,24 +1,29 @@
package org.ruoyi.service.knowledge.impl;
import org.ruoyi.common.core.utils.MapstructUtils;
import org.ruoyi.common.core.utils.StringUtils;
import org.ruoyi.common.mybatis.core.page.TableDataInfo;
import org.ruoyi.common.mybatis.core.page.PageQuery;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.core.toolkit.Wrappers;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.ruoyi.common.chat.domain.vo.chat.ChatModelVo;
import org.ruoyi.common.chat.service.chat.IChatModelService;
import org.ruoyi.common.core.utils.MapstructUtils;
import org.ruoyi.common.core.utils.StringUtils;
import org.ruoyi.common.mybatis.core.page.PageQuery;
import org.ruoyi.common.mybatis.core.page.TableDataInfo;
import org.ruoyi.domain.bo.knowledge.KnowledgeFragmentBo;
import org.ruoyi.domain.bo.vector.QueryVectorBo;
import org.ruoyi.domain.entity.knowledge.KnowledgeFragment;
import org.ruoyi.domain.vo.knowledge.KnowledgeFragmentVo;
import org.ruoyi.domain.vo.knowledge.KnowledgeInfoVo;
import org.ruoyi.domain.vo.knowledge.KnowledgeRetrievalVo;
import org.ruoyi.mapper.knowledge.KnowledgeFragmentMapper;
import org.ruoyi.service.knowledge.IKnowledgeFragmentService;
import org.ruoyi.service.knowledge.IKnowledgeInfoService;
import org.ruoyi.service.retrieval.KnowledgeRetrievalService;
import org.springframework.stereotype.Service;
import java.util.List;
import java.util.Map;
import java.util.Collection;
import java.util.*;
/**
* 知识片段Service业务层处理
@@ -32,6 +37,9 @@ import java.util.Collection;
public class KnowledgeFragmentServiceImpl implements IKnowledgeFragmentService {
private final KnowledgeFragmentMapper baseMapper;
private final IKnowledgeInfoService knowledgeInfoService;
private final IChatModelService chatModelService;
private final KnowledgeRetrievalService knowledgeRetrievalService;
/**
* 查询知识片段
@@ -71,7 +79,6 @@ public class KnowledgeFragmentServiceImpl implements IKnowledgeFragmentService {
}
private LambdaQueryWrapper<KnowledgeFragment> buildQueryWrapper(KnowledgeFragmentBo bo) {
Map<String, Object> params = bo.getParams();
LambdaQueryWrapper<KnowledgeFragment> lqw = Wrappers.lambdaQuery();
lqw.orderByAsc(KnowledgeFragment::getId);
lqw.eq(bo.getDocId() != null, KnowledgeFragment::getDocId, bo.getDocId());
@@ -131,4 +138,50 @@ public class KnowledgeFragmentServiceImpl implements IKnowledgeFragmentService {
}
return baseMapper.deleteByIds(ids) > 0;
}
/**
* 检索测试核心实现 - 委托给统一的 KnowledgeRetrievalService
*/
@Override
public List<KnowledgeRetrievalVo> retrieval(KnowledgeFragmentBo bo) {
if (bo.getKnowledgeId() == null || StringUtils.isBlank(bo.getQuery())) {
return new ArrayList<>();
}
// 1. 获取知识库及模型配置(为了获取 API Key/Host 等模型参数)
KnowledgeInfoVo knowledgeInfoVo = knowledgeInfoService.queryById(bo.getKnowledgeId());
if (knowledgeInfoVo == null) {
return new ArrayList<>();
}
ChatModelVo chatModel = chatModelService.selectModelByName(knowledgeInfoVo.getEmbeddingModel());
if (chatModel == null) {
log.warn("未找到对应的向量模型配置: {}", knowledgeInfoVo.getEmbeddingModel());
return new ArrayList<>();
}
// 2. 构造通用的参数对象
QueryVectorBo queryVectorBo = new QueryVectorBo();
queryVectorBo.setQuery(bo.getQuery());
queryVectorBo.setKid(String.valueOf(bo.getKnowledgeId()));
queryVectorBo.setApiKey(chatModel.getApiKey());
queryVectorBo.setBaseUrl(chatModel.getApiHost());
queryVectorBo.setEmbeddingModelName(knowledgeInfoVo.getEmbeddingModel());
queryVectorBo.setVectorModelName(knowledgeInfoVo.getVectorModel());
// 使用前端传入的实时测试参数,若无则使用知识库默认参数
queryVectorBo.setMaxResults(bo.getTopK() != null ? bo.getTopK() : knowledgeInfoVo.getRetrieveLimit());
queryVectorBo.setSimilarityThreshold(bo.getThreshold() != null ? bo.getThreshold() : knowledgeInfoVo.getSimilarityThreshold());
queryVectorBo.setEnableHybrid(bo.getEnableHybrid() != null ? bo.getEnableHybrid() : Objects.equals(knowledgeInfoVo.getEnableHybrid(), 1));
queryVectorBo.setHybridAlpha(bo.getHybridAlpha() != null ? bo.getHybridAlpha() : knowledgeInfoVo.getHybridAlpha());
queryVectorBo.setEnableRerank(bo.getEnableRerank() != null ? bo.getEnableRerank() : Objects.equals(knowledgeInfoVo.getEnableRerank(), 1));
queryVectorBo.setRerankModelName(StringUtils.isNotBlank(bo.getRerankModel()) ? bo.getRerankModel() : knowledgeInfoVo.getRerankModel());
queryVectorBo.setRerankTopN(bo.getTopK() != null ? bo.getTopK() : knowledgeInfoVo.getRerankTopN());
queryVectorBo.setRerankScoreThreshold(bo.getThreshold() != null ? bo.getThreshold() : knowledgeInfoVo.getRerankScoreThreshold());
// 3. 执行统一检索
return knowledgeRetrievalService.retrieve(queryVectorBo);
}
}

View File

@@ -12,6 +12,7 @@ import lombok.extern.slf4j.Slf4j;
import org.ruoyi.domain.bo.knowledge.KnowledgeInfoBo;
import org.ruoyi.domain.entity.knowledge.KnowledgeInfo;
import org.ruoyi.domain.vo.knowledge.KnowledgeInfoVo;
import org.ruoyi.mapper.knowledge.KnowledgeAttachMapper;
import org.ruoyi.mapper.knowledge.KnowledgeInfoMapper;
import org.ruoyi.service.knowledge.IKnowledgeInfoService;
import org.springframework.stereotype.Service;
@@ -33,6 +34,8 @@ public class KnowledgeInfoServiceImpl implements IKnowledgeInfoService {
private final KnowledgeInfoMapper baseMapper;
private final KnowledgeAttachMapper knowledgeAttachMapper;
/**
* 查询知识库
*
@@ -55,6 +58,8 @@ public class KnowledgeInfoServiceImpl implements IKnowledgeInfoService {
public TableDataInfo<KnowledgeInfoVo> queryPageList(KnowledgeInfoBo bo, PageQuery pageQuery) {
LambdaQueryWrapper<KnowledgeInfo> lqw = buildQueryWrapper(bo);
Page<KnowledgeInfoVo> result = baseMapper.selectVoPage(pageQuery.build(), lqw);
// 批量填充文档数
fillDocumentCount(result.getRecords());
return TableDataInfo.build(result);
}
@@ -87,6 +92,17 @@ public class KnowledgeInfoServiceImpl implements IKnowledgeInfoService {
return lqw;
}
/**
* 批量填充知识库列表每一条记录的文档数documentCount
*/
private void fillDocumentCount(List<KnowledgeInfoVo> records) {
if (records == null || records.isEmpty()) return;
for (KnowledgeInfoVo vo : records) {
int count = knowledgeAttachMapper.countByKnowledgeId(vo.getId());
vo.setDocumentCount(count);
}
}
/**
* 新增知识库
*

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