Skip to content

Commit c4102a1

Browse files
pythonHuangclaude
andcommitted
feat: Skill管理系统-AI技能实体与CRUD(名称/分组/描述/内容/启停),单个导入(JSON或markdown首个#标题为名)与批量导入、单个/批量导出JSON,管理页支持分组筛选与搜索;大模型节点新增技能多选(执行时技能内容拼入系统提示词,通过应用层注入解析回调)
Co-Authored-By: Claude Code <noreply@anthropic.com>
1 parent cde620c commit c4102a1

12 files changed

Lines changed: 543 additions & 7 deletions

File tree

Lines changed: 214 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,214 @@
1+
using System.Text.Json;
2+
using Juggle.Application.Models.Response;
3+
using Juggle.Application.Services;
4+
using Juggle.Domain.Entities;
5+
using Juggle.Infrastructure.Persistence;
6+
using Microsoft.AspNetCore.Authorization;
7+
using Microsoft.AspNetCore.Mvc;
8+
using Microsoft.EntityFrameworkCore;
9+
10+
namespace Juggle.Api.Controllers.Api;
11+
12+
/// <summary>
13+
/// AI 技能管理:CRUD、分组/启停、单个/批量导入导出。
14+
/// 技能可被大模型节点勾选,执行时拼入系统提示词。
15+
/// </summary>
16+
[ApiController]
17+
[Route("api/skill")]
18+
[Authorize]
19+
public class SkillController : ControllerBase
20+
{
21+
private readonly JuggleDbContext _db;
22+
private readonly ITenantAccessor _tenant;
23+
24+
public SkillController(JuggleDbContext db, ITenantAccessor tenant)
25+
{
26+
_db = db;
27+
_tenant = tenant;
28+
}
29+
30+
/// <summary>技能列表(可选 group 筛选;仅启用的可加 enabled=1)</summary>
31+
[HttpGet("list")]
32+
public async Task<ApiResult> List([FromQuery] string? group, [FromQuery] int? enabled)
33+
{
34+
var query = _db.Skills.Where(s => s.Deleted == 0);
35+
if (!string.IsNullOrWhiteSpace(group)) query = query.Where(s => s.GroupName == group);
36+
if (enabled.HasValue) query = query.Where(s => s.Enabled == enabled.Value);
37+
return ApiResult.Success(await query.OrderBy(s => s.GroupName).ThenByDescending(s => s.Id).ToListAsync());
38+
}
39+
40+
/// <summary>保存(新增/编辑)</summary>
41+
[HttpPost("save")]
42+
public async Task<ApiResult> Save([FromBody] SkillSaveRequest req)
43+
{
44+
SkillEntity entity;
45+
if (req.Id > 0)
46+
{
47+
entity = await _db.Skills.FindAsync(req.Id) ?? throw new Exception("技能不存在");
48+
}
49+
else
50+
{
51+
entity = new SkillEntity { CreatedAt = DateTime.Now.ToString("o"), TenantId = _tenant.TenantId };
52+
_db.Skills.Add(entity);
53+
}
54+
entity.SkillName = req.SkillName;
55+
entity.GroupName = req.GroupName;
56+
entity.Description = req.Description;
57+
entity.Content = req.Content;
58+
entity.Enabled = req.Enabled ? 1 : 0;
59+
entity.UpdatedAt = DateTime.Now.ToString("o");
60+
await _db.SaveChangesAsync();
61+
return ApiResult.Success(entity.Id);
62+
}
63+
64+
/// <summary>删除</summary>
65+
[HttpDelete("delete/{id}")]
66+
public async Task<ApiResult> Delete(long id)
67+
{
68+
var entity = await _db.Skills.FindAsync(id);
69+
if (entity == null) return ApiResult.Fail("技能不存在");
70+
entity.Deleted = 1;
71+
await _db.SaveChangesAsync();
72+
return ApiResult.Success();
73+
}
74+
75+
/// <summary>启用/禁用</summary>
76+
[HttpPost("toggle")]
77+
public async Task<ApiResult> Toggle([FromBody] SkillToggleRequest req)
78+
{
79+
var entity = await _db.Skills.FindAsync(req.Id);
80+
if (entity == null) return ApiResult.Fail("技能不存在");
81+
entity.Enabled = req.Enabled ? 1 : 0;
82+
entity.UpdatedAt = DateTime.Now.ToString("o");
83+
await _db.SaveChangesAsync();
84+
return ApiResult.Success();
85+
}
86+
87+
/// <summary>
88+
/// 导入技能(单个):JSON(skillName/groupName/description/content)
89+
/// 或纯 markdown(首个 "# 名称" 作为技能名,其余为内容)。
90+
/// </summary>
91+
[HttpPost("import")]
92+
public async Task<ApiResult> Import([FromBody] SkillImportRequest req)
93+
{
94+
try
95+
{
96+
var (name, group, desc, content) = ParseSkillImport(req.FileName ?? "", req.Content ?? "");
97+
var entity = new SkillEntity
98+
{
99+
SkillName = name,
100+
GroupName = string.IsNullOrWhiteSpace(group) ? "未分组" : group,
101+
Description = desc,
102+
Content = content,
103+
Enabled = 1,
104+
CreatedAt = DateTime.Now.ToString("o"),
105+
TenantId = _tenant.TenantId
106+
};
107+
_db.Skills.Add(entity);
108+
await _db.SaveChangesAsync();
109+
return ApiResult.Success(entity.Id);
110+
}
111+
catch (Exception ex) { return ApiResult.Fail(ex.Message); }
112+
}
113+
114+
/// <summary>批量导入(数组,每项 {fileName, content})</summary>
115+
[HttpPost("batch-import")]
116+
public async Task<ApiResult> BatchImport([FromBody] List<SkillImportRequest> req)
117+
{
118+
try
119+
{
120+
var count = 0;
121+
foreach (var item in req)
122+
{
123+
var (name, group, desc, content) = ParseSkillImport(item.FileName ?? "", item.Content ?? "");
124+
if (string.IsNullOrWhiteSpace(name) && string.IsNullOrWhiteSpace(content)) continue;
125+
_db.Skills.Add(new SkillEntity
126+
{
127+
SkillName = string.IsNullOrWhiteSpace(name) ? item.FileName : name,
128+
GroupName = string.IsNullOrWhiteSpace(group) ? "未分组" : group,
129+
Description = desc,
130+
Content = content,
131+
Enabled = 1,
132+
CreatedAt = DateTime.Now.ToString("o"),
133+
TenantId = _tenant.TenantId
134+
});
135+
count++;
136+
}
137+
await _db.SaveChangesAsync();
138+
return ApiResult.Success(new { count });
139+
}
140+
catch (Exception ex) { return ApiResult.Fail(ex.Message); }
141+
}
142+
143+
/// <summary>导出单个技能</summary>
144+
[HttpGet("export/{id}")]
145+
public async Task<ApiResult> Export(long id)
146+
{
147+
var entity = await _db.Skills.FindAsync(id);
148+
if (entity == null) return ApiResult.Fail("技能不存在");
149+
return ApiResult.Success(new { skillName = entity.SkillName, groupName = entity.GroupName, description = entity.Description, content = entity.Content });
150+
}
151+
152+
/// <summary>批量导出(ids 逗号分隔)</summary>
153+
[HttpGet("batch-export")]
154+
public async Task<ApiResult> BatchExport([FromQuery] string ids)
155+
{
156+
var idList = (ids ?? "").Split(',').Where(s => long.TryParse(s, out _)).Select(long.Parse).ToList();
157+
var list = await _db.Skills.Where(s => idList.Contains(s.Id) && s.Deleted == 0).ToListAsync();
158+
return ApiResult.Success(list.Select(s => new { skillName = s.SkillName, groupName = s.GroupName, description = s.Description, content = s.Content }));
159+
}
160+
161+
/// <summary>解析导入内容:JSON 或 markdown(# 名称 为首行标题)。</summary>
162+
private static (string Name, string Group, string Desc, string Content) ParseSkillImport(string fileName, string content)
163+
{
164+
var text = content?.Trim() ?? "";
165+
if (text.StartsWith("{"))
166+
{
167+
try
168+
{
169+
using var doc = JsonDocument.Parse(text);
170+
var root = doc.RootElement;
171+
string? Get(string p) => root.TryGetProperty(p, out var v) && v.ValueKind == JsonValueKind.String ? v.GetString() : null;
172+
return (Get("skillName") ?? Get("name") ?? "未命名技能",
173+
Get("groupName") ?? Get("group") ?? "",
174+
Get("description") ?? "",
175+
Get("content") ?? "");
176+
}
177+
catch { /* 非 JSON 按 markdown 处理 */ }
178+
}
179+
// markdown:# 标题为名称
180+
var lines = text.Split('\n');
181+
var name = "未命名技能";
182+
var bodyStart = 0;
183+
if (lines.Length > 0 && lines[0].TrimStart().StartsWith("#"))
184+
{
185+
name = lines[0].TrimStart().TrimStart('#').Trim();
186+
bodyStart = 1;
187+
}
188+
if (name == "未命名技能" && !string.IsNullOrWhiteSpace(fileName))
189+
name = Path.GetFileNameWithoutExtension(fileName);
190+
return (name, "", "", string.Join('\n', lines.Skip(bodyStart)).Trim());
191+
}
192+
}
193+
194+
public class SkillSaveRequest
195+
{
196+
public long Id { get; set; }
197+
public string SkillName { get; set; } = "";
198+
public string GroupName { get; set; } = "";
199+
public string Description { get; set; } = "";
200+
public string Content { get; set; } = "";
201+
public bool Enabled { get; set; } = true;
202+
}
203+
204+
public class SkillToggleRequest
205+
{
206+
public long Id { get; set; }
207+
public bool Enabled { get; set; }
208+
}
209+
210+
public class SkillImportRequest
211+
{
212+
public string? FileName { get; set; }
213+
public string? Content { get; set; }
214+
}

‎Juggle.Api/Program.cs‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -258,6 +258,8 @@ await ctx.Response.WriteAsync(
258258
try { db.Database.ExecuteSqlRaw("CREATE TABLE IF NOT EXISTS t_ai_conversation(id INTEGER PRIMARY KEY AUTOINCREMENT, assistant_id INTEGER, assistant_name TEXT, system_prompt TEXT, input_params TEXT, output_params TEXT, messages TEXT, outputs TEXT, provider_id INTEGER, model TEXT, title TEXT, status INTEGER DEFAULT 0, created_at TEXT, created_by INTEGER, updated_at TEXT, updated_by INTEGER, tenant_id INTEGER, deleted INTEGER DEFAULT 0);"); } catch { }
259259
// Redis 多实例配置表
260260
try { db.Database.ExecuteSqlRaw("CREATE TABLE IF NOT EXISTS t_redis_config(id INTEGER PRIMARY KEY AUTOINCREMENT, config_name TEXT, host TEXT, port TEXT, password TEXT, db TEXT, is_default INTEGER DEFAULT 0, created_at TEXT, created_by INTEGER, updated_at TEXT, updated_by INTEGER, tenant_id INTEGER, deleted INTEGER DEFAULT 0);"); } catch { }
261+
// AI 技能表
262+
try { db.Database.ExecuteSqlRaw("CREATE TABLE IF NOT EXISTS t_skill(id INTEGER PRIMARY KEY AUTOINCREMENT, skill_name TEXT, group_name TEXT, description TEXT, content TEXT, enabled INTEGER DEFAULT 1, market_skill_id INTEGER, created_at TEXT, created_by INTEGER, updated_at TEXT, updated_by INTEGER, tenant_id INTEGER, deleted INTEGER DEFAULT 0);"); } catch { }
261263
// 知识库 4 表
262264
try { db.Database.ExecuteSqlRaw("CREATE TABLE IF NOT EXISTS t_kb(id INTEGER PRIMARY KEY AUTOINCREMENT, kb_name TEXT, description TEXT, chunk_size INTEGER DEFAULT 500, chunk_overlap INTEGER DEFAULT 50, retrieve_type TEXT DEFAULT 'text', vector_model TEXT, enabled INTEGER DEFAULT 1, chunk_count INTEGER DEFAULT 0, created_at TEXT, created_by INTEGER, updated_at TEXT, updated_by INTEGER, tenant_id INTEGER, deleted INTEGER DEFAULT 0);"); } catch { }
263265
try { db.Database.ExecuteSqlRaw("CREATE TABLE IF NOT EXISTS t_kb_document(id INTEGER PRIMARY KEY AUTOINCREMENT, kb_id INTEGER, doc_name TEXT, doc_type TEXT, content TEXT, status INTEGER DEFAULT 0, created_at TEXT, created_by INTEGER, updated_at TEXT, updated_by INTEGER, tenant_id INTEGER, deleted INTEGER DEFAULT 0);"); } catch { }

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

Lines changed: 20 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -47,15 +47,33 @@ private async Task<Dictionary<long, string>> GetRedisConnStrsAsync()
4747
return map;
4848
}
4949

50-
/// <summary>构建引擎(注入大模型对话/视觉函数、Redis 多实例连接与知识库检索,供 AI/文件解析/Redis/KB_SEARCH 节点使用)。</summary>
50+
/// <summary>按技能 ID 列表拼接技能提示词(AI 节点 skills 使用)。</summary>
51+
private async Task<string> BuildSkillsPromptAsync(List<long> ids)
52+
{
53+
var skills = await _db.Skills
54+
.Where(s => ids.Contains(s.Id) && s.Deleted == 0 && s.Enabled == 1)
55+
.ToListAsync();
56+
if (skills.Count == 0) return "";
57+
var sb = new System.Text.StringBuilder();
58+
foreach (var s in skills)
59+
{
60+
sb.AppendLine($"## 技能:{s.SkillName}");
61+
sb.AppendLine(s.Content);
62+
sb.AppendLine();
63+
}
64+
return sb.ToString().Trim();
65+
}
66+
67+
/// <summary>构建引擎(注入大模型对话/视觉函数、Redis 多实例连接、知识库检索与技能解析,供 AI/文件解析/Redis/KB_SEARCH 节点使用)。</summary>
5168
private async Task<FlowEngine> BuildEngineAsync(Dictionary<string, DataSourceInfo> dsInfos,
5269
Dictionary<string, string?> staticVars, Func<string, Task<string?>> flowContentLoader)
5370
=> new FlowEngine(_httpClientFactory, dsInfos, staticVars, flowContentLoader,
5471
aiChatFunc: req => req.Images.Count > 0
5572
? _aiService.ChatWithImagesAsync(req.SystemPrompt, req.UserInput, req.Images, req.ProviderId, req.Model)
5673
: _aiService.ChatAsync(req.SystemPrompt, req.UserInput, req.ProviderId, modelOverride: req.Model),
5774
redisConnStrs: await GetRedisConnStrsAsync(),
58-
kbSearchFunc: (kbId, query, topK) => _kbService.SearchAsContextAsync(kbId, query, topK));
75+
kbSearchFunc: (kbId, query, topK) => _kbService.SearchAsContextAsync(kbId, query, topK),
76+
skillResolver: BuildSkillsPromptAsync);
5977

6078
// ────────────────────────────────────────────────────────────────
6179
// 数据源

‎Juggle.Domain/Engine/FlowEngine.cs‎

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -23,14 +23,17 @@ public class FlowEngine
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;
26+
/// <summary>技能解析函数(技能ID列表 → 拼接的提示词文本),供 AI 节点 skills 使用</summary>
27+
private readonly Func<List<long>, Task<string>>? _skillResolver;
2628

2729
public FlowEngine(IHttpClientFactory httpClientFactory,
2830
Dictionary<string, DataSourceInfo>? dataSources = null,
2931
Dictionary<string, string?>? staticVariables = null,
3032
Func<string, Task<string?>>? flowContentLoader = null,
3133
Func<AiChatRequest, Task<string>>? aiChatFunc = null,
3234
Dictionary<long, string>? redisConnStrs = null,
33-
Func<long, string, int, Task<string>>? kbSearchFunc = null)
35+
Func<long, string, int, Task<string>>? kbSearchFunc = null,
36+
Func<List<long>, Task<string>>? skillResolver = null)
3437
{
3538
_httpClientFactory = httpClientFactory;
3639
_dataSources = dataSources ?? new();
@@ -39,6 +42,7 @@ public FlowEngine(IHttpClientFactory httpClientFactory,
3942
_aiChatFunc = aiChatFunc;
4043
_redisConnStrs = redisConnStrs ?? new();
4144
_kbSearchFunc = kbSearchFunc;
45+
_skillResolver = skillResolver;
4246
}
4347

4448
/// <summary>解析 Redis 连接串(按实例 ID,0=默认实例);未配置时返回 null。</summary>
@@ -169,7 +173,8 @@ public async Task<FlowResult> ExecuteAsync(
169173
"NOTIFY" => new NotifyNodeExecutor(_httpClientFactory),
170174
"TRANSFORM" => new TransformNodeExecutor(),
171175
"AI" => new AiNodeExecutor(
172-
_aiChatFunc ?? throw new InvalidOperationException("流程引擎未接入大模型,无法执行 AI 节点")),
176+
_aiChatFunc ?? throw new InvalidOperationException("流程引擎未接入大模型,无法执行 AI 节点"),
177+
_skillResolver),
173178
"FILE_PARSE" => new FileParseNodeExecutor(_aiChatFunc),
174179
"EXCEL_READ" => new ExcelReadNodeExecutor(),
175180
"FILE_WRITE" => new FileWriteNodeExecutor(),

‎Juggle.Domain/Engine/FlowModels.cs‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -300,6 +300,10 @@ public class AiConfig
300300
/// <summary>模型名(空=供应商默认模型)</summary>
301301
[JsonPropertyName("model")]
302302
public string Model { get; set; } = "";
303+
304+
/// <summary>技能 ID 列表(逗号分隔,执行时技能内容拼入系统提示词)</summary>
305+
[JsonPropertyName("skills")]
306+
public string Skills { get; set; } = "";
303307
}
304308

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

‎Juggle.Domain/Engine/NodeExecutors/AiNodeExecutor.cs‎

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,8 +11,13 @@ public record AiChatRequest(string SystemPrompt, string UserInput, List<string>
1111
public class AiNodeExecutor : INodeExecutor
1212
{
1313
private readonly Func<AiChatRequest, Task<string>> _chat;
14+
private readonly Func<List<long>, Task<string>>? _skillResolver;
1415

15-
public AiNodeExecutor(Func<AiChatRequest, Task<string>> chat) => _chat = chat;
16+
public AiNodeExecutor(Func<AiChatRequest, Task<string>> chat, Func<List<long>, Task<string>>? skillResolver = null)
17+
{
18+
_chat = chat;
19+
_skillResolver = skillResolver;
20+
}
1621

1722
public async Task<string?> ExecuteAsync(FlowNode node, FlowContext context)
1823
{
@@ -22,6 +27,19 @@ public class AiNodeExecutor : INodeExecutor
2227
var input = string.IsNullOrWhiteSpace(cfg.Input) ? "" : context.GetVariable(cfg.Input)?.ToString() ?? "";
2328
var systemPrompt = string.IsNullOrWhiteSpace(cfg.SystemPrompt) ? "你是一个智能助手。" : cfg.SystemPrompt;
2429

30+
// 已勾选技能:内容拼入系统提示词
31+
var skillIds = new List<long>();
32+
foreach (var s in (cfg.Skills ?? "").Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries))
33+
{
34+
if (long.TryParse(s, out var id)) skillIds.Add(id);
35+
}
36+
if (skillIds.Count > 0 && _skillResolver != null)
37+
{
38+
var skillsText = await _skillResolver(skillIds);
39+
if (!string.IsNullOrWhiteSpace(skillsText))
40+
systemPrompt = skillsText + "\n\n" + systemPrompt;
41+
}
42+
2543
// 图片输入(视觉模型识别)
2644
var images = new List<string>();
2745
if (!string.IsNullOrWhiteSpace(cfg.InputImages))
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
namespace Juggle.Domain.Entities;
2+
3+
/// <summary>
4+
/// AI 技能(Skill):可复用的提示词技能/工具定义。
5+
/// 可被大模型节点(AI 节点)勾选,执行时技能内容拼入系统提示词;
6+
/// 支持单个/批量导入导出,进入技能市场流转。
7+
/// </summary>
8+
public class SkillEntity : BaseEntity
9+
{
10+
/// <summary>技能名称</summary>
11+
public string? SkillName { get; set; }
12+
13+
/// <summary>技能分组</summary>
14+
public string? GroupName { get; set; }
15+
16+
/// <summary>技能描述(模型选择依据)</summary>
17+
public string? Description { get; set; }
18+
19+
/// <summary>技能内容(提示词/markdown)</summary>
20+
public string? Content { get; set; }
21+
22+
/// <summary>是否启用:1=启用 0=禁用</summary>
23+
public int Enabled { get; set; } = 1;
24+
25+
/// <summary>来源市场条目 ID(来自市场导入时记录)</summary>
26+
public long? MarketSkillId { get; set; }
27+
}

0 commit comments

Comments
 (0)