Files
ClawdDotNet/src/ClawdDotNet.Core/Api/Models/ChatMessage.cs
T
RichardandClaude Opus 5.5 837a634a22 Stufe 4c (#764): Kommentare in C#, WebUI-Skripten, CSS und HTML auf Englisch
Rund 2.600 Kommentarblöcke in 310 C#-Dateien übersetzt, Begründungen vollständig
erhalten: blockweise gleiche Anzahl wie vorher, kein Block unter 60 % seiner
Länge, der Code außerhalb der Kommentare per Tokenizer byte-identisch geprüft.
Zitate deutscher Oberflächentexte und Testdaten bleiben deutsch.

WebUI: Kommentare in JS/CSS/HTML übersetzt, JSDoc-@param an die in 4b
umbenannten Parameter angeglichen.

Dabei behoben:
- 4b hatte "tag" (HTML-Tag) als deutsches "Tag" gelesen und zu "dayValue"
  gemacht (makeElement, svg, Tagesübersicht) - zurück auf tag/day.
- Aufgaben "InProgress" bekamen die Plakettenart "laufend", für die es nie eine
  CSS-Klasse gab - jetzt "success" wie laufende Instanzen.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 12:45:03 +02:00

241 lines
8.5 KiB
C#

using System.Text.Json;
using System.Text.Json.Serialization;
namespace ClawdDotNet.Core.Api.Models;
[JsonConverter(typeof(ChatMessageConverter))]
public sealed class ChatMessage
{
public string Role { get; set; } = "";
public string? Content { get; set; }
public List<ToolCall>? ToolCalls { get; set; }
public string? ToolCallId { get; set; }
/// <summary>
/// Sets a prompt caching breakpoint on this message.
///
/// Everything in the prompt BEFORE the breakpoint (tool definitions, system prompt,
/// previous messages) is read from the cache on the next call and
/// costs only a fraction. The content is then serialized as a block array instead of a
/// plain string.
///
/// Important: the prompt section before the breakpoint must be identical to the character
/// between two calls, otherwise the cache does not apply.
/// </summary>
[JsonIgnore]
public bool CacheBreakpoint { get; set; }
/// <summary>
/// The reasoning as plain text, as the provider delivered it.
/// </summary>
public string? Reasoning { get; set; }
/// <summary>
/// The structured reasoning, unchanged.
///
/// With tool use it is sent back in the following request: the model
/// continues its started answer instead of beginning a new one. The sequence
/// must correspond exactly to the delivered one — reordering or changing it makes
/// it unusable, and with signed formats the provider can then no longer
/// match it. That's why raw JSON is held here and nothing is interpreted.
/// </summary>
public JsonElement? ReasoningDetails { get; set; }
public static ChatMessage System(string content) => new() { Role = "system", Content = content };
public static ChatMessage User(string content) => new() { Role = "user", Content = content };
public static ChatMessage Assistant(string content) => new() { Role = "assistant", Content = content };
/// <summary>
/// Builds the assistant message for a turn with tool calls.
///
/// <paramref name="content"/> is the text the model wrote next to the calls
/// ("I'll look in the configuration first"). It belongs in the
/// history: without it the model loses its own plan in the next step,
/// and the audit says <i>that</i> a tool ran, not <i>why</i>.
/// </summary>
public static ChatMessage AssistantWithToolCalls(
List<ToolCall> toolCalls,
string? content = null,
string? reasoning = null,
JsonElement? reasoningDetails = null) => new()
{
Role = "assistant",
Content = string.IsNullOrEmpty(content) ? null : content,
ToolCalls = toolCalls,
Reasoning = reasoning,
ReasoningDetails = reasoningDetails
};
public static ChatMessage ToolResponse(string toolCallId, string content) => new()
{
Role = "tool",
ToolCallId = toolCallId,
Content = content
};
}
/// <summary>
/// Serializes <see cref="ChatMessage"/>. The content normally goes out as a plain
/// string; if a <see cref="ChatMessage.CacheBreakpoint"/> is set, instead
/// as a block array with cache_control — the format providers expect for prompt caching.
///
/// When reading, both formats are accepted: responses deliver the content as a string,
/// stored contexts may contain it as an array.
/// </summary>
public sealed class ChatMessageConverter : JsonConverter<ChatMessage>
{
public override ChatMessage Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
{
if (reader.TokenType != JsonTokenType.StartObject)
throw new JsonException("ChatMessage: Objekt erwartet.");
var message = new ChatMessage();
while (reader.Read())
{
if (reader.TokenType == JsonTokenType.EndObject)
return message;
if (reader.TokenType != JsonTokenType.PropertyName)
continue;
var propertyName = reader.GetString();
reader.Read();
switch (propertyName)
{
case "role":
message.Role = reader.GetString() ?? "";
break;
case "content":
message.Content = ReadContent(ref reader);
break;
case "tool_calls":
message.ToolCalls = reader.TokenType == JsonTokenType.Null
? null
: JsonSerializer.Deserialize<List<ToolCall>>(ref reader, options);
break;
case "tool_call_id":
message.ToolCallId = reader.GetString();
break;
// The reasoning is passed through, not interpreted. "reasoning_content"
// is the same content under a different name; whichever comes first wins.
case "reasoning":
case "reasoning_content":
message.Reasoning ??= reader.TokenType == JsonTokenType.String
? reader.GetString()
: null;
if (reader.TokenType is not JsonTokenType.String and not JsonTokenType.Null)
reader.Skip();
break;
case "reasoning_details":
// Clone(), because the reader is no longer valid after this loop.
message.ReasoningDetails = reader.TokenType == JsonTokenType.Null
? null
: JsonDocument.ParseValue(ref reader).RootElement.Clone();
break;
default:
reader.Skip();
break;
}
}
throw new JsonException("ChatMessage: unerwartetes Ende.");
}
/// <summary>Accepts the content as a string or as a block array.</summary>
private static string? ReadContent(ref Utf8JsonReader reader)
{
if (reader.TokenType == JsonTokenType.Null)
return null;
if (reader.TokenType == JsonTokenType.String)
return reader.GetString();
if (reader.TokenType != JsonTokenType.StartArray)
{
reader.Skip();
return null;
}
// Block array: join the text parts.
var parts = new List<string>();
while (reader.Read() && reader.TokenType != JsonTokenType.EndArray)
{
if (reader.TokenType != JsonTokenType.StartObject)
{
reader.Skip();
continue;
}
using var block = JsonDocument.ParseValue(ref reader);
if (block.RootElement.TryGetProperty("text", out var text) &&
text.GetString() is { } value)
{
parts.Add(value);
}
}
return parts.Count > 0 ? string.Join("", parts) : null;
}
public override void Write(Utf8JsonWriter writer, ChatMessage value, JsonSerializerOptions options)
{
writer.WriteStartObject();
writer.WriteString("role", value.Role);
if (value.Content is not null)
{
if (value.CacheBreakpoint)
{
// [{ "type": "text", "text": "…", "cache_control": { "type": "ephemeral" } }]
writer.WriteStartArray("content");
writer.WriteStartObject();
writer.WriteString("type", "text");
writer.WriteString("text", value.Content);
writer.WriteStartObject("cache_control");
writer.WriteString("type", "ephemeral");
writer.WriteEndObject();
writer.WriteEndObject();
writer.WriteEndArray();
}
else
{
writer.WriteString("content", value.Content);
}
}
if (value.ToolCalls is { Count: > 0 })
{
writer.WritePropertyName("tool_calls");
JsonSerializer.Serialize(writer, value.ToolCalls, options);
}
if (value.ToolCallId is not null)
writer.WriteString("tool_call_id", value.ToolCallId);
// Back unchanged. The structured field takes precedence because only it carries signature
// and order; the plain text goes along if there is none.
if (value.ReasoningDetails is { } details)
{
writer.WritePropertyName("reasoning_details");
details.WriteTo(writer);
}
else if (value.Reasoning is not null)
{
writer.WriteString("reasoning", value.Reasoning);
}
writer.WriteEndObject();
}
}