feat(ui): complete Avalonia UI port with 7 main pages, tool settings & top MenuBar
This commit is contained in:
@@ -0,0 +1,14 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<RootNamespace>ClawdDotNet.Tools.Taskboard</RootNamespace>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\ClawdDotNet.Core\ClawdDotNet.Core.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -0,0 +1,374 @@
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using ClawdDotNet.Core.Tasks;
|
||||
using ClawdDotNet.Core.Tools;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace ClawdDotNet.Tools.Taskboard;
|
||||
|
||||
/// <summary>
|
||||
/// Das Taskboard — Aufgaben statt Delay-Schleifen (A1).
|
||||
///
|
||||
/// Aufgaben sind Markdown-Dateien mit Frontmatter im geteilten Arbeitsbereich; dieses
|
||||
/// Tool legt sie an, ändert und listet sie. Agent-zu-Agent-Delegation läuft hierüber
|
||||
/// (eine Aufgabe mit <c>assignee: @<agent></c>) statt über rekursive Nachrichten —
|
||||
/// das ist strukturell zyklenfrei. Wann eine fällige Aufgabe tatsächlich läuft,
|
||||
/// entscheidet der Scanner, nicht dieses Tool.
|
||||
/// </summary>
|
||||
public sealed class TaskboardTool : IAgentTool
|
||||
{
|
||||
private const int MaxResultChars = 8_000;
|
||||
|
||||
public string Name => "Taskboard";
|
||||
|
||||
public string Description => """
|
||||
Das gemeinsame Aufgabenbrett — überdauert Läufe und Neustarts.
|
||||
|
||||
Nutze es, um Arbeit festzuhalten und zu verteilen, statt sie im Kontext zu
|
||||
behalten: eigene offene Punkte, Aufträge an andere Agenten, wiederkehrende
|
||||
Termine. Eine Aufgabe an einen anderen Agenten legst du mit assignee='@<agentId>'
|
||||
an — sie läuft, wenn sie fällig ist, ohne dass du wartest.
|
||||
|
||||
Aktionen: task_create, task_list, task_update, task_comment
|
||||
|
||||
Assignee:
|
||||
- '@new' → frischer Lauf ohne Vorgeschichte
|
||||
- '@<agentId>'→ der genannte Agent mit seinem Kontext
|
||||
- '@human' → wartet auf einen Menschen (Standard, wenn unklar)
|
||||
|
||||
Termine (Feld 'when'): einmalig ('at'), wiederkehrend ('every') oder 'cron'.
|
||||
Die Zeitzone 'tz' ist dabei Pflicht (z.B. 'Europe/Berlin').
|
||||
|
||||
Fehlt 'when', ist die Aufgabe einmalig und sofort fällig.
|
||||
""";
|
||||
|
||||
public JsonElement InputSchema { get; } = JsonDocument.Parse("""
|
||||
{
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"action": {
|
||||
"type": "string",
|
||||
"enum": ["task_create", "task_list", "task_update", "task_comment"],
|
||||
"description": "Die auszuführende Aktion."
|
||||
},
|
||||
"id": {
|
||||
"type": "string",
|
||||
"description": "Id der Aufgabe. Pflicht bei task_update und task_comment."
|
||||
},
|
||||
"title": {
|
||||
"type": "string",
|
||||
"description": "Titel der Aufgabe. Pflicht bei task_create."
|
||||
},
|
||||
"body": {
|
||||
"type": "string",
|
||||
"description": "Beschreibung/Auftrag — geht als Aufgabenstellung an den ausführenden Agenten."
|
||||
},
|
||||
"assignee": {
|
||||
"type": "string",
|
||||
"description": "@new | @<agentId> | @human. Standard @human."
|
||||
},
|
||||
"status": {
|
||||
"type": "string",
|
||||
"enum": ["backlog", "todo", "in_progress", "in_review", "done", "canceled", "blocked"],
|
||||
"description": "Neuer Status (task_update). 'blocked' meldet einen Blocker."
|
||||
},
|
||||
"type": {
|
||||
"type": "string",
|
||||
"enum": ["work", "approval", "human_input"],
|
||||
"description": "Art der Aufgabe. Standard 'work'."
|
||||
},
|
||||
"priority": {
|
||||
"type": "integer",
|
||||
"description": "1 (niedrig) bis 5 (hoch). Standard 3."
|
||||
},
|
||||
"when": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"kind": { "type": "string", "enum": ["at", "every", "cron"] },
|
||||
"value": { "type": "string", "description": "Zeitpunkt (ISO), Intervall (z.B. '30m') oder Cron-Ausdruck." },
|
||||
"tz": { "type": "string", "description": "IANA-Zeitzone, z.B. 'Europe/Berlin'. PFLICHT." }
|
||||
},
|
||||
"description": "Termin. Fehlt er, ist die Aufgabe einmalig und sofort fällig."
|
||||
},
|
||||
"require_approval": {
|
||||
"type": "boolean",
|
||||
"description": "Wenn true, gilt die Aufgabe erst nach Review als erledigt."
|
||||
},
|
||||
"acceptance": {
|
||||
"type": "string",
|
||||
"description": "Abnahmekriterien, gegen die das Ergebnis geprüft wird."
|
||||
},
|
||||
"blocked_by": {
|
||||
"type": "array",
|
||||
"items": { "type": "string" },
|
||||
"description": "Ids der Aufgaben, die erst erledigt sein müssen."
|
||||
},
|
||||
"onlyWhenMarketOpen": {
|
||||
"type": "boolean",
|
||||
"description": "Termin nur auslösen, wenn der Markt offen ist."
|
||||
},
|
||||
"text": {
|
||||
"type": "string",
|
||||
"description": "Kommentartext (task_comment) — Ergebnis oder Kritik."
|
||||
},
|
||||
"reopen": {
|
||||
"type": "boolean",
|
||||
"description": "task_comment: die Aufgabe wieder auf 'todo' setzen, damit sie erneut läuft."
|
||||
},
|
||||
"search": {
|
||||
"type": "string",
|
||||
"description": "Freitextsuche über Titel und Beschreibung (task_list)."
|
||||
},
|
||||
"limit": {
|
||||
"type": "integer",
|
||||
"description": "Maximale Trefferzahl bei task_list. Standard 50."
|
||||
}
|
||||
},
|
||||
"required": ["action"]
|
||||
}
|
||||
""").RootElement.Clone();
|
||||
|
||||
public async Task<ToolResult> ExecuteAsync(JsonElement input, AgentToolContext context, CancellationToken ct)
|
||||
{
|
||||
if (context.Tasks is not { } repository)
|
||||
return ToolResult.Fail("Das Taskboard ist für diese Instanz nicht verfügbar.");
|
||||
|
||||
if (string.IsNullOrWhiteSpace(context.SharedWorkspacePath))
|
||||
return ToolResult.Fail("Kein geteilter Arbeitsbereich konfiguriert — das Taskboard braucht ihn.");
|
||||
|
||||
var tasksDir = Path.Combine(context.SharedWorkspacePath, "tasks");
|
||||
var board = new TaskboardService(repository, tasksDir);
|
||||
|
||||
var action = GetString(input, "action");
|
||||
if (string.IsNullOrWhiteSpace(action))
|
||||
return ToolResult.Fail("'action' ist erforderlich.");
|
||||
|
||||
try
|
||||
{
|
||||
return action switch
|
||||
{
|
||||
"task_create" => await CreateAsync(input, board, ct),
|
||||
"task_list" => await ListAsync(input, board, ct),
|
||||
"task_update" => await UpdateAsync(input, board, ct),
|
||||
"task_comment" => await CommentAsync(input, board, context.AgentId, ct),
|
||||
_ => ToolResult.Fail($"Unbekannte Aktion: {action}")
|
||||
};
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
context.Logger.LogError(ex, "Fehler im Taskboard-Tool bei Aktion {Action}", action);
|
||||
return ToolResult.Fail($"Fehler: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Aktionen ───
|
||||
|
||||
private static async Task<ToolResult> CreateAsync(
|
||||
JsonElement input, TaskboardService board, CancellationToken ct)
|
||||
{
|
||||
var title = GetString(input, "title");
|
||||
if (string.IsNullOrWhiteSpace(title))
|
||||
return ToolResult.Fail("'title' ist erforderlich.");
|
||||
|
||||
if (!TryReadWhen(input, out var when, out var whenError))
|
||||
return ToolResult.Fail(whenError!);
|
||||
|
||||
var definition = new TaskItem
|
||||
{
|
||||
Title = title.Trim(),
|
||||
Body = GetString(input, "body")?.Trim() ?? "",
|
||||
Status = TaskItemStatus.Todo,
|
||||
Type = TaskText.Type(GetString(input, "type")),
|
||||
Priority = GetInt(input, "priority") ?? 3,
|
||||
Assignee = GetString(input, "assignee")?.Trim() is { Length: > 0 } a ? a : TaskAssignee.Human,
|
||||
When = when,
|
||||
RequireApproval = GetBool(input, "require_approval") ?? false,
|
||||
Acceptance = GetString(input, "acceptance")?.Trim() ?? "",
|
||||
BlockedBy = GetStringList(input, "blocked_by"),
|
||||
OnlyWhenMarketOpen = GetBool(input, "onlyWhenMarketOpen") ?? false
|
||||
};
|
||||
|
||||
var created = await board.CreateAsync(definition, ct);
|
||||
return ToolResult.Ok(
|
||||
$"Aufgabe angelegt: {created.Id} — {created.Title}\n" +
|
||||
$"Status: {TaskText.Of(created.Status)} | Assignee: {created.Assignee} | " +
|
||||
$"Priorität: {created.Priority}{FormatWhen(created.When)}");
|
||||
}
|
||||
|
||||
private static async Task<ToolResult> ListAsync(
|
||||
JsonElement input, TaskboardService board, CancellationToken ct)
|
||||
{
|
||||
var query = new TaskQuery
|
||||
{
|
||||
Status = ParseStatusFilter(GetString(input, "status")),
|
||||
Assignee = GetString(input, "assignee"),
|
||||
Search = GetString(input, "search"),
|
||||
Limit = GetInt(input, "limit") ?? 50
|
||||
};
|
||||
|
||||
var tasks = await board.ListAsync(query, ct);
|
||||
if (tasks.Count == 0)
|
||||
return ToolResult.Ok("Keine passenden Aufgaben.");
|
||||
|
||||
return ToolResult.Ok(Render(tasks));
|
||||
}
|
||||
|
||||
private static async Task<ToolResult> UpdateAsync(
|
||||
JsonElement input, TaskboardService board, CancellationToken ct)
|
||||
{
|
||||
var id = GetString(input, "id");
|
||||
if (string.IsNullOrWhiteSpace(id))
|
||||
return ToolResult.Fail("'id' ist erforderlich.");
|
||||
|
||||
if (!TryReadWhen(input, out var when, out var whenError))
|
||||
return ToolResult.Fail(whenError!);
|
||||
var hasWhen = input.TryGetProperty("when", out _);
|
||||
|
||||
var result = await board.UpdateAsync(id.Trim(), current => current with
|
||||
{
|
||||
Title = GetString(input, "title")?.Trim() ?? current.Title,
|
||||
Body = GetString(input, "body")?.Trim() ?? current.Body,
|
||||
Status = ParseStatusFilter(GetString(input, "status")) ?? current.Status,
|
||||
Type = input.TryGetProperty("type", out _) ? TaskText.Type(GetString(input, "type")) : current.Type,
|
||||
Priority = GetInt(input, "priority") ?? current.Priority,
|
||||
Assignee = GetString(input, "assignee")?.Trim() ?? current.Assignee,
|
||||
When = hasWhen ? when : current.When,
|
||||
RequireApproval = GetBool(input, "require_approval") ?? current.RequireApproval,
|
||||
Acceptance = GetString(input, "acceptance")?.Trim() ?? current.Acceptance,
|
||||
BlockedBy = input.TryGetProperty("blocked_by", out _) ? GetStringList(input, "blocked_by") : current.BlockedBy,
|
||||
OnlyWhenMarketOpen = GetBool(input, "onlyWhenMarketOpen") ?? current.OnlyWhenMarketOpen
|
||||
}, ct);
|
||||
|
||||
if (result is null)
|
||||
return ToolResult.Fail($"Keine Aufgabe mit der Id '{id}' gefunden.");
|
||||
|
||||
return ToolResult.Ok(
|
||||
$"Aufgabe {result.Id} aktualisiert.\n" +
|
||||
$"Status: {TaskText.Of(result.Status)} | Assignee: {result.Assignee} | Priorität: {result.Priority}");
|
||||
}
|
||||
|
||||
private static async Task<ToolResult> CommentAsync(
|
||||
JsonElement input, TaskboardService board, string author, CancellationToken ct)
|
||||
{
|
||||
var id = GetString(input, "id");
|
||||
if (string.IsNullOrWhiteSpace(id))
|
||||
return ToolResult.Fail("'id' ist erforderlich.");
|
||||
|
||||
var text = GetString(input, "text");
|
||||
if (string.IsNullOrWhiteSpace(text))
|
||||
return ToolResult.Fail("'text' ist erforderlich — was soll kommentiert werden?");
|
||||
|
||||
var result = await board.AddCommentAsync(id.Trim(), author, text.Trim(), ct);
|
||||
if (result is null)
|
||||
return ToolResult.Fail($"Keine Aufgabe mit der Id '{id}' gefunden.");
|
||||
|
||||
if (GetBool(input, "reopen") == true && result.Status != TaskItemStatus.Todo)
|
||||
{
|
||||
result = await board.UpdateAsync(id.Trim(), c => c with { Status = TaskItemStatus.Todo }, ct);
|
||||
return ToolResult.Ok($"Kommentar angehängt und Aufgabe {id} wieder geöffnet (todo).");
|
||||
}
|
||||
|
||||
return ToolResult.Ok($"Kommentar an Aufgabe {id} angehängt.");
|
||||
}
|
||||
|
||||
// ─── Darstellung ───
|
||||
|
||||
private static string Render(IReadOnlyList<TaskItem> tasks)
|
||||
{
|
||||
var sb = new StringBuilder();
|
||||
sb.AppendLine($"{tasks.Count} Aufgabe(n), wichtigste zuerst:");
|
||||
sb.AppendLine();
|
||||
|
||||
var shown = 0;
|
||||
foreach (var task in tasks)
|
||||
{
|
||||
var block = new StringBuilder();
|
||||
block.Append($"{task.Id} [{TaskText.Of(task.Status)}] {task.Title}");
|
||||
block.AppendLine();
|
||||
block.AppendLine($" Assignee: {task.Assignee} | Priorität: {task.Priority}{FormatWhen(task.When)}");
|
||||
if (task.BlockedBy.Count > 0)
|
||||
block.AppendLine($" Wartet auf: {string.Join(", ", task.BlockedBy)}");
|
||||
|
||||
if (sb.Length + block.Length > MaxResultChars)
|
||||
{
|
||||
sb.AppendLine($"[… {tasks.Count - shown} weitere ausgelassen. Grenze die Suche ein.]");
|
||||
break;
|
||||
}
|
||||
|
||||
sb.Append(block);
|
||||
shown++;
|
||||
}
|
||||
|
||||
return sb.ToString().TrimEnd();
|
||||
}
|
||||
|
||||
private static string FormatWhen(TaskWhen? when)
|
||||
=> when is null ? "" : $" | Termin: {TaskText.Of(when.Kind)} {when.Value} ({when.TimeZone})";
|
||||
|
||||
// ─── Eingabe lesen ───
|
||||
|
||||
private static bool TryReadWhen(JsonElement input, out TaskWhen? when, out string? error)
|
||||
{
|
||||
when = null;
|
||||
error = null;
|
||||
|
||||
if (!input.TryGetProperty("when", out var w) || w.ValueKind != JsonValueKind.Object)
|
||||
return true; // kein Termin ist gültig (einmalige Aufgabe)
|
||||
|
||||
var kindText = w.TryGetProperty("kind", out var k) ? k.GetString() : null;
|
||||
var kind = TaskText.WhenKind(kindText);
|
||||
if (kind is null)
|
||||
{
|
||||
error = "'when.kind' muss 'at', 'every' oder 'cron' sein.";
|
||||
return false;
|
||||
}
|
||||
|
||||
var value = w.TryGetProperty("value", out var v) ? v.GetString()?.Trim() : null;
|
||||
if (string.IsNullOrWhiteSpace(value))
|
||||
{
|
||||
error = "'when.value' ist erforderlich (Zeitpunkt, Intervall oder Cron-Ausdruck).";
|
||||
return false;
|
||||
}
|
||||
|
||||
var tz = w.TryGetProperty("tz", out var t) ? t.GetString()?.Trim() : null;
|
||||
if (string.IsNullOrWhiteSpace(tz))
|
||||
{
|
||||
error = "'when.tz' ist erforderlich — ein Termin ohne Zeitzone ist mehrdeutig (z.B. 'Europe/Berlin').";
|
||||
return false;
|
||||
}
|
||||
|
||||
when = new TaskWhen { Kind = kind.Value, Value = value, TimeZone = tz };
|
||||
return true;
|
||||
}
|
||||
|
||||
private static TaskItemStatus? ParseStatusFilter(string? value)
|
||||
=> string.IsNullOrWhiteSpace(value) ? null : TaskText.Status(value);
|
||||
|
||||
private static string? GetString(JsonElement input, string name)
|
||||
=> input.TryGetProperty(name, out var value) && value.ValueKind == JsonValueKind.String
|
||||
? value.GetString()
|
||||
: null;
|
||||
|
||||
private static int? GetInt(JsonElement input, string name)
|
||||
=> input.TryGetProperty(name, out var value) && value.ValueKind == JsonValueKind.Number
|
||||
? value.GetInt32()
|
||||
: null;
|
||||
|
||||
private static bool? GetBool(JsonElement input, string name)
|
||||
=> input.TryGetProperty(name, out var value) && value.ValueKind is JsonValueKind.True or JsonValueKind.False
|
||||
? value.GetBoolean()
|
||||
: null;
|
||||
|
||||
private static IReadOnlyList<string> GetStringList(JsonElement input, string name)
|
||||
{
|
||||
if (!input.TryGetProperty(name, out var arr) || arr.ValueKind != JsonValueKind.Array)
|
||||
return [];
|
||||
|
||||
return arr.EnumerateArray()
|
||||
.Where(e => e.ValueKind == JsonValueKind.String)
|
||||
.Select(e => e.GetString()!)
|
||||
.Where(s => !string.IsNullOrWhiteSpace(s))
|
||||
.Select(s => s.Trim())
|
||||
.ToList();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user