Skip to content

Commit 96028ce

Browse files
pythonHuangclaude
andcommitted
feat: 模型助手支持多轮对话-聊天界面气泡消息/携带完整历史调用/结束对话有输出参数时生成最终JSON结果/开启新对话/对话历史抽屉(查看详情/继续对话/删除),会话持久化存储
Co-Authored-By: Claude Code <noreply@anthropic.com>
1 parent 1763067 commit 96028ce

6 files changed

Lines changed: 449 additions & 41 deletions

File tree

‎Juggle.Api/Controllers/Api/AiController.cs‎

Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -275,6 +275,81 @@ public async Task<ApiResult> RunAssistant([FromBody] AiAssistantRunRequest req)
275275
}
276276
catch (Exception ex) { return ApiResult.Fail(ex.Message); }
277277
}
278+
279+
// ==================== 多轮对话 ====================
280+
281+
/// <summary>开启新对话(快照助手配置与输入参数)</summary>
282+
[HttpPost("conversation/start")]
283+
public async Task<ApiResult> StartConversation([FromBody] AiConversationStartRequest req)
284+
{
285+
try
286+
{
287+
var assistant = await _db.AiAssistants.FirstOrDefaultAsync(a => a.Id == req.AssistantId && a.Deleted == 0)
288+
?? throw new Exception("助手不存在");
289+
var conv = await _aiService.StartConversationAsync(assistant, req.Inputs, req.ProviderId, req.Model);
290+
return ApiResult.Success(new { conv.Id, conv.Title, conv.Status });
291+
}
292+
catch (Exception ex) { return ApiResult.Fail(ex.Message); }
293+
}
294+
295+
/// <summary>多轮对话:发送消息(携带完整历史)</summary>
296+
[HttpPost("conversation/chat")]
297+
public async Task<ApiResult> ConversationChat([FromBody] AiConversationChatRequest req)
298+
{
299+
try
300+
{
301+
var conv = await _db.AiConversations.FirstOrDefaultAsync(c => c.Id == req.ConversationId && c.Deleted == 0)
302+
?? throw new Exception("会话不存在");
303+
if (conv.Status == 1) throw new Exception("会话已结束,请开启新对话");
304+
var reply = await _aiService.ConversationChatAsync(conv, req.Content, req.ProviderId, req.Model);
305+
return ApiResult.Success(new { reply });
306+
}
307+
catch (Exception ex) { return ApiResult.Fail(ex.Message); }
308+
}
309+
310+
/// <summary>结束对话:有输出参数时生成最终结果,会话标记已结束</summary>
311+
[HttpPost("conversation/end")]
312+
public async Task<ApiResult> EndConversation([FromBody] AiConversationEndRequest req)
313+
{
314+
try
315+
{
316+
var conv = await _db.AiConversations.FirstOrDefaultAsync(c => c.Id == req.ConversationId && c.Deleted == 0)
317+
?? throw new Exception("会话不存在");
318+
var result = await _aiService.EndConversationAsync(conv, req.ProviderId, req.Model);
319+
return ApiResult.Success(result);
320+
}
321+
catch (Exception ex) { return ApiResult.Fail(ex.Message); }
322+
}
323+
324+
/// <summary>会话历史列表(按助手)</summary>
325+
[HttpGet("conversations/{assistantId}")]
326+
public async Task<ApiResult> Conversations(long assistantId)
327+
=> ApiResult.Success(await _db.AiConversations
328+
.Where(c => c.Deleted == 0 && c.AssistantId == assistantId)
329+
.OrderByDescending(c => c.Id)
330+
.Select(c => new { c.Id, c.Title, c.Status, c.Model, c.CreatedAt, c.UpdatedAt })
331+
.Take(200)
332+
.ToListAsync());
333+
334+
/// <summary>会话详情(消息历史/最终输出)</summary>
335+
[HttpGet("conversation/{id}")]
336+
public async Task<ApiResult> ConversationDetail(long id)
337+
{
338+
var conv = await _db.AiConversations.FirstOrDefaultAsync(c => c.Id == id && c.Deleted == 0);
339+
if (conv == null) return ApiResult.Fail("会话不存在");
340+
return ApiResult.Success(conv);
341+
}
342+
343+
/// <summary>删除会话</summary>
344+
[HttpDelete("conversation/{id}")]
345+
public async Task<ApiResult> DeleteConversation(long id)
346+
{
347+
var conv = await _db.AiConversations.FindAsync(id);
348+
if (conv == null) return ApiResult.Fail("会话不存在");
349+
conv.Deleted = 1;
350+
await _db.SaveChangesAsync();
351+
return ApiResult.Success();
352+
}
278353
}
279354

280355
public class AiConfigRequest
@@ -366,3 +441,26 @@ public class AiAssistantRunRequest
366441
public Dictionary<string, object?>? Inputs { get; set; }
367442
public string? ExtraText { get; set; }
368443
}
444+
445+
public class AiConversationStartRequest
446+
{
447+
public long AssistantId { get; set; }
448+
public long ProviderId { get; set; }
449+
public string? Model { get; set; }
450+
public Dictionary<string, object?>? Inputs { get; set; }
451+
}
452+
453+
public class AiConversationChatRequest
454+
{
455+
public long ConversationId { get; set; }
456+
public string Content { get; set; } = "";
457+
public long ProviderId { get; set; }
458+
public string? Model { get; set; }
459+
}
460+
461+
public class AiConversationEndRequest
462+
{
463+
public long ConversationId { get; set; }
464+
public long ProviderId { get; set; }
465+
public string? Model { get; set; }
466+
}

‎Juggle.Api/Program.cs‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -252,6 +252,8 @@ await ctx.Response.WriteAsync(
252252
// AI 自定义助手表
253253
try { db.Database.ExecuteSqlRaw("CREATE TABLE IF NOT EXISTS t_ai_assistant(id INTEGER PRIMARY KEY AUTOINCREMENT, assistant_name TEXT, description TEXT, system_prompt TEXT, input_params TEXT, output_params TEXT, enabled INTEGER DEFAULT 1, created_at TEXT, created_by INTEGER, updated_at TEXT, updated_by INTEGER, tenant_id INTEGER, deleted INTEGER DEFAULT 0);"); } catch { }
254254
try { db.Database.ExecuteSqlRaw("ALTER TABLE t_ai_assistant ADD COLUMN quick_prompts TEXT DEFAULT NULL;"); } catch { }
255+
// AI 助手对话会话表
256+
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 { }
255257
// 补建参数位置字段
256258
try { db.Database.ExecuteSqlRaw("ALTER TABLE t_parameter ADD COLUMN param_position TEXT DEFAULT NULL;"); } catch { }
257259
}

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

Lines changed: 120 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -97,6 +97,17 @@ private async Task<AiConfig> ResolveConfigAsync(long? providerId)
9797

9898
/// <summary>调用大模型对话(OpenAI 兼容接口)。providerId 指定供应商,0=第一个启用供应商;modelOverride 可在供应商可用模型内切换。</summary>
9999
public async Task<string> ChatAsync(string systemPrompt, string userPrompt, long providerId = 0, AiConfig? config = null, string? modelOverride = null)
100+
{
101+
var messages = new List<(string Role, string Content)>
102+
{
103+
("system", systemPrompt),
104+
("user", userPrompt)
105+
};
106+
return await ChatMessagesAsync(messages, providerId, config, modelOverride);
107+
}
108+
109+
/// <summary>多轮对话:完整消息列表(system + 历史消息)一次性发送。</summary>
110+
public async Task<string> ChatMessagesAsync(List<(string Role, string Content)> messages, long providerId = 0, AiConfig? config = null, string? modelOverride = null)
100111
{
101112
var cfg = config ?? await ResolveConfigAsync(providerId > 0 ? providerId : null);
102113
if (!string.IsNullOrEmpty(modelOverride)) cfg.Model = modelOverride;
@@ -114,11 +125,7 @@ public async Task<string> ChatAsync(string systemPrompt, string userPrompt, long
114125
reqMsg.Content = new StringContent(JsonSerializer.Serialize(new
115126
{
116127
model = cfg.Model,
117-
messages = new object[]
118-
{
119-
new { role = "system", content = systemPrompt },
120-
new { role = "user", content = userPrompt }
121-
},
128+
messages = messages.Select(m => new { role = m.Role, content = m.Content }).ToArray(),
122129
temperature = 0.2
123130
}), Encoding.UTF8, "application/json");
124131

@@ -404,6 +411,114 @@ public async Task<object> RunAssistantAsync(AiAssistantEntity assistant, Diction
404411
catch { return new List<Dictionary<string, object?>>(); }
405412
}
406413

414+
// ==================== 多轮对话会话 ====================
415+
416+
/// <summary>开启新对话:快照助手配置,输入参数并入系统上下文。</summary>
417+
public async Task<AiConversationEntity> StartConversationAsync(AiAssistantEntity assistant,
418+
Dictionary<string, object?>? inputs, long providerId, string? model)
419+
{
420+
var conv = new AiConversationEntity
421+
{
422+
AssistantId = assistant.Id,
423+
AssistantName = assistant.AssistantName,
424+
SystemPrompt = assistant.SystemPrompt,
425+
InputParams = assistant.InputParams,
426+
OutputParams = assistant.OutputParams,
427+
ProviderId = providerId,
428+
Model = model,
429+
Messages = "[]",
430+
Status = 0,
431+
CreatedAt = DateTime.Now.ToString("o")
432+
};
433+
// 输入参数并入系统上下文(每轮对话可见)
434+
var inputParams = ParseParamList(assistant.InputParams);
435+
if (inputParams.Count > 0)
436+
{
437+
var sb = new StringBuilder();
438+
sb.AppendLine("## 输入参数");
439+
foreach (var p in inputParams)
440+
{
441+
var name = p.GetValueOrDefault("name")?.ToString() ?? "";
442+
var label = p.GetValueOrDefault("label")?.ToString() ?? name;
443+
sb.AppendLine($"- {label}:{inputs?.GetValueOrDefault(name) ?? ""}");
444+
}
445+
conv.SystemPrompt = (conv.SystemPrompt ?? "") + "\n" + sb;
446+
}
447+
_db.AiConversations.Add(conv);
448+
await _db.SaveChangesAsync();
449+
return conv;
450+
}
451+
452+
/// <summary>多轮对话:用户消息入历史,携带完整历史调用模型,回复回写历史。</summary>
453+
public async Task<string> ConversationChatAsync(AiConversationEntity conv, string userContent, long providerId = 0, string? model = null)
454+
{
455+
var history = ParseMessages(conv.Messages);
456+
history.Add(("user", userContent));
457+
var messages = new List<(string, string)> { ("system", conv.SystemPrompt ?? "") };
458+
messages.AddRange(history);
459+
var reply = await ChatMessagesAsync(messages, providerId, null, model);
460+
history.Add(("assistant", reply));
461+
conv.Messages = JsonSerializer.Serialize(history.Select(m => new { role = m.Role, content = m.Content }));
462+
if (string.IsNullOrEmpty(conv.Title))
463+
conv.Title = userContent.Length > 30 ? userContent[..30] : userContent;
464+
conv.UpdatedAt = DateTime.Now.ToString("o");
465+
await _db.SaveChangesAsync();
466+
return reply;
467+
}
468+
469+
/// <summary>结束对话:有输出参数时要求模型基于对话生成最终 JSON 结果并解析,会话标记已结束。</summary>
470+
public async Task<object> EndConversationAsync(AiConversationEntity conv, long providerId = 0, string? model = null)
471+
{
472+
var outputParams = ParseParamList(conv.OutputParams);
473+
var finalReply = "";
474+
var outputs = new Dictionary<string, object?>();
475+
if (outputParams.Count > 0)
476+
{
477+
var names = string.Join(", ", outputParams.Select(p => $"\"{p.GetValueOrDefault("name")}\""));
478+
var descs = string.Join("、", outputParams.Select(p =>
479+
$"\"{p.GetValueOrDefault("name")}\" 表示{p.GetValueOrDefault("label") ?? p.GetValueOrDefault("name")}"));
480+
var messages = new List<(string, string)> { ("system", conv.SystemPrompt ?? "") };
481+
messages.AddRange(ParseMessages(conv.Messages));
482+
messages.Add(("user", $"请根据以上对话生成最终结果,只输出 JSON(不要解释、不要 markdown 围栏),字段:{names},其中 {descs}。"));
483+
finalReply = await ChatMessagesAsync(messages, providerId, null, model);
484+
try
485+
{
486+
var json = ExtractJson(finalReply);
487+
using var doc = JsonDocument.Parse(json);
488+
foreach (var p in outputParams)
489+
{
490+
var name = p.GetValueOrDefault("name")?.ToString() ?? "";
491+
if (name.Length > 0 && doc.RootElement.TryGetProperty(name, out var v))
492+
outputs[name] = CloneToObject(v);
493+
}
494+
}
495+
catch { /* 模型未按 JSON 输出时保留原文 */ }
496+
}
497+
conv.Status = 1;
498+
conv.Outputs = JsonSerializer.Serialize(outputs);
499+
conv.UpdatedAt = DateTime.Now.ToString("o");
500+
await _db.SaveChangesAsync();
501+
return new { reply = finalReply, outputs };
502+
}
503+
504+
private static List<(string Role, string Content)> ParseMessages(string? json)
505+
{
506+
if (string.IsNullOrWhiteSpace(json)) return new List<(string, string)>();
507+
try
508+
{
509+
using var doc = JsonDocument.Parse(json);
510+
var list = new List<(string, string)>();
511+
foreach (var m in doc.RootElement.EnumerateArray())
512+
{
513+
var role = m.TryGetProperty("role", out var r) ? r.GetString() ?? "" : "";
514+
var content = m.TryGetProperty("content", out var c) ? c.GetString() ?? "" : "";
515+
if (role.Length > 0) list.Add((role, content));
516+
}
517+
return list;
518+
}
519+
catch { return new List<(string, string)>(); }
520+
}
521+
407522
/// <summary>流程生成的系统提示词:描述与设计器一致的节点格式。</summary>
408523
private static string BuildSystemPrompt()
409524
{
Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
namespace Juggle.Domain.Entities;
2+
3+
/// <summary>
4+
/// AI 助手对话会话(多轮对话)
5+
/// 记录助手配置快照(系统提示词/输入输出参数)、完整消息历史与最终输出,
6+
/// 支持进行中/已结束两种状态,已结束会话可在历史中查看。
7+
/// </summary>
8+
public class AiConversationEntity : BaseEntity
9+
{
10+
/// <summary>助手 ID</summary>
11+
public long AssistantId { get; set; }
12+
13+
/// <summary>助手名称快照</summary>
14+
public string? AssistantName { get; set; }
15+
16+
/// <summary>系统提示词快照</summary>
17+
public string? SystemPrompt { get; set; }
18+
19+
/// <summary>输入参数快照(JSON)</summary>
20+
public string? InputParams { get; set; }
21+
22+
/// <summary>输出参数快照(JSON)</summary>
23+
public string? OutputParams { get; set; }
24+
25+
/// <summary>消息历史(JSON: [{role,content}])</summary>
26+
public string? Messages { get; set; }
27+
28+
/// <summary>最终输出(JSON,结束时解析输出参数得到)</summary>
29+
public string? Outputs { get; set; }
30+
31+
/// <summary>使用的供应商 ID</summary>
32+
public long ProviderId { get; set; }
33+
34+
/// <summary>使用的模型</summary>
35+
public string? Model { get; set; }
36+
37+
/// <summary>会话标题(首条用户消息截断)</summary>
38+
public string? Title { get; set; }
39+
40+
/// <summary>状态:0=进行中 1=已结束</summary>
41+
public int Status { get; set; } = 0;
42+
}

‎Juggle.Infrastructure/Persistence/JuggleDbContext.cs‎

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,7 @@ public JuggleDbContext(DbContextOptions<JuggleDbContext> options, ICurrentTenant
6060
public DbSet<LoginLogEntity> LoginLogs { get; set; } = null!;
6161
public DbSet<AiProviderEntity> AiProviders { get; set; } = null!;
6262
public DbSet<AiAssistantEntity> AiAssistants { get; set; } = null!;
63+
public DbSet<AiConversationEntity> AiConversations { get; set; } = null!;
6364

6465
protected override void OnModelCreating(ModelBuilder modelBuilder)
6566
{
@@ -124,6 +125,27 @@ protected override void OnModelCreating(ModelBuilder modelBuilder)
124125
e.Property(p => p.QuickPrompts).HasColumnName("quick_prompts");
125126
e.Property(p => p.Enabled).HasColumnName("enabled");
126127
});
128+
modelBuilder.Entity<AiConversationEntity>().ToTable("t_ai_conversation");
129+
modelBuilder.Entity<AiConversationEntity>(e => {
130+
e.Property(p => p.Id).HasColumnName("id");
131+
e.Property(p => p.Deleted).HasColumnName("deleted");
132+
e.Property(p => p.CreatedAt).HasColumnName("created_at");
133+
e.Property(p => p.CreatedBy).HasColumnName("created_by");
134+
e.Property(p => p.UpdatedAt).HasColumnName("updated_at");
135+
e.Property(p => p.UpdatedBy).HasColumnName("updated_by");
136+
e.Property(p => p.TenantId).HasColumnName("tenant_id");
137+
e.Property(p => p.AssistantId).HasColumnName("assistant_id");
138+
e.Property(p => p.AssistantName).HasColumnName("assistant_name");
139+
e.Property(p => p.SystemPrompt).HasColumnName("system_prompt");
140+
e.Property(p => p.InputParams).HasColumnName("input_params");
141+
e.Property(p => p.OutputParams).HasColumnName("output_params");
142+
e.Property(p => p.Messages).HasColumnName("messages");
143+
e.Property(p => p.Outputs).HasColumnName("outputs");
144+
e.Property(p => p.ProviderId).HasColumnName("provider_id");
145+
e.Property(p => p.Model).HasColumnName("model");
146+
e.Property(p => p.Title).HasColumnName("title");
147+
e.Property(p => p.Status).HasColumnName("status");
148+
});
127149
modelBuilder.Entity<AlertRuleEntity>().ToTable("t_alert_rule");
128150
modelBuilder.Entity<AlertRecordEntity>().ToTable("t_alert_record");
129151
modelBuilder.Entity<DataViewEntity>().ToTable("t_data_view");

0 commit comments

Comments
 (0)