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
@@ -86,11 +86,11 @@ public static class HtmlExtraktor
bereich = doc.QuerySelectorAll(selector).ToList();
bereichsName = "selector";
if (bereich.Count == 0)
hinweis = $"Selektor '{selector}' traf nichts - Hauptinhalt verwendet.";
hinweis = $"Selector '{selector}' matched nothing - main content used.";
}
catch (Exception ex) when (ex is DomException or ArgumentException)
{
hinweis = $"Selektor '{selector}' ist ungueltig ({ex.Message}) - Hauptinhalt verwendet.";
hinweis = $"Selector '{selector}' is invalid ({ex.Message}) - main content used.";
}
}
@@ -271,7 +271,7 @@ public static class HtmlExtraktor
case "img":
{
var alt = Normalisiere(e.GetAttribute("alt") ?? "");
if (alt.Length > 0) sb.Append("[Bild: ").Append(alt).Append(']');
if (alt.Length > 0) sb.Append("[Image: ").Append(alt).Append(']');
return;
}
+4 -4
View File
@@ -37,7 +37,7 @@ public static class JsonFilter
}
catch (JsonException)
{
return (body.Trim(), "roh", "filter nicht angewendet: Der Inhalt ist kein gueltiges JSON - ungefiltert geliefert.");
return (body.Trim(), "roh", "filter not applied: Content is not valid JSON - returned unfiltered.");
}
var treffer = new List<object>();
@@ -48,10 +48,10 @@ public static class JsonFilter
var text = JsonSerializer.Serialize(ergebnis, WebInhalt.JsonOptionen);
var hinweis = gesamt == 0
? $"Keine Eintraege mit '{filter}' gefunden (Antwort insgesamt {roh.Length} Zeichen)."
? $"No entries matching '{filter}' found (response total {roh.Length} characters)."
: gesamt > MaxTreffer
? $"{gesamt} Treffer fuer '{filter}', die ersten {MaxTreffer} geliefert - grenze den filter ein."
: $"{gesamt} Treffer fuer '{filter}' aus {roh.Length} Zeichen Gesamtantwort.";
? $"{gesamt} matches for '{filter}', first {MaxTreffer} returned - narrow down the filter."
: $"{gesamt} matches for '{filter}' from {roh.Length} characters total response.";
return (text, "gefiltert", hinweis);
}
+7 -7
View File
@@ -37,31 +37,31 @@ public static class UrlGuard
if (string.IsNullOrWhiteSpace(url))
{
error = "Leere Adresse.";
error = "Empty address.";
return false;
}
if (!Uri.TryCreate(url.Trim(), UriKind.Absolute, out var parsed))
{
error = "Keine gültige absolute URL.";
error = "Not a valid absolute URL.";
return false;
}
if (parsed.Scheme != Uri.UriSchemeHttp && parsed.Scheme != Uri.UriSchemeHttps)
{
error = $"Schema '{parsed.Scheme}' ist nicht erlaubt — nur http und https.";
error = $"Scheme '{parsed.Scheme}' is not allowed — only http and https.";
return false;
}
if (IsPrivateOrLocal(parsed.Host))
{
error = $"Adresse '{parsed.Host}' liegt im lokalen oder privaten Netz und ist gesperrt.";
error = $"Address '{parsed.Host}' is in the local or private network and is blocked.";
return false;
}
if (!IsDomainAllowed(parsed.Host, allowedDomains))
{
error = $"Domain '{parsed.Host}' steht nicht auf der Whitelist dieses Agenten.";
error = $"Domain '{parsed.Host}' is not on this agent's whitelist.";
return false;
}
@@ -213,14 +213,14 @@ public static class UrlGuard
: await resolve(value, ct);
if (addresses.Length == 0)
throw new HttpRequestException($"Adresse '{host}' ließ sich nicht auflösen.");
throw new HttpRequestException($"Address '{host}' could not be resolved.");
foreach (var address in addresses)
{
if (IsPrivateOrLocal(address))
{
throw new HttpRequestException(
$"Adresse '{host}' löst auf {address} im lokalen oder privaten Netz auf und ist gesperrt.");
$"Address '{host}' resolves to {address} in the local or private network and is blocked.");
}
}
+31 -32
View File
@@ -12,23 +12,22 @@ public sealed class WebFetchTool : IAgentTool
public string Name => "WebFetch";
public string Description => """
Ruft Webseiten, JSON-/Text-Schnittstellen und RSS/Atom-Feeds ab. Nur Domains aus der
Whitelist, kein JavaScript-Rendering.
Fetches web pages, JSON/text APIs, and RSS/Atom feeds. Only whitelist domains, no JavaScript rendering.
fetch: Bei HTML kommt standardmaessig nur der Hauptinhalt als kompaktes Markdown
(Ueberschriften, Absaetze, Listen, Tabellen, Links) - der guenstigste Weg. Mit
"selector" (CSS, z.B. "table", "#kurse", "article h2") holst du gezielt nur den Teil,
den du brauchst - noch guenstiger. Linkziele (URLs) kommen nur mit links=true - nur
setzen, wenn du die Adressen wirklich brauchst. mode="full" liefert den ganzen Seitentext und kostet
ein Vielfaches: nur verwenden, wenn der Hauptinhalt fehlt oder unvollstaendig ist.
JSON, Text und XML kommen unveraendert. Brauchst du aus einer grossen JSON-Antwort nur
bestimmte Eintraege (z.B. eine Stadt), nimm "filter": dann kommen nur die Objekte, die den
Text enthalten - viel guenstiger als Blaettern. Lange Inhalte kommen in Abschnitten: die Antwort
nennt nextOffset - nur weiterlesen, wenn der Rest fuer den Auftrag wirklich gebraucht wird.
fetch: For HTML, by default only the main content is returned as compact Markdown
(headings, paragraphs, lists, tables, links) - the most cost-effective way. With
"selector" (CSS, e.g. "table", "#content", "article h2") you selectively retrieve only the part
you need - even more economical. Link targets (URLs) are only included with links=true - only
set if you really need the URLs. mode="full" returns the full page text and costs
much more: only use when main content is missing or incomplete.
JSON, text and XML are returned unchanged. If you only need specific entries from a large JSON response
(e.g., a city), use "filter": only objects containing the text will be returned - much cheaper
than pagination. Long content is returned in chunks: the response includes nextOffset - only continue
reading if the remainder is really needed for the task.
rss: Titel, Link, Datum und kurze Beschreibung je Eintrag ("limit" begrenzt die Anzahl).
rss: Title, link, date, and short description per item ("limit" caps the count).
Waehle immer den fuer den Zweck guenstigsten Weg.
Always choose the most efficient path for your purpose.
""";
/// <summary>
@@ -58,21 +57,21 @@ public sealed class WebFetchTool : IAgentTool
"action": {
"type": "string",
"enum": ["fetch", "rss"],
"description": "fetch=Seite oder Schnittstelle abrufen, rss=RSS/Atom-Feed lesen"
"description": "fetch=fetch webpage or API, rss=read RSS/Atom feed"
},
"url": { "type": "string" },
"selector":{ "type": "string",
"description": "fetch, optional: CSS-Selektor fuer den benoetigten Teil, z.B. 'table', 'article', '#inhalt'" },
"description": "fetch, optional: CSS selector for the required section, e.g. 'table', 'article', '#content'" },
"mode": { "type": "string", "enum": ["auto", "full"],
"description": "fetch: auto (Standard) = Hauptinhalt als Markdown; full = ganzer Seitentext, nur wenn auto nicht reicht" },
"description": "fetch: auto (default) = main content as Markdown; full = full page text, only if auto is insufficient" },
"links": { "type": "boolean",
"description": "fetch: Linkziele als [Text](URL) mitliefern (Standard false - kostet auf linkreichen Seiten viel)" },
"description": "fetch: include link targets as [Text](URL) (default false - expensive on link-heavy pages)" },
"filter": { "type": "string",
"description": "fetch, nur JSON/JSONP: nur Eintraege liefern, die diesen Text enthalten (ohne Gross-/Kleinschreibung), z.B. 'Berlin'" },
"description": "fetch, JSON/JSONP only: only return entries containing this text (case-insensitive), e.g. 'Berlin'" },
"offset": { "type": "integer",
"description": "fetch: Fortsetzung eines langen Inhalts - Wert aus nextOffset der vorigen Antwort" },
"description": "fetch: continuation of long content - value from nextOffset of previous response" },
"limit": { "type": "integer",
"description": "rss: hoechstens so viele Eintraege (Standard 20, maximal 50)" }
"description": "rss: maximum number of items (default 20, max 50)" }
}
}
""").RootElement.Clone();
@@ -91,7 +90,7 @@ public sealed class WebFetchTool : IAgentTool
? JsonSerializer.Deserialize<Dictionary<string, object?>>(je.GetRawText())
: context.ToolConfig;
if (config == null) return ToolResult.Fail("WebFetch Konfiguration fehlt.");
if (config == null) return ToolResult.Fail("WebFetch configuration missing.");
var allowedDomains = config.GetValueOrDefault("allowedDomains") is JsonElement ad
? ad.EnumerateArray().Select(x => x.GetString()!).ToList()
@@ -105,13 +104,13 @@ public sealed class WebFetchTool : IAgentTool
{
"fetch" => await FetchPageAsync(url, allowedDomains, config, input, ct),
"rss" => await FetchRssAsync(url, allowedDomains, input, ct),
_ => ToolResult.Fail($"Unbekannte Aktion: {action}")
_ => ToolResult.Fail($"Unknown action: {action}")
};
}
catch (Exception ex)
{
context.Logger.LogError(ex, "Fehler in WebFetch");
return ToolResult.Fail($"Fehler: {ex.Message}");
context.Logger.LogError(ex, "Error in WebFetch");
return ToolResult.Fail($"Error: {ex.Message}");
}
}
@@ -130,12 +129,12 @@ public sealed class WebFetchTool : IAgentTool
// Compiler das nicht an. Die Pruefung sagt dasselbe nachvollziehbar und ersetzt
// das Ausrufezeichen, das hier stand.
if (response is null)
return ToolResult.Fail($"Keine Antwort von {url}.");
return ToolResult.Fail($"No response from {url}.");
using var _ = response;
if (!response.IsSuccessStatusCode)
return ToolResult.Fail($"Seite nicht erreichbar: {url} → HTTP {(int)response.StatusCode} {response.ReasonPhrase}");
return ToolResult.Fail($"Page unreachable: {url} → HTTP {(int)response.StatusCode} {response.ReasonPhrase}");
var body = await response.Content.ReadAsStringAsync(ct);
var maxKb = GetConfigInt(config, "maxResponseKb", 512);
@@ -214,12 +213,12 @@ public sealed class WebFetchTool : IAgentTool
// Compiler das nicht an. Die Pruefung sagt dasselbe nachvollziehbar und ersetzt
// das Ausrufezeichen, das hier stand.
if (response is null)
return ToolResult.Fail($"Keine Antwort von {url}.");
return ToolResult.Fail($"No response from {url}.");
using var _ = response;
if (!response.IsSuccessStatusCode)
return ToolResult.Fail($"RSS-Feed nicht erreichbar: {url} → HTTP {(int)response.StatusCode} {response.ReasonPhrase}");
return ToolResult.Fail($"RSS feed unreachable: {url} → HTTP {(int)response.StatusCode} {response.ReasonPhrase}");
var xml = await response.Content.ReadAsStringAsync(ct);
var doc = XDocument.Parse(xml);
@@ -305,7 +304,7 @@ public sealed class WebFetchTool : IAgentTool
{
if (!UrlGuard.TryValidate(current, allowedDomains, out var uri, out var error))
{
var context = hop == 0 ? "" : $" (Weiterleitung {hop} von {url})";
var context = hop == 0 ? "" : $" (Redirect {hop} from {url})";
return (null, ToolResult.Fail($"{error}{context}"));
}
@@ -318,7 +317,7 @@ public sealed class WebFetchTool : IAgentTool
response.Dispose();
if (location is null)
return (null, ToolResult.Fail($"Weiterleitung ohne Zieladresse bei {current}."));
return (null, ToolResult.Fail($"Redirect without target address at {current}."));
// Relative Weiterleitungen gegen die aktuelle Adresse auflösen.
current = location.IsAbsoluteUri
@@ -327,7 +326,7 @@ public sealed class WebFetchTool : IAgentTool
}
return (null, ToolResult.Fail(
$"Mehr als {MaxRedirects} Weiterleitungen ausgehend von {url} — abgebrochen."));
$"More than {MaxRedirects} redirects starting from {url} — aborted."));
}
private static bool IsRedirect(HttpStatusCode status) => status is
+5 -5
View File
@@ -94,28 +94,28 @@ public static class WebInhalt
{
text = voll;
bereich = "vollstaendig";
hinweis = "Hauptinhalt zu kurz - vollstaendiger Seitentext geliefert. Baut die Seite ihren "
+ "Inhalt per JavaScript auf, fehlt er auch hier.";
hinweis = "Main content too short - full page text returned. If the page builds its "
+ "content via JavaScript, it is missing here as well.";
}
}
}
if (art == "html" && !string.IsNullOrWhiteSpace(filter))
{
const string nurJson = "filter gilt nur fuer JSON - fuer HTML nimm selector.";
const string nurJson = "filter only applies to JSON - for HTML use selector.";
hinweis = hinweis is null ? nurJson : $"{hinweis} {nurJson}";
}
var (teil, start, naechster) = Abschnitt(text, offset, maxZeichen);
if (naechster is not null)
{
var fortsetzung = $"Inhalt gekuerzt ({teil.Length} von {text.Length} Zeichen) - mit offset={naechster} weiterlesen, nur falls noetig.";
var fortsetzung = $"Content truncated ({teil.Length} of {text.Length} characters) - continue with offset={naechster} only if necessary.";
// #632: Jeder weitere Abschnitt bleibt im Kontext und wird bei jedem folgenden Schritt
// erneut bezahlt. Fuer die DWD-Warnungen las ein Agent fuenf Abschnitte - 160.000 Tokens
// fuer eine Frage nach einer einzigen Stadt.
if (art == "json" && bereich != "gefiltert")
fortsetzung += " Suchst du bestimmte Eintraege, hole sie mit 'filter' (z.B. filter=\"Berlin\") statt weiterzublaettern.";
fortsetzung += " If looking for specific entries, retrieve them with 'filter' (e.g. filter=\"Berlin\") instead of paginating.";
hinweis = hinweis is null ? fortsetzung : $"{hinweis} {fortsetzung}";
}