feat: Review-Agent (Multi-Agent-Kommunikation)
- Session hat optionalen ReviewAgentId + MaxReviewIterations - Nach end_turn des Worker-Agents wird der Review-Agent aufgerufen - Review-Agent bekommt Worker-Konversation als Kontext + kann Tools nutzen - [APPROVED] in Review-Antwort → fertig, sonst Feedback an Worker - Worker korrigiert und wird erneut reviewed (max N Runden) - SSE-Events: review_start, review_text, review_tool_use, review_feedback, review_approved - Chat-UI: Review-Phase als eigene Bubble mit Header, Text, Status - Session-Page: Review-Agent-Picker (gleicher Agent-Card-Helper) - Dashboard: Review-Agent-Badge auf Session-Cards - DB-Migration: review_agent_id, max_review_iterations auf sessions
This commit is contained in:
1 parent
2598656f1c
commit
5fe3f8ac87
12 files changed
+619
-18
No files matched your search
@@ -85,7 +85,7 @@ public static class ChatEndpoint
|
||||
|
||||
try
|
||||
{
|
||||
await anthropic.StreamChat(res, ctx.ModelId, ctx.SystemPrompt, messages, ctx.ChainId, ctx.Workspace, sessionId, agentId: ctx.AgentId, useMemory: ctx.UseMemory, webSearchVersion: ctx.WebSearchVersion, maxIterations: ctx.MaxIterations, toolChoice: ctx.ToolChoice, cancellationToken: linkedCts.Token);
|
||||
await anthropic.StreamChat(res, ctx.ModelId, ctx.SystemPrompt, messages, ctx.ChainId, ctx.Workspace, sessionId, agentId: ctx.AgentId, useMemory: ctx.UseMemory, webSearchVersion: ctx.WebSearchVersion, maxIterations: ctx.MaxIterations, toolChoice: ctx.ToolChoice, review: ctx.Review, cancellationToken: linkedCts.Token);
|
||||
}
|
||||
finally
|
||||
{
|
||||
|
||||
@@ -77,8 +77,10 @@ public static class SessionEndpoint
|
||||
var context = body.TryGetProperty("context", out var ctxEl) ? ctxEl.GetString() : null;
|
||||
var customCommands = body.TryGetProperty("customCommands", out var cc) && cc.ValueKind != JsonValueKind.Null ? cc.GetRawText() : null;
|
||||
var avatarScale = body.TryGetProperty("avatarScale", out var asc) ? asc.GetDouble() : 1.0;
|
||||
var reviewAgentId = body.TryGetProperty("reviewAgentId", out var ra) ? ra.GetString() : null;
|
||||
var maxReviewIterations = body.TryGetProperty("maxReviewIterations", out var mri) ? mri.GetInt32() : 3;
|
||||
|
||||
var session = await db.CreateSession(userId, name, description, image, agentId, workspace, context, customCommands, avatarScale);
|
||||
var session = await db.CreateSession(userId, name, description, image, agentId, workspace, context, customCommands, avatarScale, reviewAgentId, maxReviewIterations);
|
||||
await ctx.Response.WriteAsJsonAsync(session);
|
||||
}
|
||||
|
||||
|
||||
@@ -17,4 +17,20 @@ public class ChatContext
|
||||
public int MaxIterations { get; set; }
|
||||
public AgentRecord Agent { get; set; }
|
||||
public SessionRecord Session { get; set; }
|
||||
|
||||
// Review-Agent
|
||||
public ReviewContext Review { get; set; }
|
||||
}
|
||||
|
||||
public class ReviewContext
|
||||
{
|
||||
public string ModelId { get; set; }
|
||||
public List<object> SystemPrompt { get; set; }
|
||||
public string AgentId { get; set; }
|
||||
public string Workspace { get; set; }
|
||||
public bool UseMemory { get; set; }
|
||||
public string WebSearchVersion { get; set; }
|
||||
public string ToolChoice { get; set; }
|
||||
public int MaxReviewIterations { get; set; }
|
||||
public AgentRecord Agent { get; set; }
|
||||
}
|
||||
@@ -18,6 +18,8 @@ public class SessionRecord
|
||||
public string TelegramChatId { get; set; }
|
||||
public string TelegramAllowedChatIds { get; set; }
|
||||
public double AvatarScale { get; set; } = 1.0;
|
||||
public string ReviewAgentId { get; set; }
|
||||
public int MaxReviewIterations { get; set; } = 3;
|
||||
public string CreatedAt { get; set; }
|
||||
public string UpdatedAt { get; set; }
|
||||
}
|
||||
@@ -47,11 +47,13 @@ public class AnthropicService
|
||||
string webSearchVersion = null,
|
||||
int maxIterations = 100,
|
||||
string toolChoice = "any",
|
||||
ReviewContext review = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var sw = System.Diagnostics.Stopwatch.StartNew();
|
||||
var usage = new TokenUsage();
|
||||
int iterations = 0;
|
||||
int reviewRound = 0;
|
||||
string lastStopReason = "";
|
||||
|
||||
var chain = new ChainRecord
|
||||
@@ -175,6 +177,31 @@ public class AnthropicService
|
||||
|
||||
if (stopReason != "tool_use")
|
||||
{
|
||||
// ── Review-Agent Phase ──
|
||||
if (review != null && stopReason == "end_turn" && reviewRound < review.MaxReviewIterations)
|
||||
{
|
||||
var reviewResult = await RunReview(response, review, messages, chainId, workspace, sessionId, usage, iterations, reviewRound, cancellationToken);
|
||||
iterations += reviewResult.ReviewIterationCount;
|
||||
reviewRound++;
|
||||
|
||||
if (reviewResult.HasFeedback)
|
||||
{
|
||||
// Feedback als User-Message an Worker zurückgeben → nächste Iteration
|
||||
messages.Add(new Dictionary<string, object>
|
||||
{
|
||||
["role"] = "user",
|
||||
["content"] = new List<object> { new { type = "text", text = reviewResult.Feedback } }
|
||||
});
|
||||
await _db.SaveMessage(new MessageRecord
|
||||
{
|
||||
ChainId = chainId, Iteration = iterations,
|
||||
Role = "user", Type = "text",
|
||||
Content = reviewResult.Feedback
|
||||
});
|
||||
continue; // → nächste Worker-Iteration
|
||||
}
|
||||
}
|
||||
|
||||
var finalStopReason = string.IsNullOrEmpty(stopReason) ? "unexpected_end" : stopReason;
|
||||
await FinalizeChain(chain, usage, iterations, finalStopReason, sw, streamErrorInfo);
|
||||
await TryWriteSse(response, "done", new
|
||||
@@ -242,6 +269,331 @@ public class AnthropicService
|
||||
}
|
||||
}
|
||||
|
||||
// ── Review-Agent Logik ──
|
||||
|
||||
private class ReviewResult
|
||||
{
|
||||
public bool HasFeedback { get; set; }
|
||||
public string Feedback { get; set; }
|
||||
public int ReviewIterationCount { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Führt den Review-Agent aus. Der Review-Agent bekommt die gesamte Konversation
|
||||
/// und kann Tools nutzen (z.B. read_file um die Arbeit zu prüfen).
|
||||
/// Antwortet er mit [APPROVED] → fertig. Sonst → Feedback wird an den Worker zurückgegeben.
|
||||
/// reviewRound = aktuelle Runde (0-basiert), wird vom Caller hochgezählt.
|
||||
/// </summary>
|
||||
private async Task<ReviewResult> RunReview(
|
||||
HttpResponse response,
|
||||
ReviewContext review,
|
||||
List<object> workerMessages,
|
||||
string chainId,
|
||||
string workspace,
|
||||
string sessionId,
|
||||
TokenUsage usage,
|
||||
int baseIterations,
|
||||
int reviewRound,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
|
||||
// SSE: Review-Phase Start
|
||||
await TryWriteSse(response, "review_start", new
|
||||
{
|
||||
round = reviewRound + 1,
|
||||
maxRounds = review.MaxReviewIterations,
|
||||
agentId = review.AgentId,
|
||||
agentName = review.Agent?.Name ?? "Review-Agent"
|
||||
}, cancellationToken);
|
||||
|
||||
// Review-Agent bekommt eigene Message-History
|
||||
var reviewMessages = BuildReviewMessages(workerMessages);
|
||||
|
||||
// Review-Agent iteriert mit eigenen Tools
|
||||
var (reviewText, reviewIterations) = await RunReviewAgentChat(
|
||||
response, review, reviewMessages, chainId, workspace, sessionId,
|
||||
usage, baseIterations, cancellationToken);
|
||||
|
||||
var result = new ReviewResult { ReviewIterationCount = reviewIterations };
|
||||
|
||||
// Prüfe ob [APPROVED]
|
||||
if (!string.IsNullOrEmpty(reviewText) && reviewText.Contains("[APPROVED]"))
|
||||
{
|
||||
await TryWriteSse(response, "review_approved", new
|
||||
{
|
||||
round = reviewRound + 1,
|
||||
agentName = review.Agent?.Name ?? "Review-Agent"
|
||||
}, cancellationToken);
|
||||
result.HasFeedback = false;
|
||||
return result;
|
||||
}
|
||||
|
||||
// Feedback – an Worker zurückspielen
|
||||
result.HasFeedback = true;
|
||||
result.Feedback = $"[Review-Feedback von {review.Agent?.Name ?? "Review-Agent"} (Runde {reviewRound + 1}/{review.MaxReviewIterations})]:\n{reviewText}";
|
||||
|
||||
await TryWriteSse(response, "review_feedback", new
|
||||
{
|
||||
round = reviewRound + 1,
|
||||
maxRounds = review.MaxReviewIterations,
|
||||
feedback = reviewText,
|
||||
agentName = review.Agent?.Name ?? "Review-Agent"
|
||||
}, cancellationToken);
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Baut die Messages für den Review-Agent auf.
|
||||
/// Der Review-Agent bekommt die komplette Worker-Konversation als eine User-Message.
|
||||
/// </summary>
|
||||
private List<object> BuildReviewMessages(List<object> workerMessages)
|
||||
{
|
||||
// Worker-Konversation zusammenfassen
|
||||
var summary = new StringBuilder();
|
||||
summary.AppendLine("Hier ist die bisherige Konversation zwischen User und Worker-Agent. Bitte überprüfe ob die Aufgabe korrekt erledigt wurde:\n");
|
||||
summary.AppendLine("---");
|
||||
|
||||
foreach (var msg in workerMessages)
|
||||
{
|
||||
var json = JsonSerializer.Serialize(msg);
|
||||
var el = JsonSerializer.Deserialize<JsonElement>(json);
|
||||
|
||||
if (!el.TryGetProperty("role", out var role)) continue;
|
||||
var roleStr = role.GetString() ?? "";
|
||||
|
||||
if (el.TryGetProperty("content", out var content))
|
||||
{
|
||||
if (content.ValueKind == JsonValueKind.String)
|
||||
{
|
||||
summary.AppendLine($"\n**{roleStr}:** {content.GetString()}");
|
||||
}
|
||||
else if (content.ValueKind == JsonValueKind.Array)
|
||||
{
|
||||
foreach (var block in content.EnumerateArray())
|
||||
{
|
||||
var blockType = block.TryGetProperty("type", out var bt) ? bt.GetString() ?? "" : "";
|
||||
if (blockType == "text")
|
||||
{
|
||||
var text = block.TryGetProperty("text", out var t) ? t.GetString() ?? "" : "";
|
||||
summary.AppendLine($"\n**{roleStr}:** {text}");
|
||||
}
|
||||
else if (blockType == "tool_use")
|
||||
{
|
||||
var toolName = block.TryGetProperty("name", out var tn) ? tn.GetString() ?? "" : "";
|
||||
var toolInput = block.TryGetProperty("input", out var ti) ? ti.ToString() : "{}";
|
||||
summary.AppendLine($"\n**{roleStr} [Tool: {toolName}]:** {toolInput}");
|
||||
}
|
||||
else if (blockType == "tool_result")
|
||||
{
|
||||
var resultContent = block.TryGetProperty("content", out var rc) ? rc.ToString() : "";
|
||||
// Tool-Results kürzen für Review-Kontext
|
||||
if (resultContent.Length > 2000)
|
||||
resultContent = resultContent[..2000] + "\n[... gekürzt]";
|
||||
summary.AppendLine($"\n**{roleStr} [Tool-Result]:** {resultContent}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
summary.AppendLine("\n---");
|
||||
summary.AppendLine("\nBitte überprüfe ob die Aufgabe korrekt erledigt wurde. Du kannst dazu auch Tools verwenden (z.B. Dateien lesen). Antworte mit exakt [APPROVED] wenn alles korrekt ist, oder beschreibe was falsch ist und korrigiert werden muss.");
|
||||
|
||||
return new List<object>
|
||||
{
|
||||
new Dictionary<string, object>
|
||||
{
|
||||
["role"] = "user",
|
||||
["content"] = new List<object> { new { type = "text", text = summary.ToString() } }
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Führt den Review-Agent Chat aus (non-streaming für den Agent, aber SSE-Events für den Client).
|
||||
/// Gibt den finalen Text und die Anzahl der Iterationen zurück.
|
||||
/// </summary>
|
||||
private async Task<(string text, int iterations)> RunReviewAgentChat(
|
||||
HttpResponse response,
|
||||
ReviewContext review,
|
||||
List<object> messages,
|
||||
string chainId,
|
||||
string workspace,
|
||||
string sessionId,
|
||||
TokenUsage usage,
|
||||
int baseIterations,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
int iterations = 0;
|
||||
var finalText = new StringBuilder();
|
||||
int maxToolIterations = 20; // Review-Agent bekommt max 20 Tool-Iterationen
|
||||
|
||||
while (iterations < maxToolIterations)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
iterations++;
|
||||
|
||||
var payload = new Dictionary<string, object>
|
||||
{
|
||||
["model"] = review.ModelId,
|
||||
["max_tokens"] = 8192,
|
||||
["messages"] = messages,
|
||||
["stream"] = false
|
||||
};
|
||||
|
||||
if (review.SystemPrompt != null && review.SystemPrompt.Count > 0)
|
||||
payload["system"] = review.SystemPrompt;
|
||||
|
||||
var hasWorkspace = !string.IsNullOrEmpty(workspace);
|
||||
var includeMemory = review.UseMemory && _memoryService != null && !string.IsNullOrEmpty(review.AgentId);
|
||||
var hasTools = hasWorkspace || includeMemory;
|
||||
var hasWebSearch = !string.IsNullOrEmpty(review.WebSearchVersion);
|
||||
|
||||
if (hasTools || hasWebSearch)
|
||||
{
|
||||
var tools = new List<object>();
|
||||
if (hasTools)
|
||||
tools.AddRange(_toolService.GetToolDefinitions(includeFileTools: hasWorkspace, includeMemory: includeMemory));
|
||||
if (hasWebSearch)
|
||||
tools.Add(new { type = review.WebSearchVersion, name = "web_search", max_uses = 5 });
|
||||
payload["tools"] = tools;
|
||||
var effectiveChoice = (review.ToolChoice == "any" && iterations == 1) ? "any" : "auto";
|
||||
payload["tool_choice"] = new { type = effectiveChoice };
|
||||
}
|
||||
|
||||
var client = CreateClient();
|
||||
var json = JsonSerializer.Serialize(payload, new JsonSerializerOptions { WriteIndented = true });
|
||||
LogPayload(json, sessionId, chainId, baseIterations + iterations);
|
||||
var httpContent = new StringContent(json, Encoding.UTF8, "application/json");
|
||||
|
||||
var httpResp = await client.SendAsync(
|
||||
new HttpRequestMessage(HttpMethod.Post, "https://api.anthropic.com/v1/messages") { Content = httpContent },
|
||||
cancellationToken: cancellationToken);
|
||||
|
||||
var respBody = await httpResp.Content.ReadAsStringAsync(cancellationToken);
|
||||
|
||||
if (!httpResp.IsSuccessStatusCode)
|
||||
{
|
||||
return ($"Review-Agent API Fehler: {(int)httpResp.StatusCode}: {respBody}", iterations);
|
||||
}
|
||||
|
||||
var resp = JsonSerializer.Deserialize<JsonElement>(respBody);
|
||||
|
||||
// Usage tracken (auf den gemeinsamen Zähler)
|
||||
if (resp.TryGetProperty("usage", out var u))
|
||||
{
|
||||
if (u.TryGetProperty("input_tokens", out var it)) usage.InputTokens += it.GetInt64();
|
||||
if (u.TryGetProperty("output_tokens", out var ot)) usage.OutputTokens += ot.GetInt64();
|
||||
if (u.TryGetProperty("cache_creation_input_tokens", out var cc)) usage.CacheCreationTokens += cc.GetInt64();
|
||||
if (u.TryGetProperty("cache_read_input_tokens", out var cr)) usage.CacheReadTokens += cr.GetInt64();
|
||||
}
|
||||
|
||||
var stopReason = resp.TryGetProperty("stop_reason", out var sr) ? sr.GetString() ?? "" : "";
|
||||
var content = resp.GetProperty("content");
|
||||
|
||||
var assistantContent = new List<object>();
|
||||
var toolResults = new List<object>();
|
||||
|
||||
foreach (var block in content.EnumerateArray())
|
||||
{
|
||||
var blockType = block.GetProperty("type").GetString() ?? "";
|
||||
|
||||
if (blockType == "text")
|
||||
{
|
||||
var text = block.GetProperty("text").GetString() ?? "";
|
||||
assistantContent.Add(new { type = "text", text });
|
||||
finalText.Append(text);
|
||||
|
||||
// Review-Text per SSE an den Client streamen
|
||||
await TryWriteSse(response, "review_text", new
|
||||
{
|
||||
text,
|
||||
agentName = review.Agent?.Name ?? "Review-Agent",
|
||||
agentId = review.AgentId
|
||||
}, cancellationToken);
|
||||
|
||||
// In DB speichern (als Review-Message markiert)
|
||||
await _db.SaveMessage(new MessageRecord
|
||||
{
|
||||
ChainId = chainId,
|
||||
Iteration = baseIterations + iterations,
|
||||
Role = "assistant",
|
||||
Type = "text",
|
||||
Content = $"[Review: {review.Agent?.Name ?? "Review-Agent"}] {text}"
|
||||
});
|
||||
}
|
||||
else if (blockType == "tool_use")
|
||||
{
|
||||
var toolId = block.GetProperty("id").GetString() ?? "";
|
||||
var toolName = block.GetProperty("name").GetString() ?? "";
|
||||
var toolInput = block.GetProperty("input");
|
||||
|
||||
assistantContent.Add(new { type = "tool_use", id = toolId, name = toolName, input = toolInput });
|
||||
|
||||
// Tool-Use per SSE
|
||||
await TryWriteSse(response, "review_tool_use", new
|
||||
{
|
||||
toolName,
|
||||
toolId,
|
||||
input = toolInput.ToString(),
|
||||
agentName = review.Agent?.Name ?? "Review-Agent"
|
||||
}, cancellationToken);
|
||||
|
||||
await _db.SaveMessage(new MessageRecord
|
||||
{
|
||||
ChainId = chainId,
|
||||
Iteration = baseIterations + iterations,
|
||||
Role = "assistant",
|
||||
Type = "tool_use",
|
||||
Content = toolInput.GetRawText(),
|
||||
ToolName = toolName,
|
||||
ToolCallId = toolId
|
||||
});
|
||||
|
||||
string toolResult;
|
||||
bool isError = false;
|
||||
try { toolResult = await _toolService.ExecuteTool(toolName, toolInput, workspace, _memoryService, review.AgentId, cancellationToken); }
|
||||
catch (Exception ex) { toolResult = $"Fehler: {ex.Message}"; isError = true; }
|
||||
|
||||
const int MaxToolResultChars = 20_000;
|
||||
var truncatedResult = toolResult.Length > MaxToolResultChars
|
||||
? toolResult[..MaxToolResultChars] + $"\n\n[... gekürzt, {toolResult.Length - MaxToolResultChars} weitere Zeichen ...]"
|
||||
: toolResult;
|
||||
|
||||
toolResults.Add(new
|
||||
{
|
||||
type = "tool_result",
|
||||
tool_use_id = toolId,
|
||||
content = truncatedResult,
|
||||
is_error = isError
|
||||
});
|
||||
|
||||
await _db.SaveMessage(new MessageRecord
|
||||
{
|
||||
ChainId = chainId,
|
||||
Iteration = baseIterations + iterations,
|
||||
Role = "user",
|
||||
Type = "tool_result",
|
||||
Content = toolResult,
|
||||
ToolName = toolName,
|
||||
ToolCallId = toolId,
|
||||
IsError = isError
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
messages.Add(new Dictionary<string, object> { ["role"] = "assistant", ["content"] = assistantContent });
|
||||
|
||||
if (stopReason == "end_turn" || stopReason == "stop_sequence" || toolResults.Count == 0)
|
||||
break;
|
||||
|
||||
messages.Add(new Dictionary<string, object> { ["role"] = "user", ["content"] = toolResults });
|
||||
}
|
||||
|
||||
return (finalText.ToString(), iterations);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Non-Streaming Chat für Telegram. Gibt den finalen Antworttext zurück.
|
||||
/// onIteration wird nach jeder Iteration aufgerufen (z.B. für Typing-Indicator).
|
||||
|
||||
+38
-1
@@ -106,6 +106,42 @@ public class ChatService
|
||||
if (!string.IsNullOrWhiteSpace(systemPromptText))
|
||||
systemPrompt.Add(new { type = "text", text = systemPromptText });
|
||||
|
||||
// Review-Agent vorbereiten (falls konfiguriert)
|
||||
ReviewContext reviewContext = null;
|
||||
if (!string.IsNullOrEmpty(session.ReviewAgentId))
|
||||
{
|
||||
var reviewAgent = await _db.GetAgent(session.ReviewAgentId);
|
||||
if (reviewAgent != null)
|
||||
{
|
||||
var reviewModelId = ResolveModelId(reviewAgent.Model);
|
||||
var reviewMarked = await _db.GetMarkedMessages(sessionId);
|
||||
var reviewMemDir = _memoryService.GetAgentMemoryDir(reviewAgent.AgentId);
|
||||
var reviewSkills = await _db.GetAgentSkills(reviewAgent.AgentId);
|
||||
var reviewWorkspace = workspace; // gleicher Workspace wie Worker
|
||||
var reviewSystemText = SystemPromptBuilder.Build(
|
||||
reviewAgent, session, reviewMarked, _memoryService, reviewMemDir,
|
||||
reviewSkills, _skillService, userPersona: user?.GetEffectivePersona(),
|
||||
resolvedWorkspace: reviewWorkspace);
|
||||
|
||||
var reviewPrompt = new List<object>();
|
||||
if (!string.IsNullOrWhiteSpace(reviewSystemText))
|
||||
reviewPrompt.Add(new { type = "text", text = reviewSystemText });
|
||||
|
||||
reviewContext = new ReviewContext
|
||||
{
|
||||
ModelId = reviewModelId,
|
||||
SystemPrompt = reviewPrompt,
|
||||
AgentId = reviewAgent.AgentId,
|
||||
Workspace = reviewWorkspace,
|
||||
UseMemory = reviewAgent.UseMemory,
|
||||
WebSearchVersion = reviewAgent.WebSearchVersion,
|
||||
ToolChoice = reviewAgent.ToolChoice ?? "any",
|
||||
MaxReviewIterations = session.MaxReviewIterations > 0 ? session.MaxReviewIterations : 3,
|
||||
Agent = reviewAgent
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return new ChatContext
|
||||
{
|
||||
ChainId = chainId,
|
||||
@@ -120,7 +156,8 @@ public class ChatService
|
||||
WebSearchVersion = agent.WebSearchVersion,
|
||||
MaxIterations = maxIterations,
|
||||
Agent = agent,
|
||||
Session = session
|
||||
Session = session,
|
||||
Review = reviewContext
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -171,6 +171,8 @@ public class DatabaseService
|
||||
TryAddColumn(conn, "sessions", "avatar_scale", "REAL NOT NULL DEFAULT 1.0");
|
||||
TryAddColumn(conn, "agents", "avatar_scale", "REAL NOT NULL DEFAULT 1.0");
|
||||
TryAddColumn(conn, "cronjobs", "notify_session_id", "TEXT");
|
||||
TryAddColumn(conn, "sessions", "review_agent_id", "TEXT");
|
||||
TryAddColumn(conn, "sessions", "max_review_iterations", "INTEGER NOT NULL DEFAULT 3");
|
||||
|
||||
conn.Execute(@"
|
||||
CREATE TABLE IF NOT EXISTS agent_skills (
|
||||
@@ -367,7 +369,7 @@ public class DatabaseService
|
||||
|
||||
// ── Sessions ──
|
||||
|
||||
public async Task<SessionRecord> CreateSession(string userId, string name = null, string description = null, string image = null, string agentId = null, string workspace = null, string context = null, string customCommands = null, double avatarScale = 1.0)
|
||||
public async Task<SessionRecord> CreateSession(string userId, string name = null, string description = null, string image = null, string agentId = null, string workspace = null, string context = null, string customCommands = null, double avatarScale = 1.0, string reviewAgentId = null, int maxReviewIterations = 3)
|
||||
{
|
||||
using var conn = CreateConnection();
|
||||
var now = DateTime.UtcNow.ToString("o");
|
||||
@@ -383,12 +385,14 @@ public class DatabaseService
|
||||
Context = context,
|
||||
CustomCommands = customCommands,
|
||||
AvatarScale = avatarScale,
|
||||
ReviewAgentId = reviewAgentId,
|
||||
MaxReviewIterations = maxReviewIterations,
|
||||
CreatedAt = now,
|
||||
UpdatedAt = now
|
||||
};
|
||||
await conn.ExecuteAsync(@"
|
||||
INSERT INTO sessions (session_id, user_id, name, description, image, agent_id, workspace, context, custom_commands, avatar_scale, created_at, updated_at)
|
||||
VALUES (@SessionId, @UserId, @Name, @Description, @Image, @AgentId, @Workspace, @Context, @CustomCommands, @AvatarScale, @CreatedAt, @UpdatedAt)
|
||||
INSERT INTO sessions (session_id, user_id, name, description, image, agent_id, workspace, context, custom_commands, avatar_scale, review_agent_id, max_review_iterations, created_at, updated_at)
|
||||
VALUES (@SessionId, @UserId, @Name, @Description, @Image, @AgentId, @Workspace, @Context, @CustomCommands, @AvatarScale, @ReviewAgentId, @MaxReviewIterations, @CreatedAt, @UpdatedAt)
|
||||
", session);
|
||||
return session;
|
||||
}
|
||||
@@ -432,6 +436,8 @@ public class DatabaseService
|
||||
s.Context,
|
||||
s.CustomCommands,
|
||||
s.AvatarScale,
|
||||
s.ReviewAgentId,
|
||||
s.MaxReviewIterations,
|
||||
s.IsDeleted,
|
||||
s.CreatedAt,
|
||||
s.UpdatedAt,
|
||||
@@ -482,6 +488,8 @@ public class DatabaseService
|
||||
if (body.TryGetProperty("context", out var ctx)) { sets.Add("context = @context"); p.Add("context", ctx.GetString()); }
|
||||
if (body.TryGetProperty("customCommands", out var cc)) { sets.Add("custom_commands = @customCommands"); p.Add("customCommands", cc.ValueKind == System.Text.Json.JsonValueKind.Null ? null : cc.GetRawText()); }
|
||||
if (body.TryGetProperty("avatarScale", out var asc)) { sets.Add("avatar_scale = @avatarScale"); p.Add("avatarScale", asc.GetDouble()); }
|
||||
if (body.TryGetProperty("reviewAgentId", out var ra)) { sets.Add("review_agent_id = @reviewAgentId"); p.Add("reviewAgentId", ra.ValueKind == System.Text.Json.JsonValueKind.Null ? null : ra.GetString()); }
|
||||
if (body.TryGetProperty("maxReviewIterations", out var mri)) { sets.Add("max_review_iterations = @maxReviewIterations"); p.Add("maxReviewIterations", mri.GetInt32()); }
|
||||
|
||||
await conn.ExecuteAsync($"UPDATE sessions SET {string.Join(", ", sets)} WHERE session_id = @sessionId", p);
|
||||
}
|
||||
|
||||
@@ -973,3 +973,74 @@
|
||||
text-align: center;
|
||||
padding: var(--page-padding);
|
||||
}
|
||||
|
||||
/* Review Bubbles */
|
||||
|
||||
.review-bubble {
|
||||
align-self: flex-start;
|
||||
background: var(--f1-bg);
|
||||
border: var(--border-width) solid var(--f1-border);
|
||||
border-left: 3px solid var(--secondary);
|
||||
max-width: 80%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--badge-spacing);
|
||||
}
|
||||
|
||||
.review-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--badge-spacing);
|
||||
color: var(--secondary);
|
||||
}
|
||||
|
||||
.review-round {
|
||||
opacity: var(--semi-transparent);
|
||||
font-size: var(--font-size);
|
||||
}
|
||||
|
||||
.review-content {
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.review-tool-use {
|
||||
font-size: var(--font-size);
|
||||
opacity: var(--semi-transparent);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--badge-spacing);
|
||||
}
|
||||
|
||||
.review-status {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--badge-spacing);
|
||||
padding-top: var(--badge-spacing);
|
||||
border-top: var(--border-width) solid var(--f2-border);
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.review-approved {
|
||||
color: var(--success);
|
||||
}
|
||||
|
||||
.review-rejected {
|
||||
color: var(--warning);
|
||||
}
|
||||
|
||||
/* Review Iterations Row */
|
||||
|
||||
.review-iterations-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--box-spacing);
|
||||
}
|
||||
|
||||
.review-iterations-row label {
|
||||
margin: 0;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.review-iterations-row input[type="number"] {
|
||||
width: 5em;
|
||||
}
|
||||
@@ -108,6 +108,15 @@
|
||||
<div class="agent-select-empty"><i class="fa fa-user-circle-o"></i> Kein Agent ausgewählt</div>
|
||||
</div>
|
||||
<input type="hidden" id="agentSelect" value="">
|
||||
<label>Review-Agent <small>(optional)</small></label>
|
||||
<div id="reviewAgentSelectCard" class="agent-select-card">
|
||||
<div class="agent-select-empty"><i class="fa fa-check-circle-o"></i> Kein Review-Agent</div>
|
||||
</div>
|
||||
<input type="hidden" id="reviewAgentSelect" value="">
|
||||
<div class="review-iterations-row" id="reviewIterationsRow" hidden>
|
||||
<label>Max. Review-Runden</label>
|
||||
<input type="number" id="maxReviewIterations" min="1" max="10" value="3">
|
||||
</div>
|
||||
<div class="more">
|
||||
<a>Weitere Einstellungen</a>
|
||||
<div hidden>
|
||||
|
||||
@@ -207,6 +207,27 @@ async function sendMessage() {
|
||||
currentToolBox = null;
|
||||
break;
|
||||
|
||||
case 'review_start':
|
||||
endStreamBubble();
|
||||
addReviewBubble('start', data);
|
||||
break;
|
||||
|
||||
case 'review_text':
|
||||
appendToReviewText(data.text);
|
||||
break;
|
||||
|
||||
case 'review_tool_use':
|
||||
addReviewToolUse(data);
|
||||
break;
|
||||
|
||||
case 'review_feedback':
|
||||
finalizeReviewBubble('feedback', data);
|
||||
break;
|
||||
|
||||
case 'review_approved':
|
||||
finalizeReviewBubble('approved', data);
|
||||
break;
|
||||
|
||||
case 'error':
|
||||
endStreamBubble();
|
||||
addErrorBubble(data.error, true);
|
||||
|
||||
@@ -21,6 +21,10 @@ registerPage('session', {
|
||||
const workspaceEl = document.getElementById('sessionWorkspace');
|
||||
const agentSelect = document.getElementById('agentSelect');
|
||||
const agentSelectCard = document.getElementById('agentSelectCard');
|
||||
const reviewAgentSelect = document.getElementById('reviewAgentSelect');
|
||||
const reviewAgentSelectCard = document.getElementById('reviewAgentSelectCard');
|
||||
const reviewIterationsRow = document.getElementById('reviewIterationsRow');
|
||||
const maxReviewIterationsEl = document.getElementById('maxReviewIterations');
|
||||
const imagePreview = document.getElementById('sessionImagePreview');
|
||||
const saveBtn = document.getElementById('sessionSaveBtn');
|
||||
const cancelBtn = document.getElementById('sessionCancelBtn');
|
||||
@@ -83,33 +87,54 @@ registerPage('session', {
|
||||
agentSkillsMap[a.agentId] = await apiGetAgentSkills(a.agentId);
|
||||
}));
|
||||
|
||||
function renderAgentSelectCard(agent) {
|
||||
agentSelectCard.innerHTML = '';
|
||||
function renderAgentInCard(cardEl, agent, emptyIcon, emptyText) {
|
||||
cardEl.innerHTML = '';
|
||||
if (!agent) {
|
||||
agentSelectCard.innerHTML = '<div class="agent-select-empty"><i class="fa fa-user-circle-o"></i> Kein Agent ausgewählt</div>';
|
||||
cardEl.innerHTML = '<div class="agent-select-empty"><i class="fa ' + emptyIcon + '"></i> ' + emptyText + '</div>';
|
||||
return;
|
||||
}
|
||||
const skills = agentSkillsMap[agent.agentId] || [];
|
||||
const inner = buildAgentCard(agent, skills);
|
||||
// Die inneren Elemente (card-image + card-body) in die Select-Card übernehmen
|
||||
while (inner.firstChild) agentSelectCard.appendChild(inner.firstChild);
|
||||
// Pencil-Icon hinzufügen
|
||||
agentSelectCard.insertAdjacentHTML('beforeend', '<span class="agent-select-change"><i class="fa fa-pencil"></i></span>');
|
||||
while (inner.firstChild) cardEl.appendChild(inner.firstChild);
|
||||
cardEl.insertAdjacentHTML('beforeend', '<span class="agent-select-change"><i class="fa fa-pencil"></i></span>');
|
||||
}
|
||||
|
||||
// Initiale Anzeige
|
||||
function updateReviewVisibility() {
|
||||
reviewIterationsRow.hidden = !reviewAgentSelect.value;
|
||||
}
|
||||
|
||||
// Initiale Anzeige – Agent
|
||||
if (session && session.agentId) {
|
||||
agentSelect.value = session.agentId;
|
||||
renderAgentSelectCard(agentMap[session.agentId] || null);
|
||||
renderAgentInCard(agentSelectCard, agentMap[session.agentId] || null, 'fa-user-circle-o', 'Kein Agent ausgewählt');
|
||||
} else {
|
||||
renderAgentSelectCard(null);
|
||||
renderAgentInCard(agentSelectCard, null, 'fa-user-circle-o', 'Kein Agent ausgewählt');
|
||||
}
|
||||
|
||||
// Klick auf Card → Modal öffnen
|
||||
// Initiale Anzeige – Review-Agent
|
||||
if (session && session.reviewAgentId) {
|
||||
reviewAgentSelect.value = session.reviewAgentId;
|
||||
renderAgentInCard(reviewAgentSelectCard, agentMap[session.reviewAgentId] || null, 'fa-check-circle-o', 'Kein Review-Agent');
|
||||
maxReviewIterationsEl.value = session.maxReviewIterations || 3;
|
||||
} else {
|
||||
renderAgentInCard(reviewAgentSelectCard, null, 'fa-check-circle-o', 'Kein Review-Agent');
|
||||
}
|
||||
updateReviewVisibility();
|
||||
|
||||
// Klick auf Agent-Card → Modal öffnen
|
||||
agentSelectCard.addEventListener('click', () => {
|
||||
openAgentPickerModal(agents, agentSkillsMap, agentSelect.value, (selectedId) => {
|
||||
agentSelect.value = selectedId || '';
|
||||
renderAgentSelectCard(selectedId ? agentMap[selectedId] : null);
|
||||
renderAgentInCard(agentSelectCard, selectedId ? agentMap[selectedId] : null, 'fa-user-circle-o', 'Kein Agent ausgewählt');
|
||||
});
|
||||
});
|
||||
|
||||
// Klick auf Review-Agent-Card → Modal öffnen
|
||||
reviewAgentSelectCard.addEventListener('click', () => {
|
||||
openAgentPickerModal(agents, agentSkillsMap, reviewAgentSelect.value, (selectedId) => {
|
||||
reviewAgentSelect.value = selectedId || '';
|
||||
renderAgentInCard(reviewAgentSelectCard, selectedId ? agentMap[selectedId] : null, 'fa-check-circle-o', 'Kein Review-Agent');
|
||||
updateReviewVisibility();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -160,6 +185,8 @@ registerPage('session', {
|
||||
description: descEl.value.trim(),
|
||||
image: _sessionAvatarEditor.getImage(),
|
||||
agentId: agentSelect.value || null,
|
||||
reviewAgentId: reviewAgentSelect.value || null,
|
||||
maxReviewIterations: parseInt(maxReviewIterationsEl.value) || 3,
|
||||
context: _sessionContextMde.value().trim() || null,
|
||||
workspace: workspaceEl.value.trim() || null,
|
||||
customCommands: commands,
|
||||
|
||||
@@ -13,6 +13,7 @@ function buildSessionCard(session, agent) {
|
||||
// Badges
|
||||
const badges = [];
|
||||
if (agent) badges.push(`<span class="badge">${esc(agent.name)}</span>`);
|
||||
if (session.reviewAgentId) badges.push(`<span class="badge badge-feature" title="Review-Agent"><i class="fa fa-check-circle-o"></i></span>`);
|
||||
if (session.context) badges.push(`<span class="badge badge-feature" title="${esc(session.context)}"><i class="fa fa-file-text-o"></i></span>`);
|
||||
if (session.telegramConfigured) badges.push(`<span class="badge ${session.telegramLinked ? 'badge-feature' : 'badge-off'}" title="Telegram ${session.telegramLinked ? 'verbunden' : 'konfiguriert'}"><i class="fa fa-paper-plane"></i></span>`);
|
||||
|
||||
@@ -637,6 +638,61 @@ function addWarningBubble(text, streamed = false) {
|
||||
scrollDown();
|
||||
}
|
||||
|
||||
// ── Review Bubbles ──
|
||||
|
||||
let _reviewBubble = null;
|
||||
let _reviewTextEl = null;
|
||||
let _reviewText = '';
|
||||
|
||||
function addReviewBubble(type, data) {
|
||||
_reviewBubble = document.createElement('div');
|
||||
_reviewBubble.className = 'msg review-bubble streamed';
|
||||
const header = document.createElement('div');
|
||||
header.className = 'review-header';
|
||||
header.innerHTML = '<i class="fa fa-check-circle-o"></i> <strong>' + esc(data.agentName || 'Review-Agent') + '</strong> <span class="review-round">Runde ' + data.round + '/' + data.maxRounds + '</span>';
|
||||
_reviewBubble.appendChild(header);
|
||||
_reviewTextEl = document.createElement('div');
|
||||
_reviewTextEl.className = 'review-content markdown';
|
||||
_reviewBubble.appendChild(_reviewTextEl);
|
||||
_reviewText = '';
|
||||
chatEl().appendChild(_reviewBubble);
|
||||
scrollDown();
|
||||
}
|
||||
|
||||
function appendToReviewText(text) {
|
||||
if (!_reviewBubble) return;
|
||||
_reviewText += text;
|
||||
_reviewTextEl.innerHTML = renderMarkdown(_reviewText);
|
||||
scrollDown();
|
||||
}
|
||||
|
||||
function addReviewToolUse(data) {
|
||||
if (!_reviewBubble) return;
|
||||
const toolDiv = document.createElement('div');
|
||||
toolDiv.className = 'review-tool-use';
|
||||
toolDiv.innerHTML = '<i class="fa fa-wrench"></i> <span class="tool-name">' + esc(data.toolName) + '</span>';
|
||||
_reviewBubble.appendChild(toolDiv);
|
||||
scrollDown();
|
||||
}
|
||||
|
||||
function finalizeReviewBubble(type, data) {
|
||||
if (!_reviewBubble) return;
|
||||
const status = document.createElement('div');
|
||||
status.className = 'review-status';
|
||||
if (type === 'approved') {
|
||||
status.className += ' review-approved';
|
||||
status.innerHTML = '<i class="fa fa-check"></i> Approved';
|
||||
} else {
|
||||
status.className += ' review-rejected';
|
||||
status.innerHTML = '<i class="fa fa-repeat"></i> Feedback – Worker korrigiert...';
|
||||
}
|
||||
_reviewBubble.appendChild(status);
|
||||
_reviewBubble = null;
|
||||
_reviewTextEl = null;
|
||||
_reviewText = '';
|
||||
scrollDown();
|
||||
}
|
||||
|
||||
// ── Streaming Bubble ──
|
||||
|
||||
let _streamDiv = null;
|
||||
|
||||
Reference in new issue
Block a user