mirror of
https://gitcode.com/ageerle/ruoyi-ai.git
synced 2026-05-04 22:13:59 +00:00
Compare commits
1 Commits
260504-fea
...
260503-fea
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
410cb0b6f2 |
63
.monkeycode/MEMORY.md
Normal file
63
.monkeycode/MEMORY.md
Normal 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 报表生成
|
||||||
|
- 用户希望在报表页面内通过提示词继续动态编辑页面
|
||||||
320
ruoyi-admin/src/main/resources/static/report/index.html
Normal file
320
ruoyi-admin/src/main/resources/static/report/index.html
Normal 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>
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -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));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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) {
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -11,7 +11,10 @@ import java.nio.file.Path;
|
|||||||
import java.nio.file.Paths;
|
import java.nio.file.Paths;
|
||||||
import java.nio.file.StandardOpenOption;
|
import java.nio.file.StandardOpenOption;
|
||||||
import java.util.Arrays;
|
import java.util.Arrays;
|
||||||
|
import java.util.ArrayList;
|
||||||
import java.util.List;
|
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);
|
String originalContent = Files.readString(path, StandardCharsets.UTF_8);
|
||||||
List<String> originalLines = Arrays.asList(originalContent.split("\n"));
|
|
||||||
|
|
||||||
// 应用diff
|
// 应用diff
|
||||||
try {
|
try {
|
||||||
// 这里简化处理,直接用新内容替换
|
|
||||||
// 在实际应用中,可能需要更复杂的diff解析
|
|
||||||
String newContent = applyDiff(originalContent, diff);
|
String newContent = applyDiff(originalContent, diff);
|
||||||
|
|
||||||
// 写入文件
|
// 写入文件
|
||||||
@@ -104,14 +104,100 @@ public class EditFileTool implements BuiltinToolProvider {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 简化的diff应用逻辑
|
* 仅支持 unified diff(包含 @@ hunk 头)
|
||||||
* 实际应用中可能需要使用更复杂的diff解析器
|
|
||||||
*/
|
*/
|
||||||
private String applyDiff(String originalContent, String diff) {
|
private String applyDiff(String originalContent, String diff) {
|
||||||
// 这里简化处理,实际应用中需要解析diff格式
|
List<String> originalLines = new ArrayList<>(Arrays.asList(originalContent.split("\n", -1)));
|
||||||
// 目前将diff作为新内容直接替换
|
List<String> diffLines = Arrays.asList(diff.split("\n", -1));
|
||||||
// 可以考虑使用jgit等库来解析 unified diff 格式
|
|
||||||
return diff;
|
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) {
|
private boolean isWithinWorkspace(Path filePath) {
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -29,6 +29,7 @@ import lombok.RequiredArgsConstructor;
|
|||||||
import lombok.SneakyThrows;
|
import lombok.SneakyThrows;
|
||||||
import lombok.extern.slf4j.Slf4j;
|
import lombok.extern.slf4j.Slf4j;
|
||||||
import org.ruoyi.agent.ChartGenerationAgent;
|
import org.ruoyi.agent.ChartGenerationAgent;
|
||||||
|
import org.ruoyi.agent.CodingAgent;
|
||||||
import org.ruoyi.agent.EchartsAgent;
|
import org.ruoyi.agent.EchartsAgent;
|
||||||
import org.ruoyi.agent.SkillsAgent;
|
import org.ruoyi.agent.SkillsAgent;
|
||||||
import org.ruoyi.agent.SqlAgent;
|
import org.ruoyi.agent.SqlAgent;
|
||||||
@@ -54,6 +55,12 @@ import org.ruoyi.domain.bo.vector.QueryVectorBo;
|
|||||||
import org.ruoyi.domain.vo.knowledge.KnowledgeInfoVo;
|
import org.ruoyi.domain.vo.knowledge.KnowledgeInfoVo;
|
||||||
import org.ruoyi.factory.ChatServiceFactory;
|
import org.ruoyi.factory.ChatServiceFactory;
|
||||||
import org.ruoyi.mcp.service.core.ToolProviderFactory;
|
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.observability.*;
|
||||||
import org.ruoyi.service.chat.AbstractChatService;
|
import org.ruoyi.service.chat.AbstractChatService;
|
||||||
import org.ruoyi.service.chat.IChatMessageService;
|
import org.ruoyi.service.chat.IChatMessageService;
|
||||||
@@ -316,11 +323,25 @@ public class ChatServiceFacade implements IChatService {
|
|||||||
.listener(new MyAgentListener())
|
.listener(new MyAgentListener())
|
||||||
.build();
|
.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
|
// 构建监督者 Agent - 管理多个子 Agent
|
||||||
SupervisorAgent supervisor = AgenticServices.supervisorBuilder()
|
SupervisorAgent supervisor = AgenticServices.supervisorBuilder()
|
||||||
.chatModel(plannerModel)
|
.chatModel(plannerModel)
|
||||||
//.listener(new SupervisorStreamListener(null))
|
//.listener(new SupervisorStreamListener(null))
|
||||||
.subAgents(skillsAgent,searchAgent, sqlAgent, chartGenerationAgent, echartsAgent)
|
.subAgents(skillsAgent,searchAgent, sqlAgent, chartGenerationAgent, echartsAgent, codingAgent)
|
||||||
// 加入历史上下文 - 使用 ChatMemoryProvider 提供持久化的聊天内存
|
// 加入历史上下文 - 使用 ChatMemoryProvider 提供持久化的聊天内存
|
||||||
//.chatMemoryProvider(memoryId -> createChatMemory(chatRequest.getSessionId()))
|
//.chatMemoryProvider(memoryId -> createChatMemory(chatRequest.getSessionId()))
|
||||||
.responseStrategy(SupervisorResponseStrategy.LAST)
|
.responseStrategy(SupervisorResponseStrategy.LAST)
|
||||||
@@ -618,4 +639,3 @@ public class ChatServiceFacade implements IChatService {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,15 @@
|
|||||||
|
package org.ruoyi.service.report;
|
||||||
|
|
||||||
|
import org.ruoyi.domain.dto.request.AiReportGenerateRequest;
|
||||||
|
import org.ruoyi.domain.dto.request.AiReportExecuteRequest;
|
||||||
|
import org.ruoyi.domain.dto.request.AiReportRefineRequest;
|
||||||
|
import org.ruoyi.domain.dto.response.AiReportResponse;
|
||||||
|
|
||||||
|
public interface IAiReportService {
|
||||||
|
|
||||||
|
AiReportResponse generate(AiReportGenerateRequest request);
|
||||||
|
|
||||||
|
AiReportResponse execute(AiReportExecuteRequest request);
|
||||||
|
|
||||||
|
AiReportResponse refine(AiReportRefineRequest request);
|
||||||
|
}
|
||||||
@@ -0,0 +1,304 @@
|
|||||||
|
package org.ruoyi.service.report.impl;
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.databind.JsonNode;
|
||||||
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||||
|
import dev.langchain4j.model.chat.ChatModel;
|
||||||
|
import dev.langchain4j.model.openai.OpenAiChatModel;
|
||||||
|
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.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.beans.factory.annotation.Value;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
|
||||||
|
import java.io.BufferedReader;
|
||||||
|
import java.io.InputStreamReader;
|
||||||
|
import java.nio.charset.StandardCharsets;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.concurrent.TimeUnit;
|
||||||
|
import java.util.regex.Matcher;
|
||||||
|
import java.util.regex.Pattern;
|
||||||
|
|
||||||
|
@Slf4j
|
||||||
|
@Service
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
public class AiReportServiceImpl implements IAiReportService {
|
||||||
|
|
||||||
|
private static final Pattern MYSQL_JDBC_PATTERN =
|
||||||
|
Pattern.compile("^jdbc:mysql://([^:/?]+)(?::(\\d+))?/([^?]+).*$");
|
||||||
|
|
||||||
|
private static final int DEFAULT_MAX_ROWS = 100;
|
||||||
|
private static final int ABSOLUTE_MAX_ROWS = 1000;
|
||||||
|
|
||||||
|
private final IChatModelService chatModelService;
|
||||||
|
private final ObjectMapper objectMapper;
|
||||||
|
|
||||||
|
@Value("${spring.datasource.dynamic.datasource.master.url:}")
|
||||||
|
private String jdbcUrl;
|
||||||
|
|
||||||
|
@Value("${spring.datasource.dynamic.datasource.master.username:}")
|
||||||
|
private String dbUsername;
|
||||||
|
|
||||||
|
@Value("${spring.datasource.dynamic.datasource.master.password:}")
|
||||||
|
private String dbPassword;
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public AiReportResponse generate(AiReportGenerateRequest request) {
|
||||||
|
ChatModel model = buildModel(request.getModel());
|
||||||
|
int maxRows = resolveMaxRows(request.getMaxRows());
|
||||||
|
|
||||||
|
String tableInfo = executeShellSql("SHOW TABLES", 10);
|
||||||
|
|
||||||
|
String sqlPlanPrompt = """
|
||||||
|
你是数据分析师。基于用户需求和表信息,只生成一个安全的 SELECT SQL。
|
||||||
|
返回 JSON,不要返回解释:
|
||||||
|
{"title":"","summary":"","sql":""}
|
||||||
|
|
||||||
|
约束:
|
||||||
|
1) SQL 必须是 SELECT 开头
|
||||||
|
2) 严禁写入/删除/更新语句
|
||||||
|
3) 尽量不要 SELECT *
|
||||||
|
4) 若用户未指定行数,默认 LIMIT %d
|
||||||
|
|
||||||
|
表信息(来自 shell 查询):
|
||||||
|
%s
|
||||||
|
|
||||||
|
用户需求:
|
||||||
|
%s
|
||||||
|
""".formatted(maxRows, tableInfo, request.getPrompt());
|
||||||
|
|
||||||
|
String planRaw = model.chat(sqlPlanPrompt);
|
||||||
|
JsonNode plan = parseJson(planRaw);
|
||||||
|
|
||||||
|
String title = plan.path("title").asText("AI 报表");
|
||||||
|
String summary = plan.path("summary").asText("根据自然语言需求自动生成");
|
||||||
|
String sql = normalizeSql(plan.path("sql").asText(""));
|
||||||
|
validateSelectSql(sql);
|
||||||
|
|
||||||
|
return AiReportResponse.builder()
|
||||||
|
.title(title)
|
||||||
|
.summary(summary)
|
||||||
|
.sql(sql)
|
||||||
|
.queryResult("")
|
||||||
|
.html("")
|
||||||
|
.build();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public AiReportResponse execute(AiReportExecuteRequest request) {
|
||||||
|
ChatModel model = buildModel(request.getModel());
|
||||||
|
String sql = normalizeSql(request.getSql());
|
||||||
|
validateSelectSql(sql);
|
||||||
|
|
||||||
|
String queryResult = executeShellSql(sql, 30);
|
||||||
|
|
||||||
|
String htmlPrompt = """
|
||||||
|
你是前端报表工程师。请生成一个完整的 HTML 页面(只输出 HTML,不要 markdown 代码块)。
|
||||||
|
页面要求:
|
||||||
|
1) 展示标题、摘要、SQL、查询结果(保留原始文本)
|
||||||
|
2) 风格专业,适合企业报表
|
||||||
|
3) 页面包含一个“继续编辑”输入框和按钮
|
||||||
|
4) 点击按钮后调用 POST /chat/report/refine
|
||||||
|
JSON: {"model":"%s","prompt":"用户输入","html":"当前页面 outerHTML","dataContext":"%s"}
|
||||||
|
5) 收到返回后,用返回的 html 替换当前页面
|
||||||
|
|
||||||
|
标题:%s
|
||||||
|
摘要:%s
|
||||||
|
SQL:%s
|
||||||
|
查询结果:
|
||||||
|
%s
|
||||||
|
""".formatted(request.getModel(), escapeForJson(queryResult), request.getTitle(), request.getSummary(), sql, queryResult);
|
||||||
|
|
||||||
|
String html = stripCodeFence(model.chat(htmlPrompt));
|
||||||
|
|
||||||
|
return AiReportResponse.builder()
|
||||||
|
.title(request.getTitle())
|
||||||
|
.summary(request.getSummary())
|
||||||
|
.sql(sql)
|
||||||
|
.queryResult(queryResult)
|
||||||
|
.html(html)
|
||||||
|
.build();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public AiReportResponse refine(AiReportRefineRequest request) {
|
||||||
|
ChatModel model = buildModel(request.getModel());
|
||||||
|
String dataContext = request.getDataContext() == null ? "" : request.getDataContext();
|
||||||
|
|
||||||
|
String refinePrompt = """
|
||||||
|
你是前端报表工程师。请基于当前 HTML 和用户要求进行修改。
|
||||||
|
仅输出完整 HTML,不要解释。
|
||||||
|
|
||||||
|
修改要求:
|
||||||
|
%s
|
||||||
|
|
||||||
|
数据上下文:
|
||||||
|
%s
|
||||||
|
|
||||||
|
当前 HTML:
|
||||||
|
%s
|
||||||
|
""".formatted(request.getPrompt(), dataContext, request.getHtml());
|
||||||
|
|
||||||
|
String updatedHtml = stripCodeFence(model.chat(refinePrompt));
|
||||||
|
|
||||||
|
return AiReportResponse.builder()
|
||||||
|
.title("AI 报表(已编辑)")
|
||||||
|
.summary(request.getPrompt())
|
||||||
|
.sql("")
|
||||||
|
.queryResult(dataContext)
|
||||||
|
.html(updatedHtml)
|
||||||
|
.build();
|
||||||
|
}
|
||||||
|
|
||||||
|
private String executeShellSql(String sql, int timeoutSeconds) {
|
||||||
|
MysqlConnectionInfo info = parseMysqlConnection(jdbcUrl);
|
||||||
|
List<String> command = new ArrayList<>();
|
||||||
|
command.add("mysql");
|
||||||
|
command.add("--batch");
|
||||||
|
command.add("--raw");
|
||||||
|
command.add("--default-character-set=utf8mb4");
|
||||||
|
command.add("-h");
|
||||||
|
command.add(info.host());
|
||||||
|
command.add("-P");
|
||||||
|
command.add(String.valueOf(info.port()));
|
||||||
|
command.add("-u");
|
||||||
|
command.add(dbUsername);
|
||||||
|
command.add("-D");
|
||||||
|
command.add(info.database());
|
||||||
|
command.add("-e");
|
||||||
|
command.add(sql);
|
||||||
|
|
||||||
|
ProcessBuilder pb = new ProcessBuilder(command);
|
||||||
|
pb.redirectErrorStream(true);
|
||||||
|
pb.environment().put("MYSQL_PWD", dbPassword == null ? "" : dbPassword);
|
||||||
|
|
||||||
|
try {
|
||||||
|
Process process = pb.start();
|
||||||
|
String output;
|
||||||
|
try (BufferedReader reader = new BufferedReader(new InputStreamReader(process.getInputStream(), StandardCharsets.UTF_8))) {
|
||||||
|
StringBuilder sb = new StringBuilder();
|
||||||
|
String line;
|
||||||
|
while ((line = reader.readLine()) != null) {
|
||||||
|
sb.append(line).append('\n');
|
||||||
|
}
|
||||||
|
output = sb.toString().trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
boolean finished = process.waitFor(timeoutSeconds, TimeUnit.SECONDS);
|
||||||
|
if (!finished) {
|
||||||
|
process.destroyForcibly();
|
||||||
|
throw new IllegalArgumentException("shell 查询超时");
|
||||||
|
}
|
||||||
|
|
||||||
|
int code = process.exitValue();
|
||||||
|
if (code != 0) {
|
||||||
|
throw new IllegalArgumentException("shell 查询失败: " + output);
|
||||||
|
}
|
||||||
|
return output;
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.error("shell 查询执行失败, sql={}", sql, e);
|
||||||
|
throw new IllegalArgumentException("shell 查询失败: " + e.getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private MysqlConnectionInfo parseMysqlConnection(String url) {
|
||||||
|
if (url == null || url.isBlank()) {
|
||||||
|
throw new IllegalArgumentException("未配置 MySQL JDBC URL");
|
||||||
|
}
|
||||||
|
|
||||||
|
Matcher matcher = MYSQL_JDBC_PATTERN.matcher(url.trim());
|
||||||
|
if (!matcher.matches()) {
|
||||||
|
throw new IllegalArgumentException("无法解析 JDBC URL: " + url);
|
||||||
|
}
|
||||||
|
|
||||||
|
String host = matcher.group(1);
|
||||||
|
int port = matcher.group(2) == null ? 3306 : Integer.parseInt(matcher.group(2));
|
||||||
|
String database = matcher.group(3);
|
||||||
|
if (database.contains("/")) {
|
||||||
|
database = database.substring(0, database.indexOf('/'));
|
||||||
|
}
|
||||||
|
return new MysqlConnectionInfo(host, port, database);
|
||||||
|
}
|
||||||
|
|
||||||
|
private ChatModel buildModel(String modelName) {
|
||||||
|
ChatModelVo modelVo = chatModelService.selectModelByName(modelName);
|
||||||
|
if (modelVo == null) {
|
||||||
|
throw new IllegalArgumentException("模型不存在: " + modelName);
|
||||||
|
}
|
||||||
|
|
||||||
|
return OpenAiChatModel.builder()
|
||||||
|
.baseUrl(modelVo.getApiHost())
|
||||||
|
.apiKey(modelVo.getApiKey())
|
||||||
|
.modelName(modelVo.getModelName())
|
||||||
|
.build();
|
||||||
|
}
|
||||||
|
|
||||||
|
private JsonNode parseJson(String raw) {
|
||||||
|
try {
|
||||||
|
String candidate = raw.trim();
|
||||||
|
if (candidate.startsWith("```") && candidate.contains("{")) {
|
||||||
|
int start = candidate.indexOf('{');
|
||||||
|
int end = candidate.lastIndexOf('}');
|
||||||
|
candidate = candidate.substring(start, end + 1);
|
||||||
|
}
|
||||||
|
return objectMapper.readTree(candidate);
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.error("解析 SQL 计划 JSON 失败: {}", raw, e);
|
||||||
|
throw new IllegalArgumentException("模型返回格式错误,未能解析 SQL 计划");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private String stripCodeFence(String content) {
|
||||||
|
String value = content == null ? "" : content.trim();
|
||||||
|
if (value.startsWith("```")) {
|
||||||
|
int firstLineBreak = value.indexOf('\n');
|
||||||
|
int lastFence = value.lastIndexOf("```");
|
||||||
|
if (firstLineBreak > -1 && lastFence > firstLineBreak) {
|
||||||
|
return value.substring(firstLineBreak + 1, lastFence).trim();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
private int resolveMaxRows(Integer maxRows) {
|
||||||
|
if (maxRows == null || maxRows < 1) {
|
||||||
|
return DEFAULT_MAX_ROWS;
|
||||||
|
}
|
||||||
|
return Math.min(maxRows, ABSOLUTE_MAX_ROWS);
|
||||||
|
}
|
||||||
|
|
||||||
|
private String normalizeSql(String sql) {
|
||||||
|
String value = sql == null ? "" : sql.trim();
|
||||||
|
if (value.endsWith(";")) {
|
||||||
|
value = value.substring(0, value.length() - 1).trim();
|
||||||
|
}
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void validateSelectSql(String sql) {
|
||||||
|
if (sql.isBlank()) {
|
||||||
|
throw new IllegalArgumentException("SQL 不能为空");
|
||||||
|
}
|
||||||
|
String upperSql = sql.toUpperCase();
|
||||||
|
if (!upperSql.startsWith("SELECT")) {
|
||||||
|
throw new IllegalArgumentException("仅允许 SELECT SQL");
|
||||||
|
}
|
||||||
|
if (upperSql.contains(";") || upperSql.contains("UPDATE ") || upperSql.contains("DELETE ")
|
||||||
|
|| upperSql.contains("INSERT ") || upperSql.contains("DROP ") || upperSql.contains("ALTER ")) {
|
||||||
|
throw new IllegalArgumentException("SQL 含有不允许的语句");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private String escapeForJson(String value) {
|
||||||
|
return value.replace("\\", "\\\\").replace("\"", "\\\"").replace("\n", "\\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
private record MysqlConnectionInfo(String host, int port, String database) {
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user