diff --git a/.github/dependabot.yml b/.github/dependabot.yml index 0fd1f19..9ed2405 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -5,6 +5,9 @@ updates: schedule: interval: "weekly" day: "wednesday" + ignore: + - dependency-name: "dotnet-sdk" + versions: [">=11.0.0-0"] - package-ecosystem: "nuget" directory: "/" diff --git a/Directory.Packages.props b/Directory.Packages.props index 2d093e4..f3bc39f 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -13,6 +13,8 @@ + + diff --git a/Version.props b/Version.props index f6c7cba..c9edbff 100644 --- a/Version.props +++ b/Version.props @@ -6,5 +6,7 @@ 18.3.0 5.3.0 10.4.1 + 2.9.1 + 10.4.1 diff --git a/netagents.slnx b/netagents.slnx index a56c23c..bd4e7fd 100644 --- a/netagents.slnx +++ b/netagents.slnx @@ -4,6 +4,7 @@ + diff --git a/src/Qyl.ChatKit/Actions.cs b/src/Qyl.ChatKit/Actions.cs new file mode 100644 index 0000000..bc37061 --- /dev/null +++ b/src/Qyl.ChatKit/Actions.cs @@ -0,0 +1,61 @@ +using System.Text.Json.Serialization; + +namespace Qyl.ChatKit; + +/// Whether the action is handled on the client or the server. +[JsonConverter(typeof(JsonStringEnumConverter))] +public enum Handler +{ + [JsonStringEnumMemberName("client")] + Client, + + [JsonStringEnumMemberName("server")] + Server +} + +/// Visual loading behavior when the action executes. +[JsonConverter(typeof(JsonStringEnumConverter))] +public enum LoadingBehavior +{ + [JsonStringEnumMemberName("auto")] + Auto, + + [JsonStringEnumMemberName("none")] + None, + + [JsonStringEnumMemberName("self")] + Self, + + [JsonStringEnumMemberName("container")] + Container +} + +/// Fully resolved action configuration sent over the wire. +public sealed record ActionConfig +{ + [JsonPropertyName("type")] + public required string Type { get; init; } + + [JsonPropertyName("payload")] + public object? Payload { get; init; } + + [JsonPropertyName("handler")] + public Handler Handler { get; init; } = Handler.Server; + + [JsonPropertyName("loadingBehavior")] + public LoadingBehavior LoadingBehavior { get; init; } = LoadingBehavior.Auto; + + [JsonPropertyName("streaming")] + public bool Streaming { get; init; } = true; +} + +/// Generic action carrying a type discriminator and payload. +public sealed record Action + where TType : notnull +{ + [JsonPropertyName("type")] + public required TType Type { get; init; } + + [JsonPropertyName("payload")] + public TPayload? Payload { get; init; } +} diff --git a/src/Qyl.ChatKit/AgentContext.cs b/src/Qyl.ChatKit/AgentContext.cs new file mode 100644 index 0000000..296ed05 --- /dev/null +++ b/src/Qyl.ChatKit/AgentContext.cs @@ -0,0 +1,178 @@ +using System.Threading.Channels; +using Qyl.ChatKit.Widgets; + +namespace Qyl.ChatKit; + +/// +/// Context object passed to agent callbacks, providing access to the store, +/// thread metadata, and a channel for emitting events back to the stream processor. +/// +public sealed class AgentContext +{ + public required ThreadMetadata Thread { get; init; } + public required IStore Store { get; init; } + public required TContext RequestContext { get; init; } + public TimeProvider TimeProvider { get; init; } = TimeProvider.System; + + public string? PreviousResponseId { get; set; } + public ClientToolCall? ClientToolCall { get; set; } + public WorkflowItem? WorkflowItem { get; set; } + public GeneratedImageItem? GeneratedImageItem { get; set; } + + private readonly Channel _events = Channel.CreateUnbounded(); + + /// Reader for the event channel, consumed by the stream processor. + internal ChannelReader EventReader => _events.Reader; + + /// Signal that no more events will be written. + internal void Complete() => _events.Writer.TryComplete(); + + /// Generate a new store-backed id for the given item type. + public string GenerateId(StoreItemType type, ThreadMetadata? thread = null) => + type == StoreItemType.Thread + ? Store.GenerateThreadId(RequestContext) + : Store.GenerateItemId(type, thread ?? Thread, RequestContext); + + /// Stream a widget into the thread by enqueueing widget events. + public async ValueTask StreamWidgetAsync( + WidgetRoot widget, string? copyText = null, CancellationToken ct = default) + { + await foreach (var evt in WidgetDiff.StreamWidgetAsync( + Thread, widget, copyText, + t => Store.GenerateItemId(t, Thread, RequestContext), + TimeProvider, ct)) + { + await _events.Writer.WriteAsync(evt, ct); + } + } + + /// Stream an async sequence of widget roots into the thread. + public async ValueTask StreamWidgetAsync( + IAsyncEnumerable widgetStream, string? copyText = null, + CancellationToken ct = default) + { + await foreach (var evt in WidgetDiff.StreamWidgetAsync( + Thread, widgetStream, copyText, + t => Store.GenerateItemId(t, Thread, RequestContext), + TimeProvider, ct)) + { + await _events.Writer.WriteAsync(evt, ct); + } + } + + /// Begin streaming a new workflow item. + public async ValueTask StartWorkflowAsync(Workflow workflow, CancellationToken ct = default) + { + WorkflowItem = new WorkflowItem + { + Id = GenerateId(StoreItemType.Workflow), + CreatedAt = TimeProvider.GetUtcNow().UtcDateTime, + Workflow = workflow, + ThreadId = Thread.Id, + }; + + if (workflow.Type != "reasoning" && workflow.Tasks.Count == 0) + return; // Defer sending added event until we have tasks + + await StreamAsync(new ThreadItemAddedEvent { Item = WorkflowItem }, ct); + } + + /// Append a workflow task and stream the appropriate event. + public async ValueTask AddWorkflowTaskAsync(ChatKitTask task, CancellationToken ct = default) + { + WorkflowItem ??= new WorkflowItem + { + Id = GenerateId(StoreItemType.Workflow), + CreatedAt = TimeProvider.GetUtcNow().UtcDateTime, + Workflow = new Workflow { Type = "custom", Tasks = [] }, + ThreadId = Thread.Id, + }; + + var tasks = WorkflowItem.Workflow.Tasks.ToList(); + tasks.Add(task); + WorkflowItem = WorkflowItem with + { + Workflow = WorkflowItem.Workflow with { Tasks = tasks }, + }; + + if (WorkflowItem.Workflow.Type != "reasoning" && tasks.Count == 1) + { + await StreamAsync(new ThreadItemAddedEvent { Item = WorkflowItem }, ct); + } + else + { + await StreamAsync(new ThreadItemUpdatedEvent + { + ItemId = WorkflowItem.Id, + Update = new WorkflowTaskAdded + { + Task = task, + TaskIndex = tasks.Count - 1, + }, + }, ct); + } + } + + /// Update an existing workflow task and stream the delta. + public async ValueTask UpdateWorkflowTaskAsync( + ChatKitTask task, int taskIndex, CancellationToken ct = default) + { + if (WorkflowItem is null) + throw new InvalidOperationException("Workflow is not set"); + + var tasks = WorkflowItem.Workflow.Tasks.ToList(); + tasks[taskIndex] = task; + WorkflowItem = WorkflowItem with + { + Workflow = WorkflowItem.Workflow with { Tasks = tasks }, + }; + + await StreamAsync(new ThreadItemUpdatedEvent + { + ItemId = WorkflowItem.Id, + Update = new WorkflowTaskUpdated + { + Task = task, + TaskIndex = taskIndex, + }, + }, ct); + } + + /// Finalize the active workflow item, optionally attaching a summary. + public async ValueTask EndWorkflowAsync( + WorkflowSummary? summary = null, bool expanded = false, CancellationToken ct = default) + { + if (WorkflowItem is null) + return; + + var finalSummary = summary ?? WorkflowItem.Workflow.Summary; + if (finalSummary is null) + { + var delta = TimeProvider.GetUtcNow().UtcDateTime - WorkflowItem.CreatedAt; + finalSummary = new DurationSummary { Duration = (int)delta.TotalSeconds }; + } + + WorkflowItem = WorkflowItem with + { + Workflow = WorkflowItem.Workflow with + { + Summary = finalSummary, + Expanded = expanded, + }, + }; + + await StreamAsync(new ThreadItemDoneEvent { Item = WorkflowItem }, ct); + WorkflowItem = null; + } + + /// Enqueue a ThreadStreamEvent for downstream processing. + public async ValueTask StreamAsync(ThreadStreamEvent evt, CancellationToken ct = default) => + await _events.Writer.WriteAsync(evt, ct); +} + +/// Returned from tool methods to indicate a client-side tool call. +public sealed record ClientToolCall +{ + public required string Name { get; init; } + public required Dictionary Arguments { get; init; } +} diff --git a/src/Qyl.ChatKit/Attachments.cs b/src/Qyl.ChatKit/Attachments.cs new file mode 100644 index 0000000..1bd771f --- /dev/null +++ b/src/Qyl.ChatKit/Attachments.cs @@ -0,0 +1,101 @@ +using System.Text.Json.Serialization; + +namespace Qyl.ChatKit; + +/// Base metadata shared by all attachments. +[JsonPolymorphic(TypeDiscriminatorPropertyName = "type")] +[JsonDerivedType(typeof(FileAttachment), "file")] +[JsonDerivedType(typeof(ImageAttachment), "image")] +public abstract record AttachmentBase +{ + [JsonPropertyName("id")] + public required string Id { get; init; } + + [JsonPropertyName("name")] + public required string Name { get; init; } + + [JsonPropertyName("mime_type")] + public required string MimeType { get; init; } + + /// + /// Two-phase upload instructions. + /// Should be set to null after upload is complete or when using direct upload. + /// + [JsonPropertyName("upload_descriptor")] + public AttachmentUploadDescriptor? UploadDescriptor { get; init; } + + /// + /// The thread the attachment belongs to, if any. + /// Added when the user message that contains the attachment is saved to store. + /// + [JsonPropertyName("thread_id")] + public string? ThreadId { get; init; } + + /// + /// Integration-only metadata stored with the attachment. + /// Ignored by ChatKit and not returned in server responses. + /// + [JsonPropertyName("metadata")] + public Dictionary? Metadata { get; init; } +} + +/// Attachment representing a generic file. +public sealed record FileAttachment : AttachmentBase; + +/// Attachment representing an image resource. +public sealed record ImageAttachment : AttachmentBase +{ + [JsonPropertyName("preview_url")] + public required Uri PreviewUrl { get; init; } +} + +/// Two-phase upload instructions. +public sealed record AttachmentUploadDescriptor +{ + [JsonPropertyName("url")] + public required Uri Url { get; init; } + + /// The HTTP method to use when uploading the file for two-phase upload. + [JsonPropertyName("method")] + public required string Method { get; init; } + + /// Optional headers to include in the upload request. + [JsonPropertyName("headers")] + public Dictionary Headers { get; init; } = new(); +} + +/// Metadata needed to initialize an attachment. +public sealed record AttachmentCreateParams +{ + [JsonPropertyName("name")] + public required string Name { get; init; } + + [JsonPropertyName("size")] + public required int Size { get; init; } + + [JsonPropertyName("mime_type")] + public required string MimeType { get; init; } +} + +/// Audio input data for transcription. +public sealed record AudioInput +{ + /// Audio data bytes. + [JsonPropertyName("data")] + public required byte[] Data { get; init; } + + /// Raw MIME type for the audio payload, e.g. "audio/webm;codecs=opus". + [JsonPropertyName("mime_type")] + public required string MimeType { get; init; } + + /// Media type for the audio payload, e.g. "audio/webm". + [JsonIgnore] + public string MediaType => MimeType.Split(';', 2)[0]; +} + +/// Input speech transcription result. +public sealed record TranscriptionResult +{ + [JsonPropertyName("text")] + public required string Text { get; init; } +} diff --git a/src/Qyl.ChatKit/ChatKitJsonOptions.cs b/src/Qyl.ChatKit/ChatKitJsonOptions.cs new file mode 100644 index 0000000..7156607 --- /dev/null +++ b/src/Qyl.ChatKit/ChatKitJsonOptions.cs @@ -0,0 +1,14 @@ +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace Qyl.ChatKit; + +/// Shared JSON serializer options for the ChatKit wire protocol. +internal static class ChatKitJsonOptions +{ + public static JsonSerializerOptions Default { get; } = new() + { + DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull, + PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower, + }; +} diff --git a/src/Qyl.ChatKit/ChatKitServer.cs b/src/Qyl.ChatKit/ChatKitServer.cs new file mode 100644 index 0000000..1d38910 --- /dev/null +++ b/src/Qyl.ChatKit/ChatKitServer.cs @@ -0,0 +1,723 @@ +using System.Runtime.CompilerServices; +using System.Text.Json; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Abstractions; + +namespace Qyl.ChatKit; + +/// +/// Abstract ChatKit server that routes incoming requests to streaming or non-streaming handlers. +/// Subclasses implement to produce thread stream events. +/// +public abstract class ChatKitServer( + IStore store, + IAttachmentStore? attachmentStore = null, + TimeProvider? timeProvider = null, + ILogger? logger = null) +{ + private const int DefaultPageSize = 20; + private const string DefaultErrorMessage = "An error occurred when generating a response."; + + protected IStore Store { get; } = store; + protected TimeProvider Clock { get; } = timeProvider ?? TimeProvider.System; + protected ILogger Logger { get; } = logger ?? NullLogger.Instance; + + // -- Abstract / virtual hooks -- + + /// Stream response events for a new or retried user message. + public abstract IAsyncEnumerable RespondAsync( + ThreadMetadata thread, + UserMessageItem? inputUserMessage, + TContext context, + CancellationToken ct = default); + + /// Persist user feedback for one or more thread items. + public virtual ValueTask AddFeedbackAsync( + string threadId, + IReadOnlyList itemIds, + FeedbackKind feedback, + TContext context, + CancellationToken ct = default) => default; + + /// Transcribe speech audio to text. + public virtual ValueTask TranscribeAsync( + AudioInput audioInput, + TContext context, + CancellationToken ct = default) => + throw new NotImplementedException( + "TranscribeAsync() must be overridden to support the input.transcribe request."); + + /// Handle a widget or client-dispatched action and yield response events. + public virtual IAsyncEnumerable ActionAsync( + ThreadMetadata thread, + Action action, + WidgetItem? sender, + TContext context, + CancellationToken ct = default) => + throw new NotImplementedException( + "ActionAsync() must be overridden to react to actions."); + + /// Handle a synchronous custom action and return a single item update. + public virtual ValueTask SyncActionAsync( + ThreadMetadata thread, + Action action, + WidgetItem? sender, + TContext context, + CancellationToken ct = default) => + throw new NotImplementedException( + "SyncActionAsync() must be overridden to react to sync actions."); + + /// Return stream-level runtime options. Allows cancellation by default. + public virtual StreamOptions GetStreamOptions(ThreadMetadata thread, TContext context) => + new() { AllowCancel = true }; + + /// + /// Perform cleanup when a stream is cancelled. The default implementation persists + /// non-empty pending assistant messages and adds a hidden context marker. + /// + public virtual async ValueTask HandleStreamCancelledAsync( + ThreadMetadata thread, + IReadOnlyList pendingItems, + TContext context, + CancellationToken ct = default) + { + foreach (var item in pendingItems) + { + if (item is not AssistantMessageItem assistant) + continue; + + var isEmpty = assistant.Content.Count == 0 || + assistant.Content.All(c => string.IsNullOrWhiteSpace(c.Text)); + if (!isEmpty) + await Store.AddThreadItemAsync(thread.Id, assistant, context); + } + + await Store.AddThreadItemAsync( + thread.Id, + new SdkHiddenContextItem + { + Id = Store.GenerateItemId(StoreItemType.SdkHiddenContext, thread, context), + ThreadId = thread.Id, + CreatedAt = Clock.GetUtcNow().UtcDateTime, + Content = "The user cancelled the stream. Stop responding to the prior request.", + }, + context); + } + + // -- Main entry point -- + + /// Parse an incoming request and route it to the appropriate handler. + public async ValueTask ProcessAsync( + string request, TContext context, CancellationToken ct = default) + { + var parsed = JsonSerializer.Deserialize(request, ChatKitJsonOptions.Default) + ?? throw new JsonException("Failed to deserialize ChatKit request."); + + if (ChatKitRequest.IsStreamingRequest(parsed)) + return new StreamingResult(ProcessStreamingAsync(parsed, context, ct)); + + return new NonStreamingResult( + await ProcessNonStreamingAsync(parsed, context, ct)); + } + + // -- Non-streaming -- + + private async Task ProcessNonStreamingAsync( + ChatKitRequest request, TContext context, CancellationToken ct) + { + switch (request) + { + case ThreadsGetByIdReq r: + { + var thread = await LoadFullThreadAsync(r.Params.ThreadId, context, ct); + return Serialize(ToThreadResponse(thread)); + } + + case ThreadsListReq r: + { + var p = r.Params; + var threads = await Store.LoadThreadsAsync( + p.Limit ?? DefaultPageSize, p.After, p.Order, context); + return Serialize(new Page + { + HasMore = threads.HasMore, + After = threads.After, + Data = threads.Data.Select(t => ToThreadResponse(t)).ToList(), + }); + } + + case ItemsFeedbackReq r: + { + await AddFeedbackAsync( + r.Params.ThreadId, r.Params.ItemIds, r.Params.Kind, context, ct); + return "{}"u8.ToArray(); + } + + case AttachmentsCreateReq r: + { + var attachStore = GetAttachmentStore(); + var attachment = await attachStore.CreateAttachmentAsync(r.Params, context); + await Store.SaveAttachmentAsync(attachment, context); + return Serialize(attachment); + } + + case AttachmentsDeleteReq r: + { + var attachStore = GetAttachmentStore(); + await attachStore.DeleteAttachmentAsync(r.Params.AttachmentId, context); + await Store.DeleteAttachmentAsync(r.Params.AttachmentId, context); + return "{}"u8.ToArray(); + } + + case InputTranscribeReq r: + { + var audioBytes = Convert.FromBase64String(r.Params.AudioBase64); + var result = await TranscribeAsync( + new AudioInput { Data = audioBytes, MimeType = r.Params.MimeType }, context, ct); + return Serialize(result); + } + + case ItemsListReq r: + { + var p = r.Params; + var items = await Store.LoadThreadItemsAsync( + p.ThreadId, p.After, p.Limit ?? DefaultPageSize, p.Order, context); + // Filter out hidden context items + var filtered = items.Data + .Where(i => i is not (HiddenContextItem or SdkHiddenContextItem)) + .ToList(); + return Serialize(new Page + { + Data = filtered, + HasMore = items.HasMore, + After = items.After, + }); + } + + case ThreadsUpdateReq r: + { + var thread = await Store.LoadThreadAsync(r.Params.ThreadId, context); + thread = thread with { Title = r.Params.Title }; + await Store.SaveThreadAsync(thread, context); + return Serialize(ToThreadResponse(thread)); + } + + case ThreadsDeleteReq r: + { + await Store.DeleteThreadAsync(r.Params.ThreadId, context); + return "{}"u8.ToArray(); + } + + case ThreadsSyncCustomActionReq r: + return await ProcessSyncCustomActionAsync(r, context, ct); + + default: + throw new InvalidOperationException( + $"Unknown non-streaming request type: {request.GetType().Name}"); + } + } + + private async Task ProcessSyncCustomActionAsync( + ThreadsSyncCustomActionReq request, TContext context, CancellationToken ct) + { + var threadMeta = await Store.LoadThreadAsync(request.Params.ThreadId, context); + + WidgetItem? senderWidget = null; + if (request.Params.ItemId is not null) + { + var loaded = await Store.LoadItemAsync( + request.Params.ThreadId, request.Params.ItemId, context); + senderWidget = loaded as WidgetItem + ?? throw new InvalidOperationException( + "threads.sync_custom_action requires a widget sender item"); + } + + var result = await SyncActionAsync( + threadMeta, request.Params.Action, senderWidget, context, ct); + return Serialize(result); + } + + // -- Streaming -- + + private async IAsyncEnumerable ProcessStreamingAsync( + ChatKitRequest request, TContext context, + [EnumeratorCancellation] CancellationToken ct = default) + { + IAsyncEnumerable events; + try + { + events = ProcessStreamingImplAsync(request, context, ct); + } + catch (Exception ex) when (ex is not OperationCanceledException) + { + Logger.LogError(ex, "Error while generating streamed response"); + throw; + } + + await foreach (var evt in events.WithCancellation(ct)) + { + var json = JsonSerializer.SerializeToUtf8Bytes(evt, ChatKitJsonOptions.Default); + var chunk = new byte[6 + json.Length + 2]; // "data: " + json + "\n\n" + "data: "u8.CopyTo(chunk); + json.CopyTo(chunk, 6); + chunk[^2] = (byte)'\n'; + chunk[^1] = (byte)'\n'; + yield return chunk; + } + } + + private async IAsyncEnumerable ProcessStreamingImplAsync( + ChatKitRequest request, TContext context, + [EnumeratorCancellation] CancellationToken ct = default) + { + switch (request) + { + case ThreadsCreateReq r: + { + var thread = new Thread + { + Id = Store.GenerateThreadId(context), + CreatedAt = Clock.GetUtcNow().UtcDateTime, + Items = new Page(), + }; + await Store.SaveThreadAsync(thread, context); + yield return new ThreadCreatedEvent { Thread = ToThreadResponse(thread) }; + + var userMessage = await BuildUserMessageItemAsync(r.Params.Input, thread, context); + await foreach (var evt in ProcessNewThreadItemRespondAsync(thread, userMessage, context, ct)) + yield return evt; + break; + } + + case ThreadsAddUserMessageReq r: + { + var thread = await Store.LoadThreadAsync(r.Params.ThreadId, context); + var userMessage = await BuildUserMessageItemAsync(r.Params.Input, thread, context); + await foreach (var evt in ProcessNewThreadItemRespondAsync(thread, userMessage, context, ct)) + yield return evt; + break; + } + + case ThreadsAddClientToolOutputReq r: + { + var thread = await Store.LoadThreadAsync(r.Params.ThreadId, context); + var items = await Store.LoadThreadItemsAsync(thread.Id, null, 1, "desc", context); + var toolCall = items.Data + .OfType() + .FirstOrDefault(i => i.Status == "pending") + ?? throw new InvalidOperationException( + $"Last thread item in {thread.Id} was not a ClientToolCallItem"); + + var completed = toolCall with { Output = r.Params.Result, Status = "completed" }; + await Store.SaveItemAsync(thread.Id, completed, context); + await CleanupPendingClientToolCallAsync(thread, context); + + await foreach (var evt in ProcessEventsAsync( + thread, context, ct => RespondAsync(thread, null, context, ct), ct)) + yield return evt; + break; + } + + case ThreadsRetryAfterItemReq r: + { + var threadMeta = await Store.LoadThreadAsync(r.Params.ThreadId, context); + List itemsToRemove = []; + UserMessageItem? userMessageItem = null; + + await foreach (var item in PaginateThreadItemsReverseAsync(r.Params.ThreadId, context, ct)) + { + if (item.Id == r.Params.ItemId) + { + userMessageItem = item as UserMessageItem + ?? throw new InvalidOperationException( + $"Item {r.Params.ItemId} is not a user message"); + break; + } + itemsToRemove.Add(item); + } + + if (userMessageItem is not null) + { + foreach (var item in itemsToRemove) + await Store.DeleteThreadItemAsync(r.Params.ThreadId, item.Id, context); + + await foreach (var evt in ProcessEventsAsync( + threadMeta, context, + ct => RespondAsync(threadMeta, userMessageItem, context, ct), ct)) + yield return evt; + } + break; + } + + case ThreadsCustomActionReq r: + { + var threadMeta = await Store.LoadThreadAsync(r.Params.ThreadId, context); + + WidgetItem? senderWidget = null; + if (r.Params.ItemId is not null) + { + var loaded = await Store.LoadItemAsync( + r.Params.ThreadId, r.Params.ItemId, context); + if (loaded is not WidgetItem widget) + { + yield return new ErrorEvent + { + Code = "stream.error", + AllowRetry = false, + }; + yield break; + } + senderWidget = widget; + } + + await foreach (var evt in ProcessEventsAsync( + threadMeta, context, + ct => ActionAsync(threadMeta, r.Params.Action, senderWidget, context, ct), ct)) + yield return evt; + break; + } + + default: + throw new InvalidOperationException( + $"Unknown streaming request type: {request.GetType().Name}"); + } + } + + // -- Core event processing loop -- + + private async IAsyncEnumerable ProcessNewThreadItemRespondAsync( + ThreadMetadata thread, + UserMessageItem item, + TContext context, + [EnumeratorCancellation] CancellationToken ct = default) + { + foreach (var attachment in item.Attachments) + await Store.SaveAttachmentAsync(attachment, context); + + await Store.AddThreadItemAsync(thread.Id, item, context); + yield return new ThreadItemDoneEvent { Item = item }; + + await foreach (var evt in ProcessEventsAsync( + thread, context, ct => RespondAsync(thread, item, context, ct), ct)) + yield return evt; + } + + private IAsyncEnumerable ProcessEventsAsync( + ThreadMetadata thread, + TContext context, + Func> streamFactory, + CancellationToken ct = default) + { + var channel = System.Threading.Channels.Channel.CreateUnbounded(); + _ = RunEventLoopAsync(thread, context, streamFactory, channel.Writer, ct); + return channel.Reader.ReadAllAsync(ct); + } + + /// + /// Core event loop: reads from the response stream, persists state, and writes + /// client-visible events to . Error handling converts + /// known exceptions to frames on the SSE stream. + /// + private async Task RunEventLoopAsync( + ThreadMetadata thread, + TContext context, + Func> streamFactory, + System.Threading.Channels.ChannelWriter writer, + CancellationToken ct) + { + var lastThread = thread; + Dictionary pendingItems = []; + + try + { + await Task.Yield(); // allow the response to start streaming + + await writer.WriteAsync(new StreamOptionsEvent + { + StreamOptions = GetStreamOptions(thread, context), + }, ct); + + await foreach (var evt in streamFactory(ct).WithCancellation(ct)) + { + if (evt is ThreadItemAddedEvent added) + pendingItems[added.Item.Id] = added.Item; + + switch (evt) + { + case ThreadItemDoneEvent done: + await Store.AddThreadItemAsync(thread.Id, done.Item, context); + pendingItems.Remove(done.Item.Id); + break; + + case ThreadItemRemovedEvent removed: + await Store.DeleteThreadItemAsync(thread.Id, removed.ItemId, context); + pendingItems.Remove(removed.ItemId); + break; + + case ThreadItemReplacedEvent replaced: + await Store.SaveItemAsync(thread.Id, replaced.Item, context); + pendingItems.Remove(replaced.Item.Id); + break; + + case ThreadItemUpdatedEvent updated: + UpdatePendingItems(pendingItems, updated); + break; + } + + // Don't send hidden context items back to the client + var shouldSwallow = evt is ThreadItemDoneEvent { Item: HiddenContextItem or SdkHiddenContextItem }; + if (!shouldSwallow) + await writer.WriteAsync(evt, ct); + + if (thread != lastThread) + { + lastThread = thread; + await Store.SaveThreadAsync(thread, context); + await writer.WriteAsync( + new ThreadUpdatedEvent { Thread = ToThreadResponse(thread) }, ct); + } + } + + if (thread != lastThread) + { + lastThread = thread; + await Store.SaveThreadAsync(thread, context); + await writer.WriteAsync( + new ThreadUpdatedEvent { Thread = ToThreadResponse(thread) }, ct); + } + } + catch (OperationCanceledException) + { + await HandleStreamCancelledAsync( + thread, pendingItems.Values.ToList(), context, CancellationToken.None); + writer.Complete(); + return; + } + catch (CustomStreamError e) + { + await writer.WriteAsync(new ErrorEvent + { + Code = "custom", + Message = e.Message, + AllowRetry = e.AllowRetry, + }, CancellationToken.None); + } + catch (StreamError e) + { + await writer.WriteAsync(new ErrorEvent + { + Code = e.Code.ToString(), + AllowRetry = e.AllowRetry, + }, CancellationToken.None); + } + catch (Exception e) when (e is not OutOfMemoryException and not StackOverflowException) + { + // Intentional: the SSE protocol requires delivering an error frame to the client + // rather than dropping the connection. The exception is fully logged. + Logger.LogError(e, "Unhandled exception in stream processing"); + await writer.WriteAsync(new ErrorEvent + { + Code = "stream.error", + AllowRetry = true, + }, CancellationToken.None); + } + + // Final thread-change check after errors + if (thread != lastThread) + { + await Store.SaveThreadAsync(thread, context); + await writer.WriteAsync( + new ThreadUpdatedEvent { Thread = ToThreadResponse(thread) }, CancellationToken.None); + } + + writer.Complete(); + } + + // -- Pending item tracking -- + + private void UpdatePendingItems( + Dictionary pendingItems, + ThreadItemUpdatedEvent evt) + { + if (!pendingItems.TryGetValue(evt.ItemId, out var updatedItem)) + return; + + switch (updatedItem) + { + case AssistantMessageItem assistant when evt.Update is + AssistantMessageContentPartAdded or + AssistantMessageContentPartTextDelta or + AssistantMessageContentPartAnnotationAdded or + AssistantMessageContentPartDone: + { + // Build updated content + var content = assistant.Content.ToList(); + int targetIndex = evt.Update switch + { + AssistantMessageContentPartAdded u => u.ContentIndex, + AssistantMessageContentPartTextDelta u => u.ContentIndex, + AssistantMessageContentPartAnnotationAdded u => u.ContentIndex, + AssistantMessageContentPartDone u => u.ContentIndex, + _ => -1, + }; + + while (content.Count <= targetIndex) + content.Add(new AssistantMessageContent { Text = "", Annotations = [] }); + + switch (evt.Update) + { + case AssistantMessageContentPartAdded u: + content[u.ContentIndex] = u.Content; + break; + case AssistantMessageContentPartTextDelta u: + var existing = content[u.ContentIndex]; + content[u.ContentIndex] = existing with { Text = existing.Text + u.Delta }; + break; + case AssistantMessageContentPartAnnotationAdded u: + var part = content[u.ContentIndex]; + var annotations = part.Annotations.ToList(); + if (u.AnnotationIndex <= annotations.Count) + annotations.Insert(u.AnnotationIndex, u.Annotation); + else + annotations.Add(u.Annotation); + content[u.ContentIndex] = part with { Annotations = annotations }; + break; + case AssistantMessageContentPartDone u: + content[u.ContentIndex] = u.Content; + break; + } + + pendingItems[evt.ItemId] = assistant with { Content = content }; + break; + } + + case WorkflowItem workflow when evt.Update is WorkflowTaskUpdated or WorkflowTaskAdded: + { + var tasks = workflow.Workflow.Tasks.ToList(); + + switch (evt.Update) + { + case WorkflowTaskUpdated u: + tasks[u.TaskIndex] = u.Task; + break; + case WorkflowTaskAdded u: + tasks.Add(u.Task); + break; + } + + pendingItems[evt.ItemId] = workflow with + { + Workflow = workflow.Workflow with { Tasks = tasks }, + }; + break; + } + } + } + + // -- Helpers -- + + private async ValueTask BuildUserMessageItemAsync( + UserMessageInput input, ThreadMetadata thread, TContext context) + { + var attachments = new List(input.Attachments.Count); + foreach (var attachmentId in input.Attachments) + { + var att = await Store.LoadAttachmentAsync(attachmentId, context); + attachments.Add(att with { ThreadId = thread.Id }); + } + + return new UserMessageItem + { + Id = Store.GenerateItemId(StoreItemType.Message, thread, context), + Content = input.Content, + ThreadId = thread.Id, + Attachments = attachments, + QuotedText = input.QuotedText, + InferenceOptions = input.InferenceOptions, + CreatedAt = Clock.GetUtcNow().UtcDateTime, + }; + } + + private async Task LoadFullThreadAsync( + string threadId, TContext context, CancellationToken ct) + { + var meta = await Store.LoadThreadAsync(threadId, context); + var items = await Store.LoadThreadItemsAsync( + threadId, null, DefaultPageSize, "asc", context); + + return new Thread + { + Id = meta.Id, + Title = meta.Title, + CreatedAt = meta.CreatedAt, + Status = meta.Status, + AllowedImageDomains = meta.AllowedImageDomains, + Items = items, + }; + } + + private async IAsyncEnumerable PaginateThreadItemsReverseAsync( + string threadId, TContext context, + [EnumeratorCancellation] CancellationToken ct = default) + { + string? after = null; + while (true) + { + var items = await Store.LoadThreadItemsAsync( + threadId, after, DefaultPageSize, "desc", context); + + foreach (var item in items.Data) + yield return item; + + if (!items.HasMore) + break; + after = items.After; + } + } + + private async ValueTask CleanupPendingClientToolCallAsync( + ThreadMetadata thread, TContext context) + { + var items = await Store.LoadThreadItemsAsync( + thread.Id, null, DefaultPageSize, "desc", context); + + foreach (var item in items.Data.OfType()) + { + if (item.Status == "pending") + { + Logger.LogWarning("Client tool call {CallId} was not completed, ignoring", item.CallId); + await Store.DeleteThreadItemAsync(thread.Id, item.Id, context); + } + } + } + + private IAttachmentStore GetAttachmentStore() => + attachmentStore ?? throw new InvalidOperationException( + "AttachmentStore is not configured. Provide an IAttachmentStore to handle file operations."); + + private static byte[] Serialize(T obj) => + JsonSerializer.SerializeToUtf8Bytes(obj, ChatKitJsonOptions.Default); + + private static Thread ToThreadResponse(ThreadMetadata thread) + { + var items = thread is Thread full ? full.Items : new Page(); + var filtered = items.Data + .Where(i => i is not (HiddenContextItem or SdkHiddenContextItem)) + .ToList(); + + return new Thread + { + Id = thread.Id, + Title = thread.Title, + CreatedAt = thread.CreatedAt, + Status = thread.Status, + AllowedImageDomains = thread.AllowedImageDomains, + Items = new Page + { + Data = filtered, + HasMore = items.HasMore, + After = items.After, + }, + }; + } +} diff --git a/src/Qyl.ChatKit/Errors.cs b/src/Qyl.ChatKit/Errors.cs new file mode 100644 index 0000000..c5c350c --- /dev/null +++ b/src/Qyl.ChatKit/Errors.cs @@ -0,0 +1,39 @@ +using System.Text.Json.Serialization; + +namespace Qyl.ChatKit; + +/// +/// Known error codes emitted by the ChatKit stream protocol. +/// Not a closed set -- new codes can be added as needed. +/// +[JsonConverter(typeof(JsonStringEnumConverter))] +public enum ErrorCode +{ + [JsonStringEnumMemberName("stream.error")] + StreamError +} + +/// +/// Error with a specific error code that maps to a localized user-facing error message. +/// +public sealed class StreamError(ErrorCode code, bool? allowRetry = null) + : Exception($"Stream error: {code}") +{ + public ErrorCode Code { get; } = code; + + public bool AllowRetry { get; } = allowRetry ?? code switch + { + ErrorCode.StreamError => true, + _ => false + }; +} + +/// +/// Error with a custom user-facing error message. The message should be localized +/// as needed before raising the error. +/// +public sealed class CustomStreamError(string message, bool allowRetry = false) + : Exception(message) +{ + public bool AllowRetry { get; } = allowRetry; +} diff --git a/src/Qyl.ChatKit/FeedbackKind.cs b/src/Qyl.ChatKit/FeedbackKind.cs new file mode 100644 index 0000000..f597b34 --- /dev/null +++ b/src/Qyl.ChatKit/FeedbackKind.cs @@ -0,0 +1,14 @@ +using System.Text.Json.Serialization; + +namespace Qyl.ChatKit; + +/// Feedback sentiment for a thread item. +[JsonConverter(typeof(JsonStringEnumConverter))] +public enum FeedbackKind +{ + [JsonStringEnumMemberName("positive")] + Positive, + + [JsonStringEnumMemberName("negative")] + Negative +} diff --git a/src/Qyl.ChatKit/IStore.cs b/src/Qyl.ChatKit/IStore.cs new file mode 100644 index 0000000..d7f4eed --- /dev/null +++ b/src/Qyl.ChatKit/IStore.cs @@ -0,0 +1,126 @@ +using System.Text.Json.Serialization; + +namespace Qyl.ChatKit; + +/// Kind of item for store ID generation. +[JsonConverter(typeof(JsonStringEnumConverter))] +public enum StoreItemType +{ + [JsonStringEnumMemberName("thread")] + Thread, + + [JsonStringEnumMemberName("message")] + Message, + + [JsonStringEnumMemberName("tool_call")] + ToolCall, + + [JsonStringEnumMemberName("task")] + Task, + + [JsonStringEnumMemberName("workflow")] + Workflow, + + [JsonStringEnumMemberName("attachment")] + Attachment, + + [JsonStringEnumMemberName("sdk_hidden_context")] + SdkHiddenContext +} + +/// Generates prefixed identifiers for store items. +public static class StoreIdGenerator +{ + private static readonly Dictionary Prefixes = new() + { + [StoreItemType.Thread] = "thr", + [StoreItemType.Message] = "msg", + [StoreItemType.ToolCall] = "tc", + [StoreItemType.Workflow] = "wf", + [StoreItemType.Task] = "tsk", + [StoreItemType.Attachment] = "atc", + [StoreItemType.SdkHiddenContext] = "shcx" + }; + + public static string GenerateId(StoreItemType itemType) + { + var prefix = Prefixes[itemType]; + return $"{prefix}_{Guid.NewGuid():N}"[..12]; + } +} + +/// Thrown when a requested entity is not found in the store. +public sealed class NotFoundException() : Exception("Entity not found"); + +/// Attachment-specific store operations. +public interface IAttachmentStore +{ + /// Delete an attachment by id. + Task DeleteAttachmentAsync(string attachmentId, TContext context); + + /// Create an attachment record from upload metadata. + Task CreateAttachmentAsync(AttachmentCreateParams input, TContext context); + + /// + /// Return a new identifier for a file. Override to customize file ID generation. + /// + string GenerateAttachmentId(string mimeType, TContext context) => + StoreIdGenerator.GenerateId(StoreItemType.Attachment); +} + +/// Primary store interface for threads, items, and attachments. +public interface IStore +{ + /// Return a new identifier for a thread. + string GenerateThreadId(TContext context) => + StoreIdGenerator.GenerateId(StoreItemType.Thread); + + /// Return a new identifier for a thread item. + string GenerateItemId(StoreItemType itemType, ThreadMetadata thread, TContext context) => + StoreIdGenerator.GenerateId(itemType); + + /// Load a thread's metadata by id. + Task LoadThreadAsync(string threadId, TContext context); + + /// Persist thread metadata (title, status, etc.). + Task SaveThreadAsync(ThreadMetadata thread, TContext context); + + /// Load a page of thread items with pagination controls. + Task> LoadThreadItemsAsync( + string threadId, + string? after, + int limit, + string order, + TContext context); + + /// Upsert attachment metadata. + Task SaveAttachmentAsync(AttachmentBase attachment, TContext context); + + /// Load attachment metadata by id. + Task LoadAttachmentAsync(string attachmentId, TContext context); + + /// Delete attachment metadata by id. + Task DeleteAttachmentAsync(string attachmentId, TContext context); + + /// Load a page of threads with pagination controls. + Task> LoadThreadsAsync( + int limit, + string? after, + string order, + TContext context); + + /// Persist a newly created thread item. + Task AddThreadItemAsync(string threadId, ThreadItem item, TContext context); + + /// Upsert a thread item by id. + Task SaveItemAsync(string threadId, ThreadItem item, TContext context); + + /// Load a thread item by id. + Task LoadItemAsync(string threadId, string itemId, TContext context); + + /// Delete a thread and its items. + Task DeleteThreadAsync(string threadId, TContext context); + + /// Delete a thread item by id. + Task DeleteThreadItemAsync(string threadId, string itemId, TContext context); +} diff --git a/src/Qyl.ChatKit/IconName.cs b/src/Qyl.ChatKit/IconName.cs new file mode 100644 index 0000000..8824102 --- /dev/null +++ b/src/Qyl.ChatKit/IconName.cs @@ -0,0 +1,76 @@ +using System.Diagnostics.CodeAnalysis; + +namespace Qyl.ChatKit; + +/// +/// Known icon name constants. Accepts vendor:* and lucide:* prefixed +/// custom values, so the actual wire type is . +/// +[SuppressMessage("ReSharper", "InconsistentNaming")] +public static class IconName +{ + public const string Agent = "agent"; + public const string Analytics = "analytics"; + public const string Atom = "atom"; + public const string Batch = "batch"; + public const string Bolt = "bolt"; + public const string BookOpen = "book-open"; + public const string BookClosed = "book-closed"; + public const string BookClock = "book-clock"; + public const string Bug = "bug"; + public const string Calendar = "calendar"; + public const string Chart = "chart"; + public const string Check = "check"; + public const string CheckCircle = "check-circle"; + public const string CheckCircleFilled = "check-circle-filled"; + public const string ChevronLeft = "chevron-left"; + public const string ChevronRight = "chevron-right"; + public const string CircleQuestion = "circle-question"; + public const string Clock = "clock"; + public const string Compass = "compass"; + public const string Confetti = "confetti"; + public const string Cube = "cube"; + public const string Desktop = "desktop"; + public const string Document = "document"; + public const string Dot = "dot"; + public const string DotsHorizontal = "dots-horizontal"; + public const string DotsVertical = "dots-vertical"; + public const string EmptyCircle = "empty-circle"; + public const string ExternalLink = "external-link"; + public const string Globe = "globe"; + public const string Keys = "keys"; + public const string Lab = "lab"; + public const string Images = "images"; + public const string Info = "info"; + public const string Lifesaver = "lifesaver"; + public const string Lightbulb = "lightbulb"; + public const string Mail = "mail"; + public const string MapPin = "map-pin"; + public const string Maps = "maps"; + public const string Mobile = "mobile"; + public const string Name = "name"; + public const string Notebook = "notebook"; + public const string NotebookPencil = "notebook-pencil"; + public const string PageBlank = "page-blank"; + public const string Phone = "phone"; + public const string Play = "play"; + public const string Plus = "plus"; + public const string Profile = "profile"; + public const string ProfileCard = "profile-card"; + public const string Reload = "reload"; + public const string Star = "star"; + public const string StarFilled = "star-filled"; + public const string Search = "search"; + public const string Sparkle = "sparkle"; + public const string SparkleDouble = "sparkle-double"; + public const string SquareCode = "square-code"; + public const string SquareImage = "square-image"; + public const string SquareText = "square-text"; + public const string Suitcase = "suitcase"; + public const string SettingsSlider = "settings-slider"; + public const string User = "user"; + public const string Wreath = "wreath"; + public const string Write = "write"; + public const string WriteAlt = "write-alt"; + public const string WriteAlt2 = "write-alt2"; +} diff --git a/src/Qyl.ChatKit/Messages.cs b/src/Qyl.ChatKit/Messages.cs new file mode 100644 index 0000000..3d4b4be --- /dev/null +++ b/src/Qyl.ChatKit/Messages.cs @@ -0,0 +1,94 @@ +using System.Text.Json.Serialization; + +namespace Qyl.ChatKit; + +/// Payload describing a user message submission. +public sealed record UserMessageInput +{ + [JsonPropertyName("content")] + public required IReadOnlyList Content { get; init; } + + [JsonPropertyName("attachments")] + public IReadOnlyList Attachments { get; init; } = []; + + [JsonPropertyName("quoted_text")] + public string? QuotedText { get; init; } + + [JsonPropertyName("inference_options")] + public required InferenceOptions InferenceOptions { get; init; } +} + +/// Polymorphic user message content payloads. +[JsonPolymorphic(TypeDiscriminatorPropertyName = "type")] +[JsonDerivedType(typeof(UserMessageTextContent), "input_text")] +[JsonDerivedType(typeof(UserMessageTagContent), "input_tag")] +public abstract record UserMessageContent; + +/// User message content containing plaintext. +public sealed record UserMessageTextContent : UserMessageContent +{ + [JsonPropertyName("text")] + public required string Text { get; init; } +} + +/// User message content representing an interactive tag. +public sealed record UserMessageTagContent : UserMessageContent +{ + [JsonPropertyName("id")] + public required string Id { get; init; } + + [JsonPropertyName("text")] + public required string Text { get; init; } + + [JsonPropertyName("data")] + public Dictionary Data { get; init; } = new(); + + [JsonPropertyName("group")] + public string? Group { get; init; } + + [JsonPropertyName("interactive")] + public bool Interactive { get; init; } +} + +/// Assistant message content consisting of text and annotations. +public sealed record AssistantMessageContent +{ + [JsonPropertyName("type")] + public string Type { get; init; } = "output_text"; + + [JsonPropertyName("text")] + public required string Text { get; init; } + + [JsonPropertyName("annotations")] + public IReadOnlyList Annotations { get; init; } = []; +} + +/// Reference to supporting context attached to assistant output. +public sealed record Annotation +{ + [JsonPropertyName("type")] + public string Type { get; init; } = "annotation"; + + [JsonPropertyName("source")] + public required SourceBase Source { get; init; } + + [JsonPropertyName("index")] + public int? Index { get; init; } +} + +/// Model and tool configuration for message processing. +public sealed record InferenceOptions +{ + [JsonPropertyName("tool_choice")] + public ToolChoice? ToolChoice { get; init; } + + [JsonPropertyName("model")] + public string? Model { get; init; } +} + +/// Explicit tool selection for the assistant to invoke. +public sealed record ToolChoice +{ + [JsonPropertyName("id")] + public required string Id { get; init; } +} diff --git a/src/Qyl.ChatKit/Page.cs b/src/Qyl.ChatKit/Page.cs new file mode 100644 index 0000000..7ed5b9c --- /dev/null +++ b/src/Qyl.ChatKit/Page.cs @@ -0,0 +1,16 @@ +using System.Text.Json.Serialization; + +namespace Qyl.ChatKit; + +/// Paginated collection of records returned from the API. +public sealed record Page +{ + [JsonPropertyName("data")] + public IReadOnlyList Data { get; init; } = []; + + [JsonPropertyName("has_more")] + public bool HasMore { get; init; } + + [JsonPropertyName("after")] + public string? After { get; init; } +} diff --git a/src/Qyl.ChatKit/Qyl.ChatKit.csproj b/src/Qyl.ChatKit/Qyl.ChatKit.csproj new file mode 100644 index 0000000..e1cf759 --- /dev/null +++ b/src/Qyl.ChatKit/Qyl.ChatKit.csproj @@ -0,0 +1,17 @@ + + + net10.0 + Qyl.ChatKit + + ChatKit protocol SDK: threaded chat server with streaming, widgets, actions, and MAF agent bridge. + qyl;chatkit;chat;streaming;maf;agents + + + + + + + + + + diff --git a/src/Qyl.ChatKit/Requests.cs b/src/Qyl.ChatKit/Requests.cs new file mode 100644 index 0000000..411751c --- /dev/null +++ b/src/Qyl.ChatKit/Requests.cs @@ -0,0 +1,279 @@ +using System.Text.Json.Serialization; + +namespace Qyl.ChatKit; + +/// Base class for all request payloads. +[JsonPolymorphic(TypeDiscriminatorPropertyName = "type")] +[JsonDerivedType(typeof(ThreadsGetByIdReq), "threads.get_by_id")] +[JsonDerivedType(typeof(ThreadsCreateReq), "threads.create")] +[JsonDerivedType(typeof(ThreadsListReq), "threads.list")] +[JsonDerivedType(typeof(ThreadsAddUserMessageReq), "threads.add_user_message")] +[JsonDerivedType(typeof(ThreadsAddClientToolOutputReq), "threads.add_client_tool_output")] +[JsonDerivedType(typeof(ThreadsCustomActionReq), "threads.custom_action")] +[JsonDerivedType(typeof(ThreadsSyncCustomActionReq), "threads.sync_custom_action")] +[JsonDerivedType(typeof(ThreadsRetryAfterItemReq), "threads.retry_after_item")] +[JsonDerivedType(typeof(ItemsFeedbackReq), "items.feedback")] +[JsonDerivedType(typeof(AttachmentsDeleteReq), "attachments.delete")] +[JsonDerivedType(typeof(AttachmentsCreateReq), "attachments.create")] +[JsonDerivedType(typeof(InputTranscribeReq), "input.transcribe")] +[JsonDerivedType(typeof(ItemsListReq), "items.list")] +[JsonDerivedType(typeof(ThreadsUpdateReq), "threads.update")] +[JsonDerivedType(typeof(ThreadsDeleteReq), "threads.delete")] +public abstract record ChatKitRequest +{ + /// Arbitrary integration-specific metadata. + [JsonPropertyName("metadata")] + public Dictionary Metadata { get; init; } = new(); + + /// Return true if the given request should be processed as streaming. + public static bool IsStreamingRequest(ChatKitRequest request) => request is + ThreadsCreateReq or + ThreadsAddUserMessageReq or + ThreadsRetryAfterItemReq or + ThreadsAddClientToolOutputReq or + ThreadsCustomActionReq; +} + +// -- Parameter records -- + +/// Parameters for retrieving a thread by id. +public sealed record ThreadGetByIdParams +{ + [JsonPropertyName("thread_id")] + public required string ThreadId { get; init; } +} + +/// User input required to create a thread. +public sealed record ThreadCreateParams +{ + [JsonPropertyName("input")] + public required UserMessageInput Input { get; init; } +} + +/// Pagination parameters for listing threads. +public sealed record ThreadListParams +{ + [JsonPropertyName("limit")] + public int? Limit { get; init; } + + [JsonPropertyName("order")] + public string Order { get; init; } = "desc"; + + [JsonPropertyName("after")] + public string? After { get; init; } +} + +/// Parameters for adding a user message to a thread. +public sealed record ThreadAddUserMessageParams +{ + [JsonPropertyName("input")] + public required UserMessageInput Input { get; init; } + + [JsonPropertyName("thread_id")] + public required string ThreadId { get; init; } +} + +/// Parameters for recording tool output in a thread. +public sealed record ThreadAddClientToolOutputParams +{ + [JsonPropertyName("thread_id")] + public required string ThreadId { get; init; } + + [JsonPropertyName("result")] + public object? Result { get; init; } +} + +/// Parameters describing the custom action to execute. +public sealed record ThreadCustomActionParams +{ + [JsonPropertyName("thread_id")] + public required string ThreadId { get; init; } + + [JsonPropertyName("item_id")] + public string? ItemId { get; init; } + + [JsonPropertyName("action")] + public required Action Action { get; init; } +} + +/// Parameters specifying which item to retry. +public sealed record ThreadRetryAfterItemParams +{ + [JsonPropertyName("thread_id")] + public required string ThreadId { get; init; } + + [JsonPropertyName("item_id")] + public required string ItemId { get; init; } +} + +/// Parameters describing feedback targets and sentiment. +public sealed record ItemFeedbackParams +{ + [JsonPropertyName("thread_id")] + public required string ThreadId { get; init; } + + [JsonPropertyName("item_ids")] + public required IReadOnlyList ItemIds { get; init; } + + [JsonPropertyName("kind")] + public required FeedbackKind Kind { get; init; } +} + +/// Parameters identifying an attachment to delete. +public sealed record AttachmentDeleteParams +{ + [JsonPropertyName("attachment_id")] + public required string AttachmentId { get; init; } +} + +/// Parameters for speech transcription. +public sealed record InputTranscribeParams +{ + /// Base64-encoded audio bytes. + [JsonPropertyName("audio_base64")] + public required string AudioBase64 { get; init; } + + /// Raw MIME type for the audio payload, e.g. "audio/webm;codecs=opus". + [JsonPropertyName("mime_type")] + public required string MimeType { get; init; } +} + +/// Pagination parameters for listing thread items. +public sealed record ItemsListParams +{ + [JsonPropertyName("thread_id")] + public required string ThreadId { get; init; } + + [JsonPropertyName("limit")] + public int? Limit { get; init; } + + [JsonPropertyName("order")] + public string Order { get; init; } = "desc"; + + [JsonPropertyName("after")] + public string? After { get; init; } +} + +/// Parameters for updating a thread's properties. +public sealed record ThreadUpdateParams +{ + [JsonPropertyName("thread_id")] + public required string ThreadId { get; init; } + + [JsonPropertyName("title")] + public required string Title { get; init; } +} + +/// Parameters identifying a thread to delete. +public sealed record ThreadDeleteParams +{ + [JsonPropertyName("thread_id")] + public required string ThreadId { get; init; } +} + +// -- Request types -- + +/// Request to fetch a single thread by its identifier. +public sealed record ThreadsGetByIdReq : ChatKitRequest +{ + [JsonPropertyName("params")] + public required ThreadGetByIdParams Params { get; init; } +} + +/// Request to create a new thread from a user message. +public sealed record ThreadsCreateReq : ChatKitRequest +{ + [JsonPropertyName("params")] + public required ThreadCreateParams Params { get; init; } +} + +/// Request to list threads. +public sealed record ThreadsListReq : ChatKitRequest +{ + [JsonPropertyName("params")] + public required ThreadListParams Params { get; init; } +} + +/// Request to append a user message to a thread. +public sealed record ThreadsAddUserMessageReq : ChatKitRequest +{ + [JsonPropertyName("params")] + public required ThreadAddUserMessageParams Params { get; init; } +} + +/// Request to add a client tool's output to a thread. +public sealed record ThreadsAddClientToolOutputReq : ChatKitRequest +{ + [JsonPropertyName("params")] + public required ThreadAddClientToolOutputParams Params { get; init; } +} + +/// Request to execute a custom action within a thread. +public sealed record ThreadsCustomActionReq : ChatKitRequest +{ + [JsonPropertyName("params")] + public required ThreadCustomActionParams Params { get; init; } +} + +/// Request to execute a custom action and return a single item update. +public sealed record ThreadsSyncCustomActionReq : ChatKitRequest +{ + [JsonPropertyName("params")] + public required ThreadCustomActionParams Params { get; init; } +} + +/// Request to retry processing after a specific thread item. +public sealed record ThreadsRetryAfterItemReq : ChatKitRequest +{ + [JsonPropertyName("params")] + public required ThreadRetryAfterItemParams Params { get; init; } +} + +/// Request to submit feedback on specific items. +public sealed record ItemsFeedbackReq : ChatKitRequest +{ + [JsonPropertyName("params")] + public required ItemFeedbackParams Params { get; init; } +} + +/// Request to remove an attachment. +public sealed record AttachmentsDeleteReq : ChatKitRequest +{ + [JsonPropertyName("params")] + public required AttachmentDeleteParams Params { get; init; } +} + +/// Request to register a new attachment. +public sealed record AttachmentsCreateReq : ChatKitRequest +{ + [JsonPropertyName("params")] + public required AttachmentCreateParams Params { get; init; } +} + +/// Request to transcribe an audio payload into text. +public sealed record InputTranscribeReq : ChatKitRequest +{ + [JsonPropertyName("params")] + public required InputTranscribeParams Params { get; init; } +} + +/// Request to list items inside a thread. +public sealed record ItemsListReq : ChatKitRequest +{ + [JsonPropertyName("params")] + public required ItemsListParams Params { get; init; } +} + +/// Request to update thread metadata. +public sealed record ThreadsUpdateReq : ChatKitRequest +{ + [JsonPropertyName("params")] + public required ThreadUpdateParams Params { get; init; } +} + +/// Request to delete a thread. +public sealed record ThreadsDeleteReq : ChatKitRequest +{ + [JsonPropertyName("params")] + public required ThreadDeleteParams Params { get; init; } +} diff --git a/src/Qyl.ChatKit/ResponseStreamConverter.cs b/src/Qyl.ChatKit/ResponseStreamConverter.cs new file mode 100644 index 0000000..524f7da --- /dev/null +++ b/src/Qyl.ChatKit/ResponseStreamConverter.cs @@ -0,0 +1,48 @@ +namespace Qyl.ChatKit; + +/// +/// Adapts streamed data (image generation results, citations) into ChatKit types. +/// Override methods to customize how model output maps to thread items. +/// +public class ResponseStreamConverter +{ + /// + /// Expected number of partial image updates. Used to normalize progress to [0, 1]. + /// Null means no progress normalization. + /// + public int? PartialImages { get; init; } + + /// Convert a base64-encoded image into a URL stored on thread items. + public virtual ValueTask Base64ImageToUrlAsync( + string imageId, string base64Image, int? partialImageIndex = null) => + new($"data:image/png;base64,{base64Image}"); + + /// Convert a partial image index into normalized progress [0, 1]. + public virtual float PartialImageIndexToProgress(int partialImageIndex) => + PartialImages is > 0 + ? Math.Min(1f, (float)partialImageIndex / PartialImages.Value) + : 0f; + + /// Convert a file citation into an assistant message annotation. + public virtual ValueTask FileCitationToAnnotationAsync( + string? filename, int? index) + { + if (filename is null) + return new((Annotation?)null); + + return new(new Annotation + { + Source = new FileSource { Filename = filename, Title = filename }, + Index = index, + }); + } + + /// Convert a URL citation into an assistant message annotation. + public virtual ValueTask UrlCitationToAnnotationAsync( + string url, string? title, int? index) => + new(new Annotation + { + Source = new UrlSource { Url = url, Title = title ?? url }, + Index = index, + }); +} diff --git a/src/Qyl.ChatKit/Sources.cs b/src/Qyl.ChatKit/Sources.cs new file mode 100644 index 0000000..765172a --- /dev/null +++ b/src/Qyl.ChatKit/Sources.cs @@ -0,0 +1,72 @@ +using System.Text.Json.Serialization; + +namespace Qyl.ChatKit; + +/// Base class for sources displayed to users. +[JsonPolymorphic(TypeDiscriminatorPropertyName = "type")] +[JsonDerivedType(typeof(FileSource), "file")] +[JsonDerivedType(typeof(UrlSource), "url")] +[JsonDerivedType(typeof(EntitySource), "entity")] +public abstract record SourceBase +{ + [JsonPropertyName("title")] + public required string Title { get; init; } + + [JsonPropertyName("description")] + public string? Description { get; init; } + + [JsonPropertyName("timestamp")] + public string? Timestamp { get; init; } + + [JsonPropertyName("group")] + public string? Group { get; init; } +} + +/// Source metadata for file-based references. +public sealed record FileSource : SourceBase +{ + [JsonPropertyName("filename")] + public required string Filename { get; init; } +} + +/// Source metadata for external URLs. +public sealed record UrlSource : SourceBase +{ + [JsonPropertyName("url")] + public required string Url { get; init; } + + [JsonPropertyName("attribution")] + public string? Attribution { get; init; } +} + +/// Source metadata for entity references. +public sealed record EntitySource : SourceBase +{ + [JsonPropertyName("id")] + public required string Id { get; init; } + + /// Icon name. Accepts vendor:* and lucide:* prefixes. + [JsonPropertyName("icon")] + public string? Icon { get; init; } + + /// + /// Optional label shown with the icon in the default entity hover header + /// when no preview callback is provided. + /// + [JsonPropertyName("label")] + public string? Label { get; init; } + + /// + /// Optional label for the inline annotation view. When not provided, the icon is used instead. + /// + [JsonPropertyName("inline_label")] + public string? InlineLabel { get; init; } + + /// Per-entity toggle to wire client callbacks and render this entity as interactive. + [JsonPropertyName("interactive")] + public bool Interactive { get; init; } + + /// Additional data for the entity source that is passed to client entity callbacks. + [JsonPropertyName("data")] + public Dictionary Data { get; init; } = new(); +} diff --git a/src/Qyl.ChatKit/StreamingResult.cs b/src/Qyl.ChatKit/StreamingResult.cs new file mode 100644 index 0000000..6927c98 --- /dev/null +++ b/src/Qyl.ChatKit/StreamingResult.cs @@ -0,0 +1,14 @@ +namespace Qyl.ChatKit; + +/// Wraps an async stream of SSE byte chunks. +public sealed class StreamingResult(IAsyncEnumerable stream) : IAsyncEnumerable +{ + public IAsyncEnumerator GetAsyncEnumerator(CancellationToken ct = default) + => stream.GetAsyncEnumerator(ct); +} + +/// Wraps a single JSON byte response. +public sealed class NonStreamingResult(byte[] json) +{ + public byte[] Json { get; } = json; +} diff --git a/src/Qyl.ChatKit/ThreadEvents.cs b/src/Qyl.ChatKit/ThreadEvents.cs new file mode 100644 index 0000000..06782a6 --- /dev/null +++ b/src/Qyl.ChatKit/ThreadEvents.cs @@ -0,0 +1,135 @@ +using System.Text.Json.Serialization; + +namespace Qyl.ChatKit; + +/// Union of all streaming events emitted to clients. +[JsonPolymorphic(TypeDiscriminatorPropertyName = "type")] +[JsonDerivedType(typeof(ThreadCreatedEvent), "thread.created")] +[JsonDerivedType(typeof(ThreadUpdatedEvent), "thread.updated")] +[JsonDerivedType(typeof(ThreadItemAddedEvent), "thread.item.added")] +[JsonDerivedType(typeof(ThreadItemUpdatedEvent), "thread.item.updated")] +[JsonDerivedType(typeof(ThreadItemDoneEvent), "thread.item.done")] +[JsonDerivedType(typeof(ThreadItemRemovedEvent), "thread.item.removed")] +[JsonDerivedType(typeof(ThreadItemReplacedEvent), "thread.item.replaced")] +[JsonDerivedType(typeof(StreamOptionsEvent), "stream_options")] +[JsonDerivedType(typeof(ProgressUpdateEvent), "progress_update")] +[JsonDerivedType(typeof(ClientEffectEvent), "client_effect")] +[JsonDerivedType(typeof(ErrorEvent), "error")] +[JsonDerivedType(typeof(NoticeEvent), "notice")] +public abstract record ThreadStreamEvent; + +/// Event emitted when a thread is created. +public sealed record ThreadCreatedEvent : ThreadStreamEvent +{ + [JsonPropertyName("thread")] + public required Thread Thread { get; init; } +} + +/// Event emitted when a thread is updated. +public sealed record ThreadUpdatedEvent : ThreadStreamEvent +{ + [JsonPropertyName("thread")] + public required Thread Thread { get; init; } +} + +/// Event emitted when a new item is added to a thread. +public sealed record ThreadItemAddedEvent : ThreadStreamEvent +{ + [JsonPropertyName("item")] + public required ThreadItem Item { get; init; } +} + +/// Event describing an update to an existing thread item. +public sealed record ThreadItemUpdatedEvent : ThreadStreamEvent +{ + [JsonPropertyName("item_id")] + public required string ItemId { get; init; } + + [JsonPropertyName("update")] + public required ThreadItemUpdate Update { get; init; } +} + +/// Event emitted when a thread item is marked complete. +public sealed record ThreadItemDoneEvent : ThreadStreamEvent +{ + [JsonPropertyName("item")] + public required ThreadItem Item { get; init; } +} + +/// Event emitted when a thread item is removed. +public sealed record ThreadItemRemovedEvent : ThreadStreamEvent +{ + [JsonPropertyName("item_id")] + public required string ItemId { get; init; } +} + +/// Event emitted when a thread item is replaced. +public sealed record ThreadItemReplacedEvent : ThreadStreamEvent +{ + [JsonPropertyName("item")] + public required ThreadItem Item { get; init; } +} + +/// Settings that control runtime stream behavior. +public sealed record StreamOptions +{ + /// Allow the client to request cancellation mid-stream. + [JsonPropertyName("allow_cancel")] + public required bool AllowCancel { get; init; } +} + +/// Event emitted to set stream options at runtime. +public sealed record StreamOptionsEvent : ThreadStreamEvent +{ + [JsonPropertyName("stream_options")] + public required StreamOptions StreamOptions { get; init; } +} + +/// Event providing incremental progress from the assistant. +public sealed record ProgressUpdateEvent : ThreadStreamEvent +{ + /// Icon name. Accepts vendor:* and lucide:* prefixes. + [JsonPropertyName("icon")] + public string? Icon { get; init; } + + [JsonPropertyName("text")] + public required string Text { get; init; } +} + +/// Event emitted to trigger a client side-effect. +public sealed record ClientEffectEvent : ThreadStreamEvent +{ + [JsonPropertyName("name")] + public required string Name { get; init; } + + [JsonPropertyName("data")] + public Dictionary Data { get; init; } = new(); +} + +/// Event indicating an error occurred while processing a thread. +public sealed record ErrorEvent : ThreadStreamEvent +{ + [JsonPropertyName("code")] + public string Code { get; init; } = "custom"; + + [JsonPropertyName("message")] + public string? Message { get; init; } + + [JsonPropertyName("allow_retry")] + public bool AllowRetry { get; init; } +} + +/// Event conveying a user-facing notice. +public sealed record NoticeEvent : ThreadStreamEvent +{ + /// Severity level: "info", "warning", or "danger". + [JsonPropertyName("level")] + public required string Level { get; init; } + + /// Supports markdown. + [JsonPropertyName("message")] + public required string Message { get; init; } + + [JsonPropertyName("title")] + public string? Title { get; init; } +} diff --git a/src/Qyl.ChatKit/ThreadItemConverter.cs b/src/Qyl.ChatKit/ThreadItemConverter.cs new file mode 100644 index 0000000..136f661 --- /dev/null +++ b/src/Qyl.ChatKit/ThreadItemConverter.cs @@ -0,0 +1,216 @@ +using System.Text.Json; +using Microsoft.Extensions.AI; + +namespace Qyl.ChatKit; + +/// +/// Converts ChatKit instances to MAF +/// objects for sending to an . +/// +public class ThreadItemConverter +{ + /// Convert an attachment into a content part for the model. + public virtual ValueTask AttachmentToContentAsync(AttachmentBase attachment) => + throw new NotImplementedException( + "An Attachment was included in a UserMessageItem but AttachmentToContentAsync was not implemented."); + + /// Convert a tag into a content part for the model. + public virtual ValueTask TagToContentAsync(UserMessageTagContent tag) => + throw new NotImplementedException( + "A Tag was included in a UserMessageItem but TagToContentAsync was not implemented."); + + public virtual ValueTask ConvertUserMessageAsync( + UserMessageItem item, bool isLastMessage = true) + { + List textParts = []; + List rawTags = []; + + foreach (var part in item.Content) + { + switch (part) + { + case UserMessageTextContent text: + textParts.Add(text.Text); + break; + case UserMessageTagContent tag: + textParts.Add($"@{tag.Text}"); + rawTags.Add(tag); + break; + } + } + + var userMessage = new ChatMessage(ChatRole.User, string.Concat(textParts)); + + // Quoted text context + if (item.QuotedText is not null && isLastMessage) + { + userMessage.Contents.Add(new TextContent( + $"\nThe user is referring to this in particular: \n{item.QuotedText}")); + } + + // Deduplicated tag context + if (rawTags.Count > 0) + { + HashSet seen = []; + List uniqueTags = []; + foreach (var tag in rawTags) + { + if (seen.Add(tag.Text)) + uniqueTags.Add(tag); + } + + if (uniqueTags.Count > 0) + { + userMessage.Contents.Add(new TextContent( + """ + # User-provided context for @-mentions + - When referencing resolved entities, use their canonical names **without** '@'. + - The '@' form appears only in user text and should not be echoed. + """)); + } + } + + return new ValueTask(userMessage); + } + + public virtual ValueTask ConvertAssistantMessageAsync(AssistantMessageItem item) + { + var contents = item.Content + .Select(c => (AIContent)new TextContent(c.Text)) + .ToList(); + + return new ValueTask(new ChatMessage(ChatRole.Assistant, contents)); + } + + public virtual ValueTask ConvertClientToolCallAsync(ClientToolCallItem item) + { + if (item.Status == "pending") + return new ValueTask((ChatMessage?)null); + + var args = JsonSerializer.Serialize(item.Arguments); + var output = JsonSerializer.Serialize(item.Output); + + var message = new ChatMessage(ChatRole.Assistant, + [ + new FunctionCallContent(item.CallId, item.Name, new Dictionary { ["args"] = args }), + ]); + + // The result message follows + var resultMessage = new ChatMessage(ChatRole.Tool, + [ + new FunctionResultContent(item.CallId, output), + ]); + + // Return the function call; the caller collects both via ToAgentInputAsync + return new ValueTask(message); + } + + public virtual ValueTask ConvertWidgetAsync(WidgetItem item) + { + var json = JsonSerializer.Serialize(item.Widget, ChatKitJsonOptions.Default); + return new ValueTask(new ChatMessage(ChatRole.User, + $"The following graphical UI widget (id: {item.Id}) was displayed to the user:{json}")); + } + + public virtual ValueTask ConvertWorkflowAsync(WorkflowItem item) + { + var messages = new List(); + foreach (var task in item.Workflow.Tasks) + { + if (task is not CustomTask custom || (custom.Title is null && custom.Content is null)) + continue; + + var title = custom.Title ?? ""; + var content = custom.Content ?? ""; + var taskText = title.Length > 0 && content.Length > 0 + ? $"{title}: {content}" + : title.Length > 0 ? title : content; + + messages.Add( + $"A message was displayed to the user that the following task was performed:\n\n{taskText}\n"); + } + + if (messages.Count == 0) + return new ValueTask((ChatMessage?)null); + + return new ValueTask( + new ChatMessage(ChatRole.User, string.Join("\n", messages))); + } + + public virtual ValueTask ConvertTaskAsync(TaskItem item) + { + if (item.Task is not CustomTask custom || (custom.Title is null && custom.Content is null)) + return new ValueTask((ChatMessage?)null); + + var title = custom.Title ?? ""; + var content = custom.Content ?? ""; + var taskText = title.Length > 0 && content.Length > 0 + ? $"{title}: {content}" + : title.Length > 0 ? title : content; + + return new ValueTask(new ChatMessage(ChatRole.User, + $"A message was displayed to the user that the following task was performed:\n\n{taskText}\n")); + } + + public virtual ValueTask ConvertHiddenContextAsync(HiddenContextItem item) + { + if (item.Content is not string text) + throw new NotImplementedException( + "HiddenContextItems with non-string content require a custom ConvertHiddenContextAsync override."); + + return new ValueTask(new ChatMessage(ChatRole.User, + $"Hidden context for the agent (not shown to the user):\n\n{text}\n")); + } + + public virtual ValueTask ConvertSdkHiddenContextAsync(SdkHiddenContextItem item) => + new(new ChatMessage(ChatRole.User, + $"Hidden context for the agent (not shown to the user):\n\n{item.Content}\n")); + + public virtual ValueTask ConvertGeneratedImageAsync(GeneratedImageItem item) + { + if (item.Image is null) + return new ValueTask((ChatMessage?)null); + + return new ValueTask(new ChatMessage(ChatRole.User, + [ + new TextContent("The following image was generated by the agent."), + new UriContent(new Uri(item.Image.Url), "image/png"), + ])); + } + + public virtual ValueTask ConvertEndOfTurnAsync(EndOfTurnItem item) => + new((ChatMessage?)null); + + /// + /// Convert a sequence of thread items into MAF chat messages for model input. + /// + public async ValueTask> ToAgentInputAsync( + IReadOnlyList items) + { + var output = new List(); + + for (var i = 0; i < items.Count; i++) + { + var isLast = i == items.Count - 1; + var message = items[i] switch + { + UserMessageItem user => await ConvertUserMessageAsync(user, isLast), + AssistantMessageItem assistant => await ConvertAssistantMessageAsync(assistant), + ClientToolCallItem toolCall => await ConvertClientToolCallAsync(toolCall), + WidgetItem widget => await ConvertWidgetAsync(widget), + WorkflowItem workflow => await ConvertWorkflowAsync(workflow), + TaskItem task => await ConvertTaskAsync(task), + HiddenContextItem hidden => await ConvertHiddenContextAsync(hidden), + SdkHiddenContextItem sdkHidden => await ConvertSdkHiddenContextAsync(sdkHidden), + GeneratedImageItem image => await ConvertGeneratedImageAsync(image), + EndOfTurnItem eot => await ConvertEndOfTurnAsync(eot), + _ => null, + }; + + if (message is not null) + output.Add(message); + } + + return output; + } +} diff --git a/src/Qyl.ChatKit/ThreadItemUpdates.cs b/src/Qyl.ChatKit/ThreadItemUpdates.cs new file mode 100644 index 0000000..b177310 --- /dev/null +++ b/src/Qyl.ChatKit/ThreadItemUpdates.cs @@ -0,0 +1,121 @@ +using System.Text.Json.Serialization; +using Qyl.ChatKit.Widgets; + +namespace Qyl.ChatKit; + +/// Union of possible updates applied to thread items. +[JsonPolymorphic(TypeDiscriminatorPropertyName = "type")] +[JsonDerivedType(typeof(AssistantMessageContentPartAdded), "assistant_message.content_part.added")] +[JsonDerivedType(typeof(AssistantMessageContentPartTextDelta), "assistant_message.content_part.text_delta")] +[JsonDerivedType(typeof(AssistantMessageContentPartAnnotationAdded), "assistant_message.content_part.annotation_added")] +[JsonDerivedType(typeof(AssistantMessageContentPartDone), "assistant_message.content_part.done")] +[JsonDerivedType(typeof(WidgetStreamingTextValueDelta), "widget.streaming_text.value_delta")] +[JsonDerivedType(typeof(WidgetRootUpdated), "widget.root.updated")] +[JsonDerivedType(typeof(WidgetComponentUpdated), "widget.component.updated")] +[JsonDerivedType(typeof(WorkflowTaskAdded), "workflow.task.added")] +[JsonDerivedType(typeof(WorkflowTaskUpdated), "workflow.task.updated")] +[JsonDerivedType(typeof(GeneratedImageUpdated), "generated_image.updated")] +public abstract record ThreadItemUpdate; + +/// Event emitted when new assistant content is appended. +public sealed record AssistantMessageContentPartAdded : ThreadItemUpdate +{ + [JsonPropertyName("content_index")] + public required int ContentIndex { get; init; } + + [JsonPropertyName("content")] + public required AssistantMessageContent Content { get; init; } +} + +/// Event carrying incremental assistant text output. +public sealed record AssistantMessageContentPartTextDelta : ThreadItemUpdate +{ + [JsonPropertyName("content_index")] + public required int ContentIndex { get; init; } + + [JsonPropertyName("delta")] + public required string Delta { get; init; } +} + +/// Event announcing a new annotation on assistant content. +public sealed record AssistantMessageContentPartAnnotationAdded : ThreadItemUpdate +{ + [JsonPropertyName("content_index")] + public required int ContentIndex { get; init; } + + [JsonPropertyName("annotation_index")] + public required int AnnotationIndex { get; init; } + + [JsonPropertyName("annotation")] + public required Annotation Annotation { get; init; } +} + +/// Event indicating an assistant content part is finalized. +public sealed record AssistantMessageContentPartDone : ThreadItemUpdate +{ + [JsonPropertyName("content_index")] + public required int ContentIndex { get; init; } + + [JsonPropertyName("content")] + public required AssistantMessageContent Content { get; init; } +} + +/// Event streaming widget text deltas. +public sealed record WidgetStreamingTextValueDelta : ThreadItemUpdate +{ + [JsonPropertyName("component_id")] + public required string ComponentId { get; init; } + + [JsonPropertyName("delta")] + public required string Delta { get; init; } + + [JsonPropertyName("done")] + public required bool Done { get; init; } +} + +/// Event published when the widget root changes. +public sealed record WidgetRootUpdated : ThreadItemUpdate +{ + [JsonPropertyName("widget")] + public required WidgetRoot Widget { get; init; } +} + +/// Event emitted when a widget component updates. +public sealed record WidgetComponentUpdated : ThreadItemUpdate +{ + [JsonPropertyName("component_id")] + public required string ComponentId { get; init; } + + [JsonPropertyName("component")] + public required WidgetComponentBase Component { get; init; } +} + +/// Event emitted when a workflow task is added. +public sealed record WorkflowTaskAdded : ThreadItemUpdate +{ + [JsonPropertyName("task_index")] + public required int TaskIndex { get; init; } + + [JsonPropertyName("task")] + public required ChatKitTask Task { get; init; } +} + +/// Event emitted when a workflow task is updated. +public sealed record WorkflowTaskUpdated : ThreadItemUpdate +{ + [JsonPropertyName("task_index")] + public required int TaskIndex { get; init; } + + [JsonPropertyName("task")] + public required ChatKitTask Task { get; init; } +} + +/// Event emitted when a generated image is updated. +public sealed record GeneratedImageUpdated : ThreadItemUpdate +{ + [JsonPropertyName("image")] + public required GeneratedImage Image { get; init; } + + [JsonPropertyName("progress")] + public double? Progress { get; init; } +} diff --git a/src/Qyl.ChatKit/ThreadItems.cs b/src/Qyl.ChatKit/ThreadItems.cs new file mode 100644 index 0000000..a5cd1bd --- /dev/null +++ b/src/Qyl.ChatKit/ThreadItems.cs @@ -0,0 +1,140 @@ +using System.Text.Json.Serialization; +using Qyl.ChatKit.Widgets; + +namespace Qyl.ChatKit; + +/// Base fields shared by all thread items. +[JsonPolymorphic(TypeDiscriminatorPropertyName = "type")] +[JsonDerivedType(typeof(UserMessageItem), "user_message")] +[JsonDerivedType(typeof(AssistantMessageItem), "assistant_message")] +[JsonDerivedType(typeof(ClientToolCallItem), "client_tool_call")] +[JsonDerivedType(typeof(WidgetItem), "widget")] +[JsonDerivedType(typeof(GeneratedImageItem), "generated_image")] +[JsonDerivedType(typeof(WorkflowItem), "workflow")] +[JsonDerivedType(typeof(TaskItem), "task")] +[JsonDerivedType(typeof(HiddenContextItem), "hidden_context_item")] +[JsonDerivedType(typeof(SdkHiddenContextItem), "sdk_hidden_context")] +[JsonDerivedType(typeof(EndOfTurnItem), "end_of_turn")] +public abstract record ThreadItem +{ + [JsonPropertyName("id")] + public required string Id { get; init; } + + [JsonPropertyName("thread_id")] + public required string ThreadId { get; init; } + + [JsonPropertyName("created_at")] + public required DateTime CreatedAt { get; init; } +} + +/// Thread item representing a user message. +public sealed record UserMessageItem : ThreadItem +{ + [JsonPropertyName("content")] + public required IReadOnlyList Content { get; init; } + + [JsonPropertyName("attachments")] + public IReadOnlyList Attachments { get; init; } = []; + + [JsonPropertyName("quoted_text")] + public string? QuotedText { get; init; } + + [JsonPropertyName("inference_options")] + public required InferenceOptions InferenceOptions { get; init; } +} + +/// Thread item representing an assistant message. +public sealed record AssistantMessageItem : ThreadItem +{ + [JsonPropertyName("content")] + public required IReadOnlyList Content { get; init; } +} + +/// Thread item capturing a client tool call. +public sealed record ClientToolCallItem : ThreadItem +{ + [JsonPropertyName("status")] + public string Status { get; init; } = "pending"; + + [JsonPropertyName("call_id")] + public required string CallId { get; init; } + + [JsonPropertyName("name")] + public required string Name { get; init; } + + [JsonPropertyName("arguments")] + public Dictionary Arguments { get; init; } = new(); + + [JsonPropertyName("output")] + public object? Output { get; init; } +} + +/// Thread item containing widget content. +public sealed record WidgetItem : ThreadItem +{ + [JsonPropertyName("widget")] + public required WidgetRoot Widget { get; init; } + + [JsonPropertyName("copy_text")] + public string? CopyText { get; init; } +} + +/// Generated image metadata. +public sealed record GeneratedImage +{ + [JsonPropertyName("id")] + public required string Id { get; init; } + + [JsonPropertyName("url")] + public required string Url { get; init; } +} + +/// Thread item containing a generated image. +public sealed record GeneratedImageItem : ThreadItem +{ + [JsonPropertyName("image")] + public GeneratedImage? Image { get; init; } +} + +/// Thread item representing a workflow. +public sealed record WorkflowItem : ThreadItem +{ + [JsonPropertyName("workflow")] + public required Workflow Workflow { get; init; } +} + +/// Thread item containing a task. +public sealed record TaskItem : ThreadItem +{ + [JsonPropertyName("task")] + public required ChatKitTask Task { get; init; } +} + +/// Marker item indicating the assistant ends its turn. +public sealed record EndOfTurnItem : ThreadItem; + +/// +/// Hidden context is never sent to the client. It is only used internally to store +/// additional context in a specific place in the thread. +/// +public sealed record HiddenContextItem : ThreadItem +{ + [JsonPropertyName("content")] + public object? Content { get; init; } +} + +/// +/// Hidden context used by the SDK for storing additional context for internal operations. +/// +public sealed record SdkHiddenContextItem : ThreadItem +{ + [JsonPropertyName("content")] + public required string Content { get; init; } +} + +/// Single thread item update returned by a sync custom action. +public sealed record SyncCustomActionResponse +{ + [JsonPropertyName("updated_item")] + public ThreadItem? UpdatedItem { get; init; } +} diff --git a/src/Qyl.ChatKit/ThreadTypes.cs b/src/Qyl.ChatKit/ThreadTypes.cs new file mode 100644 index 0000000..5501ea7 --- /dev/null +++ b/src/Qyl.ChatKit/ThreadTypes.cs @@ -0,0 +1,56 @@ +using System.Text.Json.Serialization; + +namespace Qyl.ChatKit; + +/// Metadata describing a thread without its items. +public record ThreadMetadata +{ + [JsonPropertyName("id")] + public required string Id { get; init; } + + [JsonPropertyName("title")] + public string? Title { get; init; } + + [JsonPropertyName("created_at")] + public required DateTime CreatedAt { get; init; } + + [JsonPropertyName("status")] + public ThreadStatus Status { get; init; } = new ActiveStatus(); + + [JsonPropertyName("allowed_image_domains")] + public IReadOnlyList? AllowedImageDomains { get; init; } + + [JsonPropertyName("metadata")] + public Dictionary Metadata { get; init; } = new(); +} + +/// Thread with its paginated items. +public sealed record Thread : ThreadMetadata +{ + [JsonPropertyName("items")] + public required Page Items { get; init; } +} + +/// Union of lifecycle states for a thread. +[JsonPolymorphic(TypeDiscriminatorPropertyName = "type")] +[JsonDerivedType(typeof(ActiveStatus), "active")] +[JsonDerivedType(typeof(LockedStatus), "locked")] +[JsonDerivedType(typeof(ClosedStatus), "closed")] +public abstract record ThreadStatus; + +/// Status indicating the thread is active. +public sealed record ActiveStatus : ThreadStatus; + +/// Status indicating the thread is locked. +public sealed record LockedStatus : ThreadStatus +{ + [JsonPropertyName("reason")] + public string? Reason { get; init; } +} + +/// Status indicating the thread is closed. +public sealed record ClosedStatus : ThreadStatus +{ + [JsonPropertyName("reason")] + public string? Reason { get; init; } +} diff --git a/src/Qyl.ChatKit/WidgetDiff.cs b/src/Qyl.ChatKit/WidgetDiff.cs new file mode 100644 index 0000000..a58fc47 --- /dev/null +++ b/src/Qyl.ChatKit/WidgetDiff.cs @@ -0,0 +1,314 @@ +using System.Runtime.CompilerServices; +using System.Text.Json; +using Qyl.ChatKit.Widgets; + +namespace Qyl.ChatKit; + +/// +/// Compares two instances and produces streaming deltas, +/// and streams widget roots as sequences. +/// +public static class WidgetDiff +{ + /// + /// Compare two widget roots and return a list of deltas describing + /// the minimal updates needed to transform + /// into . + /// + public static IReadOnlyList DiffWidget(WidgetRoot before, WidgetRoot after) + { + if (FullReplace(before, after)) + return [new WidgetRootUpdated { Widget = after }]; + + var beforeNodes = FindAllStreamingTextComponents(before); + var afterNodes = FindAllStreamingTextComponents(after); + + List deltas = []; + + foreach (var (id, afterNode) in afterNodes) + { + if (!beforeNodes.TryGetValue(id, out var beforeNode)) + { + throw new InvalidOperationException( + $"Node {id} was not present when the widget was initially rendered. " + + "All nodes with ID must persist across all widget updates."); + } + + var beforeValue = GetStringValue(beforeNode) ?? ""; + var afterValue = GetStringValue(afterNode) ?? ""; + + if (beforeValue != afterValue) + { + if (!afterValue.StartsWith(beforeValue, StringComparison.Ordinal)) + { + throw new InvalidOperationException( + $"Node {id} was updated with a new value that is not a prefix of the initial value. " + + "All widget updates must be cumulative."); + } + + var done = !IsStreaming(afterNode); + deltas.Add(new WidgetStreamingTextValueDelta + { + ComponentId = id, + Delta = afterValue[beforeValue.Length..], + Done = done, + }); + } + } + + return deltas; + } + + /// + /// Stream a single widget root as a . + /// + public static async IAsyncEnumerable StreamWidgetAsync( + ThreadMetadata thread, + WidgetRoot widget, + string? copyText = null, + Func? generateId = null, + TimeProvider? timeProvider = null, + [EnumeratorCancellation] CancellationToken ct = default) + { + var clock = timeProvider ?? TimeProvider.System; + var id = (generateId ?? (t => StoreIdGenerator.GenerateId(t)))(StoreItemType.Message); + + yield return new ThreadItemDoneEvent + { + Item = new WidgetItem + { + Id = id, + ThreadId = thread.Id, + CreatedAt = clock.GetUtcNow().UtcDateTime, + Widget = widget, + CopyText = copyText, + }, + }; + + await Task.CompletedTask; + } + + /// + /// Stream an async sequence of widget roots as instances, + /// computing deltas between successive states. + /// + public static async IAsyncEnumerable StreamWidgetAsync( + ThreadMetadata thread, + IAsyncEnumerable widgetStream, + string? copyText = null, + Func? generateId = null, + TimeProvider? timeProvider = null, + [EnumeratorCancellation] CancellationToken ct = default) + { + var clock = timeProvider ?? TimeProvider.System; + var id = (generateId ?? (t => StoreIdGenerator.GenerateId(t)))(StoreItemType.Message); + + await using var enumerator = widgetStream.GetAsyncEnumerator(ct); + + if (!await enumerator.MoveNextAsync()) + yield break; + + var initialState = enumerator.Current; + + var item = new WidgetItem + { + Id = id, + ThreadId = thread.Id, + CreatedAt = clock.GetUtcNow().UtcDateTime, + Widget = initialState, + CopyText = copyText, + }; + + yield return new ThreadItemAddedEvent { Item = item }; + + var lastState = initialState; + + while (await enumerator.MoveNextAsync()) + { + var newState = enumerator.Current; + foreach (var update in DiffWidget(lastState, newState)) + { + yield return new ThreadItemUpdatedEvent + { + ItemId = id, + Update = update, + }; + } + lastState = newState; + } + + yield return new ThreadItemDoneEvent + { + Item = item with { Widget = lastState }, + }; + } + + // -- Private helpers -- + + private static bool IsStreamingText(WidgetComponentBase component) => + component is Markdown or Text; + + private static string? GetStringValue(WidgetComponentBase component) => + component switch + { + Markdown md => md.Value, + Text txt => txt.Value, + _ => null, + }; + + private static bool IsStreaming(WidgetComponentBase component) => + component switch + { + Markdown md => md.Streaming ?? false, + Text txt => txt.Streaming ?? false, + _ => false, + }; + + private static bool FullReplace(WidgetComponentBase before, WidgetComponentBase after) + { + if (before.GetType() != after.GetType()) + return true; + if (before.Id != after.Id) + return true; + if (before.Key != after.Key) + return true; + + var beforeJson = JsonSerializer.SerializeToUtf8Bytes(before, before.GetType(), ChatKitJsonOptions.Default); + var afterJson = JsonSerializer.SerializeToUtf8Bytes(after, after.GetType(), ChatKitJsonOptions.Default); + + using var beforeDoc = JsonDocument.Parse(beforeJson); + using var afterDoc = JsonDocument.Parse(afterJson); + + var beforeRoot = beforeDoc.RootElement; + var afterRoot = afterDoc.RootElement; + + var bothStreamingText = IsStreamingText(before) && IsStreamingText(after); + + foreach (var prop in beforeRoot.EnumerateObject()) + { + // Skip the value field for streaming text -- delta handling covers it + if (bothStreamingText && prop.Name == "value") + { + var afterValueStr = GetStringValue(after) ?? ""; + var beforeValueStr = GetStringValue(before) ?? ""; + if (afterValueStr.StartsWith(beforeValueStr, StringComparison.Ordinal)) + continue; + } + + if (!afterRoot.TryGetProperty(prop.Name, out var afterProp)) + return true; + + if (!JsonElementEquals(prop.Value, afterProp)) + return true; + } + + // Check for new properties in after that were not in before + foreach (var prop in afterRoot.EnumerateObject()) + { + if (bothStreamingText && prop.Name == "value") + continue; + + if (!beforeRoot.TryGetProperty(prop.Name, out _)) + return true; + } + + return false; + } + + private static bool JsonElementEquals(JsonElement a, JsonElement b) + { + if (a.ValueKind != b.ValueKind) + return false; + + return a.ValueKind switch + { + JsonValueKind.Object => ObjectEquals(a, b), + JsonValueKind.Array => ArrayEquals(a, b), + JsonValueKind.String => a.GetString() == b.GetString(), + JsonValueKind.Number => a.GetRawText() == b.GetRawText(), + JsonValueKind.True => true, + JsonValueKind.False => true, + JsonValueKind.Null => true, + _ => a.GetRawText() == b.GetRawText(), + }; + } + + private static bool ObjectEquals(JsonElement a, JsonElement b) + { + var aProps = new Dictionary(); + foreach (var prop in a.EnumerateObject()) + aProps[prop.Name] = prop.Value; + + var bProps = new Dictionary(); + foreach (var prop in b.EnumerateObject()) + bProps[prop.Name] = prop.Value; + + if (aProps.Count != bProps.Count) + return false; + + foreach (var (key, aVal) in aProps) + { + if (!bProps.TryGetValue(key, out var bVal)) + return false; + if (!JsonElementEquals(aVal, bVal)) + return false; + } + + return true; + } + + private static bool ArrayEquals(JsonElement a, JsonElement b) + { + var aLen = a.GetArrayLength(); + var bLen = b.GetArrayLength(); + if (aLen != bLen) + return false; + + using var aEnum = a.EnumerateArray(); + using var bEnum = b.EnumerateArray(); + + while (aEnum.MoveNext() && bEnum.MoveNext()) + { + if (!JsonElementEquals(aEnum.Current, bEnum.Current)) + return false; + } + + return true; + } + + private static Dictionary FindAllStreamingTextComponents( + WidgetComponentBase component) + { + var result = new Dictionary(); + CollectStreamingTextComponents(component, result); + return result; + } + + private static void CollectStreamingTextComponents( + WidgetComponentBase component, + Dictionary result) + { + if (IsStreamingText(component) && component.Id is not null) + result[component.Id] = component; + + var children = GetChildren(component); + if (children is null) + return; + + foreach (var child in children) + CollectStreamingTextComponents(child, result); + } + + private static IEnumerable? GetChildren(WidgetComponentBase component) => + component switch + { + Card card => card.Children, + ListView listView => listView.Children, + BoxLayoutProps box => box.Children, + Transition transition => transition.Children is not null + ? [transition.Children] + : null, + ListViewItem lvi => lvi.Children, + _ => null, + }; +} diff --git a/src/Qyl.ChatKit/Widgets/ChartTypes.cs b/src/Qyl.ChatKit/Widgets/ChartTypes.cs new file mode 100644 index 0000000..f8fe693 --- /dev/null +++ b/src/Qyl.ChatKit/Widgets/ChartTypes.cs @@ -0,0 +1,118 @@ +using System.Text.Json.Serialization; + +namespace Qyl.ChatKit.Widgets; + +/// Configuration object for the X axis. +public sealed record XAxisConfig +{ + [JsonPropertyName("dataKey")] + public required string DataKey { get; init; } + + [JsonPropertyName("hide")] + public bool? Hide { get; init; } + + [JsonPropertyName("labels")] + public Dictionary? Labels { get; init; } +} + +/// Polymorphic base for chart series definitions. +[JsonPolymorphic(TypeDiscriminatorPropertyName = "type")] +[JsonDerivedType(typeof(BarSeries), "bar")] +[JsonDerivedType(typeof(AreaSeries), "area")] +[JsonDerivedType(typeof(LineSeries), "line")] +public abstract record SeriesBase +{ + [JsonPropertyName("label")] + public string? Label { get; init; } + + [JsonPropertyName("dataKey")] + public required string DataKey { get; init; } + + [JsonPropertyName("color")] + public object? Color { get; init; } +} + +/// A bar series plotted from a numeric dataKey. Supports stacking. +public sealed record BarSeries : SeriesBase +{ + [JsonPropertyName("stack")] + public string? Stack { get; init; } +} + +/// An area series plotted from a numeric dataKey. Supports stacking and curves. +public sealed record AreaSeries : SeriesBase +{ + [JsonPropertyName("stack")] + public string? Stack { get; init; } + + [JsonPropertyName("curveType")] + public CurveType? CurveType { get; init; } +} + +/// A line series plotted from a numeric dataKey. Supports curves. +public sealed record LineSeries : SeriesBase +{ + [JsonPropertyName("curveType")] + public CurveType? CurveType { get; init; } +} + +/// Data visualization component for bar/line/area charts. +public sealed record Chart : WidgetComponentBase +{ + [JsonPropertyName("data")] + public required IReadOnlyList> Data { get; init; } + + [JsonPropertyName("series")] + public required IReadOnlyList Series { get; init; } + + [JsonPropertyName("xAxis")] + public required object XAxis { get; init; } + + [JsonPropertyName("showYAxis")] + public bool? ShowYAxis { get; init; } + + [JsonPropertyName("showLegend")] + public bool? ShowLegend { get; init; } + + [JsonPropertyName("showTooltip")] + public bool? ShowTooltip { get; init; } + + [JsonPropertyName("barGap")] + public int? BarGap { get; init; } + + [JsonPropertyName("barCategoryGap")] + public int? BarCategoryGap { get; init; } + + [JsonPropertyName("flex")] + public object? Flex { get; init; } + + [JsonPropertyName("height")] + public object? Height { get; init; } + + [JsonPropertyName("width")] + public object? Width { get; init; } + + [JsonPropertyName("size")] + public object? Size { get; init; } + + [JsonPropertyName("minHeight")] + public object? MinHeight { get; init; } + + [JsonPropertyName("minWidth")] + public object? MinWidth { get; init; } + + [JsonPropertyName("minSize")] + public object? MinSize { get; init; } + + [JsonPropertyName("maxHeight")] + public object? MaxHeight { get; init; } + + [JsonPropertyName("maxWidth")] + public object? MaxWidth { get; init; } + + [JsonPropertyName("maxSize")] + public object? MaxSize { get; init; } + + [JsonPropertyName("aspectRatio")] + public object? AspectRatio { get; init; } +} diff --git a/src/Qyl.ChatKit/Widgets/WidgetComponentBase.cs b/src/Qyl.ChatKit/Widgets/WidgetComponentBase.cs new file mode 100644 index 0000000..60d2854 --- /dev/null +++ b/src/Qyl.ChatKit/Widgets/WidgetComponentBase.cs @@ -0,0 +1,182 @@ +using System.Text.Json.Serialization; + +namespace Qyl.ChatKit.Widgets; + +/// Color values for light and dark themes. +public sealed record ThemeColor +{ + [JsonPropertyName("dark")] + public required string Dark { get; init; } + + [JsonPropertyName("light")] + public required string Light { get; init; } +} + +/// Shorthand spacing values applied to a widget. +public sealed record Spacing +{ + [JsonPropertyName("top")] + public object? Top { get; init; } + + [JsonPropertyName("right")] + public object? Right { get; init; } + + [JsonPropertyName("bottom")] + public object? Bottom { get; init; } + + [JsonPropertyName("left")] + public object? Left { get; init; } + + [JsonPropertyName("x")] + public object? X { get; init; } + + [JsonPropertyName("y")] + public object? Y { get; init; } +} + +/// Border style definition for an edge. +public sealed record Border +{ + [JsonPropertyName("size")] + public required int Size { get; init; } + + [JsonPropertyName("color")] + public object? Color { get; init; } + + [JsonPropertyName("style")] + public string? Style { get; init; } +} + +/// Composite border configuration applied across edges. +public sealed record Borders +{ + [JsonPropertyName("top")] + public object? Top { get; init; } + + [JsonPropertyName("right")] + public object? Right { get; init; } + + [JsonPropertyName("bottom")] + public object? Bottom { get; init; } + + [JsonPropertyName("left")] + public object? Left { get; init; } + + [JsonPropertyName("x")] + public object? X { get; init; } + + [JsonPropertyName("y")] + public object? Y { get; init; } +} + +/// Editable field options for text widgets. +public sealed record EditableProps +{ + [JsonPropertyName("name")] + public required string Name { get; init; } + + [JsonPropertyName("autoFocus")] + public bool? AutoFocus { get; init; } + + [JsonPropertyName("autoSelect")] + public bool? AutoSelect { get; init; } + + [JsonPropertyName("autoComplete")] + public string? AutoComplete { get; init; } + + [JsonPropertyName("allowAutofillExtensions")] + public bool? AllowAutofillExtensions { get; init; } + + [JsonPropertyName("pattern")] + public string? Pattern { get; init; } + + [JsonPropertyName("placeholder")] + public string? Placeholder { get; init; } + + [JsonPropertyName("required")] + public bool? Required { get; init; } +} + +/// Selectable option used by the Select widget. +public sealed record SelectOption +{ + [JsonPropertyName("value")] + public required string Value { get; init; } + + [JsonPropertyName("label")] + public required string Label { get; init; } + + [JsonPropertyName("disabled")] + public bool? Disabled { get; init; } + + [JsonPropertyName("description")] + public string? Description { get; init; } +} + +/// Option inside a RadioGroup widget. +public sealed record RadioOption +{ + [JsonPropertyName("label")] + public required string Label { get; init; } + + [JsonPropertyName("value")] + public required string Value { get; init; } + + [JsonPropertyName("disabled")] + public bool? Disabled { get; init; } +} + +/// Configuration for confirm/cancel actions within a card. +public sealed record CardAction +{ + [JsonPropertyName("label")] + public required string Label { get; init; } + + [JsonPropertyName("action")] + public required ActionConfig Action { get; init; } +} + +/// Base model for all widget components. +[JsonPolymorphic(TypeDiscriminatorPropertyName = "type")] +[JsonDerivedType(typeof(Text), "Text")] +[JsonDerivedType(typeof(Title), "Title")] +[JsonDerivedType(typeof(Caption), "Caption")] +[JsonDerivedType(typeof(Markdown), "Markdown")] +[JsonDerivedType(typeof(Badge), "Badge")] +[JsonDerivedType(typeof(Box), "Box")] +[JsonDerivedType(typeof(Row), "Row")] +[JsonDerivedType(typeof(Col), "Col")] +[JsonDerivedType(typeof(Form), "Form")] +[JsonDerivedType(typeof(Divider), "Divider")] +[JsonDerivedType(typeof(Icon), "Icon")] +[JsonDerivedType(typeof(Image), "Image")] +[JsonDerivedType(typeof(Button), "Button")] +[JsonDerivedType(typeof(Spacer), "Spacer")] +[JsonDerivedType(typeof(Select), "Select")] +[JsonDerivedType(typeof(DatePicker), "DatePicker")] +[JsonDerivedType(typeof(Checkbox), "Checkbox")] +[JsonDerivedType(typeof(Input), "Input")] +[JsonDerivedType(typeof(Label), "Label")] +[JsonDerivedType(typeof(RadioGroup), "RadioGroup")] +[JsonDerivedType(typeof(Textarea), "Textarea")] +[JsonDerivedType(typeof(Transition), "Transition")] +[JsonDerivedType(typeof(Chart), "Chart")] +[JsonDerivedType(typeof(ListViewItem), "ListViewItem")] +[JsonDerivedType(typeof(Card), "Card")] +[JsonDerivedType(typeof(ListView), "ListView")] +[JsonDerivedType(typeof(BasicRoot), "Basic")] +public abstract record WidgetComponentBase +{ + [JsonPropertyName("key")] + public string? Key { get; init; } + + [JsonPropertyName("id")] + public string? Id { get; init; } +} + +/// Polymorphic base for top-level widget roots. +[JsonPolymorphic(TypeDiscriminatorPropertyName = "type")] +[JsonDerivedType(typeof(Card), "Card")] +[JsonDerivedType(typeof(ListView), "ListView")] +[JsonDerivedType(typeof(BasicRoot), "Basic")] +public abstract record WidgetRoot : WidgetComponentBase; diff --git a/src/Qyl.ChatKit/Widgets/WidgetComponents.cs b/src/Qyl.ChatKit/Widgets/WidgetComponents.cs new file mode 100644 index 0000000..854115a --- /dev/null +++ b/src/Qyl.ChatKit/Widgets/WidgetComponents.cs @@ -0,0 +1,637 @@ +using System.Text.Json.Serialization; + +namespace Qyl.ChatKit.Widgets; + +/// Shared layout properties for flexible container widgets (Box, Row, Col, Form). +public abstract record BoxLayoutProps : WidgetComponentBase +{ + [JsonPropertyName("children")] + public IReadOnlyList? Children { get; init; } + + [JsonPropertyName("align")] + public Alignment? Align { get; init; } + + [JsonPropertyName("justify")] + public Justification? Justify { get; init; } + + [JsonPropertyName("wrap")] + public string? Wrap { get; init; } + + [JsonPropertyName("flex")] + public object? Flex { get; init; } + + [JsonPropertyName("gap")] + public object? Gap { get; init; } + + [JsonPropertyName("height")] + public object? Height { get; init; } + + [JsonPropertyName("width")] + public object? Width { get; init; } + + [JsonPropertyName("size")] + public object? Size { get; init; } + + [JsonPropertyName("minHeight")] + public object? MinHeight { get; init; } + + [JsonPropertyName("minWidth")] + public object? MinWidth { get; init; } + + [JsonPropertyName("minSize")] + public object? MinSize { get; init; } + + [JsonPropertyName("maxHeight")] + public object? MaxHeight { get; init; } + + [JsonPropertyName("maxWidth")] + public object? MaxWidth { get; init; } + + [JsonPropertyName("maxSize")] + public object? MaxSize { get; init; } + + [JsonPropertyName("padding")] + public object? Padding { get; init; } + + [JsonPropertyName("margin")] + public object? Margin { get; init; } + + [JsonPropertyName("border")] + public object? Border { get; init; } + + [JsonPropertyName("radius")] + public RadiusValue? Radius { get; init; } + + [JsonPropertyName("background")] + public object? Background { get; init; } + + [JsonPropertyName("aspectRatio")] + public object? AspectRatio { get; init; } +} + +/// Plain text with typography controls. +public sealed record Text : WidgetComponentBase +{ + [JsonPropertyName("value")] + public required string Value { get; init; } + + [JsonPropertyName("streaming")] + public bool? Streaming { get; init; } + + [JsonPropertyName("italic")] + public bool? Italic { get; init; } + + [JsonPropertyName("lineThrough")] + public bool? LineThrough { get; init; } + + [JsonPropertyName("color")] + public object? Color { get; init; } + + [JsonPropertyName("weight")] + public string? Weight { get; init; } + + [JsonPropertyName("width")] + public object? Width { get; init; } + + [JsonPropertyName("size")] + public TextSize? Size { get; init; } + + [JsonPropertyName("textAlign")] + public TextAlign? TextAlign { get; init; } + + [JsonPropertyName("truncate")] + public bool? Truncate { get; init; } + + [JsonPropertyName("minLines")] + public int? MinLines { get; init; } + + [JsonPropertyName("maxLines")] + public int? MaxLines { get; init; } + + [JsonPropertyName("editable")] + public object? Editable { get; init; } +} + +/// Prominent headline text. +public sealed record Title : WidgetComponentBase +{ + [JsonPropertyName("value")] + public required string Value { get; init; } + + [JsonPropertyName("color")] + public object? Color { get; init; } + + [JsonPropertyName("weight")] + public string? Weight { get; init; } + + [JsonPropertyName("size")] + public TitleSize? Size { get; init; } + + [JsonPropertyName("textAlign")] + public TextAlign? TextAlign { get; init; } + + [JsonPropertyName("truncate")] + public bool? Truncate { get; init; } + + [JsonPropertyName("maxLines")] + public int? MaxLines { get; init; } +} + +/// Supporting caption text. +public sealed record Caption : WidgetComponentBase +{ + [JsonPropertyName("value")] + public required string Value { get; init; } + + [JsonPropertyName("color")] + public object? Color { get; init; } + + [JsonPropertyName("weight")] + public string? Weight { get; init; } + + [JsonPropertyName("size")] + public CaptionSize? Size { get; init; } + + [JsonPropertyName("textAlign")] + public TextAlign? TextAlign { get; init; } + + [JsonPropertyName("truncate")] + public bool? Truncate { get; init; } + + [JsonPropertyName("maxLines")] + public int? MaxLines { get; init; } +} + +/// Markdown content, optionally streamed. +public sealed record Markdown : WidgetComponentBase +{ + [JsonPropertyName("value")] + public required string Value { get; init; } + + [JsonPropertyName("streaming")] + public bool? Streaming { get; init; } +} + +/// Small badge indicating status or categorization. +public sealed record Badge : WidgetComponentBase +{ + [JsonPropertyName("label")] + public required string Label { get; init; } + + [JsonPropertyName("color")] + public string? Color { get; init; } + + [JsonPropertyName("variant")] + public string? Variant { get; init; } + + [JsonPropertyName("size")] + public string? Size { get; init; } + + [JsonPropertyName("pill")] + public bool? Pill { get; init; } +} + +/// Generic flex container with direction control. +public sealed record Box : BoxLayoutProps +{ + [JsonPropertyName("direction")] + public string? Direction { get; init; } +} + +/// Horizontal flex container. +public sealed record Row : BoxLayoutProps; + +/// Vertical flex container. +public sealed record Col : BoxLayoutProps; + +/// Form wrapper capable of submitting onSubmitAction. +public sealed record Form : BoxLayoutProps +{ + [JsonPropertyName("onSubmitAction")] + public ActionConfig? OnSubmitAction { get; init; } + + [JsonPropertyName("direction")] + public string? Direction { get; init; } +} + +/// Visual divider separating content sections. +public sealed record Divider : WidgetComponentBase +{ + [JsonPropertyName("color")] + public object? Color { get; init; } + + [JsonPropertyName("size")] + public object? Size { get; init; } + + [JsonPropertyName("spacing")] + public object? Spacing { get; init; } + + [JsonPropertyName("flush")] + public bool? Flush { get; init; } +} + +/// Icon component referencing a built-in icon name. +public sealed record Icon : WidgetComponentBase +{ + [JsonPropertyName("name")] + public required string Name { get; init; } + + [JsonPropertyName("color")] + public object? Color { get; init; } + + [JsonPropertyName("size")] + public IconSize? Size { get; init; } +} + +/// Image component with sizing and fitting controls. +public sealed record Image : WidgetComponentBase +{ + [JsonPropertyName("src")] + public required string Src { get; init; } + + [JsonPropertyName("alt")] + public string? Alt { get; init; } + + [JsonPropertyName("fit")] + public string? Fit { get; init; } + + [JsonPropertyName("position")] + public string? Position { get; init; } + + [JsonPropertyName("radius")] + public RadiusValue? Radius { get; init; } + + [JsonPropertyName("frame")] + public bool? Frame { get; init; } + + [JsonPropertyName("flush")] + public bool? Flush { get; init; } + + [JsonPropertyName("height")] + public object? Height { get; init; } + + [JsonPropertyName("width")] + public object? Width { get; init; } + + [JsonPropertyName("size")] + public object? Size { get; init; } + + [JsonPropertyName("minHeight")] + public object? MinHeight { get; init; } + + [JsonPropertyName("minWidth")] + public object? MinWidth { get; init; } + + [JsonPropertyName("minSize")] + public object? MinSize { get; init; } + + [JsonPropertyName("maxHeight")] + public object? MaxHeight { get; init; } + + [JsonPropertyName("maxWidth")] + public object? MaxWidth { get; init; } + + [JsonPropertyName("maxSize")] + public object? MaxSize { get; init; } + + [JsonPropertyName("margin")] + public object? Margin { get; init; } + + [JsonPropertyName("background")] + public object? Background { get; init; } + + [JsonPropertyName("aspectRatio")] + public object? AspectRatio { get; init; } + + [JsonPropertyName("flex")] + public object? Flex { get; init; } +} + +/// Button component optionally wired to an action. +public sealed record Button : WidgetComponentBase +{ + [JsonPropertyName("submit")] + public bool? Submit { get; init; } + + [JsonPropertyName("label")] + public string? Label { get; init; } + + [JsonPropertyName("onClickAction")] + public ActionConfig? OnClickAction { get; init; } + + [JsonPropertyName("iconStart")] + public string? IconStart { get; init; } + + [JsonPropertyName("iconEnd")] + public string? IconEnd { get; init; } + + [JsonPropertyName("style")] + public string? Style { get; init; } + + [JsonPropertyName("iconSize")] + public string? IconSize { get; init; } + + [JsonPropertyName("color")] + public string? Color { get; init; } + + [JsonPropertyName("variant")] + public ControlVariant? Variant { get; init; } + + [JsonPropertyName("size")] + public ControlSize? Size { get; init; } + + [JsonPropertyName("pill")] + public bool? Pill { get; init; } + + [JsonPropertyName("uniform")] + public bool? Uniform { get; init; } + + [JsonPropertyName("block")] + public bool? Block { get; init; } + + [JsonPropertyName("disabled")] + public bool? Disabled { get; init; } +} + +/// Flexible spacer used to push content apart. +public sealed record Spacer : WidgetComponentBase +{ + [JsonPropertyName("minSize")] + public object? MinSize { get; init; } +} + +/// Select dropdown component. +public sealed record Select : WidgetComponentBase +{ + [JsonPropertyName("name")] + public required string Name { get; init; } + + [JsonPropertyName("options")] + public required IReadOnlyList Options { get; init; } + + [JsonPropertyName("onChangeAction")] + public ActionConfig? OnChangeAction { get; init; } + + [JsonPropertyName("placeholder")] + public string? Placeholder { get; init; } + + [JsonPropertyName("defaultValue")] + public string? DefaultValue { get; init; } + + [JsonPropertyName("variant")] + public ControlVariant? Variant { get; init; } + + [JsonPropertyName("size")] + public ControlSize? Size { get; init; } + + [JsonPropertyName("pill")] + public bool? Pill { get; init; } + + [JsonPropertyName("block")] + public bool? Block { get; init; } + + [JsonPropertyName("clearable")] + public bool? Clearable { get; init; } + + [JsonPropertyName("disabled")] + public bool? Disabled { get; init; } + + [JsonPropertyName("searchable")] + public bool? Searchable { get; init; } +} + +/// Date picker input component. +public sealed record DatePicker : WidgetComponentBase +{ + [JsonPropertyName("name")] + public required string Name { get; init; } + + [JsonPropertyName("onChangeAction")] + public ActionConfig? OnChangeAction { get; init; } + + [JsonPropertyName("placeholder")] + public string? Placeholder { get; init; } + + [JsonPropertyName("defaultValue")] + public DateTimeOffset? DefaultValue { get; init; } + + [JsonPropertyName("min")] + public DateTimeOffset? Min { get; init; } + + [JsonPropertyName("max")] + public DateTimeOffset? Max { get; init; } + + [JsonPropertyName("variant")] + public ControlVariant? Variant { get; init; } + + [JsonPropertyName("size")] + public ControlSize? Size { get; init; } + + [JsonPropertyName("side")] + public string? Side { get; init; } + + [JsonPropertyName("align")] + public string? Align { get; init; } + + [JsonPropertyName("pill")] + public bool? Pill { get; init; } + + [JsonPropertyName("block")] + public bool? Block { get; init; } + + [JsonPropertyName("clearable")] + public bool? Clearable { get; init; } + + [JsonPropertyName("disabled")] + public bool? Disabled { get; init; } +} + +/// Checkbox input component. +public sealed record Checkbox : WidgetComponentBase +{ + [JsonPropertyName("name")] + public required string Name { get; init; } + + [JsonPropertyName("label")] + public string? Label { get; init; } + + [JsonPropertyName("defaultChecked")] + public bool? DefaultChecked { get; init; } + + [JsonPropertyName("onChangeAction")] + public ActionConfig? OnChangeAction { get; init; } + + [JsonPropertyName("disabled")] + public bool? Disabled { get; init; } + + [JsonPropertyName("required")] + public bool? Required { get; init; } +} + +/// Single-line text input component. +public sealed record Input : WidgetComponentBase +{ + [JsonPropertyName("name")] + public required string Name { get; init; } + + [JsonPropertyName("inputType")] + public string? InputType { get; init; } + + [JsonPropertyName("defaultValue")] + public string? DefaultValue { get; init; } + + [JsonPropertyName("required")] + public bool? Required { get; init; } + + [JsonPropertyName("pattern")] + public string? Pattern { get; init; } + + [JsonPropertyName("placeholder")] + public string? Placeholder { get; init; } + + [JsonPropertyName("allowAutofillExtensions")] + public bool? AllowAutofillExtensions { get; init; } + + [JsonPropertyName("autoSelect")] + public bool? AutoSelect { get; init; } + + [JsonPropertyName("autoFocus")] + public bool? AutoFocus { get; init; } + + [JsonPropertyName("disabled")] + public bool? Disabled { get; init; } + + [JsonPropertyName("variant")] + public string? Variant { get; init; } + + [JsonPropertyName("size")] + public ControlSize? Size { get; init; } + + [JsonPropertyName("gutterSize")] + public string? GutterSize { get; init; } + + [JsonPropertyName("pill")] + public bool? Pill { get; init; } +} + +/// Form label associated with a field. +public sealed record Label : WidgetComponentBase +{ + [JsonPropertyName("value")] + public required string Value { get; init; } + + [JsonPropertyName("fieldName")] + public required string FieldName { get; init; } + + [JsonPropertyName("size")] + public TextSize? Size { get; init; } + + [JsonPropertyName("weight")] + public string? Weight { get; init; } + + [JsonPropertyName("textAlign")] + public TextAlign? TextAlign { get; init; } + + [JsonPropertyName("color")] + public object? Color { get; init; } +} + +/// Grouped radio input control. +public sealed record RadioGroup : WidgetComponentBase +{ + [JsonPropertyName("name")] + public required string Name { get; init; } + + [JsonPropertyName("options")] + public IReadOnlyList? Options { get; init; } + + [JsonPropertyName("ariaLabel")] + public string? AriaLabel { get; init; } + + [JsonPropertyName("onChangeAction")] + public ActionConfig? OnChangeAction { get; init; } + + [JsonPropertyName("defaultValue")] + public string? DefaultValue { get; init; } + + [JsonPropertyName("direction")] + public string? Direction { get; init; } + + [JsonPropertyName("disabled")] + public bool? Disabled { get; init; } + + [JsonPropertyName("required")] + public bool? Required { get; init; } +} + +/// Multiline text input component. +public sealed record Textarea : WidgetComponentBase +{ + [JsonPropertyName("name")] + public required string Name { get; init; } + + [JsonPropertyName("defaultValue")] + public string? DefaultValue { get; init; } + + [JsonPropertyName("required")] + public bool? Required { get; init; } + + [JsonPropertyName("pattern")] + public string? Pattern { get; init; } + + [JsonPropertyName("placeholder")] + public string? Placeholder { get; init; } + + [JsonPropertyName("autoSelect")] + public bool? AutoSelect { get; init; } + + [JsonPropertyName("autoFocus")] + public bool? AutoFocus { get; init; } + + [JsonPropertyName("disabled")] + public bool? Disabled { get; init; } + + [JsonPropertyName("variant")] + public string? Variant { get; init; } + + [JsonPropertyName("size")] + public ControlSize? Size { get; init; } + + [JsonPropertyName("gutterSize")] + public string? GutterSize { get; init; } + + [JsonPropertyName("rows")] + public int? Rows { get; init; } + + [JsonPropertyName("autoResize")] + public bool? AutoResize { get; init; } + + [JsonPropertyName("maxRows")] + public int? MaxRows { get; init; } + + [JsonPropertyName("allowAutofillExtensions")] + public bool? AllowAutofillExtensions { get; init; } +} + +/// Wrapper enabling transitions for a child component. +public sealed record Transition : WidgetComponentBase +{ + [JsonPropertyName("children")] + public WidgetComponentBase? Children { get; init; } +} + +/// Single row inside a ListView component. +public sealed record ListViewItem : WidgetComponentBase +{ + [JsonPropertyName("children")] + public required IReadOnlyList Children { get; init; } + + [JsonPropertyName("onClickAction")] + public ActionConfig? OnClickAction { get; init; } + + [JsonPropertyName("gap")] + public object? Gap { get; init; } + + [JsonPropertyName("align")] + public Alignment? Align { get; init; } +} diff --git a/src/Qyl.ChatKit/Widgets/WidgetEnums.cs b/src/Qyl.ChatKit/Widgets/WidgetEnums.cs new file mode 100644 index 0000000..97fdf39 --- /dev/null +++ b/src/Qyl.ChatKit/Widgets/WidgetEnums.cs @@ -0,0 +1,292 @@ +using System.Text.Json.Serialization; + +namespace Qyl.ChatKit.Widgets; + +/// Allowed corner radius tokens. +[JsonConverter(typeof(JsonStringEnumConverter))] +public enum RadiusValue +{ + [JsonStringEnumMemberName("2xs")] + TwoXs, + + [JsonStringEnumMemberName("xs")] + Xs, + + [JsonStringEnumMemberName("sm")] + Sm, + + [JsonStringEnumMemberName("md")] + Md, + + [JsonStringEnumMemberName("lg")] + Lg, + + [JsonStringEnumMemberName("xl")] + Xl, + + [JsonStringEnumMemberName("2xl")] + TwoXl, + + [JsonStringEnumMemberName("3xl")] + ThreeXl, + + [JsonStringEnumMemberName("4xl")] + FourXl, + + [JsonStringEnumMemberName("full")] + Full, + + [JsonStringEnumMemberName("100%")] + HundredPercent, + + [JsonStringEnumMemberName("none")] + None +} + +/// Horizontal text alignment options. +[JsonConverter(typeof(JsonStringEnumConverter))] +public enum TextAlign +{ + [JsonStringEnumMemberName("start")] + Start, + + [JsonStringEnumMemberName("center")] + Center, + + [JsonStringEnumMemberName("end")] + End +} + +/// Body text size tokens. +[JsonConverter(typeof(JsonStringEnumConverter))] +public enum TextSize +{ + [JsonStringEnumMemberName("xs")] + Xs, + + [JsonStringEnumMemberName("sm")] + Sm, + + [JsonStringEnumMemberName("md")] + Md, + + [JsonStringEnumMemberName("lg")] + Lg, + + [JsonStringEnumMemberName("xl")] + Xl +} + +/// Title text size tokens. +[JsonConverter(typeof(JsonStringEnumConverter))] +public enum TitleSize +{ + [JsonStringEnumMemberName("sm")] + Sm, + + [JsonStringEnumMemberName("md")] + Md, + + [JsonStringEnumMemberName("lg")] + Lg, + + [JsonStringEnumMemberName("xl")] + Xl, + + [JsonStringEnumMemberName("2xl")] + TwoXl, + + [JsonStringEnumMemberName("3xl")] + ThreeXl, + + [JsonStringEnumMemberName("4xl")] + FourXl, + + [JsonStringEnumMemberName("5xl")] + FiveXl +} + +/// Caption text size tokens. +[JsonConverter(typeof(JsonStringEnumConverter))] +public enum CaptionSize +{ + [JsonStringEnumMemberName("sm")] + Sm, + + [JsonStringEnumMemberName("md")] + Md, + + [JsonStringEnumMemberName("lg")] + Lg +} + +/// Icon size tokens. +[JsonConverter(typeof(JsonStringEnumConverter))] +public enum IconSize +{ + [JsonStringEnumMemberName("xs")] + Xs, + + [JsonStringEnumMemberName("sm")] + Sm, + + [JsonStringEnumMemberName("md")] + Md, + + [JsonStringEnumMemberName("lg")] + Lg, + + [JsonStringEnumMemberName("xl")] + Xl, + + [JsonStringEnumMemberName("2xl")] + TwoXl, + + [JsonStringEnumMemberName("3xl")] + ThreeXl +} + +/// Flexbox alignment options. +[JsonConverter(typeof(JsonStringEnumConverter))] +public enum Alignment +{ + [JsonStringEnumMemberName("start")] + Start, + + [JsonStringEnumMemberName("center")] + Center, + + [JsonStringEnumMemberName("end")] + End, + + [JsonStringEnumMemberName("baseline")] + Baseline, + + [JsonStringEnumMemberName("stretch")] + Stretch +} + +/// Flexbox justification options. +[JsonConverter(typeof(JsonStringEnumConverter))] +public enum Justification +{ + [JsonStringEnumMemberName("start")] + Start, + + [JsonStringEnumMemberName("center")] + Center, + + [JsonStringEnumMemberName("end")] + End, + + [JsonStringEnumMemberName("between")] + Between, + + [JsonStringEnumMemberName("around")] + Around, + + [JsonStringEnumMemberName("evenly")] + Evenly, + + [JsonStringEnumMemberName("stretch")] + Stretch +} + +/// Button and input style variants. +[JsonConverter(typeof(JsonStringEnumConverter))] +public enum ControlVariant +{ + [JsonStringEnumMemberName("solid")] + Solid, + + [JsonStringEnumMemberName("soft")] + Soft, + + [JsonStringEnumMemberName("outline")] + Outline, + + [JsonStringEnumMemberName("ghost")] + Ghost +} + +/// Button and input size variants. +[JsonConverter(typeof(JsonStringEnumConverter))] +public enum ControlSize +{ + [JsonStringEnumMemberName("3xs")] + ThreeXs, + + [JsonStringEnumMemberName("2xs")] + TwoXs, + + [JsonStringEnumMemberName("xs")] + Xs, + + [JsonStringEnumMemberName("sm")] + Sm, + + [JsonStringEnumMemberName("md")] + Md, + + [JsonStringEnumMemberName("lg")] + Lg, + + [JsonStringEnumMemberName("xl")] + Xl, + + [JsonStringEnumMemberName("2xl")] + TwoXl, + + [JsonStringEnumMemberName("3xl")] + ThreeXl +} + +/// Interpolation curve types for area and line series. +[JsonConverter(typeof(JsonStringEnumConverter))] +public enum CurveType +{ + [JsonStringEnumMemberName("basis")] + Basis, + + [JsonStringEnumMemberName("basisClosed")] + BasisClosed, + + [JsonStringEnumMemberName("basisOpen")] + BasisOpen, + + [JsonStringEnumMemberName("bumpX")] + BumpX, + + [JsonStringEnumMemberName("bumpY")] + BumpY, + + [JsonStringEnumMemberName("bump")] + Bump, + + [JsonStringEnumMemberName("linear")] + Linear, + + [JsonStringEnumMemberName("linearClosed")] + LinearClosed, + + [JsonStringEnumMemberName("natural")] + Natural, + + [JsonStringEnumMemberName("monotoneX")] + MonotoneX, + + [JsonStringEnumMemberName("monotoneY")] + MonotoneY, + + [JsonStringEnumMemberName("monotone")] + Monotone, + + [JsonStringEnumMemberName("step")] + Step, + + [JsonStringEnumMemberName("stepBefore")] + StepBefore, + + [JsonStringEnumMemberName("stepAfter")] + StepAfter +} diff --git a/src/Qyl.ChatKit/Widgets/WidgetRoots.cs b/src/Qyl.ChatKit/Widgets/WidgetRoots.cs new file mode 100644 index 0000000..48b2e2c --- /dev/null +++ b/src/Qyl.ChatKit/Widgets/WidgetRoots.cs @@ -0,0 +1,77 @@ +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace Qyl.ChatKit.Widgets; + +/// Versatile container used for structuring widget content. +public sealed record Card : WidgetRoot +{ + [JsonPropertyName("asForm")] + public bool? AsForm { get; init; } + + [JsonPropertyName("children")] + public required IReadOnlyList Children { get; init; } + + [JsonPropertyName("background")] + public object? Background { get; init; } + + [JsonPropertyName("size")] + public string? Size { get; init; } + + [JsonPropertyName("padding")] + public object? Padding { get; init; } + + [JsonPropertyName("status")] + public object? Status { get; init; } + + [JsonPropertyName("collapsed")] + public bool? Collapsed { get; init; } + + [JsonPropertyName("confirm")] + public CardAction? Confirm { get; init; } + + [JsonPropertyName("cancel")] + public CardAction? Cancel { get; init; } + + [JsonPropertyName("theme")] + public string? Theme { get; init; } +} + +/// Container for rendering collections of list items. +public sealed record ListView : WidgetRoot +{ + [JsonPropertyName("children")] + public required IReadOnlyList Children { get; init; } + + [JsonPropertyName("limit")] + public object? Limit { get; init; } + + [JsonPropertyName("status")] + public object? Status { get; init; } + + [JsonPropertyName("theme")] + public string? Theme { get; init; } +} + +/// Layout root capable of nesting components or other roots. +public sealed record BasicRoot : WidgetRoot +{ + [JsonPropertyName("children")] + public object? Children { get; init; } + + [JsonExtensionData] + public Dictionary? ExtensionData { get; init; } +} + +/// +/// Widget component with a statically defined base shape but dynamically +/// defined additional fields loaded from a widget template or JSON schema. +/// +public sealed record DynamicWidgetComponent : WidgetComponentBase +{ + [JsonPropertyName("children")] + public object? Children { get; init; } + + [JsonExtensionData] + public Dictionary? ExtensionData { get; init; } +} diff --git a/src/Qyl.ChatKit/Workflows.cs b/src/Qyl.ChatKit/Workflows.cs new file mode 100644 index 0000000..9e2eabe --- /dev/null +++ b/src/Qyl.ChatKit/Workflows.cs @@ -0,0 +1,121 @@ +using System.Text.Json.Serialization; + +namespace Qyl.ChatKit; + +/// Workflow attached to a thread with optional summary. +public sealed record Workflow +{ + /// Workflow kind: "custom" or "reasoning". + [JsonPropertyName("type")] + public required string Type { get; init; } + + [JsonPropertyName("tasks")] + public IReadOnlyList Tasks { get; init; } = []; + + [JsonPropertyName("summary")] + public WorkflowSummary? Summary { get; init; } + + [JsonPropertyName("expanded")] + public bool Expanded { get; init; } +} + +/// Summary variants available for workflows. +[JsonPolymorphic] +[JsonDerivedType(typeof(CustomSummary), typeDiscriminator: "custom")] +[JsonDerivedType(typeof(DurationSummary), typeDiscriminator: "duration")] +public abstract record WorkflowSummary; + +/// Custom summary for a workflow. +public sealed record CustomSummary : WorkflowSummary +{ + [JsonPropertyName("title")] + public required string Title { get; init; } + + /// Icon name. Accepts vendor:* and lucide:* prefixes. + [JsonPropertyName("icon")] + public string? Icon { get; init; } +} + +/// Summary providing total workflow duration. +public sealed record DurationSummary : WorkflowSummary +{ + /// The duration of the workflow in seconds. + [JsonPropertyName("duration")] + public required int Duration { get; init; } +} + +// -- Task types (named ChatKitTask to avoid conflict with System.Threading.Tasks.Task) -- + +/// Base fields common to all workflow tasks. +[JsonPolymorphic(TypeDiscriminatorPropertyName = "type")] +[JsonDerivedType(typeof(CustomTask), "custom")] +[JsonDerivedType(typeof(SearchTask), "web_search")] +[JsonDerivedType(typeof(ThoughtTask), "thought")] +[JsonDerivedType(typeof(FileTask), "file")] +[JsonDerivedType(typeof(ImageTask), "image")] +public abstract record ChatKitTask +{ + /// + /// Only used when rendering the task as part of a workflow. + /// Indicates the status of the task. + /// + [JsonPropertyName("status_indicator")] + public string StatusIndicator { get; init; } = "none"; +} + +/// Workflow task displaying custom content. +public sealed record CustomTask : ChatKitTask +{ + [JsonPropertyName("title")] + public string? Title { get; init; } + + /// Icon name. Accepts vendor:* and lucide:* prefixes. + [JsonPropertyName("icon")] + public string? Icon { get; init; } + + [JsonPropertyName("content")] + public string? Content { get; init; } +} + +/// Workflow task representing a web search. +public sealed record SearchTask : ChatKitTask +{ + [JsonPropertyName("title")] + public string? Title { get; init; } + + [JsonPropertyName("title_query")] + public string? TitleQuery { get; init; } + + [JsonPropertyName("queries")] + public IReadOnlyList Queries { get; init; } = []; + + [JsonPropertyName("sources")] + public IReadOnlyList Sources { get; init; } = []; +} + +/// Workflow task capturing assistant reasoning. +public sealed record ThoughtTask : ChatKitTask +{ + [JsonPropertyName("title")] + public string? Title { get; init; } + + [JsonPropertyName("content")] + public required string Content { get; init; } +} + +/// Workflow task referencing file sources. +public sealed record FileTask : ChatKitTask +{ + [JsonPropertyName("title")] + public string? Title { get; init; } + + [JsonPropertyName("sources")] + public IReadOnlyList Sources { get; init; } = []; +} + +/// Workflow task rendering image content. +public sealed record ImageTask : ChatKitTask +{ + [JsonPropertyName("title")] + public string? Title { get; init; } +} diff --git a/tests/Qyl.Agents.Tests/Qyl.Agents.Tests.csproj b/tests/Qyl.Agents.Tests/Qyl.Agents.Tests.csproj index 2237c22..09a34be 100644 --- a/tests/Qyl.Agents.Tests/Qyl.Agents.Tests.csproj +++ b/tests/Qyl.Agents.Tests/Qyl.Agents.Tests.csproj @@ -9,6 +9,8 @@ + +