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:
Meleki committed 2026-03-06 16:04:00 +01:00
1 parent 2598656f1c
commit 5fe3f8ac87
12 files changed
+619 -18

No files matched your search

+1 -1
View File
@@ -85,7 +85,7 @@ public static class ChatEndpoint
try 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 finally
{ {
+3 -1
View File
@@ -77,8 +77,10 @@ public static class SessionEndpoint
var context = body.TryGetProperty("context", out var ctxEl) ? ctxEl.GetString() : null; 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 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 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); await ctx.Response.WriteAsJsonAsync(session);
} }
+16
View File
@@ -17,4 +17,20 @@ public class ChatContext
public int MaxIterations { get; set; } public int MaxIterations { get; set; }
public AgentRecord Agent { get; set; } public AgentRecord Agent { get; set; }
public SessionRecord Session { 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; }
} }
+2
View File
@@ -18,6 +18,8 @@ public class SessionRecord
public string TelegramChatId { get; set; } public string TelegramChatId { get; set; }
public string TelegramAllowedChatIds { get; set; } public string TelegramAllowedChatIds { get; set; }
public double AvatarScale { get; set; } = 1.0; 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 CreatedAt { get; set; }
public string UpdatedAt { get; set; } public string UpdatedAt { get; set; }
} }
+352
View File
@@ -47,11 +47,13 @@ public class AnthropicService
string webSearchVersion = null, string webSearchVersion = null,
int maxIterations = 100, int maxIterations = 100,
string toolChoice = "any", string toolChoice = "any",
ReviewContext review = null,
CancellationToken cancellationToken = default) CancellationToken cancellationToken = default)
{ {
var sw = System.Diagnostics.Stopwatch.StartNew(); var sw = System.Diagnostics.Stopwatch.StartNew();
var usage = new TokenUsage(); var usage = new TokenUsage();
int iterations = 0; int iterations = 0;
int reviewRound = 0;
string lastStopReason = ""; string lastStopReason = "";
var chain = new ChainRecord var chain = new ChainRecord
@@ -175,6 +177,31 @@ public class AnthropicService
if (stopReason != "tool_use") 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; var finalStopReason = string.IsNullOrEmpty(stopReason) ? "unexpected_end" : stopReason;
await FinalizeChain(chain, usage, iterations, finalStopReason, sw, streamErrorInfo); await FinalizeChain(chain, usage, iterations, finalStopReason, sw, streamErrorInfo);
await TryWriteSse(response, "done", new 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> /// <summary>
/// Non-Streaming Chat für Telegram. Gibt den finalen Antworttext zurück. /// Non-Streaming Chat für Telegram. Gibt den finalen Antworttext zurück.
/// onIteration wird nach jeder Iteration aufgerufen (z.B. für Typing-Indicator). /// onIteration wird nach jeder Iteration aufgerufen (z.B. für Typing-Indicator).
+38 -1
View File
@@ -106,6 +106,42 @@ public class ChatService
if (!string.IsNullOrWhiteSpace(systemPromptText)) if (!string.IsNullOrWhiteSpace(systemPromptText))
systemPrompt.Add(new { type = "text", text = 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 return new ChatContext
{ {
ChainId = chainId, ChainId = chainId,
@@ -120,7 +156,8 @@ public class ChatService
WebSearchVersion = agent.WebSearchVersion, WebSearchVersion = agent.WebSearchVersion,
MaxIterations = maxIterations, MaxIterations = maxIterations,
Agent = agent, Agent = agent,
Session = session Session = session,
Review = reviewContext
}; };
} }
+11 -3
View File
@@ -171,6 +171,8 @@ public class DatabaseService
TryAddColumn(conn, "sessions", "avatar_scale", "REAL NOT NULL DEFAULT 1.0"); TryAddColumn(conn, "sessions", "avatar_scale", "REAL NOT NULL DEFAULT 1.0");
TryAddColumn(conn, "agents", "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, "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(@" conn.Execute(@"
CREATE TABLE IF NOT EXISTS agent_skills ( CREATE TABLE IF NOT EXISTS agent_skills (
@@ -367,7 +369,7 @@ public class DatabaseService
// ── Sessions ── // ── 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(); using var conn = CreateConnection();
var now = DateTime.UtcNow.ToString("o"); var now = DateTime.UtcNow.ToString("o");
@@ -383,12 +385,14 @@ public class DatabaseService
Context = context, Context = context,
CustomCommands = customCommands, CustomCommands = customCommands,
AvatarScale = avatarScale, AvatarScale = avatarScale,
ReviewAgentId = reviewAgentId,
MaxReviewIterations = maxReviewIterations,
CreatedAt = now, CreatedAt = now,
UpdatedAt = now UpdatedAt = now
}; };
await conn.ExecuteAsync(@" 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) 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, @CreatedAt, @UpdatedAt) VALUES (@SessionId, @UserId, @Name, @Description, @Image, @AgentId, @Workspace, @Context, @CustomCommands, @AvatarScale, @ReviewAgentId, @MaxReviewIterations, @CreatedAt, @UpdatedAt)
", session); ", session);
return session; return session;
} }
@@ -432,6 +436,8 @@ public class DatabaseService
s.Context, s.Context,
s.CustomCommands, s.CustomCommands,
s.AvatarScale, s.AvatarScale,
s.ReviewAgentId,
s.MaxReviewIterations,
s.IsDeleted, s.IsDeleted,
s.CreatedAt, s.CreatedAt,
s.UpdatedAt, 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("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("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("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); await conn.ExecuteAsync($"UPDATE sessions SET {string.Join(", ", sets)} WHERE session_id = @sessionId", p);
} }
+71
View File
@@ -973,3 +973,74 @@
text-align: center; text-align: center;
padding: var(--page-padding); 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;
}
+9
View File
@@ -108,6 +108,15 @@
<div class="agent-select-empty"><i class="fa fa-user-circle-o"></i> Kein Agent ausgewählt</div> <div class="agent-select-empty"><i class="fa fa-user-circle-o"></i> Kein Agent ausgewählt</div>
</div> </div>
<input type="hidden" id="agentSelect" value=""> <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"> <div class="more">
<a>Weitere Einstellungen</a> <a>Weitere Einstellungen</a>
<div hidden> <div hidden>
+21
View File
@@ -207,6 +207,27 @@ async function sendMessage() {
currentToolBox = null; currentToolBox = null;
break; 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': case 'error':
endStreamBubble(); endStreamBubble();
addErrorBubble(data.error, true); addErrorBubble(data.error, true);
+39 -12
View File
@@ -21,6 +21,10 @@ registerPage('session', {
const workspaceEl = document.getElementById('sessionWorkspace'); const workspaceEl = document.getElementById('sessionWorkspace');
const agentSelect = document.getElementById('agentSelect'); const agentSelect = document.getElementById('agentSelect');
const agentSelectCard = document.getElementById('agentSelectCard'); 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 imagePreview = document.getElementById('sessionImagePreview');
const saveBtn = document.getElementById('sessionSaveBtn'); const saveBtn = document.getElementById('sessionSaveBtn');
const cancelBtn = document.getElementById('sessionCancelBtn'); const cancelBtn = document.getElementById('sessionCancelBtn');
@@ -83,33 +87,54 @@ registerPage('session', {
agentSkillsMap[a.agentId] = await apiGetAgentSkills(a.agentId); agentSkillsMap[a.agentId] = await apiGetAgentSkills(a.agentId);
})); }));
function renderAgentSelectCard(agent) { function renderAgentInCard(cardEl, agent, emptyIcon, emptyText) {
agentSelectCard.innerHTML = ''; cardEl.innerHTML = '';
if (!agent) { 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; return;
} }
const skills = agentSkillsMap[agent.agentId] || []; const skills = agentSkillsMap[agent.agentId] || [];
const inner = buildAgentCard(agent, skills); const inner = buildAgentCard(agent, skills);
// Die inneren Elemente (card-image + card-body) in die Select-Card übernehmen while (inner.firstChild) cardEl.appendChild(inner.firstChild);
while (inner.firstChild) agentSelectCard.appendChild(inner.firstChild); cardEl.insertAdjacentHTML('beforeend', '<span class="agent-select-change"><i class="fa fa-pencil"></i></span>');
// Pencil-Icon hinzufügen
agentSelectCard.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) { if (session && session.agentId) {
agentSelect.value = 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 { } 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', () => { agentSelectCard.addEventListener('click', () => {
openAgentPickerModal(agents, agentSkillsMap, agentSelect.value, (selectedId) => { openAgentPickerModal(agents, agentSkillsMap, agentSelect.value, (selectedId) => {
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(), description: descEl.value.trim(),
image: _sessionAvatarEditor.getImage(), image: _sessionAvatarEditor.getImage(),
agentId: agentSelect.value || null, agentId: agentSelect.value || null,
reviewAgentId: reviewAgentSelect.value || null,
maxReviewIterations: parseInt(maxReviewIterationsEl.value) || 3,
context: _sessionContextMde.value().trim() || null, context: _sessionContextMde.value().trim() || null,
workspace: workspaceEl.value.trim() || null, workspace: workspaceEl.value.trim() || null,
customCommands: commands, customCommands: commands,
+56
View File
@@ -13,6 +13,7 @@ function buildSessionCard(session, agent) {
// Badges // Badges
const badges = []; const badges = [];
if (agent) badges.push(`<span class="badge">${esc(agent.name)}</span>`); 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.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>`); 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(); 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 ── // ── Streaming Bubble ──
let _streamDiv = null; let _streamDiv = null;