feat: Plan-Modus – Toggle im Chat zum Umschalten zwischen Plan/Ausführung

- Toggle-Button (🗺️) im Chat-Input-Bereich neben Explorer
- Plan-Modus wird per ChatRequest.planMode an Backend gesendet
- Backend: Write-Tools gefiltert (nur read_file, list_files, glob, grep,
  todo_write, memory_*, ask_user_choice bleiben), System-Prompt-Block
  ergänzt der Änderungen verbietet, tool_choice nie 'any' erzwungen
- ChainRecord.PlanMode: pro Chain gespeichert (informativ für History)
- Chain-Meta-Badge '📋 Plan-Modus' bei Plan-Chains
- Choice-Response erbt planMode vom vorherigen Chain
- CSS: .plan-mode-active Styling (Primary-Color Highlight)
- Placeholder wechselt zu 'Plannen...' wenn aktiv
- Plan-Modus wird bei Session-Wechsel zurückgesetzt
This commit is contained in:
Meleki committed 2026-03-07 12:07:39 +01:00
1 parent 1c11ffa3cd
commit 6d95f9aec2
13 files changed
+123 -13

No files matched your search

+14 -2
View File
@@ -52,7 +52,19 @@ public static class ChatEndpoint
var userId = req.HttpContext.GetUserId();
var maxIterations = config.GetValue<int>("MaxIterations", 100);
var ctx = await chatService.PrepareContext(sessionId, userId, maxIterations);
// Bei Choice-Response: planMode vom vorherigen Chain übernehmen
var effectivePlanMode = body.PlanMode;
if (isChoiceResponse && !effectivePlanMode)
{
var prevChainId = await chatService.FindChainWithToolCall(sessionId, body.ChoiceToolCallId);
if (prevChainId != null)
{
var prevChain = await db.GetChain(prevChainId);
if (prevChain != null) effectivePlanMode = prevChain.PlanMode;
}
}
var ctx = await chatService.PrepareContext(sessionId, userId, maxIterations, planMode: effectivePlanMode);
if (ctx == null)
{
res.StatusCode = 400;
@@ -132,7 +144,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: effectiveToolChoice, review: ctx.Review, finalizer: ctx.Finalizer, allowUserChoice: ctx.Agent?.AllowUserChoice ?? true, 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: effectiveToolChoice, review: ctx.Review, finalizer: ctx.Finalizer, allowUserChoice: ctx.Agent?.AllowUserChoice ?? true, planMode: ctx.PlanMode, cancellationToken: linkedCts.Token);
}
finally
{
+1
View File
@@ -19,5 +19,6 @@ public class ChainRecord
public string ToolNames { get; set; }
public string ArchiveId { get; set; }
public string StreamErrorInfo { get; set; }
public bool PlanMode { get; set; }
public string CreatedAt { get; set; }
}
+1
View File
@@ -17,6 +17,7 @@ public class ChatContext
public int MaxIterations { get; set; }
public AgentRecord Agent { get; set; }
public SessionRecord Session { get; set; }
public bool PlanMode { get; set; }
// Review-Agent
public ReviewContext Review { get; set; }
+6
View File
@@ -144,4 +144,10 @@ public class ChatRequest
/// </summary>
[JsonPropertyName("choiceResult")]
public string ChoiceResult { get; set; }
/// <summary>
/// Wenn true: Plan-Modus aktiv. Nur Read-only Tools, keine Datei-Änderungen.
/// </summary>
[JsonPropertyName("planMode")]
public bool PlanMode { get; set; }
}
+25 -3
View File
@@ -50,6 +50,7 @@ public class AnthropicService
ReviewContext review = null,
FinalizerContext finalizer = null,
bool allowUserChoice = true,
bool planMode = false,
CancellationToken cancellationToken = default)
{
var sw = System.Diagnostics.Stopwatch.StartNew();
@@ -58,6 +59,20 @@ public class AnthropicService
int reviewRound = 0;
string lastStopReason = "";
// Plan-Modus: System-Prompt-Ergänzung
if (planMode && systemPrompt != null)
{
systemPrompt.Add(new { type = "text", text = @"## Plan-Modus aktiv
Der User hat den Plan-Modus aktiviert. Du DARFST KEINE Änderungen vornehmen:
- KEINE Dateien erstellen, bearbeiten oder löschen
- KEINE Shell-Befehle ausführen die Änderungen vornehmen (Commits, Installationen etc.)
- NUR lesen, analysieren, suchen und planen
- Nutze read_file, list_files, glob, grep und web_search um den Codebase zu verstehen
- Nutze ask_user_choice um Rückfragen zu stellen und Anforderungen zu klären
- Erstelle einen strukturierten Plan mit Aufgaben, Abhängigkeiten und Reihenfolge
- Der User wird den Plan-Modus deaktivieren wenn du mit der Umsetzung beginnen sollst" });
}
var chain = new ChainRecord
{
ChainId = chainId,
@@ -93,13 +108,19 @@ public class AnthropicService
{
var tools = new List<object>();
if (hasTools)
tools.AddRange(_toolService.GetToolDefinitions(includeFileTools: hasWorkspace, includeMemory: includeMemory, includeUserChoice: allowUserChoice));
{
var allTools = _toolService.GetToolDefinitions(includeFileTools: hasWorkspace, includeMemory: includeMemory, includeUserChoice: allowUserChoice);
if (planMode)
allTools = _toolService.FilterReadOnlyTools(allTools);
tools.AddRange(allTools);
}
if (hasWebSearch)
tools.Add(new { type = webSearchVersion, name = "web_search", max_uses = 5 });
payload["tools"] = tools;
// "any" erzwingt Tool-Nutzung, aber nur in der ersten Iteration,
// damit Claude nicht endlos Tools aufruft wenn die Aufgabe erledigt ist.
var effectiveChoice = (toolChoice == "any" && iterations == 1) ? "any" : "auto";
// Im Plan-Modus nie "any" erzwingen – Claude soll frei antworten können.
var effectiveChoice = (!planMode && toolChoice == "any" && iterations == 1) ? "any" : "auto";
payload["tool_choice"] = new { type = effectiveChoice };
}
@@ -252,7 +273,8 @@ public class AnthropicService
chainId,
durationMs = sw.ElapsedMilliseconds,
stopReason = finalStopReason,
streamErrorInfo
streamErrorInfo,
planMode
}, cancellationToken);
return;
}
+5 -3
View File
@@ -42,7 +42,7 @@ public class ChatService
/// <summary>
/// Baut den vollständigen ChatContext auf: Agent, Model, SystemPrompt, Chain, Workspace etc.
/// </summary>
public async Task<ChatContext> PrepareContext(string sessionId, string userId, int maxIterations = 100)
public async Task<ChatContext> PrepareContext(string sessionId, string userId, int maxIterations = 100, bool planMode = false)
{
var session = await _db.GetSession(sessionId);
if (session == null || (userId != null && session.UserId != userId) || string.IsNullOrEmpty(session.AgentId))
@@ -94,7 +94,8 @@ public class ChatService
Model = modelId,
SystemPrompt = systemPromptText,
ToolChoice = (hasWorkspace || includeMemory) ? toolChoice : null,
ToolNames = toolNames
ToolNames = toolNames,
PlanMode = planMode
});
await _db.UpdateSessionTimestamp(sessionId);
@@ -193,7 +194,8 @@ public class ChatService
Agent = agent,
Session = session,
Review = reviewContext,
Finalizer = finalizerContext
Finalizer = finalizerContext,
PlanMode = planMode
};
}
+3 -2
View File
@@ -179,6 +179,7 @@ public class DatabaseService
TryAddColumn(conn, "agents", "avatar_offset_y", "REAL NOT NULL DEFAULT 0");
TryAddColumn(conn, "sessions", "finalizer_agent_id", "TEXT");
TryAddColumn(conn, "agents", "allow_user_choice", "INTEGER NOT NULL DEFAULT 1");
TryAddColumn(conn, "chains", "plan_mode", "INTEGER NOT NULL DEFAULT 0");
conn.Execute(@"
CREATE TABLE IF NOT EXISTS agent_skills (
@@ -579,8 +580,8 @@ public class DatabaseService
using var conn = CreateConnection();
chain.CreatedAt = DateTime.UtcNow.ToString("o");
await conn.ExecuteAsync(@"
INSERT INTO chains (chain_id, session_id, archive_id, agent_id, model, system_prompt, tool_choice, tool_names, input_tokens, output_tokens, iterations, stop_reason, duration_ms, created_at)
VALUES (@ChainId, @SessionId, @ArchiveId, @AgentId, @Model, @SystemPrompt, @ToolChoice, @ToolNames, @InputTokens, @OutputTokens, @Iterations, @StopReason, @DurationMs, @CreatedAt)
INSERT INTO chains (chain_id, session_id, archive_id, agent_id, model, system_prompt, tool_choice, tool_names, input_tokens, output_tokens, iterations, stop_reason, duration_ms, plan_mode, created_at)
VALUES (@ChainId, @SessionId, @ArchiveId, @AgentId, @Model, @SystemPrompt, @ToolChoice, @ToolNames, @InputTokens, @OutputTokens, @Iterations, @StopReason, @DurationMs, @PlanMode, @CreatedAt)
", chain);
}
+28
View File
@@ -17,6 +17,34 @@ public class ToolService
{
public const string AskUserChoiceToolName = "ask_user_choice";
/// <summary>
/// Read-only Tools die im Plan-Modus verfügbar bleiben.
/// Write-Tools (write_file, edit_file, delete_file, execute) werden entfernt.
/// </summary>
private static readonly HashSet<string> ReadOnlyToolNames = new HashSet<string>
{
"read_file", "list_files", "glob", "grep", "todo_write",
"memory_read", "memory_write", "memory_list",
AskUserChoiceToolName
};
public object[] FilterReadOnlyTools(object[] tools)
{
var filtered = new List<object>();
foreach (var tool in tools)
{
var json = JsonSerializer.Serialize(tool);
var el = JsonSerializer.Deserialize<JsonElement>(json);
if (el.TryGetProperty("name", out var nameProp))
{
var name = nameProp.GetString() ?? "";
if (ReadOnlyToolNames.Contains(name))
filtered.Add(tool);
}
}
return filtered.ToArray();
}
public object[] GetToolDefinitions(bool includeFileTools = false, bool includeMemory = false, bool includeUserChoice = true)
{
var allTools = new List<object>();
+7
View File
@@ -378,6 +378,13 @@
color: var(--dangerous);
}
/* Plan Mode Toggle */
#planModeBtn.plan-mode-active {
color: var(--primary);
background: color-mix(in srgb, var(--primary) 15%, transparent);
border-radius: var(--border-radius);
}
/* Emoji Picker */
.emoji-picker {
+1
View File
@@ -322,6 +322,7 @@
<input type="file" id="fileInput" accept="image/jpeg,image/png,image/gif,image/webp,application/pdf" multiple hidden>
<button id="fileBtn" class="icon-btn" title="Datei anhängen"><i class="fa fa-paperclip"></i></button>
<button id="explorerBtn" class="icon-btn" title="Workspace durchsuchen"><i class="fa fa-folder"></i></button>
<button id="planModeBtn" class="icon-btn" title="Plan-Modus umschalten"><i class="fa fa-map"></i></button>
<textarea id="input" placeholder="Nachricht..." rows="1"></textarea>
<button id="emojiBtn" class="icon-btn" title="Emoji einfügen"><i class="fa fa-smile-o"></i></button>
<button id="send" class="icon-btn"><i class="fa fa-paper-plane"></i></button>
+4 -2
View File
@@ -41,11 +41,13 @@ async function apiMe() {
// ── API Calls ──
async function apiChat(content, sessionId, signal) {
async function apiChat(content, sessionId, signal, planMode) {
var payload = { content, sessionId };
if (planMode) payload.planMode = true;
return fetch('/api/chat', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ content, sessionId }),
body: JSON.stringify(payload),
signal
});
}
+19 -1
View File
@@ -2,6 +2,7 @@
function buildMetaBadges(d) {
const badges = [];
if (d.planMode) badges.push('📋 Plan-Modus');
badges.push(prettyModelName(d.model));
badges.push('Tokens: ' + (d.inputTokens || 0) + ' / ' + (d.outputTokens || 0));
if (d.cacheCreationTokens > 0 || d.cacheReadTokens > 0) {
@@ -137,6 +138,23 @@ function handleStreamEvent(eventType, data) {
}
}
// ── Plan Mode ──
let _planModeActive = false;
function togglePlanMode() {
_planModeActive = !_planModeActive;
var btn = $('#planModeBtn');
if (btn) {
btn.classList.toggle('plan-mode-active', _planModeActive);
btn.title = _planModeActive ? 'Plan-Modus deaktivieren' : 'Plan-Modus aktivieren';
}
var input = $('#input');
if (input) {
input.placeholder = _planModeActive ? 'Plannen...' : 'Nachricht...';
}
}
// ── Chat State ──
let isStreaming = false;
@@ -254,7 +272,7 @@ async function sendMessage() {
_chatAbandoned = false;
try {
const resp = await apiChat(content, getCurrentSessionId(), _abortController.signal);
const resp = await apiChat(content, getCurrentSessionId(), _abortController.signal, _planModeActive);
const reader = resp.body.getReader();
const decoder = new TextDecoder();
let buffer = '';
+9
View File
@@ -304,6 +304,15 @@ registerPage('chat', {
});
}
// Plan Mode Toggle
const planModeBtn = document.getElementById('planModeBtn');
if (planModeBtn) {
// Plan-Modus bei Session-Wechsel zurücksetzen
_planModeActive = false;
planModeBtn.classList.remove('plan-mode-active');
planModeBtn.addEventListener('click', togglePlanMode);
}
// File Upload
const fileBtn = document.getElementById('fileBtn');
const fileInput = document.getElementById('fileInput');