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>
241 lines
8.5 KiB
C#
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();
|
|
}
|
|
}
|