Skip to content

Commit ada3be3

Browse files
committed
feat: 大模型节点新增工具调用-可勾选接口/流程作为函数调用tools,模型按需调用工具后结果回传生成最终回答(最多4轮),接口按参数拼query/body直接HTTP调用(mock优先),流程按入参递归执行子流程引擎返回出参JSON,AiChatRequest扩展ToolsJson/History多轮历史,AiChatResult含工具调用解析,FlowExecutionService注入工具定义解析与执行回调,前端节点面板工具调用多选+帮助弹窗更新
1 parent f7ba172 commit ada3be3

7 files changed

Lines changed: 309 additions & 23 deletions

File tree

‎Juggle.Application/Services/Flow/FlowExecutionService.cs‎

Lines changed: 130 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -71,7 +71,136 @@ private async Task<FlowEngine> BuildEngineAsync(Dictionary<string, DataSourceInf
7171
aiChatFunc: req => _aiService.ChatRequestAsync(req),
7272
redisConnStrs: await GetRedisConnStrsAsync(),
7373
kbSearchFunc: (kbId, query, topK) => _kbService.SearchAsContextAsync(kbId, query, topK),
74-
skillResolver: BuildSkillsPromptAsync);
74+
skillResolver: BuildSkillsPromptAsync,
75+
toolsResolver: BuildToolDefsAsync,
76+
toolRunner: (name, argsJson) => RunToolAsync(name, argsJson, dsInfos, staticVars, flowContentLoader));
77+
78+
/// <summary>构建工具定义(AI 节点函数调用:接口/流程 → OpenAI tools JSON 数组)。</summary>
79+
private async Task<string> BuildToolDefsAsync(string toolApis, string toolFlows)
80+
{
81+
var tools = new List<Dictionary<string, object?>>();
82+
83+
foreach (var code in SplitCsv(toolApis))
84+
{
85+
var api = await _db.Apis.FirstOrDefaultAsync(a => a.MethodCode == code && a.Deleted == 0);
86+
if (api == null) continue;
87+
var apiParams = await _db.Parameters
88+
.Where(p => p.OwnerId == api.Id && p.ParamType == 1 && p.Deleted == 0)
89+
.OrderBy(p => p.SortNum).ToListAsync();
90+
var props = new Dictionary<string, object?>();
91+
foreach (var p in apiParams)
92+
{
93+
var prop = new Dictionary<string, object?> { ["type"] = "string" };
94+
if (!string.IsNullOrEmpty(p.Description)) prop["description"] = p.Description;
95+
props[p.ParamCode ?? ""] = prop;
96+
}
97+
tools.Add(new Dictionary<string, object?>
98+
{
99+
["type"] = "function",
100+
["function"] = new Dictionary<string, object?>
101+
{
102+
["name"] = $"api:{api.MethodCode}",
103+
["description"] = $"{api.MethodName}。{api.MethodDesc} 调用方式:{api.RequestType} {api.Url}",
104+
["parameters"] = new Dictionary<string, object?> { ["type"] = "object", ["properties"] = props }
105+
}
106+
});
107+
}
108+
109+
foreach (var key in SplitCsv(toolFlows))
110+
{
111+
var flow = await _db.FlowDefinitions.FirstOrDefaultAsync(f => f.FlowKey == key && f.Deleted == 0);
112+
if (flow == null) continue;
113+
var flowParams = await _db.Parameters
114+
.Where(p => p.OwnerId == flow.Id && p.ParamType == 5 && p.Deleted == 0)
115+
.OrderBy(p => p.SortNum).ToListAsync();
116+
var props = new Dictionary<string, object?>();
117+
foreach (var p in flowParams)
118+
{
119+
var prop = new Dictionary<string, object?> { ["type"] = "string" };
120+
if (!string.IsNullOrEmpty(p.Description)) prop["description"] = p.Description;
121+
props[p.ParamCode ?? ""] = prop;
122+
}
123+
tools.Add(new Dictionary<string, object?>
124+
{
125+
["type"] = "function",
126+
["function"] = new Dictionary<string, object?>
127+
{
128+
["name"] = $"flow:{flow.FlowKey}",
129+
["description"] = $"{flow.FlowName}。{flow.FlowDesc} 流程编排调用",
130+
["parameters"] = new Dictionary<string, object?> { ["type"] = "object", ["properties"] = props }
131+
}
132+
});
133+
}
134+
135+
return JsonSerializer.Serialize(tools);
136+
}
137+
138+
/// <summary>执行工具(AI 节点函数调用:api:xxx 直接 HTTP 调用,flow:xxx 递归执行子流程 → 结果文本)。</summary>
139+
private async Task<string> RunToolAsync(string name, string argsJson,
140+
Dictionary<string, DataSourceInfo> dsInfos, Dictionary<string, string?> staticVars,
141+
Func<string, Task<string?>> flowContentLoader)
142+
{
143+
Dictionary<string, object?> args;
144+
try { args = JsonSerializer.Deserialize<Dictionary<string, object?>>(argsJson) ?? new(); }
145+
catch { args = new(); }
146+
147+
if (name.StartsWith("api:"))
148+
{
149+
var methodCode = name[4..];
150+
var api = await _db.Apis.FirstOrDefaultAsync(a => a.MethodCode == methodCode && a.Deleted == 0)
151+
?? throw new Exception($"接口 [{methodCode}] 不存在");
152+
if (!string.IsNullOrEmpty(api.MockJson)) return api.MockJson;
153+
154+
var client = _httpClientFactory.CreateClient();
155+
client.Timeout = TimeSpan.FromSeconds(60);
156+
var requestType = (api.RequestType ?? "GET").ToUpper();
157+
string body;
158+
if (requestType == "GET" || requestType == "DELETE")
159+
{
160+
var query = string.Join("&", args.Select(kv =>
161+
$"{Uri.EscapeDataString(kv.Key)}={Uri.EscapeDataString(kv.Value?.ToString() ?? "")}"));
162+
var url = api.Url ?? "";
163+
if (query.Length > 0) url = url.Contains('?') ? $"{url}&{query}" : $"{url}?{query}";
164+
var resp = requestType == "GET" ? await client.GetAsync(url) : await client.DeleteAsync(url);
165+
body = await resp.Content.ReadAsStringAsync();
166+
}
167+
else
168+
{
169+
HttpContent content;
170+
if (api.ContentType == "FORM")
171+
{
172+
var formStr = string.Join("&", args.Select(kv =>
173+
$"{Uri.EscapeDataString(kv.Key)}={Uri.EscapeDataString(kv.Value?.ToString() ?? "")}"));
174+
content = new StringContent(formStr, System.Text.Encoding.UTF8, "application/x-www-form-urlencoded");
175+
}
176+
else
177+
{
178+
content = new StringContent(JsonSerializer.Serialize(args), System.Text.Encoding.UTF8, "application/json");
179+
}
180+
var resp = requestType == "PUT" ? await client.PutAsync(api.Url, content) : await client.PostAsync(api.Url, content);
181+
body = await resp.Content.ReadAsStringAsync();
182+
}
183+
return body.Length > 8000 ? body[..8000] : body;
184+
}
185+
186+
if (name.StartsWith("flow:"))
187+
{
188+
var flowKey = name[5..];
189+
var flowContent = await flowContentLoader(flowKey);
190+
if (string.IsNullOrEmpty(flowContent) || flowContent == "[]")
191+
throw new Exception($"流程 [{flowKey}] 不存在或未发布");
192+
var engine = await BuildEngineAsync(dsInfos, staticVars, flowContentLoader);
193+
var result = await engine.ExecuteAsync(flowContent, args, flowKey);
194+
if (!result.Success) throw new Exception(result.ErrorMessage);
195+
return JsonSerializer.Serialize(result.OutputData);
196+
}
197+
198+
throw new Exception($"未知工具: {name}");
199+
}
200+
201+
/// <summary>拆分逗号分隔列表(去空白去空项)。</summary>
202+
private static List<string> SplitCsv(string csv)
203+
=> (csv ?? "").Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries).ToList();
75204

76205
// ────────────────────────────────────────────────────────────────
77206
// 数据源

‎Juggle.Application/Services/Impl/AiService.cs‎

Lines changed: 35 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -186,8 +186,8 @@ public async Task<string> ChatMessagesAsync(List<(string Role, string Content)>
186186
return content?.Trim() ?? "";
187187
}
188188

189-
/// <summary>按 AI 节点请求调用大模型(统一入口:支持图片输入、温度/最大输出字数/随机种子/深度思考等模型参数)。</summary>
190-
public async Task<string> ChatRequestAsync(AiChatRequest req)
189+
/// <summary>按 AI 节点请求调用大模型(统一入口:支持图片输入、温度/最大输出字数/随机种子/深度思考等模型参数,以及函数调用 tools 与多轮历史)。</summary>
190+
public async Task<AiChatResult> ChatRequestAsync(AiChatRequest req)
191191
{
192192
var cfg = await ResolveConfigAsync(req.ProviderId > 0 ? req.ProviderId : null);
193193
if (!string.IsNullOrEmpty(req.Model)) cfg.Model = req.Model;
@@ -210,19 +210,28 @@ public async Task<string> ChatRequestAsync(AiChatRequest req)
210210
userContent = content.ToArray();
211211
}
212212

213+
// 消息:system + user + 历史(工具调用多轮回传)
214+
var messages = new List<object>
215+
{
216+
new Dictionary<string, object?> { ["role"] = "system", ["content"] = req.SystemPrompt },
217+
new Dictionary<string, object?> { ["role"] = "user", ["content"] = userContent }
218+
};
219+
if (req.History is { Count: > 0 }) messages.AddRange(req.History);
220+
213221
var payload = new Dictionary<string, object?>
214222
{
215223
["model"] = cfg.Model,
216-
["messages"] = new object[]
217-
{
218-
new { role = "system", content = req.SystemPrompt },
219-
new { role = "user", content = userContent }
220-
},
224+
["messages"] = messages,
221225
["temperature"] = req.Temperature ?? 0.2
222226
};
223227
if (req.MaxTokens is > 0) payload["max_tokens"] = req.MaxTokens.Value;
224228
if (req.Seed.HasValue) payload["seed"] = req.Seed.Value;
225229
if (req.EnableThinking) payload["enable_thinking"] = true;
230+
if (!string.IsNullOrWhiteSpace(req.ToolsJson))
231+
{
232+
try { payload["tools"] = JsonSerializer.Deserialize<JsonElement>(req.ToolsJson); }
233+
catch { /* 工具定义非法则忽略 */ }
234+
}
226235

227236
var client = _httpClientFactory.CreateClient();
228237
client.Timeout = TimeSpan.FromSeconds(180);
@@ -234,8 +243,26 @@ public async Task<string> ChatRequestAsync(AiChatRequest req)
234243
var body = await resp.Content.ReadAsStringAsync();
235244
if (!resp.IsSuccessStatusCode)
236245
throw new Exception($"AI 调用失败({(int)resp.StatusCode}): {Truncate(body, 300)}");
246+
237247
using var doc = JsonDocument.Parse(body);
238-
return doc.RootElement.GetProperty("choices")[0].GetProperty("message").GetProperty("content").GetString()?.Trim() ?? "";
248+
var msg = doc.RootElement.GetProperty("choices")[0].GetProperty("message");
249+
var result = new AiChatResult();
250+
if (msg.TryGetProperty("content", out var contentEl) && contentEl.ValueKind == JsonValueKind.String)
251+
result.Text = contentEl.GetString()?.Trim() ?? "";
252+
if (msg.TryGetProperty("tool_calls", out var tcs) && tcs.ValueKind == JsonValueKind.Array)
253+
{
254+
foreach (var tc in tcs.EnumerateArray())
255+
{
256+
if (!tc.TryGetProperty("function", out var fn)) continue;
257+
result.ToolCalls.Add(new AiToolCall
258+
{
259+
Id = tc.TryGetProperty("id", out var idEl) ? idEl.GetString() ?? "" : "",
260+
Name = fn.GetProperty("name").GetString() ?? "",
261+
Arguments = fn.TryGetProperty("arguments", out var argsEl) ? argsEl.GetString() ?? "{}" : "{}"
262+
});
263+
}
264+
}
265+
return result;
239266
}
240267

241268
private static string Truncate(string s, int len) => s.Length <= len ? s : s[..len] + "...";

‎Juggle.Domain/Engine/FlowEngine.cs‎

Lines changed: 13 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -17,23 +17,29 @@ public class FlowEngine
1717
private readonly Dictionary<string, string?> _staticVarSnapshot;
1818
/// <summary>根据 flowKey 加载最新已发布流程内容(供 SUB_FLOW 节点调用),可为 null(不支持子流程)</summary>
1919
private readonly Func<string, Task<string?>>? _flowContentLoader;
20-
/// <summary>大模型对话函数(AiChatRequest → reply),供 AI 节点与文件解析(图片)调用,可为 null(未接入大模型)</summary>
21-
private readonly Func<AiChatRequest, Task<string>>? _aiChatFunc;
20+
/// <summary>大模型对话函数(AiChatRequest → 结果含工具调用),供 AI 节点与文件解析(图片)调用,可为 null(未接入大模型)</summary>
21+
private readonly Func<AiChatRequest, Task<AiChatResult>>? _aiChatFunc;
2222
/// <summary>Redis 实例连接串(实例ID → 连接串,0=默认实例),供 REDIS_GET/REDIS_SET 节点使用</summary>
2323
private readonly Dictionary<long, string> _redisConnStrs;
2424
/// <summary>知识库检索函数(kbId, query, topK → 上下文文本),供 KB_SEARCH 节点使用</summary>
2525
private readonly Func<long, string, int, Task<string>>? _kbSearchFunc;
2626
/// <summary>技能解析函数(技能ID列表 → 拼接的提示词文本),供 AI 节点 skills 使用</summary>
2727
private readonly Func<List<long>, Task<string>>? _skillResolver;
28+
/// <summary>工具定义解析函数(toolApis,toolFlows → OpenAI tools JSON),供 AI 节点函数调用使用</summary>
29+
private readonly Func<string, string, Task<string>>? _toolsResolver;
30+
/// <summary>工具执行函数(工具名, 参数 JSON → 结果文本),供 AI 节点函数调用使用</summary>
31+
private readonly Func<string, string, Task<string>>? _toolRunner;
2832

2933
public FlowEngine(IHttpClientFactory httpClientFactory,
3034
Dictionary<string, DataSourceInfo>? dataSources = null,
3135
Dictionary<string, string?>? staticVariables = null,
3236
Func<string, Task<string?>>? flowContentLoader = null,
33-
Func<AiChatRequest, Task<string>>? aiChatFunc = null,
37+
Func<AiChatRequest, Task<AiChatResult>>? aiChatFunc = null,
3438
Dictionary<long, string>? redisConnStrs = null,
3539
Func<long, string, int, Task<string>>? kbSearchFunc = null,
36-
Func<List<long>, Task<string>>? skillResolver = null)
40+
Func<List<long>, Task<string>>? skillResolver = null,
41+
Func<string, string, Task<string>>? toolsResolver = null,
42+
Func<string, string, Task<string>>? toolRunner = null)
3743
{
3844
_httpClientFactory = httpClientFactory;
3945
_dataSources = dataSources ?? new();
@@ -43,6 +49,8 @@ public FlowEngine(IHttpClientFactory httpClientFactory,
4349
_redisConnStrs = redisConnStrs ?? new();
4450
_kbSearchFunc = kbSearchFunc;
4551
_skillResolver = skillResolver;
52+
_toolsResolver = toolsResolver;
53+
_toolRunner = toolRunner;
4654
}
4755

4856
/// <summary>解析 Redis 连接串(按实例 ID,0=默认实例);未配置时返回 null。</summary>
@@ -174,7 +182,7 @@ public async Task<FlowResult> ExecuteAsync(
174182
"TRANSFORM" => new TransformNodeExecutor(),
175183
"AI" => new AiNodeExecutor(
176184
_aiChatFunc ?? throw new InvalidOperationException("流程引擎未接入大模型,无法执行 AI 节点"),
177-
_skillResolver),
185+
_skillResolver, _toolsResolver, _toolRunner),
178186
"FILE_PARSE" => new FileParseNodeExecutor(_aiChatFunc),
179187
"EXCEL_READ" => new ExcelReadNodeExecutor(),
180188
"FILE_WRITE" => new FileWriteNodeExecutor(),

‎Juggle.Domain/Engine/FlowModels.cs‎

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -320,6 +320,14 @@ public class AiConfig
320320
/// <summary>随机种子(seed,固定后输出更稳定;空=随机)</summary>
321321
[JsonPropertyName("seed")]
322322
public int? Seed { get; set; }
323+
324+
/// <summary>可调用的接口 methodCode 列表(逗号分隔,函数调用工具)</summary>
325+
[JsonPropertyName("toolApis")]
326+
public string ToolApis { get; set; } = "";
327+
328+
/// <summary>可调用的流程 flowKey 列表(逗号分隔,函数调用工具)</summary>
329+
[JsonPropertyName("toolFlows")]
330+
public string ToolFlows { get; set; } = "";
323331
}
324332

325333
/// <summary>文件解析节点配置(FILE_PARSE 节点)</summary>

0 commit comments

Comments
 (0)