i18n: Englisch als Hauptsprache, Sprachpakete fuer Desktop und WebUI (#661)

Umbau durch Gemini Flash, Durchsicht und Nacharbeit durch Claude.

Umfang:
- Desktop: LocalizationManager (JSON-Pakete, Rueckfall auf Englisch,
  Umschalten zur Laufzeit), Localize-Markup-Erweiterung, Sprachwahl in den
  Einstellungen (AppSettings.Language, Vorgabe "en"); 317 Schluessel je Sprache
- WebUI: alle Ansichten auf t()/data-i18n, 656 Schluessel je Sprache
- Agenten-Werkzeuge: Beschreibungen, Schemata und Meldungen auf Englisch
- Tests an die englischen Meldungen angepasst, keine Pruefung abgeschwaecht

Nacharbeit bei der Durchsicht:
- ~25 Kommentarbloecke mit Begruendungen waren ersatzlos entfallen, darunter
  sicherheitsrelevante (NTFS-Datenstrom beim shared:-Praefix, Bremse gegen
  Agentenschleifen, doppelter POST beim Anlegen einer Instanz). Wieder
  eingesetzt: auf Englisch, wo die Datei umgestellt ist, sonst im Wortlaut.
- Drei WebUI-Schluessel fehlten (logs.paused, instances.instanceOfflineError,
  instances.noInstanceActiveError). Weil t() bei fehlendem Schluessel den
  Schluessel selbst liefert, griff der "|| err.message"-Rueckfall nie — die
  Oberflaeche haette den Schluesselnamen angezeigt.

Geprueft: Schluesselgleichheit en/de, Platzhalter je Schluessel, jeder im
Code benutzte Schluessel vorhanden, node --check fuer alle Module,
1.220 Tests gruen.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Richard
2026-09-24 09:43:51 +02:00
co-authored by Claude Opus 5.5
parent 69718122f3
commit ad71682c2d
97 changed files with 4103 additions and 1928 deletions
@@ -22,28 +22,28 @@ public sealed class TaskboardTool : IAgentTool
public string Name => "Taskboard";
public string Description => """
Das gemeinsame Aufgabenbrett — überdauert Läufe und Neustarts.
The shared taskboard — persists across runs and restarts.
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.
Use it to track and distribute work instead of keeping it in context:
own open items, assignments to other agents, recurring schedules.
To assign a task to another agent, create it with assignee='@<agentId>' —
it runs when due, without you waiting.
Aktionen: task_create, task_find, task_list, task_update, task_comment
Actions: task_create, task_find, task_list, task_update, task_comment
Bevor du etwas meldest: task_find mit dem geplanten Titel. Gibt es die Aufgabe
schon, hänge deine Beobachtung mit task_comment an, statt sie erneut anzulegen —
sonst weiß niemand, ob ein Befund neu ist oder seit Tagen unbeantwortet liegt.
Before reporting something: run task_find with the planned title. If the task
already exists, append your observation with task_comment instead of recreating it —
otherwise nobody knows whether a finding is new or has been sitting unanswered for days.
Assignee:
- '@new' → frischer Lauf ohne Vorgeschichte
- '@<agentId>'→ der genannte Agent mit seinem Kontext
- '@human' → wartet auf einen Menschen (Standard, wenn unklar)
- '@new' → fresh run without history
- '@<agentId>'→ the named agent with their context
- '@human' → waits for a human (default if unclear)
Termine (Feld 'when'): einmalig ('at'), wiederkehrend ('every') oder 'cron'.
Die Zeitzone 'tz' ist dabei Pflicht (z.B. 'Europe/Berlin').
Schedules ('when' field): one-time ('at'), recurring ('every') or 'cron'.
The time zone 'tz' is required (e.g. 'Europe/Berlin').
Fehlt 'when', ist die Aufgabe einmalig und sofort fällig.
If 'when' is missing, the task is one-time and due immediately.
""";
public JsonElement InputSchema { get; } = JsonDocument.Parse("""
@@ -53,83 +53,83 @@ public sealed class TaskboardTool : IAgentTool
"action": {
"type": "string",
"enum": ["task_create", "task_find", "task_list", "task_update", "task_comment"],
"description": "Die auszuführende Aktion. 'task_find' sucht offene Aufgaben mit ähnlichem Titel — immer vor task_create."
"description": "The action to execute. 'task_find' searches open tasks with similar titles — always call before task_create."
},
"id": {
"type": "string",
"description": "Id der Aufgabe. Pflicht bei task_update und task_comment."
"description": "Task ID. Required for task_update and task_comment."
},
"title": {
"type": "string",
"description": "Titel der Aufgabe. Pflicht bei task_create und task_find."
"description": "Task title. Required for task_create and task_find."
},
"force": {
"type": "boolean",
"description": "task_create: trotz gleichlautender offener Aufgabe wirklich eine zweite anlegen. Nur, wenn es ein anderer Sachverhalt ist."
"description": "task_create: create a second task even if a matching open task exists. Only use if it is a different issue."
},
"body": {
"type": "string",
"description": "Beschreibung/Auftrag — geht als Aufgabenstellung an den ausführenden Agenten."
"description": "Description/assignment — passed as task instructions to executing agent."
},
"assignee": {
"type": "string",
"description": "@new | @<agentId> | @human. Standard @human."
"description": "@new | @<agentId> | @human. Default @human."
},
"status": {
"type": "string",
"enum": ["backlog", "todo", "in_progress", "in_review", "done", "canceled", "blocked"],
"description": "Neuer Status (task_update). 'blocked' meldet einen Blocker."
"description": "New status (task_update). 'blocked' reports a blocker."
},
"type": {
"type": "string",
"enum": ["work", "approval", "human_input"],
"description": "Art der Aufgabe. Standard 'work'."
"description": "Type of task. Default 'work'."
},
"priority": {
"type": "integer",
"description": "1 (niedrig) bis 5 (hoch). Standard 3."
"description": "1 (low) to 5 (high). Default 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." }
"value": { "type": "string", "description": "Timestamp (ISO), interval (e.g. '30m') or cron expression." },
"tz": { "type": "string", "description": "IANA timezone, e.g. 'Europe/Berlin'. REQUIRED." }
},
"description": "Termin. Fehlt er, ist die Aufgabe einmalig und sofort fällig."
"description": "Schedule. If omitted, the task is one-time and due immediately."
},
"require_approval": {
"type": "boolean",
"description": "Wenn true, gilt die Aufgabe erst nach Review als erledigt."
"description": "If true, the task is only considered done after review."
},
"acceptance": {
"type": "string",
"description": "Abnahmekriterien, gegen die das Ergebnis geprüft wird."
"description": "Acceptance criteria against which the result is verified."
},
"blocked_by": {
"type": "array",
"items": { "type": "string" },
"description": "Ids der Aufgaben, die erst erledigt sein müssen."
"description": "IDs of tasks that must be completed first."
},
"onlyWhenMarketOpen": {
"type": "boolean",
"description": "Termin nur auslösen, wenn der Markt offen ist."
"description": "Only trigger schedule when market is open."
},
"text": {
"type": "string",
"description": "Kommentartext (task_comment) — Ergebnis oder Kritik."
"description": "Comment text (task_comment) — result or feedback."
},
"reopen": {
"type": "boolean",
"description": "task_comment: die Aufgabe wieder auf 'todo' setzen, damit sie erneut läuft."
"description": "task_comment: set task back to 'todo' so it runs again."
},
"search": {
"type": "string",
"description": "Freitextsuche über Titel und Beschreibung (task_list)."
"description": "Free-text search across title and description (task_list)."
},
"limit": {
"type": "integer",
"description": "Maximale Trefferzahl bei task_list. Standard 50."
"description": "Maximum number of results in task_list. Default 50."
}
},
"required": ["action"]
@@ -139,17 +139,17 @@ public sealed class TaskboardTool : IAgentTool
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.");
return ToolResult.Fail("The taskboard is not available for this instance.");
if (string.IsNullOrWhiteSpace(context.SharedWorkspacePath))
return ToolResult.Fail("Kein geteilter Arbeitsbereich konfiguriert — das Taskboard braucht ihn.");
return ToolResult.Fail("No shared workspace configured — the taskboard requires one.");
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.");
return ToolResult.Fail("'action' is required.");
try
{
@@ -160,49 +160,49 @@ public sealed class TaskboardTool : IAgentTool
"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}")
_ => ToolResult.Fail($"Unknown action: {action}")
};
}
catch (Exception ex)
{
context.Logger.LogError(ex, "Fehler im Taskboard-Tool bei Aktion {Action}", action);
return ToolResult.Fail($"Fehler: {ex.Message}");
context.Logger.LogError(ex, "Error in taskboard tool for action {Action}", action);
return ToolResult.Fail($"Error: {ex.Message}");
}
}
// ─── Aktionen ───
/// <summary>
/// #674: Offene Aufgaben mit ähnlichem Titel — die Frage vor dem Anlegen.
/// #674: Searches open tasks with similar titles — the question to ask before creating one.
///
/// <para>Die Freitextsuche in task_list beantwortet sie nicht: sie findet nur, was
/// wörtlich übereinstimmt, und liefert eine Liste, die der Agent selbst deuten muss.
/// Hier steht die Antwort im ersten Satz.</para>
/// <para>The free-text search in task_list does not answer it: it only finds literal
/// matches and returns a list the agent has to interpret itself. Here the answer is in
/// the first sentence.</para>
/// </summary>
private static async Task<ToolResult> FindAsync(
JsonElement input, TaskboardService board, CancellationToken ct)
{
var title = GetString(input, "title") ?? GetString(input, "search");
if (string.IsNullOrWhiteSpace(title))
return ToolResult.Fail("'title' ist erforderlich — wonach soll gesucht werden?");
return ToolResult.Fail("'title' is required — what should be searched for?");
var treffer = await SucheAehnlicheAsync(board, title, null, ct);
if (treffer.Count == 0)
return ToolResult.Ok($"Keine offene Aufgabe ähnelt \"{title.Trim()}\". Du kannst sie mit task_create anlegen.");
return ToolResult.Ok($"No open task resembles \"{title.Trim()}\". You can create it with task_create.");
var sb = new StringBuilder();
sb.AppendLine($"{treffer.Count} offene Aufgabe(n) ähneln \"{title.Trim()}\":");
sb.AppendLine($"{treffer.Count} open task(s) resemble \"{title.Trim()}\":");
sb.AppendLine();
foreach (var (task, match) in treffer.Take(10))
{
sb.AppendLine($"{task.Id} [{TaskText.Of(task.Status)}] {task.Title}");
sb.AppendLine($" {(match == TaskTitleMatch.Duplicate ? "derselbe Sachverhalt" : "verwandt")}" +
$" | Assignee: {task.Assignee} | Priorität: {task.Priority}" +
$" | offen seit {task.CreatedAt:dd.MM. HH:mm}");
sb.AppendLine($" {(match == TaskTitleMatch.Duplicate ? "same issue" : "related")}" +
$" | Assignee: {task.Assignee} | Priority: {task.Priority}" +
$" | open since {task.CreatedAt:dd.MM. HH:mm}");
}
sb.AppendLine();
sb.Append("Hänge deine Beobachtung mit task_comment an die bestehende Aufgabe an, statt eine zweite anzulegen.");
sb.Append("Append your observation to the existing task using task_comment instead of creating a second one.");
return ToolResult.Ok(sb.ToString());
}
@@ -219,13 +219,13 @@ public sealed class TaskboardTool : IAgentTool
{
var title = GetString(input, "title");
if (string.IsNullOrWhiteSpace(title))
return ToolResult.Fail("'title' ist erforderlich.");
return ToolResult.Fail("'title' is required.");
if (!TryReadWhen(input, out var when, out var whenError))
return ToolResult.Fail(whenError!);
// #674: Derselbe Befund zum zweiten Mal ist keine zweite Aufgabe. Er wird an der
// bestehenden vermerkt — damit man später sieht, wie oft und wie lange schon.
// #674: The same finding a second time is not a second task. It is recorded on the
// existing one — so that later one can see how often and for how long.
if (GetBool(input, "force") != true)
{
var dublette = (await SucheAehnlicheAsync(board, title, null, ct))
@@ -235,13 +235,13 @@ public sealed class TaskboardTool : IAgentTool
{
await board.AddCommentAsync(
dublette.Id, author,
$"Erneut beobachtet — {author} wollte dieselbe Aufgabe anlegen: \"{title.Trim()}\"",
$"Observed again — {author} wanted to create the same task: \"{title.Trim()}\"",
ct);
return ToolResult.Fail(
$"Nicht angelegt: {dublette.Id} \"{dublette.Title}\" ist seit {dublette.CreatedAt:dd.MM. HH:mm} " +
$"offen und meint dasselbe. Dein erneutes Auftreten wurde dort vermerkt.\n" +
$"Ergänze sie mit task_comment, oder lege bei wirklich anderem Sachverhalt mit force=true an.");
$"Not created: {dublette.Id} \"{dublette.Title}\" has been open since {dublette.CreatedAt:dd.MM. HH:mm} " +
$"and refers to the same issue. Your reoccurrence was recorded there.\n" +
$"Add details using task_comment, or use force=true if this is truly a different issue.");
}
}
@@ -262,9 +262,9 @@ public sealed class TaskboardTool : IAgentTool
var created = await board.CreateAsync(definition, ct);
return ToolResult.Ok(
$"Aufgabe angelegt: {created.Id} — {created.Title}\n" +
$"Task created: {created.Id} — {created.Title}\n" +
$"Status: {TaskText.Of(created.Status)} | Assignee: {created.Assignee} | " +
$"Priorität: {created.Priority}{FormatWhen(created.When)}" + Hinweis(created));
$"Priority: {created.Priority}{FormatWhen(created.When)}" + Hinweis(created));
}
private static async Task<ToolResult> ListAsync(
@@ -280,7 +280,7 @@ public sealed class TaskboardTool : IAgentTool
var tasks = await board.ListAsync(query, ct);
if (tasks.Count == 0)
return ToolResult.Ok("Keine passenden Aufgaben.");
return ToolResult.Ok("No matching tasks.");
return ToolResult.Ok(Render(tasks));
}
@@ -290,7 +290,7 @@ public sealed class TaskboardTool : IAgentTool
{
var id = GetString(input, "id");
if (string.IsNullOrWhiteSpace(id))
return ToolResult.Fail("'id' ist erforderlich.");
return ToolResult.Fail("'id' is required.");
if (!TryReadWhen(input, out var when, out var whenError))
return ToolResult.Fail(whenError!);
@@ -312,11 +312,11 @@ public sealed class TaskboardTool : IAgentTool
}, ct);
if (result is null)
return ToolResult.Fail($"Keine Aufgabe mit der Id '{id}' gefunden.");
return ToolResult.Fail($"No task found with ID '{id}'.");
return ToolResult.Ok(
$"Aufgabe {result.Id} aktualisiert.\n" +
$"Status: {TaskText.Of(result.Status)} | Assignee: {result.Assignee} | Priorität: {result.Priority}" + Hinweis(result));
$"Task {result.Id} updated.\n" +
$"Status: {TaskText.Of(result.Status)} | Assignee: {result.Assignee} | Priority: {result.Priority}" + Hinweis(result));
}
private static async Task<ToolResult> CommentAsync(
@@ -324,23 +324,23 @@ public sealed class TaskboardTool : IAgentTool
{
var id = GetString(input, "id");
if (string.IsNullOrWhiteSpace(id))
return ToolResult.Fail("'id' ist erforderlich.");
return ToolResult.Fail("'id' is required.");
var text = GetString(input, "text");
if (string.IsNullOrWhiteSpace(text))
return ToolResult.Fail("'text' ist erforderlich — was soll kommentiert werden?");
return ToolResult.Fail("'text' is required — what should be commented?");
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.");
return ToolResult.Fail($"No task found with ID '{id}'.");
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($"Comment appended and task {id} reopened (todo).");
}
return ToolResult.Ok($"Kommentar an Aufgabe {id} angehängt.");
return ToolResult.Ok($"Comment appended to task {id}.");
}
// ─── Darstellung ───
@@ -348,7 +348,7 @@ public sealed class TaskboardTool : IAgentTool
private static string Render(IReadOnlyList<TaskItem> tasks)
{
var sb = new StringBuilder();
sb.AppendLine($"{tasks.Count} Aufgabe(n), wichtigste zuerst:");
sb.AppendLine($"{tasks.Count} task(s), highest priority first:");
sb.AppendLine();
var shown = 0;
@@ -357,13 +357,13 @@ public sealed class TaskboardTool : IAgentTool
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)}");
block.AppendLine($" Assignee: {task.Assignee} | Priority: {task.Priority}{FormatWhen(task.When)}");
if (task.BlockedBy.Count > 0)
block.AppendLine($" Wartet auf: {string.Join(", ", task.BlockedBy)}");
block.AppendLine($" Waiting for: {string.Join(", ", task.BlockedBy)}");
if (sb.Length + block.Length > MaxResultChars)
{
sb.AppendLine($"[… {tasks.Count - shown} weitere ausgelassen. Grenze die Suche ein.]");
sb.AppendLine($"[… {tasks.Count - shown} more omitted. Narrow down your search.]");
break;
}
@@ -375,7 +375,7 @@ public sealed class TaskboardTool : IAgentTool
}
private static string FormatWhen(TaskWhen? when)
=> when is null ? "" : $" | Termin: {TaskText.Of(when.Kind)} {when.Value} ({when.TimeZone})";
=> when is null ? "" : $" | Schedule: {TaskText.Of(when.Kind)} {when.Value} ({when.TimeZone})";
// ─── Eingabe lesen ───
@@ -391,21 +391,21 @@ public sealed class TaskboardTool : IAgentTool
var kind = TaskText.WhenKind(kindText);
if (kind is null)
{
error = "'when.kind' muss 'at', 'every' oder 'cron' sein.";
error = "'when.kind' must be 'at', 'every', or 'cron'.";
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).";
error = "'when.value' is required (timestamp, interval, or cron expression).";
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').";
error = "'when.tz' is required — a schedule without a timezone is ambiguous (e.g. 'Europe/Berlin').";
return false;
}
@@ -422,7 +422,7 @@ public sealed class TaskboardTool : IAgentTool
return true;
}
/// <summary>#598: Wiederkehrend mit @agent - einmal beim Anlegen sagen, was das kostet.</summary>
/// <summary>#598: Recurring with @agent - notify once on creation about costs.</summary>
private static string Hinweis(TaskItem task)
=> TaskHinweise.WachsenderKontext(task) is { } hinweis ? "\n" + hinweis : "";