diff --git a/src/Libraries/Microsoft.Extensions.AI.Testing/Microsoft.Extensions.AI.Testing.csproj b/src/Libraries/Microsoft.Extensions.AI.Testing/Microsoft.Extensions.AI.Testing.csproj new file mode 100644 index 00000000000..de798bd94d9 --- /dev/null +++ b/src/Libraries/Microsoft.Extensions.AI.Testing/Microsoft.Extensions.AI.Testing.csproj @@ -0,0 +1,36 @@ + + + + Microsoft.Extensions.AI + Deterministic mock implementations for testing applications built on Microsoft.Extensions.AI. + AI + Testing + $(PackageTags);Testing;AI;Chat + true + + + + preview + false + n/a + n/a + + + + $(TargetFrameworks);netstandard2.0 + $(NoWarn);MEAI001 + true + true + + + + true + true + true + + + + + + + diff --git a/src/Libraries/Microsoft.Extensions.AI.Testing/Microsoft.Extensions.AI.Testing.json b/src/Libraries/Microsoft.Extensions.AI.Testing/Microsoft.Extensions.AI.Testing.json new file mode 100644 index 00000000000..52e7a513563 --- /dev/null +++ b/src/Libraries/Microsoft.Extensions.AI.Testing/Microsoft.Extensions.AI.Testing.json @@ -0,0 +1,189 @@ +{ + "Name": "Microsoft.Extensions.AI.Testing, Version=10.9.0.0, Culture=neutral, PublicKeyToken=31bf3856ad364e35", + "Types": [ + { + "Type": "class Microsoft.Extensions.AI.MockChatClient : Microsoft.Extensions.AI.IChatClient, System.IDisposable", + "Stage": "Experimental", + "Methods": [ + { + "Member": "Microsoft.Extensions.AI.MockChatClient.MockChatClient();", + "Stage": "Experimental" + }, + { + "Member": "virtual Microsoft.Extensions.AI.MockChatClient Microsoft.Extensions.AI.MockChatClient.AddException(System.Func requestPredicate, System.Func exceptionFactory, bool singleUse = false);", + "Stage": "Experimental" + }, + { + "Member": "virtual Microsoft.Extensions.AI.MockChatClient Microsoft.Extensions.AI.MockChatClient.AddResponse(System.Func requestPredicate, System.Func> getResponse, bool singleUse = false);", + "Stage": "Experimental" + }, + { + "Member": "virtual Microsoft.Extensions.AI.MockChatClient Microsoft.Extensions.AI.MockChatClient.AddResponse(System.Func requestPredicate, System.Func> getResponse, System.Func> getStreamingResponse, bool singleUse = false);", + "Stage": "Experimental" + }, + { + "Member": "virtual Microsoft.Extensions.AI.MockChatClient Microsoft.Extensions.AI.MockChatClient.AddResponses(System.Collections.Generic.Dictionary responses, System.Func? requestPredicate = null, bool singleUse = false, System.Func>? getResponse = null);", + "Stage": "Experimental" + }, + { + "Member": "virtual Microsoft.Extensions.AI.MockChatClient Microsoft.Extensions.AI.MockChatClient.AddResponses(System.Collections.Generic.IEnumerable> responses, System.Func? requestPredicate = null, bool singleUse = false, System.Func>? getResponse = null);", + "Stage": "Experimental" + }, + { + "Member": "virtual Microsoft.Extensions.AI.MockChatClient Microsoft.Extensions.AI.MockChatClient.AddResponsesFromDictionary(System.Collections.Generic.Dictionary responses, System.Func? requestPredicate, bool singleUse, System.Func>? getResponse);", + "Stage": "Experimental" + }, + { + "Member": "virtual Microsoft.Extensions.AI.MockChatClient Microsoft.Extensions.AI.MockChatClient.AddResponsesFromEnumerable(System.Collections.Generic.IEnumerable> responses, System.Func? requestPredicate, bool singleUse, System.Func>? getResponse);", + "Stage": "Experimental" + }, + { + "Member": "virtual Microsoft.Extensions.AI.MockChatClient Microsoft.Extensions.AI.MockChatClient.AddSeed(System.Func requestPredicate, System.Func> getResponse, System.Func> getStreamingResponse, bool singleUse);", + "Stage": "Experimental" + }, + { + "Member": "virtual Microsoft.Extensions.AI.MockChatClient Microsoft.Extensions.AI.MockChatClient.AddStreamingResponse(System.Func requestPredicate, System.Func> getResponse, bool singleUse = false);", + "Stage": "Experimental" + }, + { + "Member": "virtual Microsoft.Extensions.AI.MockChatClient Microsoft.Extensions.AI.MockChatClient.ClearResponses();", + "Stage": "Experimental" + }, + { + "Member": "void Microsoft.Extensions.AI.MockChatClient.Dispose();", + "Stage": "Experimental" + }, + { + "Member": "virtual void Microsoft.Extensions.AI.MockChatClient.Dispose(bool disposing);", + "Stage": "Experimental" + }, + { + "Member": "virtual System.Threading.Tasks.Task Microsoft.Extensions.AI.MockChatClient.GetResponseAsync(System.Collections.Generic.IEnumerable messages, Microsoft.Extensions.AI.ChatOptions? options = null, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken));", + "Stage": "Experimental" + }, + { + "Member": "virtual object? Microsoft.Extensions.AI.MockChatClient.GetService(System.Type serviceType, object? serviceKey = null);", + "Stage": "Experimental" + }, + { + "Member": "virtual System.Collections.Generic.IAsyncEnumerable Microsoft.Extensions.AI.MockChatClient.GetStreamingResponseAsync(System.Collections.Generic.IEnumerable messages, Microsoft.Extensions.AI.ChatOptions? options = null, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken));", + "Stage": "Experimental" + }, + { + "Member": "virtual Microsoft.Extensions.AI.MockChatClient.SeededResponse Microsoft.Extensions.AI.MockChatClient.MatchSeededResponse(Microsoft.Extensions.AI.MockChatClientRequest request);", + "Stage": "Experimental" + } + ], + "Properties": [ + { + "Member": "System.Collections.Generic.IReadOnlyList Microsoft.Extensions.AI.MockChatClient.Requests { get; }", + "Stage": "Experimental" + }, + { + "Member": "System.IServiceProvider? Microsoft.Extensions.AI.MockChatClient.Services { get; set; }", + "Stage": "Experimental" + } + ] + }, + { + "Type": "sealed class Microsoft.Extensions.AI.MockChatClient.SeededResponse", + "Stage": "Experimental", + "Methods": [ + { + "Member": "Microsoft.Extensions.AI.MockChatClient.SeededResponse.SeededResponse();", + "Stage": "Experimental" + } + ], + "Properties": [ + { + "Member": "System.Func Microsoft.Extensions.AI.MockChatClient.SeededResponse.RequestPredicate { get; init; }", + "Stage": "Experimental" + }, + { + "Member": "System.Func> Microsoft.Extensions.AI.MockChatClient.SeededResponse.ResponseFactory { get; init; }", + "Stage": "Experimental" + }, + { + "Member": "bool Microsoft.Extensions.AI.MockChatClient.SeededResponse.SingleUse { get; init; }", + "Stage": "Experimental" + }, + { + "Member": "System.Func> Microsoft.Extensions.AI.MockChatClient.SeededResponse.StreamingFactory { get; init; }", + "Stage": "Experimental" + } + ] + }, + { + "Type": "sealed class Microsoft.Extensions.AI.MockChatClientRequest", + "Stage": "Experimental", + "Methods": [ + { + "Member": "Microsoft.Extensions.AI.MockChatClientRequest.MockChatClientRequest(System.Collections.Generic.IReadOnlyList messages, Microsoft.Extensions.AI.ChatOptions? options, bool isStreaming);", + "Stage": "Experimental" + } + ], + "Properties": [ + { + "Member": "bool Microsoft.Extensions.AI.MockChatClientRequest.IsStreaming { get; }", + "Stage": "Experimental" + }, + { + "Member": "string? Microsoft.Extensions.AI.MockChatClientRequest.LastUserText { get; }", + "Stage": "Experimental" + }, + { + "Member": "System.Collections.Generic.IReadOnlyList Microsoft.Extensions.AI.MockChatClientRequest.Messages { get; }", + "Stage": "Experimental" + }, + { + "Member": "Microsoft.Extensions.AI.ChatOptions? Microsoft.Extensions.AI.MockChatClientRequest.Options { get; }", + "Stage": "Experimental" + } + ] + }, + { + "Type": "class Microsoft.Extensions.AI.MockEmbeddingGenerator : Microsoft.Extensions.AI.IEmbeddingGenerator>, Microsoft.Extensions.AI.IEmbeddingGenerator, System.IDisposable", + "Stage": "Experimental", + "Methods": [ + { + "Member": "Microsoft.Extensions.AI.MockEmbeddingGenerator.MockEmbeddingGenerator();", + "Stage": "Experimental" + }, + { + "Member": "void Microsoft.Extensions.AI.MockEmbeddingGenerator.Dispose();", + "Stage": "Experimental" + }, + { + "Member": "virtual void Microsoft.Extensions.AI.MockEmbeddingGenerator.Dispose(bool disposing);", + "Stage": "Experimental" + }, + { + "Member": "System.Threading.Tasks.Task>> Microsoft.Extensions.AI.MockEmbeddingGenerator.GenerateAsync(System.Collections.Generic.IEnumerable values, Microsoft.Extensions.AI.EmbeddingGenerationOptions? options = null, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken));", + "Stage": "Experimental" + }, + { + "Member": "virtual System.Threading.Tasks.Task>> Microsoft.Extensions.AI.MockEmbeddingGenerator.GenerateCoreAsync(System.Collections.Generic.IEnumerable values, Microsoft.Extensions.AI.EmbeddingGenerationOptions? options, System.Threading.CancellationToken cancellationToken);", + "Stage": "Experimental" + }, + { + "Member": "virtual object? Microsoft.Extensions.AI.MockEmbeddingGenerator.GetService(System.Type serviceType, object? serviceKey = null);", + "Stage": "Experimental" + } + ], + "Properties": [ + { + "Member": "int Microsoft.Extensions.AI.MockEmbeddingGenerator.CallCount { get; }", + "Stage": "Experimental" + }, + { + "Member": "System.Func, Microsoft.Extensions.AI.EmbeddingGenerationOptions?, System.Threading.CancellationToken, System.Threading.Tasks.Task>>>? Microsoft.Extensions.AI.MockEmbeddingGenerator.GenerateAsyncCallback { get; set; }", + "Stage": "Experimental" + }, + { + "Member": "System.Func Microsoft.Extensions.AI.MockEmbeddingGenerator.GetServiceCallback { get; set; }", + "Stage": "Experimental" + } + ] + } + ] +} \ No newline at end of file diff --git a/src/Libraries/Microsoft.Extensions.AI.Testing/MockChatClient.AddResponses.cs b/src/Libraries/Microsoft.Extensions.AI.Testing/MockChatClient.AddResponses.cs new file mode 100644 index 00000000000..8638e53e822 --- /dev/null +++ b/src/Libraries/Microsoft.Extensions.AI.Testing/MockChatClient.AddResponses.cs @@ -0,0 +1,123 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Shared.Diagnostics; + +namespace Microsoft.Extensions.AI; + +public partial class MockChatClient +{ + /// Seeds text responses from a dictionary for matching requests. + /// The response text keyed by request match values. + /// + /// An optional predicate used to select whether a response key applies to a request. By default, this uses + /// string.Equals(request.LastUserText, key, StringComparison.OrdinalIgnoreCase). + /// + /// + /// to remove every response after its first matching request; otherwise the + /// responses remain reusable. + /// + /// + /// An optional asynchronous function applied to each selected response. The function receives the response and the + /// cancellation token from the matching chat-client call. + /// + /// The same instance for chaining. + /// + /// Dictionary entries are seeded in enumeration order. When multiple entries match the same request, the last + /// entry wins. Add more specific matches last. + /// + /// is . + /// This instance has been disposed. + public virtual MockChatClient AddResponses( + Dictionary responses, + Func? requestPredicate = null, + bool singleUse = false, + Func>? getResponse = null) => + AddResponsesFromDictionary(responses, requestPredicate, singleUse, getResponse); + + /// Seeds text responses from an enumerable collection for matching requests. + /// The response text keyed by request match values. + /// + /// An optional predicate used to select whether a response key applies to a request. By default, this uses + /// string.Equals(request.LastUserText, key, StringComparison.OrdinalIgnoreCase). + /// + /// + /// to remove every response after its first matching request; otherwise the + /// responses remain reusable. + /// + /// + /// An optional asynchronous function applied to each selected response. The function receives the response and the + /// cancellation token from the matching chat-client call. + /// + /// The same instance for chaining. + /// + /// Entries are seeded in enumeration order. When multiple entries match the same request, the last entry wins. + /// Unlike dictionaries, enumerable collections can contain repeated keys. + /// + /// is . + /// This instance has been disposed. + public virtual MockChatClient AddResponses( + IEnumerable> responses, + Func? requestPredicate = null, + bool singleUse = false, + Func>? getResponse = null) => + AddResponsesFromEnumerable(responses, requestPredicate, singleUse, getResponse); + + /// Adds text response seeds from a dictionary. + /// The response text keyed by request match values. + /// Predicate used to select whether a response key applies to a request. + /// + /// to remove every response after its first matching request; otherwise the + /// responses remain reusable. + /// + /// An asynchronous function applied to each selected response. + /// The same instance for chaining. + protected virtual MockChatClient AddResponsesFromDictionary( + Dictionary responses, + Func? requestPredicate, + bool singleUse, + Func>? getResponse) + => AddResponsesFromEnumerable(responses, requestPredicate, singleUse, getResponse); + + /// Adds text response seeds from an enumerable collection. + /// The response text keyed by request match values. + /// Predicate used to select whether a response key applies to a request. + /// + /// to remove every response after its first matching request; otherwise the + /// responses remain reusable. + /// + /// An asynchronous function applied to each selected response. + /// The same instance for chaining. + protected virtual MockChatClient AddResponsesFromEnumerable( + IEnumerable> responses, + Func? requestPredicate, + bool singleUse, + Func>? getResponse) + { + _ = Throw.IfNull(responses); + ThrowIfDisposed(); + + requestPredicate ??= static (request, key) => + string.Equals(request.LastUserText, key, StringComparison.OrdinalIgnoreCase); + getResponse ??= static (response, _) => Task.FromResult(response); + + foreach (KeyValuePair response in responses) + { + string key = Throw.IfNull(response.Key); + string value = Throw.IfNull(response.Value); + + _ = AddResponse( + request => requestPredicate(request, key), + (_, cancellationToken) => getResponse( + new ChatResponse(new ChatMessage(ChatRole.Assistant, value)), + cancellationToken), + singleUse); + } + + return this; + } +} diff --git a/src/Libraries/Microsoft.Extensions.AI.Testing/MockChatClient.cs b/src/Libraries/Microsoft.Extensions.AI.Testing/MockChatClient.cs new file mode 100644 index 00000000000..236158685e8 --- /dev/null +++ b/src/Libraries/Microsoft.Extensions.AI.Testing/MockChatClient.cs @@ -0,0 +1,459 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System; +using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; +using System.Linq; +using System.Runtime.CompilerServices; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Shared.DiagnosticIds; +using Microsoft.Shared.Diagnostics; + +namespace Microsoft.Extensions.AI; + +/// +/// A deterministic implementation for tests and local mock scenarios, +/// using seeded responses based on request predicates. +/// +/// +/// +/// This client starts with no seeded responses. Each request is matched against seeded responses +/// in reverse insertion order, and the most recently added match is used. +/// +/// +/// Seeds are reusable by default. Set singleUse: true to remove a seed after its first match. +/// +/// +/// If no seed matches a request, an is thrown. Response factories +/// can return fully populated and payloads to model +/// citations, reasoning, tool calls, usage, metadata, errors, and other chat-client behavior. +/// +/// +[Experimental(DiagnosticIds.Experiments.AITesting, UrlFormat = DiagnosticIds.UrlFormat)] +public partial class MockChatClient : IChatClient +{ + private static ChatResponse CloneResponse(ChatResponse response) + { + var clone = new ChatResponse(response.Messages.Select(message => message.Clone()).ToList()) + { + AdditionalProperties = response.AdditionalProperties?.Clone(), + ContinuationToken = response.ContinuationToken, + ConversationId = response.ConversationId, + CreatedAt = response.CreatedAt, + FinishReason = response.FinishReason, + ModelId = response.ModelId, + RawRepresentation = response.RawRepresentation, + ResponseId = response.ResponseId, + Usage = response.Usage, + }; + + return clone; + } + + private static async IAsyncEnumerable EnumerateUpdatesAsync( + IEnumerable updates, + [EnumeratorCancellation] CancellationToken cancellationToken = default) + { + foreach (ChatResponseUpdate update in updates) + { + cancellationToken.ThrowIfCancellationRequested(); + yield return Throw.IfNull(update).Clone(); + } + } + + private static async IAsyncEnumerable EnumerateUpdatesAsync( + IAsyncEnumerable updates, + [EnumeratorCancellation] CancellationToken cancellationToken = default) + { + await foreach (ChatResponseUpdate update in updates.WithCancellation(cancellationToken).ConfigureAwait(false)) + { + yield return Throw.IfNull(update).Clone(); + } + } + + private static async IAsyncEnumerable GetResponseUpdatesAsync( + Func> getResponse, + MockChatClientRequest request, + [EnumeratorCancellation] CancellationToken cancellationToken = default) + { + ChatResponse response = Throw.IfNull(await getResponse(request, cancellationToken).ConfigureAwait(false)); + await foreach (ChatResponseUpdate update in EnumerateUpdatesAsync(response.ToChatResponseUpdates(), cancellationToken).ConfigureAwait(false)) + { + yield return update; + } + } + + private static Task GetResponseFromUpdatesAsync( + Func> getResponse, + MockChatClientRequest request, + CancellationToken cancellationToken) => + EnumerateUpdatesAsync(Throw.IfNull(getResponse(request, cancellationToken)), cancellationToken) + .ToChatResponseAsync(cancellationToken); + + private static async IAsyncEnumerable ThrowExceptionAsync( + Func exceptionFactory, + [EnumeratorCancellation] CancellationToken cancellationToken = default) + { + cancellationToken.ThrowIfCancellationRequested(); + await Task.FromException(Throw.IfNull(exceptionFactory())).ConfigureAwait(false); + yield break; + } + + private readonly object _sync = new(); + private readonly List _seededResponses = []; + private readonly List _requests = []; + private bool _disposed; + + /// + /// Gets or sets an optional service provider surfaced through . + /// + /// + /// This provider is queried only for non-keyed lookups when the requested service type is not + /// itself. + /// + public IServiceProvider? Services { get; set; } + + /// + /// Gets a snapshot of requests previously sent to this instance. + /// + /// + /// Requests are recorded in call order across both streaming and non-streaming APIs. + /// + public IReadOnlyList Requests + { + get + { + lock (_sync) + { + return _requests.ToArray(); + } + } + } + + /// Seeds a response factory for matching requests. + /// Predicate used to select whether this seed applies to a request. + /// + /// Asynchronously creates the response for a matching request. The supplied cancellation token is the one passed + /// to the matching chat-client call. + /// + /// + /// to remove this seed after its first matching request; otherwise it remains reusable. + /// + /// The same instance for chaining. + /// + /// If is called + /// for a matching request, the response is converted to updates by using the chat response update conversion helpers. + /// + /// + /// or is . + /// + /// This instance has been disposed. + public virtual MockChatClient AddResponse( + Func requestPredicate, + Func> getResponse, + bool singleUse = false) => + AddSeed( + requestPredicate, + getResponse, + (request, cancellationToken) => GetResponseUpdatesAsync(getResponse, request, cancellationToken), + singleUse); + + /// Seeds non-streaming and streaming response factories for matching requests. + /// Predicate used to select whether this seed applies to a request. + /// + /// Asynchronously creates the response for a matching non-streaming request. The supplied cancellation token is + /// the one passed to the matching chat-client call. + /// + /// + /// Asynchronously creates the updates for a matching streaming request. The supplied cancellation token is the + /// one passed to the matching chat-client call. + /// + /// + /// to remove this seed after its first matching request; otherwise it remains reusable. + /// + /// The same instance for chaining. + /// + /// , , or + /// is . + /// + /// This instance has been disposed. + public virtual MockChatClient AddResponse( + Func requestPredicate, + Func> getResponse, + Func> getStreamingResponse, + bool singleUse = false) => + AddSeed(requestPredicate, getResponse, getStreamingResponse, singleUse); + + /// Seeds a streaming response factory for matching requests. + /// Predicate used to select whether this seed applies to a request. + /// + /// Asynchronously creates the updates for a matching streaming request. The supplied cancellation token is the + /// one passed to the matching chat-client call. + /// + /// + /// to remove this seed after its first matching request; otherwise it remains reusable. + /// + /// The same instance for chaining. + /// + /// If is called for a + /// matching request, these updates are converted to a single response by using the chat response conversion helpers. + /// + /// + /// or is . + /// + /// This instance has been disposed. + public virtual MockChatClient AddStreamingResponse( + Func requestPredicate, + Func> getResponse, + bool singleUse = false) => + AddSeed( + requestPredicate, + (request, cancellationToken) => GetResponseFromUpdatesAsync(getResponse, request, cancellationToken), + getResponse, + singleUse); + + /// Seeds an exception for matching requests. + /// Predicate used to select whether this seed applies to a request. + /// Creates the exception to throw for each matching request. + /// + /// to remove this seed after its first matching request; otherwise it remains reusable. + /// + /// The same instance for chaining. + /// + /// or is . + /// + /// This instance has been disposed. + public virtual MockChatClient AddException( + Func requestPredicate, + Func exceptionFactory, + bool singleUse = false) + { + _ = Throw.IfNull(exceptionFactory); + + return AddSeed( + requestPredicate, + (_, _) => Task.FromException(Throw.IfNull(exceptionFactory())), + (_, cancellationToken) => ThrowExceptionAsync(exceptionFactory, cancellationToken), + singleUse); + } + + /// Removes all seeded responses and exceptions. + /// + /// This method does not clear . + /// + /// The same instance for chaining. + /// This instance has been disposed. + public virtual MockChatClient ClearResponses() + { + ThrowIfDisposed(); + + lock (_sync) + { + _seededResponses.Clear(); + } + + return this; + } + + /// + /// Returns a response for the most recently seeded match and records the request. + /// + /// The request messages. + /// Request options, if any. + /// The cancellation token. + /// A deterministic response for the most recently seeded match. + /// is . + /// No seeded response matched the request. + /// This instance has been disposed. + public virtual async Task GetResponseAsync( + IEnumerable messages, + ChatOptions? options = null, + CancellationToken cancellationToken = default) + { + _ = Throw.IfNull(messages); + ThrowIfDisposed(); + + MockChatClientRequest request = RecordRequest(messages, options, isStreaming: false); + SeededResponse seeded = MatchSeededResponse(request); + return CloneResponse(Throw.IfNull(await seeded.ResponseFactory(request, cancellationToken).ConfigureAwait(false))); + } + + /// + /// Returns streaming updates for the most recently seeded match and records the request. + /// + /// The request messages. + /// Request options, if any. + /// The cancellation token. + /// A deterministic update stream for the most recently seeded match. + /// is . + /// No seeded response matched the request. + /// This instance has been disposed. + public virtual IAsyncEnumerable GetStreamingResponseAsync( + IEnumerable messages, + ChatOptions? options = null, + CancellationToken cancellationToken = default) + { + _ = Throw.IfNull(messages); + ThrowIfDisposed(); + + MockChatClientRequest request = RecordRequest(messages, options, isStreaming: true); + SeededResponse seeded = MatchSeededResponse(request); + return EnumerateUpdatesAsync(Throw.IfNull(seeded.StreamingFactory(request, cancellationToken)), cancellationToken); + } + + /// + /// Gets a service object from this client or the optional provider. + /// + /// The type of service object to get. + /// An optional service key. Keyed lookup is not supported. + /// + /// This instance when is assignable from ; + /// otherwise a non-keyed service from when available; otherwise . + /// + /// is . + public virtual object? GetService(Type serviceType, object? serviceKey = null) + { + _ = Throw.IfNull(serviceType); + + if (serviceKey is null) + { + if (serviceType.IsInstanceOfType(this)) + { + return this; + } + + return Services?.GetService(serviceType); + } + + return null; + } + + /// Disposes this instance and clears all seeds and recorded requests. + public void Dispose() + { + Dispose(disposing: true); + GC.SuppressFinalize(this); + } + + /// Adds a response seed. + /// Predicate used to select whether this seed applies to a request. + /// Factory that creates a response for a matching request. + /// Factory that creates streaming updates for a matching request. + /// + /// to remove this seed after its first matching request; otherwise it remains reusable. + /// + /// The same instance for chaining. + protected virtual MockChatClient AddSeed( + Func requestPredicate, + Func> getResponse, + Func> getStreamingResponse, + bool singleUse) + { + _ = Throw.IfNull(requestPredicate); + _ = Throw.IfNull(getResponse); + _ = Throw.IfNull(getStreamingResponse); + + lock (_sync) + { + ThrowIfDisposed(); + + _seededResponses.Add(new SeededResponse + { + RequestPredicate = requestPredicate, + ResponseFactory = getResponse, + StreamingFactory = getStreamingResponse, + SingleUse = singleUse, + }); + } + + return this; + } + + /// Finds the most recently added seed that matches a request. + /// The request to match. + /// The matching seed. + /// is . + /// No seeded response matched the request. + protected virtual SeededResponse MatchSeededResponse(MockChatClientRequest request) + { + request = Throw.IfNull(request); + + lock (_sync) + { + for (int i = _seededResponses.Count - 1; i >= 0; i--) + { + SeededResponse seeded = _seededResponses[i]; + if (!seeded.RequestPredicate(request)) + { + continue; + } + + if (seeded.SingleUse) + { + _seededResponses.RemoveAt(i); + } + + return seeded; + } + } + + throw new InvalidOperationException( + $"No seeded response matched. Last user text: '{request.LastUserText ?? ""}'."); + } + + /// Represents a response seed. + protected sealed class SeededResponse + { + /// Gets the predicate used to select whether this seed applies to a request. + public Func RequestPredicate { get; init; } = default!; + + /// Gets the factory that creates a response for a matching request. + public Func> ResponseFactory { get; init; } = default!; + + /// Gets the factory that creates streaming updates for a matching request. + public Func> StreamingFactory { get; init; } = default!; + + /// Gets a value indicating whether this seed is removed after its first matching request. + public bool SingleUse { get; init; } + } + + /// Releases resources used by this mock client. + /// when called from . + protected virtual void Dispose(bool disposing) + { + if (!disposing) + { + return; + } + + lock (_sync) + { + _disposed = true; + _seededResponses.Clear(); + _requests.Clear(); + } + } + + private MockChatClientRequest RecordRequest(IEnumerable messages, ChatOptions? options, bool isStreaming) + { + ChatMessage[] messageArray = messages.Select(m => Throw.IfNull(m).Clone()).ToArray(); + var request = new MockChatClientRequest(messageArray, options, isStreaming); + + lock (_sync) + { + _requests.Add(request); + } + + return request; + } + + private void ThrowIfDisposed() + { + if (_disposed) + { + throw new ObjectDisposedException(nameof(MockChatClient)); + } + } +} diff --git a/src/Libraries/Microsoft.Extensions.AI.Testing/MockChatClientRequest.cs b/src/Libraries/Microsoft.Extensions.AI.Testing/MockChatClientRequest.cs new file mode 100644 index 00000000000..022d4c86455 --- /dev/null +++ b/src/Libraries/Microsoft.Extensions.AI.Testing/MockChatClientRequest.cs @@ -0,0 +1,62 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; +using System.Linq; +using Microsoft.Shared.DiagnosticIds; +using Microsoft.Shared.Diagnostics; + +namespace Microsoft.Extensions.AI; + +/// +/// Represents one request invocation captured by . +/// +/// +/// Instances are created by when +/// or +/// +/// is called. +/// +[Experimental(DiagnosticIds.Experiments.AITesting, UrlFormat = DiagnosticIds.UrlFormat)] +public sealed class MockChatClientRequest +{ + /// Initializes a new instance of the class. + /// The request messages in call order. + /// The request options, if any. + /// + /// when the request came from + /// . + /// + /// is . + public MockChatClientRequest(IReadOnlyList messages, ChatOptions? options, bool isStreaming) + { + Messages = Throw.IfNull(messages); + Options = options; + IsStreaming = isStreaming; + } + + /// Gets the request messages. + /// + /// For requests recorded by , each message is cloned when captured so later changes + /// to original message instances do not affect stored request history. + /// + public IReadOnlyList Messages { get; } + + /// Gets the request options. + public ChatOptions? Options { get; } + + /// + /// Gets a value indicating whether this request originated from the streaming chat API. + /// + public bool IsStreaming { get; } + + /// + /// Gets the text of the last user message in , if available. + /// + /// + /// Returns when there is no user message, or when the last user message has no text. + /// + public string? LastUserText => + Messages.LastOrDefault(m => m.Role == ChatRole.User)?.Text; +} diff --git a/src/Libraries/Microsoft.Extensions.AI.Testing/MockEmbeddingGenerator.cs b/src/Libraries/Microsoft.Extensions.AI.Testing/MockEmbeddingGenerator.cs new file mode 100644 index 00000000000..ae8f0b856d3 --- /dev/null +++ b/src/Libraries/Microsoft.Extensions.AI.Testing/MockEmbeddingGenerator.cs @@ -0,0 +1,103 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System; +using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Shared.DiagnosticIds; +using Microsoft.Shared.Diagnostics; + +namespace Microsoft.Extensions.AI; + +/// +/// A configurable for tests and local mock scenarios. +/// +/// The type of input accepted by the embedding generator. +/// +/// Configure to produce the embeddings required by a test. The callback receives +/// the original input enumerable and cancellation token. records every generation request. +/// +[Experimental(DiagnosticIds.Experiments.AITesting, UrlFormat = DiagnosticIds.UrlFormat)] +public class MockEmbeddingGenerator : IEmbeddingGenerator> +{ + private int _callCount; + + /// Initializes a new instance of the class. + public MockEmbeddingGenerator() + { + GetServiceCallback = DefaultGetServiceCallback; + } + + /// Gets or sets the callback that generates embeddings. + /// + /// The callback must be set before calling . + /// + public Func, EmbeddingGenerationOptions?, CancellationToken, Task>>>? GenerateAsyncCallback { get; set; } + + /// Gets or sets the callback that resolves services. + /// + /// By default, this callback returns this generator for compatible, non-keyed requests and + /// for all other requests. + /// + public Func GetServiceCallback { get; set; } + + /// Gets the number of calls to . + public int CallCount => Volatile.Read(ref _callCount); + + /// + public Task>> GenerateAsync( + IEnumerable values, + EmbeddingGenerationOptions? options = null, + CancellationToken cancellationToken = default) + { + _ = Throw.IfNull(values); + RecordCall(); + + return GenerateCoreAsync(values, options, cancellationToken); + } + + /// Generates embeddings after the request has been recorded. + /// The values to embed. + /// The generation options. + /// The cancellation token. + /// The generated embeddings. + protected virtual Task>> GenerateCoreAsync( + IEnumerable values, + EmbeddingGenerationOptions? options, + CancellationToken cancellationToken) + { + if (GenerateAsyncCallback is not { } callback) + { + throw new InvalidOperationException("No embedding generation callback has been configured."); + } + + return Throw.IfNull(callback(values, options, cancellationToken)); + } + + /// + public virtual object? GetService(Type serviceType, object? serviceKey = null) + { + _ = Throw.IfNull(serviceType); + return Throw.IfNull(GetServiceCallback)(serviceType, serviceKey); + } + + /// + public void Dispose() + { + Dispose(disposing: true); + GC.SuppressFinalize(this); + } + + /// Releases resources used by this mock generator. + /// when called from . + protected virtual void Dispose(bool disposing) + { + } + + private void RecordCall() => Interlocked.Increment(ref _callCount); + + private MockEmbeddingGenerator? DefaultGetServiceCallback(Type serviceType, object? serviceKey) => + serviceKey is null && serviceType.IsInstanceOfType(this) ? this : null; +} diff --git a/src/Libraries/Microsoft.Extensions.AI.Testing/README.md b/src/Libraries/Microsoft.Extensions.AI.Testing/README.md new file mode 100644 index 00000000000..0a7999a32f3 --- /dev/null +++ b/src/Libraries/Microsoft.Extensions.AI.Testing/README.md @@ -0,0 +1,312 @@ +# Microsoft.Extensions.AI.Testing + +Deterministic test doubles for applications built on `Microsoft.Extensions.AI`. + +> [!IMPORTANT] +> This **preview** package is for **mocking and deterministic testing** of chat and embedding behavior. Its APIs are +> experimental (`MEAI001`) and may change or be removed in future releases. + +## Install + +```console +dotnet add package Microsoft.Extensions.AI.Testing +``` + +Or in a project file: + +```xml + + + +``` + +## What you get + +- `MockChatClient` (`IChatClient`) for deterministic, request-aware chat responses. +- `MockChatClientRequest` for request matching and request-history inspection. +- `MockEmbeddingGenerator` (`IEmbeddingGenerator>`) for configurable, deterministic embeddings. + +## MockChatClient behavior + +| Behavior | Details | +| --- | --- | +| Starts empty | No responses are predefined. | +| Match order | Last response added wins. Add the most specific matches last. | +| Consumption | Seeds are reusable by default. | +| One-time seed | Set `singleUse: true` to remove a seed after its first match. | +| Unmatched request | Throws `InvalidOperationException` with the last user message text. | +| History | Every request is captured in `client.Requests` (`Messages`, `Options`, `IsStreaming`). | +| Streaming/non-streaming | Supports both `GetResponseAsync` and `GetStreamingResponseAsync`. | + +## Quick start + +`MockChatClient` accepts a required predicate plus a cancellable asynchronous response factory. The factory can inspect the captured request and receives the cancellation token from the matching chat call. + +```csharp +using Microsoft.Extensions.AI; + +var client = new MockChatClient() + .AddResponse( + static _ => true, + static (_, _) => Task.FromResult( + new ChatResponse(new ChatMessage(ChatRole.Assistant, "Fallback response."))), + singleUse: true) + .AddResponse( + static request => request.LastUserText?.Contains("hello", StringComparison.OrdinalIgnoreCase) is true, + static (_, _) => Task.FromResult( + new ChatResponse(new ChatMessage(ChatRole.Assistant, "Hello from a deterministic mock.")))); + +ChatResponse response = await client.GetResponseAsync([new(ChatRole.User, "hello")]); +Console.WriteLine(response.Text); +``` + +### Response dictionary + +Use `AddResponses` when a response dictionary is more convenient than individual response factories. The default +predicate is `string.Equals(request.LastUserText, key, StringComparison.OrdinalIgnoreCase)`. Entries are seeded in +dictionary order, so the last matching entry wins. + +```csharp +var client = new MockChatClient().AddResponses(new() +{ + ["hello"] = "Hello from a deterministic mock.", + ["goodbye"] = "Goodbye from a deterministic mock.", +}); +``` + +A dictionary deserialized from JSON works the same way: + +```csharp +using System.Text.Json; + +var client = new MockChatClient().AddResponses( + JsonSerializer.Deserialize>( + """ + { + "hello": "Hello from a deterministic mock.", + "goodbye": "Goodbye from a deterministic mock." + } + """)!); +``` + +Use an `IEnumerable>` only when repeated keys are needed. Entries are seeded in +enumeration order, so the last matching entry wins: + +```csharp +client.AddResponses( + new KeyValuePair[] + { + new("hello", "Hello again. Nice to see you."), + new("hello", "Hello. Nice to meet you."), + }, + singleUse: true); +``` + +Use `AddResponse` to return fully populated `ChatResponse` instances, including non-text content such as tool calls: + +```csharp +var client = new MockChatClient() + .AddResponse( + static request => request.LastUserText == "get-weather", + static (_, _) => Task.FromResult( + new ChatResponse( + new ChatMessage( + ChatRole.Assistant, + [new FunctionCallContent("weather-call", "GetWeather")])))); +``` + +Pass a predicate when the dictionary key needs a different matching rule: + +```csharp +client.AddResponses(new() + { + ["hello"] = "Hello from a deterministic mock.", + ["goodbye"] = "Goodbye from a deterministic mock.", + }, + static (request, key) => request.LastUserText?.StartsWith(key, StringComparison.OrdinalIgnoreCase) is true); +``` + +`singleUse` applies to every dictionary response. A custom predicate can map distinct dictionary keys to the same prompt: + +```csharp +var client = new MockChatClient() + .AddResponses( + new() + { + ["hello:2"] = "Hello again. Nice to see you.", + ["hello:1"] = "Hello. Nice to meet you.", + }, + static (request, key) => + { + string promptKey = key.Split(new[] { ':' }, 2)[0]; + return string.Equals(request.LastUserText, promptKey, StringComparison.OrdinalIgnoreCase); + }, + singleUse: true); +``` + +Chat requests can include images as `DataContent`. A predicate can match image MIME types: + +```csharp +var client = new MockChatClient() + .AddResponses( + new() + { + ["image/jpeg"] = "I see you shared a JPEG.", + ["image/png"] = "I see you shared a PNG.", + }, + static (request, mediaType) => request.Messages + .SelectMany(static message => message.Contents) + .OfType() + .Any(content => string.Equals(content.MediaType, mediaType, StringComparison.OrdinalIgnoreCase))); +``` + +Pass `getResponse` to apply common asynchronous behavior to every selected response. String values are converted to +`ChatResponse` instances before `getResponse` is invoked: + +```csharp +var client = new MockChatClient() + .AddResponses( + new() + { + ["hello"] = "Hello from a deterministic mock.", + ["goodbye"] = "Goodbye from a deterministic mock.", + }, + getResponse: async (response, cancellationToken) => + { + await Task.Delay(TimeSpan.FromMilliseconds(100), cancellationToken); + return response; + }); +``` + +## Seeding patterns + +### Non-streaming response + +```csharp +client.AddResponse( + static request => request.LastUserText == "explain", + static (_, _) => Task.FromResult( + new ChatResponse(new ChatMessage(ChatRole.Assistant, "Complete answer")))); +``` + +### Streaming response + +```csharp +using System.Runtime.CompilerServices; + +client.AddStreamingResponse( + static request => request.LastUserText == "stream", + static (_, cancellationToken) => GetUpdatesAsync(cancellationToken)); + +static async IAsyncEnumerable GetUpdatesAsync( + [EnumeratorCancellation] CancellationToken cancellationToken) +{ + cancellationToken.ThrowIfCancellationRequested(); + yield return new ChatResponseUpdate(ChatRole.Assistant, "Part 1 "); + yield return new ChatResponseUpdate(ChatRole.Assistant, "Part 2"); +} +``` + +### Distinct streaming and non-streaming responses + +```csharp +client.AddResponse( + static request => request.LastUserText == "both", + static (_, _) => Task.FromResult( + new ChatResponse(new ChatMessage(ChatRole.Assistant, "Complete answer"))), + static (_, cancellationToken) => GetUpdatesAsync(cancellationToken)); +``` + +## Exercise rich chat features + +`MockChatClient` does not synthesize model output; factories return the payloads you choose. Seed fully populated `ChatResponse` and `ChatResponseUpdate` payloads to exercise: + +- citations and other annotations +- reasoning content +- tool calls and tool results +- usage fields +- additional metadata + +For example, a response can include citations, reasoning, and usage: + +```csharp +client.AddResponse( + static request => request.LastUserText == "sources", + static (_, _) => Task.FromResult( + new ChatResponse( + [ + new ChatMessage( + ChatRole.Assistant, + [ + new TextContent("TrailMaster tracks your progress.") + { + Annotations = + [ + new CitationAnnotation + { + Title = "Example_GPS_Watch.md", + Snippet = "track your progress", + } + ] + }, + new TextReasoningContent("The document describes tracking features.") + ]) + ]) + { + Usage = new UsageDetails { InputTokenCount = 3, OutputTokenCount = 7 } + })); +``` + +## Assert what your app sent + +```csharp +MockChatClientRequest first = client.Requests[0]; +string? lastUserText = first.LastUserText; +bool wasStreaming = first.IsStreaming; +``` + +This is useful for validating prompts, routing logic, and chat options. + +## Clear and reseed + +`ClearResponses()` removes all response and exception seeds while preserving captured request history: + +```csharp +client.ClearResponses() + .AddResponse( + static _ => true, + static (_, _) => Task.FromResult( + new ChatResponse(new ChatMessage(ChatRole.Assistant, "New test scenario.")))); +``` + +## Mock errors + +Use `AddException` to model a failing provider. The factory creates a new exception for each matching request: + +```csharp +client.AddException( + static request => request.LastUserText == "UNAVAILABLE", + static () => new HttpRequestException("The provider is temporarily unavailable.")); +``` + +## Mock embeddings + +Configure `MockEmbeddingGenerator` with the embeddings a test needs. Its callback receives the original input enumerable and cancellation token, and `CallCount` records generation requests. + +```csharp +using var embeddings = new MockEmbeddingGenerator +{ + GenerateAsyncCallback = static (_, _, _) => + Task.FromResult>>( + [new(new float[] { 0.1f, 0.2f, 0.3f })]), +}; + +GeneratedEmbeddings> result = await embeddings.GenerateAsync(["trail"]); +Console.WriteLine(embeddings.CallCount); +``` + +Use the input type required by the test, such as `MockEmbeddingGenerator` or `MockEmbeddingGenerator`. + +## Feedback & Contributing + +We welcome feedback and contributions in [our GitHub repo](https://github.com/dotnet/extensions). diff --git a/src/ProjectTemplates/Microsoft.Agents.AI.ProjectTemplates/Microsoft.Agents.AI.ProjectTemplates.csproj b/src/ProjectTemplates/Microsoft.Agents.AI.ProjectTemplates/Microsoft.Agents.AI.ProjectTemplates.csproj index afbfd93c4b7..3022febd7d0 100644 --- a/src/ProjectTemplates/Microsoft.Agents.AI.ProjectTemplates/Microsoft.Agents.AI.ProjectTemplates.csproj +++ b/src/ProjectTemplates/Microsoft.Agents.AI.ProjectTemplates/Microsoft.Agents.AI.ProjectTemplates.csproj @@ -30,6 +30,7 @@ + - + + + + diff --git a/src/ProjectTemplates/Microsoft.Agents.AI.ProjectTemplates/templates/AIAgentWebApi-CSharp/MockChatClientExtensions.cs b/src/ProjectTemplates/Microsoft.Agents.AI.ProjectTemplates/templates/AIAgentWebApi-CSharp/MockChatClientExtensions.cs new file mode 100644 index 00000000000..fd642cb6290 --- /dev/null +++ b/src/ProjectTemplates/Microsoft.Agents.AI.ProjectTemplates/templates/AIAgentWebApi-CSharp/MockChatClientExtensions.cs @@ -0,0 +1,72 @@ +using Microsoft.Extensions.AI; + +#pragma warning disable MEAI001 // Mock provider uses experimental testing APIs + +internal static class MockChatClientExtensions +{ + /// Adds a canned response for matching requests. + /// The mock chat client to configure. + /// Predicate used to select whether the response applies to a request. + /// The assistant response text. + /// + /// The optional minimum simulated response delay in milliseconds. When supplied alone, it is the fixed delay. + /// + /// + /// The optional maximum simulated response delay in milliseconds. When supplied alone, a delay from zero up to this + /// value is selected. + /// + /// + /// to remove the response seed after its first matching request; otherwise it remains reusable. + /// + /// The configured . + internal static MockChatClient AddResponse( + this MockChatClient client, + Func requestPredicate, + string response, + int? minDelay = null, + int? maxDelay = null, + bool singleUse = false) + { + ArgumentNullException.ThrowIfNull(client); + ArgumentNullException.ThrowIfNull(requestPredicate); + ArgumentNullException.ThrowIfNull(response); + ValidateDelayRange(minDelay, maxDelay); + + return client.AddResponse( + requestPredicate, + async (_, cancellationToken) => + { + await Task.Delay(GetDelay(minDelay, maxDelay), cancellationToken); + return new ChatResponse(new ChatMessage(ChatRole.Assistant, response + "\n\n")); + }, + singleUse); + } + + private static int GetDelay(int? minDelay, int? maxDelay) => + (minDelay, maxDelay) switch + { + (null, null) => 0, + ({ } min, null) => min, + (null, { } max) => Random.Shared.Next(0, max), + ({ } min, { } max) => Random.Shared.Next(min, max), + }; + + private static void ValidateDelayRange(int? minDelay, int? maxDelay) + { + if (minDelay is { } minimumDelay) + { + ArgumentOutOfRangeException.ThrowIfLessThan(minimumDelay, 0, nameof(minDelay)); + } + + if (maxDelay is { } maximumDelay) + { + ArgumentOutOfRangeException.ThrowIfLessThan(maximumDelay, 0, nameof(maxDelay)); + + if (minDelay is { } configuredMinimumDelay) + { + ArgumentOutOfRangeException.ThrowIfLessThan(maximumDelay, configuredMinimumDelay, nameof(maxDelay)); + } + } + } +} +#pragma warning restore MEAI001 diff --git a/src/ProjectTemplates/Microsoft.Agents.AI.ProjectTemplates/templates/AIAgentWebApi-CSharp/MockServices.cs b/src/ProjectTemplates/Microsoft.Agents.AI.ProjectTemplates/templates/AIAgentWebApi-CSharp/MockServices.cs new file mode 100644 index 00000000000..a44d52ecabf --- /dev/null +++ b/src/ProjectTemplates/Microsoft.Agents.AI.ProjectTemplates/templates/AIAgentWebApi-CSharp/MockServices.cs @@ -0,0 +1,147 @@ +using Microsoft.Extensions.AI; + +#pragma warning disable MEAI001 // Mock provider uses experimental testing APIs + +internal static class MockServices +{ + internal static MockChatClient CreateChatClient() => + new MockChatClient() + .AddResponse( + static request => IsWriterStage(request), + """ + ## Welcome to the Mock Writing Room + + Meet The Writer, The Editor, and The Formatter. They are ready for their curtain call. + + Try: **Write a .NET story.**, **Edit this story.**, or **Format for publishing.** + """, + maxDelay: 2000) + .AddResponse( + static request => HasMessageContent(request, "Welcome to the Mock Writing Room"), + """ + The room is ready when you are. Bring a draft, and it will come back polished. + """, + maxDelay: 2000) + .AddResponse( + static request => IsWriterStage(request) && IsWritePrompt(request), + """ + Maya opened her first .NET console project during an afternoon study session. The program printed "Hello, World!", but she wanted it to ask for a number and tell her whether it was even. + + At first, compiler errors made every small change feel impossible. Instead of guessing, she read each message, used `int.TryParse`, and ran the app after every improvement. + + By the end of the session, Maya had a working program and a short list of ideas for her next project. She realized that learning .NET was not about knowing everything at once; it was about trying one small step, seeing what happened, and learning from it. + + """, + maxDelay: 2000) + .AddResponse( + static request => HasDraftContent(request, "Maya opened her first .NET console project during an afternoon study session."), + """ + **Title**: Maya's First Green Check + + Maya opened her first .NET console project during an afternoon study session. It printed "Hello, World!", but she wanted it to ask for a number and report whether it was even. + + Compiler errors made every small change feel impossible at first. Rather than guessing, Maya read each message, used `int.TryParse` to handle input safely, and ran the app after every improvement. + + By the end of the session, she had a working program and a short list of ideas for her next project. Maya learned that .NET did not require her to know everything at once: progress came from taking one small step, observing the result, and learning from it. + """, + maxDelay: 2000) + .AddResponse( + static request => IsWriterStage(request) && IsEditPrompt(request), + """ + maya opened her first .net console project and wanted it to check whether a number was even. compiler errors made her frustrated at first. + + she read the error messages, used int.tryparse, and kept running the program after each change. by the end, it worked and she was excited to build another project. + + """, + maxDelay: 2000) + .AddResponse( + static request => HasDraftContent(request, "maya opened her first .net console project and wanted it to check whether a number was even."), + """ + Maya opened her first .NET console project hoping to write a program that could identify even numbers. Compiler errors frustrated her at first, but she chose to slow down and read each message carefully. + + She used `int.TryParse` to make the input safer and ran the program after every change. By the end of the session, Maya had a working app and a new confidence that learning .NET happens one experiment at a time. + """, + maxDelay: 2000) + .AddResponse( + static request => IsWriterStage(request) && IsFormatPrompt(request), + """ + Maya learned to build her first .NET console app by reading compiler errors, trying `int.TryParse`, and testing each small change. + + """, + maxDelay: 2000) + .AddResponse( + static request => HasDraftContent(request, "Maya learned to build her first .NET console app by reading compiler errors, trying `int.TryParse`, and testing each small change."), + """ + **Title**: Maya's First .NET Project + + Maya began her first .NET console app with a simple goal: identify whether a number was even. When compiler errors appeared, she slowed down, read the messages, and made one change at a time. + + She tried `int.TryParse`, tested each improvement, and watched the program gradually come together. The project taught Maya that every small experiment is part of becoming a confident .NET developer. + """, + maxDelay: 2000); + + private static bool IsWritePrompt(MockChatClientRequest request) => + HasInteractivePrompt(request, "write"); + + private static bool IsEditPrompt(MockChatClientRequest request) => + HasInteractivePrompt(request, "edit"); + + private static bool IsFormatPrompt(MockChatClientRequest request) => + HasInteractivePrompt(request, "format"); + + private static bool IsWriterStage(MockChatClientRequest request) => + !HasAssistantMessage(request); + + private static bool HasAssistantMessage(MockChatClientRequest request) + { + foreach (ChatMessage message in request.Messages) + { + if (message.Role == ChatRole.Assistant && + !string.IsNullOrWhiteSpace(message.Text)) + { + return true; + } + } + + return false; + } + + private static bool HasDraftContent(MockChatClientRequest request, string text) => + request.LastUserText?.Contains(text, StringComparison.OrdinalIgnoreCase) is true || + HasAssistantContent(request, text); + + private static bool HasInteractivePrompt(MockChatClientRequest request, string text) + { + string? lastUserText = request.LastUserText; + return lastUserText?.Contains(text, StringComparison.OrdinalIgnoreCase) is true && + lastUserText.Contains("Welcome to the Mock Writing Room", StringComparison.OrdinalIgnoreCase) is false; + } + + private static bool HasMessageContent(MockChatClientRequest request, string text) + { + foreach (ChatMessage message in request.Messages) + { + if (message.Text?.Contains(text, StringComparison.OrdinalIgnoreCase) is true) + { + return true; + } + } + + return false; + } + + private static bool HasAssistantContent(MockChatClientRequest request, string text) + { + foreach (ChatMessage message in request.Messages) + { + if (message.Role == ChatRole.Assistant && + message.Text?.Contains(text, StringComparison.OrdinalIgnoreCase) is true) + { + return true; + } + } + + return false; + } +} +#pragma warning restore MEAI001 diff --git a/src/ProjectTemplates/Microsoft.Agents.AI.ProjectTemplates/templates/AIAgentWebApi-CSharp/Program.cs b/src/ProjectTemplates/Microsoft.Agents.AI.ProjectTemplates/templates/AIAgentWebApi-CSharp/Program.cs index 3702afef4cd..a3ad2a26d62 100644 --- a/src/ProjectTemplates/Microsoft.Agents.AI.ProjectTemplates/templates/AIAgentWebApi-CSharp/Program.cs +++ b/src/ProjectTemplates/Microsoft.Agents.AI.ProjectTemplates/templates/AIAgentWebApi-CSharp/Program.cs @@ -1,4 +1,4 @@ -#if (IsGHModels || IsOpenAI || (IsAzureOpenAI && !IsManagedIdentity)) +#if (IsOpenAI || (IsAzureOpenAI && !IsManagedIdentity)) using System.ClientModel; #elif (IsAzureOpenAI && IsManagedIdentity) using System.ClientModel.Primitives; @@ -15,26 +15,16 @@ #if (IsOllama) using OllamaSharp; #endif -#if (IsGHModels || IsAzureOpenAI) +#if (IsAzureOpenAI) using OpenAI; #endif -#if (IsGHModels || IsOpenAI || IsAzureOpenAI) +#if (IsOpenAI || IsAzureOpenAI) using OpenAI.Chat; #endif var builder = WebApplication.CreateBuilder(args); -#if (IsGHModels) -// You will need to set the token to your own value -// You can do this using Visual Studio's "Manage User Secrets" UI, or on the command line: -// cd this-project-directory -// dotnet user-secrets set "GITHUB_TOKEN" "your-github-models-token-here" -var chatClient = new ChatClient( - "gpt-4o-mini", - new ApiKeyCredential(builder.Configuration["GITHUB_TOKEN"] ?? throw new InvalidOperationException("Missing configuration: GITHUB_TOKEN")), - new OpenAIClientOptions { Endpoint = new Uri("https://models.inference.ai.azure.com") }) - .AsIChatClient(); -#elif (IsOpenAI) +#if (IsOpenAI) // You will need to set the API key to your own value // You can do this using Visual Studio's "Manage User Secrets" UI, or on the command line: // cd this-project-directory @@ -43,6 +33,8 @@ "gpt-4o-mini", new ApiKeyCredential(builder.Configuration["OPENAI_KEY"] ?? throw new InvalidOperationException("Missing configuration: OPENAI_KEY"))) .AsIChatClient(); +#elif (IsMock) +var chatClient = MockServices.CreateChatClient(); #elif (IsAzureOpenAI) // You will need to set the endpoint to your own value // You can do this using Visual Studio's "Manage User Secrets" UI, or on the command line: @@ -97,6 +89,7 @@ // Register services for OpenAI responses and conversations (also required for DevUI) builder.Services.AddOpenAIResponses(); builder.Services.AddOpenAIConversations(); +builder.Services.AddDevUI(); var app = builder.Build(); app.UseHttpsRedirection(); diff --git a/src/ProjectTemplates/Microsoft.Agents.AI.ProjectTemplates/templates/AIAgentWebApi-CSharp/README.md b/src/ProjectTemplates/Microsoft.Agents.AI.ProjectTemplates/templates/AIAgentWebApi-CSharp/README.md index eb0a525f12a..29c3698b2c3 100644 --- a/src/ProjectTemplates/Microsoft.Agents.AI.ProjectTemplates/templates/AIAgentWebApi-CSharp/README.md +++ b/src/ProjectTemplates/Microsoft.Agents.AI.ProjectTemplates/templates/AIAgentWebApi-CSharp/README.md @@ -4,8 +4,8 @@ This is an AI Agent Web API application created from the `aiagent-webapi` templa ## Prerequisites - -- A GitHub Models API token (free to get started) + +- No external AI provider prerequisites - An OpenAI API key @@ -18,38 +18,18 @@ This is an AI Agent Web API application created from the `aiagent-webapi` templa ### 1. Configure Your AI Service - -#### GitHub Models Configuration + +#### Mock Configuration -This application uses GitHub Models (model: gpt-4o-mini) for AI functionality. You'll need to configure your GitHub Models API token: +This application uses a deterministic mock chat provider seeded for the writer/editor workflow. -**Option A: Using User Secrets (Recommended for Development)** +No API keys or model runtimes are required. -```bash -dotnet user-secrets set "GITHUB_TOKEN" "your-github-models-token-here" -``` - -**Option B: Using Environment Variables** +Try prompts such as: -Set the `GITHUB_TOKEN` environment variable: - -- **Windows (PowerShell)**: - ```powershell - $env:GITHUB_TOKEN = "your-github-models-token-here" - ``` - -- **Linux/macOS**: - ```bash - export GITHUB_TOKEN="your-github-models-token-here" - ``` - -#### Get a GitHub Models Token - -1. Visit [GitHub Models](https://github.com/marketplace/models) -2. Sign in with your GitHub account -3. Select a model (e.g., gpt-4o-mini) -4. Click "Get API Key" or follow the authentication instructions -5. Copy your personal access token +1. `Write a short story about a student learning .NET.` +2. `Edit this story for grammar and style.` +3. `Format the final story for publishing.` #### OpenAI Configuration @@ -198,13 +178,13 @@ dotnet new aiagent-webapi --provider ollama --chat-model llama3.1 ### Available Parameters - **`--provider`**: Choose the AI service provider - - `githubmodels` (default) - GitHub Models + - `mock` - Deterministic mock provider - `azureopenai` - Azure OpenAI - `openai` - OpenAI Platform - `ollama` - Ollama (local development) - **`--chat-model`**: Specify the chat model/deployment name - - Default for OpenAI/Azure OpenAI/GitHub Models: `gpt-4o-mini` + - Default for OpenAI/Azure OpenAI: `gpt-4o-mini` - Default for Ollama: `llama3.2` - **`--managed-identity`**: Use managed identity for Azure services (default: `true`) @@ -223,9 +203,7 @@ dotnet new aiagent-webapi --provider ollama --chat-model llama3.1 - [AI apps for .NET developers](https://learn.microsoft.com/dotnet/ai) - [Microsoft Agent Framework Documentation](https://aka.ms/dotnet/agent-framework/docs) - -- [GitHub Models](https://github.com/marketplace/models) - + - [OpenAI Platform](https://platform.openai.com) - [Azure OpenAI Service](https://azure.microsoft.com/products/ai-services/openai-service) @@ -235,14 +213,10 @@ dotnet new aiagent-webapi --provider ollama --chat-model llama3.1 ## Troubleshooting - -**Problem**: Application fails with "Missing configuration: GITHUB_TOKEN" - -**Solution**: Make sure you've configured your GitHub Models API token using one of the methods described above. - -**Problem**: API requests fail with authentication errors + +**Problem**: Responses seem repetitive. -**Solution**: Verify your GitHub Models token is valid and hasn't expired. You may need to regenerate it from the GitHub Models website. +**Solution**: The mock provider is intentionally deterministic. Use one of the three seeded workflow prompts listed above. **Problem**: Application fails with "Missing configuration: OPENAI_KEY" diff --git a/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/Microsoft.Extensions.AI.Templates.csproj b/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/Microsoft.Extensions.AI.Templates.csproj index f83fda4db7e..a39a6b1492f 100644 --- a/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/Microsoft.Extensions.AI.Templates.csproj +++ b/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/Microsoft.Extensions.AI.Templates.csproj @@ -26,6 +26,7 @@ + diff --git a/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/templates/AIChatWeb-CSharp/.template.config/template.json b/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/templates/AIChatWeb-CSharp/.template.config/template.json index 4058affe004..5c7ccd3f487 100644 --- a/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/templates/AIChatWeb-CSharp/.template.config/template.json +++ b/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/templates/AIChatWeb-CSharp/.template.config/template.json @@ -98,6 +98,14 @@ "exclude": [ "AIChatWeb-CSharp.Web/Services/JsonVectorStore.cs" ] + }, + { + "condition": "(!IsMock)", + "exclude": [ + "AIChatWeb-CSharp.Web/Services/MockServices.cs", + "AIChatWeb-CSharp.Web/Services/MockChatClientExtensions.cs", + "AIChatWeb-CSharp.Web/Services/LexicalMockEmbeddingGenerator.cs" + ] } ] }], @@ -128,7 +136,7 @@ "type": "parameter", "displayName": "_AI service provider", "datatype": "choice", - "defaultValue": "githubmodels", + "isRequired": true, "choices": [ { "choice": "azureopenai", @@ -136,9 +144,9 @@ "description": "Uses Azure OpenAI service" }, { - "choice": "githubmodels", - "displayName": "GitHub Models", - "description": "Uses GitHub Models" + "choice": "mock", + "displayName": "Mock (for local development and testing)", + "description": "Uses deterministic, pre-seeded responses" }, { "choice": "ollama", @@ -215,14 +223,14 @@ "type": "computed", "value": "(AiServiceProvider == \"openai\")" }, - "IsGHModels": { - "type": "computed", - "value": "(AiServiceProvider == \"githubmodels\")" - }, "IsOllama": { "type": "computed", "value": "(AiServiceProvider == \"ollama\")" }, + "IsMock": { + "type": "computed", + "value": "(AiServiceProvider == \"mock\")" + }, "IsAzureAIFoundry": { "type": "computed", "value": "(AiServiceProvider == \"azureaifoundry\")" diff --git a/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/templates/AIChatWeb-CSharp/AIChatWeb-CSharp.AppHost/AppHost.cs b/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/templates/AIChatWeb-CSharp/AIChatWeb-CSharp.AppHost/AppHost.cs index 3304884fa78..fa1d10780ac 100644 --- a/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/templates/AIChatWeb-CSharp/AIChatWeb-CSharp.AppHost/AppHost.cs +++ b/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/templates/AIChatWeb-CSharp/AIChatWeb-CSharp.AppHost/AppHost.cs @@ -1,18 +1,13 @@ var builder = DistributedApplication.CreateBuilder(args); #if (IsOllama) // ASPIRE PARAMETERS -#elif (IsOpenAI || IsGHModels) +#elif (IsOpenAI) // You will need to set the connection string to your own value // You can do this using Visual Studio's "Manage User Secrets" UI, or on the command line: // cd this-project-directory -#if (IsOpenAI) // dotnet user-secrets set ConnectionStrings:openai "Key=YOUR-API-KEY" -#elif (IsGHModels) -// dotnet user-secrets set ConnectionStrings:openai "Endpoint=https://models.inference.ai.azure.com;Key=YOUR-API-KEY" -#else // IsAzureOpenAI -// dotnet user-secrets set ConnectionStrings:openai "Endpoint=https://YOUR-DEPLOYMENT-NAME.openai.azure.com;Key=YOUR-API-KEY" -#endif var openai = builder.AddConnectionString("openai"); +#elif (IsMock) #else // IsAzureOpenAI // See https://learn.microsoft.com/dotnet/aspire/azure/local-provisioning#configuration @@ -62,8 +57,9 @@ .WithReference(embeddings) .WaitFor(chat) .WaitFor(embeddings); -#elif (IsOpenAI || IsGHModels) +#elif (IsOpenAI) webApp.WithReference(openai); +#elif (IsMock) #else // IsAzureOpenAI webApp .WithReference(openai) diff --git a/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/templates/AIChatWeb-CSharp/AIChatWeb-CSharp.Web/AIChatWeb-CSharp.Web.csproj-in b/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/templates/AIChatWeb-CSharp/AIChatWeb-CSharp.Web/AIChatWeb-CSharp.Web.csproj-in index 293b315d3bd..84035f7bacd 100644 --- a/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/templates/AIChatWeb-CSharp/AIChatWeb-CSharp.Web/AIChatWeb-CSharp.Web.csproj-in +++ b/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/templates/AIChatWeb-CSharp/AIChatWeb-CSharp.Web/AIChatWeb-CSharp.Web.csproj-in @@ -13,13 +13,13 @@ #endif --> - + @@ -28,6 +28,9 @@ + + + diff --git a/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/templates/AIChatWeb-CSharp/AIChatWeb-CSharp.Web/Components/Layout/SurveyPrompt.razor b/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/templates/AIChatWeb-CSharp/AIChatWeb-CSharp.Web/Components/Layout/SurveyPrompt.razor deleted file mode 100644 index 77557f20173..00000000000 --- a/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/templates/AIChatWeb-CSharp/AIChatWeb-CSharp.Web/Components/Layout/SurveyPrompt.razor +++ /dev/null @@ -1,13 +0,0 @@ -
- - -
- How well is this template working for you? Please take a - brief survey - and tell us what you think. -
-
diff --git a/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/templates/AIChatWeb-CSharp/AIChatWeb-CSharp.Web/Components/Layout/SurveyPrompt.razor.css b/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/templates/AIChatWeb-CSharp/AIChatWeb-CSharp.Web/Components/Layout/SurveyPrompt.razor.css deleted file mode 100644 index c939b902afb..00000000000 --- a/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/templates/AIChatWeb-CSharp/AIChatWeb-CSharp.Web/Components/Layout/SurveyPrompt.razor.css +++ /dev/null @@ -1,20 +0,0 @@ -.surveyContainer { - display: flex; - justify-content: center; - gap: 0.5rem; - font-size: 0.9em; - margin: 0.5rem auto -0.7rem auto; - max-width: 1024px; - color: #444; -} - - .surveyContainer a { - text-decoration: underline; - } - - .surveyContainer .tool-icon { - margin-top: 0.15rem; - width: 1.25rem; - height: 1.25rem; - flex-shrink: 0; - } diff --git a/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/templates/AIChatWeb-CSharp/AIChatWeb-CSharp.Web/Components/Pages/Chat/Chat.razor b/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/templates/AIChatWeb-CSharp/AIChatWeb-CSharp.Web/Components/Pages/Chat/Chat.razor index 6fc5881c18f..9d33e3ff5ed 100644 --- a/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/templates/AIChatWeb-CSharp/AIChatWeb-CSharp.Web/Components/Pages/Chat/Chat.razor +++ b/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/templates/AIChatWeb-CSharp/AIChatWeb-CSharp.Web/Components/Pages/Chat/Chat.razor @@ -1,5 +1,6 @@ @page "/" @using System.ComponentModel +@using System.Xml.Linq @inject IChatClient ChatClient @inject NavigationManager Nav @inject SemanticSearch Search @@ -20,7 +21,6 @@
- @* Remove this line to eliminate the template survey message *@
@code { @@ -126,7 +126,10 @@ await InvokeAsync(StateHasChanged); var results = await Search.SearchAsync(searchPhrase, filenameFilter, maxResults: 5); return results.Select(result => - $"{result.Text}"); + new XElement( + "result", + new XAttribute("filename", result.DocumentId), + result.Text).ToString(SaveOptions.DisableFormatting)); } public void Dispose() diff --git a/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/templates/AIChatWeb-CSharp/AIChatWeb-CSharp.Web/Components/Pages/Chat/ChatMessageItem.razor b/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/templates/AIChatWeb-CSharp/AIChatWeb-CSharp.Web/Components/Pages/Chat/ChatMessageItem.razor index e45d92ab5f9..33367fa3974 100644 --- a/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/templates/AIChatWeb-CSharp/AIChatWeb-CSharp.Web/Components/Pages/Chat/ChatMessageItem.razor +++ b/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/templates/AIChatWeb-CSharp/AIChatWeb-CSharp.Web/Components/Pages/Chat/ChatMessageItem.razor @@ -1,6 +1,7 @@ @using System.Runtime.CompilerServices @using System.Text.RegularExpressions -@using System.Linq +@using System.Xml +@using System.Xml.Linq @if (Message.Role == ChatRole.User) { @@ -69,7 +70,7 @@ else if (Message.Role == ChatRole.Assistant) @code { private static readonly ConditionalWeakTable SubscribersLookup = new(); - private static readonly Regex CitationRegex = new(@"(?.*?)", RegexOptions.NonBacktracking); + private static readonly Regex CitationRegex = new(@"]*>.*?", RegexOptions.NonBacktracking | RegexOptions.Singleline); private List<(string File, string Quote)>? citations; @@ -99,9 +100,24 @@ else if (Message.Role == ChatRole.Assistant) private void ParseCitations(string text) { - var matches = CitationRegex.Matches(text); - citations = matches.Any() - ? matches.Select(m => (m.Groups["file"].Value, m.Groups["quote"].Value)).ToList() - : null; + var parsedCitations = new List<(string File, string Quote)>(); + foreach (Match match in CitationRegex.Matches(text)) + { + try + { + XElement citation = XElement.Parse(match.Value); + if (citation.Name.LocalName == "citation" && + citation.Attribute("filename")?.Value is { Length: > 0 } file) + { + parsedCitations.Add((file, citation.Value)); + } + } + catch (XmlException) + { + // Ignore malformed citation markup returned by the model. + } + } + + citations = parsedCitations.Count > 0 ? parsedCitations : null; } } diff --git a/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/templates/AIChatWeb-CSharp/AIChatWeb-CSharp.Web/Program.Aspire.cs b/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/templates/AIChatWeb-CSharp/AIChatWeb-CSharp.Web/Program.Aspire.cs index 31442718f1f..e19c44ffe45 100644 --- a/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/templates/AIChatWeb-CSharp/AIChatWeb-CSharp.Web/Program.Aspire.cs +++ b/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/templates/AIChatWeb-CSharp/AIChatWeb-CSharp.Web/Program.Aspire.cs @@ -1,5 +1,5 @@ using Microsoft.Extensions.AI; -#if (IsOpenAI || IsGHModels) +#if (IsOpenAI) using OpenAI; #endif using AIChatWeb_CSharp.Web.Components; @@ -19,7 +19,13 @@ builder.AddOllamaApiClient("embeddings") .AddEmbeddingGenerator(); #elif (IsAzureAIFoundry) -#else // (IsOpenAI || IsAzureOpenAI || IsGHModels) +#elif (IsMock) +builder.Services.AddChatClient(MockServices.CreateChatClient()) + .UseFunctionInvocation() + .UseOpenTelemetry(configure: c => + c.EnableSensitiveData = builder.Environment.IsDevelopment()); +builder.Services.AddEmbeddingGenerator(MockServices.CreateEmbeddingGenerator()); +#else // (IsOpenAI || IsAzureOpenAI) #if (IsOpenAI) var openai = builder.AddOpenAIClient("openai"); #else diff --git a/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/templates/AIChatWeb-CSharp/AIChatWeb-CSharp.Web/Program.cs b/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/templates/AIChatWeb-CSharp/AIChatWeb-CSharp.Web/Program.cs index 89a231105e9..8fb4d9f8887 100644 --- a/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/templates/AIChatWeb-CSharp/AIChatWeb-CSharp.Web/Program.cs +++ b/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/templates/AIChatWeb-CSharp/AIChatWeb-CSharp.Web/Program.cs @@ -1,4 +1,4 @@ -#if (IsGHModels || IsOpenAI || (IsAzureOpenAI && !IsManagedIdentity)) +#if (IsOpenAI || (IsAzureOpenAI && !IsManagedIdentity)) using System.ClientModel; #elif (IsAzureOpenAI && IsManagedIdentity) using System.ClientModel.Primitives; @@ -11,7 +11,7 @@ using Microsoft.Extensions.AI; #if (IsOllama) using OllamaSharp; -#elif (IsGHModels || IsOpenAI || IsAzureOpenAI) +#elif (IsOpenAI || IsAzureOpenAI) using OpenAI; #endif using AIChatWeb_CSharp.Web.Components; @@ -21,21 +21,7 @@ var builder = WebApplication.CreateBuilder(args); builder.Services.AddRazorComponents().AddInteractiveServerComponents(); -#if (IsGHModels) -// You will need to set the endpoint and key to your own values -// You can do this using Visual Studio's "Manage User Secrets" UI, or on the command line: -// cd this-project-directory -// dotnet user-secrets set GitHubModels:Token YOUR-GITHUB-TOKEN -var credential = new ApiKeyCredential(builder.Configuration["GitHubModels:Token"] ?? throw new InvalidOperationException("Missing configuration: GitHubModels:Token. See the README for details.")); -var openAIOptions = new OpenAIClientOptions() -{ - Endpoint = new Uri("https://models.inference.ai.azure.com") -}; - -var ghModelsClient = new OpenAIClient(credential, openAIOptions); -var chatClient = ghModelsClient.GetChatClient("gpt-4o-mini").AsIChatClient(); -var embeddingGenerator = ghModelsClient.GetEmbeddingClient("text-embedding-3-small").AsIEmbeddingGenerator(); -#elif (IsOllama) +#if (IsOllama) IChatClient chatClient = new OllamaApiClient(new Uri("http://localhost:11434"), "llama3.2"); IEmbeddingGenerator> embeddingGenerator = new OllamaApiClient(new Uri("http://localhost:11434"), @@ -56,6 +42,10 @@ var embeddingGenerator = openAIClient.GetEmbeddingClient("text-embedding-3-small").AsIEmbeddingGenerator(); #elif (IsAzureAIFoundry) +#elif (IsMock) +var chatClient = MockServices.CreateChatClient(); +IEmbeddingGenerator> embeddingGenerator = MockServices.CreateEmbeddingGenerator(); + #elif (IsAzureOpenAI) // You will need to set the endpoint and key to your own values // You can do this using Visual Studio's "Manage User Secrets" UI, or on the command line: diff --git a/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/templates/AIChatWeb-CSharp/AIChatWeb-CSharp.Web/README.md b/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/templates/AIChatWeb-CSharp/AIChatWeb-CSharp.Web/README.md index 14677ca5372..bbaaacd54a1 100644 --- a/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/templates/AIChatWeb-CSharp/AIChatWeb-CSharp.Web/README.md +++ b/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/templates/AIChatWeb-CSharp/AIChatWeb-CSharp.Web/README.md @@ -2,8 +2,10 @@ This project is an AI chat application that demonstrates how to chat with custom data using an AI language model. Please note that this template is currently in an early preview stage. If you have feedback, please take a [brief survey](https://aka.ms/dotnet-chat-templatePreview2-survey). +#### ---#if (!IsMock) >[!NOTE] > Before running this project you need to configure the API keys or endpoints for the providers you have chosen. See below for details specific to your choices. +#### ---#endif #### ---#if (IsAzure) ### Prerequisites @@ -21,31 +23,19 @@ This incompatibility can be addressed by upgrading to Docker Desktop 4.41.1. See #### ---#endif # Configure the AI Model Provider -#### ---#if (IsGHModels) -To use models hosted by GitHub Models, you will need to create a GitHub personal access token with `models:read` permissions, but no other scopes or permissions. See [Prototyping with AI models](https://docs.github.com/github-models/prototyping-with-ai-models) and [Managing your personal access tokens](https://docs.github.com/authentication/keeping-your-account-and-data-secure/managing-your-personal-access-tokens) in the GitHub Docs for more information. - -#### ---#if (hostIdentifier == "vs") -Configure your token for this project using .NET User Secrets: +#### ---#if (IsMock) +## Using Mock provider -1. In Visual Studio, right-click on your project in the Solution Explorer and select "Manage User Secrets". -2. This opens a `secrets.json` file where you can store your API keys without them being tracked in source control. Add the following key and value: - - ```json - { - "GitHubModels:Token": "YOUR-TOKEN" - } - ``` -#### ---#else -From the command line, configure your token for this project using .NET User Secrets by running the following commands: +The mock provider runs fully local with deterministic canned responses, so it requires no API keys, cloud credentials, or model downloads. -```sh -cd <> -dotnet user-secrets set GitHubModels:Token YOUR-TOKEN -``` -#### ---#endif +It is pre-seeded with roughly 20 TrailMaster GPS Watch responses (plus follow-up suggestions) that map to the sample documents under `wwwroot/Data`. -Learn more about [prototyping with AI models using GitHub Models](https://docs.github.com/github-models/prototyping-with-ai-models). +Good starter prompts: +- What does TrailMaster track? +- How rugged is TrailMaster? +- How does location sharing work? +- Why should I buy this watch? #### ---#endif #### ---#if (IsOpenAI) ## Using OpenAI diff --git a/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/templates/AIChatWeb-CSharp/AIChatWeb-CSharp.Web/Services/IngestedChunk.cs b/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/templates/AIChatWeb-CSharp/AIChatWeb-CSharp.Web/Services/IngestedChunk.cs index 60e6b5684e4..75994063439 100644 --- a/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/templates/AIChatWeb-CSharp/AIChatWeb-CSharp.Web/Services/IngestedChunk.cs +++ b/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/templates/AIChatWeb-CSharp/AIChatWeb-CSharp.Web/Services/IngestedChunk.cs @@ -19,7 +19,11 @@ public class IngestedChunk [VectorStoreKey(StorageName = "key")] [JsonPropertyName("key")] +#if (IsLocalVectorStore) + public required string Key { get; set; } +#else public required Guid Key { get; set; } +#endif [VectorStoreData(StorageName = "documentid")] [JsonPropertyName("documentid")] diff --git a/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/templates/AIChatWeb-CSharp/AIChatWeb-CSharp.Web/Services/LexicalMockEmbeddingGenerator.cs b/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/templates/AIChatWeb-CSharp/AIChatWeb-CSharp.Web/Services/LexicalMockEmbeddingGenerator.cs new file mode 100644 index 00000000000..b2df852e252 --- /dev/null +++ b/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/templates/AIChatWeb-CSharp/AIChatWeb-CSharp.Web/Services/LexicalMockEmbeddingGenerator.cs @@ -0,0 +1,107 @@ +using System; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Extensions.AI; + +namespace AIChatWeb_CSharp.Web.Services; + +#pragma warning disable MEAI001 // Mock provider uses experimental testing APIs + +internal sealed class LexicalMockEmbeddingGenerator : MockEmbeddingGenerator +{ + private readonly int _dimensions; + + internal LexicalMockEmbeddingGenerator(int dimensions) + { + ArgumentOutOfRangeException.ThrowIfLessThan(dimensions, 1); + _dimensions = dimensions; + } + + protected override Task>> GenerateCoreAsync( + IEnumerable values, + EmbeddingGenerationOptions? options, + CancellationToken cancellationToken) + { + if (GenerateAsyncCallback is not null) + { + return base.GenerateCoreAsync(values, options, cancellationToken); + } + + GeneratedEmbeddings> embeddings = []; + foreach (string value in values) + { + cancellationToken.ThrowIfCancellationRequested(); + embeddings.Add(new Embedding(CreateVector(value, _dimensions))); + } + + return Task.FromResult(embeddings); + } + + private static float[] CreateVector(string? value, int dimensions) + { + var vector = new float[dimensions]; + if (string.IsNullOrEmpty(value)) + { + return vector; + } + + // Hash tokens and three-character shingles so shared words and word fragments have matching dimensions. + int tokenStart = -1; + for (int i = 0; i <= value.Length; i++) + { + if (i < value.Length && char.IsLetterOrDigit(value[i])) + { + tokenStart = tokenStart < 0 ? i : tokenStart; + } + else if (tokenStart >= 0) + { + AddTokenFeatures(value, tokenStart, i - tokenStart, vector); + tokenStart = -1; + } + } + + // Normalize to unit length so cosine similarity favors lexical overlap rather than document length. + float squaredMagnitude = 0; + foreach (float component in vector) + { + squaredMagnitude += component * component; + } + + if (squaredMagnitude > 0) + { + float scale = 1 / (float)Math.Sqrt(squaredMagnitude); + for (int i = 0; i < vector.Length; i++) + { + vector[i] *= scale; + } + } + + return vector; + } + + private static void AddTokenFeatures(string value, int tokenStart, int tokenLength, float[] vector) + { + AddFeature(value, tokenStart, tokenLength, vector); + + for (int i = tokenStart; i <= tokenStart + tokenLength - 3; i++) + { + AddFeature(value, i, 3, vector); + } + } + + private static void AddFeature(string value, int start, int length, float[] vector) + { + uint hash = 2_166_136_261; + unchecked + { + for (int i = start; i < start + length; i++) + { + hash = (hash ^ char.ToLowerInvariant(value[i])) * 16_777_619; + } + } + + vector[(int)(hash % (uint)vector.Length)] += 1; + } +} +#pragma warning restore MEAI001 diff --git a/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/templates/AIChatWeb-CSharp/AIChatWeb-CSharp.Web/Services/MockChatClientExtensions.cs b/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/templates/AIChatWeb-CSharp/AIChatWeb-CSharp.Web/Services/MockChatClientExtensions.cs new file mode 100644 index 00000000000..8067a6b1d62 --- /dev/null +++ b/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/templates/AIChatWeb-CSharp/AIChatWeb-CSharp.Web/Services/MockChatClientExtensions.cs @@ -0,0 +1,297 @@ +using System.Linq; +using System.Text.Json; +using System.Xml; +using System.Xml.Linq; +using Microsoft.Extensions.AI; + +namespace AIChatWeb_CSharp.Web.Services; + +#pragma warning disable MEAI001 // Mock provider uses experimental testing APIs + +internal static class MockChatClientExtensions +{ + private const string LoadDocumentsCallId = "mock-load-documents"; + private const string SearchCallId = "mock-search"; + private const string MockConversationId = "mock-conversation"; + + /// Adds a canned response for matching requests. + /// The mock chat client to configure. + /// Predicate used to select whether the response applies to a request. + /// The assistant response text. + /// + /// The optional minimum simulated response delay in milliseconds. When supplied alone, it is the fixed delay. + /// + /// + /// The optional maximum simulated response delay in milliseconds. When supplied alone, a delay from zero up to this + /// value is selected. + /// + /// + /// Optional aichatweb follow-up suggestions returned through its structured-output request. Supply an empty + /// collection when the response is a conversation dead end. + /// + /// + /// to invoke the document-search tools and append a citation from the search result. + /// + /// + /// to remove the response seed after its first matching request; otherwise it remains reusable. + /// + /// The configured . + internal static MockChatClient AddResponse( + this MockChatClient client, + Func requestPredicate, + string response, + int? minDelay = null, + int? maxDelay = null, + IEnumerable? suggestions = null, + bool includeCitation = false, + bool singleUse = false) + { + ArgumentNullException.ThrowIfNull(client); + ArgumentNullException.ThrowIfNull(requestPredicate); + ArgumentNullException.ThrowIfNull(response); + ValidateDelayRange(minDelay, maxDelay); + + if (suggestions is null) + { + return AddResponse(client, requestPredicate, response, minDelay, maxDelay, includeCitation, singleUse); + } + + string structuredResponse = JsonSerializer.Serialize(new { data = suggestions.ToArray() }); + + MockChatClient configuredClient = AddResponse( + client, + request => request.Options?.ResponseFormat is not ChatResponseFormatJson && requestPredicate(request), + response, + minDelay, + maxDelay, + includeCitation, + singleUse); + + return AddResponse( + configuredClient, + request => request.Options?.ResponseFormat is ChatResponseFormatJson && requestPredicate(request), + structuredResponse, + minDelay, + maxDelay, + includeCitation, + singleUse); + } + + internal static string Citation(string filename, string quote) + { + ArgumentNullException.ThrowIfNull(filename); + ArgumentNullException.ThrowIfNull(quote); + + return new XElement( + "citation", + new XAttribute("filename", filename), + quote).ToString(SaveOptions.DisableFormatting); + } + + private static MockChatClient AddResponse( + MockChatClient client, + Func requestPredicate, + string response, + int? minDelay, + int? maxDelay, + bool includeCitation, + bool singleUse) => + client.AddResponse( + requestPredicate, + async (request, cancellationToken) => + { + await Task.Delay(GetDelay(minDelay, maxDelay), cancellationToken); + return CreateResponse(request, response, includeCitation); + }, + singleUse); + + private static ChatResponse CreateResponse(MockChatClientRequest request, string response, bool includeCitation) + { + if (request.Options?.ResponseFormat is ChatResponseFormatJson) + { + return new ChatResponse(new ChatMessage(ChatRole.Assistant, response)); + } + + if (TryGetFunctionResult(request, SearchCallId, out FunctionResultContent? searchResult) && searchResult is not null) + { + return CreateConversationResponse($"{response}{GetCitation(searchResult, request.LastUserText)}"); + } + + if (TryGetFunctionResult(request, LoadDocumentsCallId, out _)) + { + return CreateSearchFunctionCallResponse(request); + } + + if (includeCitation) + { + return request.Options?.ConversationId is null + ? CreateFunctionCallResponse(LoadDocumentsCallId, "LoadDocuments") + : CreateSearchFunctionCallResponse(request); + } + + return CreateConversationResponse(response); + } + + private static ChatResponse CreateConversationResponse(string response) => + new(new ChatMessage(ChatRole.Assistant, response)) + { + ConversationId = MockConversationId, + }; + + private static ChatResponse CreateFunctionCallResponse( + string callId, + string name, + IDictionary? arguments = null) => + new(new ChatMessage(ChatRole.Assistant, [new FunctionCallContent(callId, name, arguments)])); + + private static ChatResponse CreateSearchFunctionCallResponse(MockChatClientRequest request) => + CreateFunctionCallResponse( + SearchCallId, + "Search", + new Dictionary + { + ["searchPhrase"] = request.LastUserText ?? string.Empty, + }); + + private static string GetCitation(FunctionResultContent searchResult, string? searchPhrase) + { + IEnumerable results = searchResult.Result switch + { + IEnumerable result => result, + JsonElement { ValueKind: JsonValueKind.String } result when result.GetString() is { } resultText => [resultText], + JsonElement { ValueKind: JsonValueKind.Array } result => result + .EnumerateArray() + .Where(static element => element.ValueKind is JsonValueKind.String) + .Select(static element => element.GetString()!), + _ => [], + }; + + foreach (string result in results) + { + try + { + XElement element = XElement.Parse(result); + if (element.Name.LocalName != "result" || + element.Attribute("filename")?.Value is not { Length: > 0 } filename) + { + continue; + } + + string quote = GetQuote(element.Value, searchPhrase); + if (quote.Length > 0) + { + return Citation(filename, quote); + } + } + catch (XmlException) + { + // Ignore malformed search results. + } + } + + return string.Empty; + } + + private static string GetQuote(string text, string? searchPhrase) + { + string[] words = text.Split((char[]?)null, StringSplitOptions.RemoveEmptyEntries); + if (words.Length == 0) + { + return string.Empty; + } + + int start = 0; + if (!string.IsNullOrWhiteSpace(searchPhrase)) + { + int fewestOccurrences = int.MaxValue; + foreach (string queryWord in searchPhrase.Split((char[]?)null, StringSplitOptions.RemoveEmptyEntries)) + { + string term = NormalizeWord(queryWord); + if (term.Length < 4) + { + continue; + } + + int occurrences = 0; + int firstMatch = -1; + for (int i = 0; i < words.Length; i++) + { + if (IsTermMatch(NormalizeWord(words[i]), term)) + { + occurrences++; + firstMatch = firstMatch < 0 ? i : firstMatch; + } + } + + if (occurrences > 0 && occurrences < fewestOccurrences) + { + fewestOccurrences = occurrences; + start = firstMatch; + } + } + } + + return string.Join(" ", words, start, Math.Min(5, words.Length - start)); + } + + private static bool IsTermMatch(string word, string term) => + word.Length > 0 && (word.Contains(term, StringComparison.Ordinal) || term.Contains(word, StringComparison.Ordinal)); + + private static string NormalizeWord(string value) => + string.Concat(value.Where(static character => char.IsLetterOrDigit(character)).Select(static character => char.ToLowerInvariant(character))); + + private static bool TryGetFunctionResult( + MockChatClientRequest request, + string callId, + out FunctionResultContent? functionResult) + { + for (int i = request.Messages.Count - 1; i >= 0; i--) + { + ChatMessage message = request.Messages[i]; + if (message.Role == ChatRole.User) + { + break; + } + + foreach (AIContent content in message.Contents) + { + if (content is FunctionResultContent result && result.CallId == callId) + { + functionResult = result; + return true; + } + } + } + + functionResult = null; + return false; + } + + private static int GetDelay(int? minDelay, int? maxDelay) => + (minDelay, maxDelay) switch + { + (null, null) => 0, + ({ } min, null) => min, + (null, { } max) => Random.Shared.Next(0, max), + ({ } min, { } max) => Random.Shared.Next(min, max), + }; + + private static void ValidateDelayRange(int? minDelay, int? maxDelay) + { + if (minDelay is { } minimumDelayMilliseconds) + { + ArgumentOutOfRangeException.ThrowIfLessThan(minimumDelayMilliseconds, 0, nameof(minDelay)); + } + + if (maxDelay is { } maximumDelayMilliseconds) + { + ArgumentOutOfRangeException.ThrowIfLessThan(maximumDelayMilliseconds, 0, nameof(maxDelay)); + + if (minDelay is { } minimumDelayMillisecondsForMaximum) + { + ArgumentOutOfRangeException.ThrowIfLessThan(maximumDelayMilliseconds, minimumDelayMillisecondsForMaximum, nameof(maxDelay)); + } + } + } +} +#pragma warning restore MEAI001 diff --git a/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/templates/AIChatWeb-CSharp/AIChatWeb-CSharp.Web/Services/MockServices.cs b/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/templates/AIChatWeb-CSharp/AIChatWeb-CSharp.Web/Services/MockServices.cs new file mode 100644 index 00000000000..55a5bd87d7a --- /dev/null +++ b/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/templates/AIChatWeb-CSharp/AIChatWeb-CSharp.Web/Services/MockServices.cs @@ -0,0 +1,161 @@ +using Microsoft.Extensions.AI; + +namespace AIChatWeb_CSharp.Web.Services; + +#pragma warning disable MEAI001 // Mock provider uses experimental testing APIs + +internal static class MockServices +{ + internal static MockChatClient CreateChatClient() => + new MockChatClient() + .AddResponse( + static _ => true, + "Hi! I can help you plan an outdoor trip or find the right gear for it. What would you like to know?", + minDelay: 500, + maxDelay: 500, + suggestions: ["What kind of adventures?", "What kinds of products?"]) + .AddResponse( + static request => LastQuestionContains(request, "what kind of adventures", "outdoor activities"), + "Mostly off-grid trips: hiking, backpacking, trail running, and mountaineering are the classics, and plenty of people use the same gear for camping and long day hikes. Anywhere you lose cell service, the right gear makes the trip safer and easier.", + minDelay: 750, + maxDelay: 1500, + suggestions: ["Tell me about hiking adventures.", "Tell me about trail safety."]) + .AddResponse( + static request => LastQuestionContains(request, "what kinds of products"), + "Two main categories: adventure GPS watches for navigation and tracking, and emergency survival kits for backcountry safety. The GPS watch is the flagship, though a lot of people carry both. Want to dig into either one?", + minDelay: 750, + maxDelay: 1500, + suggestions: ["Tell me about GPS watches.", "Tell me about survival kits."]) + .AddResponse( + static request => LastQuestionContains(request, "hiking adventures", "hiking"), + "Hiking is right in the TrailMaster GPS Watch's wheelhouse. It's made for hikers, backpackers, and trail runners heading into remote terrain, with offline topographic maps and real-time location sharing to keep you on route and reachable.", + minDelay: 1000, + maxDelay: 3000, + suggestions: ["What does TrailMaster track?", "How rugged is TrailMaster?", "How does location sharing work?"], + includeCitation: true) + .AddResponse( + static request => LastQuestionContains(request, "gps watches", "gps watch"), + "The TrailMaster GPS Watch pairs precise positioning with a rugged build. It runs a multi-constellation receiver for GPS, GLONASS, and Galileo, plus an altimeter, barometer, and compass, so navigation holds up even where satellite coverage is thin.", + minDelay: 1000, + maxDelay: 3000, + suggestions: ["What does TrailMaster track?", "What navigation tools are built in?", "How rugged is TrailMaster?"], + includeCitation: true) + .AddResponse( + static request => LastQuestionContains(request, "trail safety", "safety"), + "Trail safety really comes down to preparation. Before heading out, make sure the watch is charged and calibrated and that you know its controls, then use real-time location sharing so someone always knows where you are. For longer trips, an emergency survival kit is a smart backup.", + minDelay: 1000, + maxDelay: 3000, + suggestions: ["How does location sharing work?", "How do I fix GPS signal loss?", "Tell me about survival kits."], + includeCitation: true) + .AddResponse( + static request => LastQuestionContains(request, "survival kits", "survival kit", "emergency kit"), + "The Life Guard X Emergency Survival Kit is built for when a trip goes sideways. It packs first aid supplies, high-calorie emergency food, a water purification system, an emergency shelter, and signaling tools so you can stay safe and reach help.", + minDelay: 1000, + maxDelay: 3000, + suggestions: ["What's in the first aid supplies?", "How does water purification work?", "How do I signal for help?"], + includeCitation: true) + .AddResponse( + static request => LastQuestionContains(request, "what does trailmaster track", "what does it track", "performance metrics", "heart rate", "elevation gain"), + "It tracks the numbers that matter for training and pacing: distance traveled, speed, elevation gain, and heart rate. You can watch them live in tracking mode or review them after the activity.", + minDelay: 1500, + maxDelay: 7500, + suggestions: [], + includeCitation: true) + .AddResponse( + static request => LastQuestionContains(request, "how rugged", "rugged", "durable", "shock-resistant", "harsh conditions"), + "It's built for abuse. The casing is durable and shock-resistant, and the reinforced strap and rugged design shrug off extreme temperatures, water, and impact out in the field.", + minDelay: 1500, + maxDelay: 7500, + suggestions: [], + includeCitation: true) + .AddResponse( + static request => LastQuestionContains(request, "location sharing", "share location", "share my location"), + "Press the share button and pick the contacts you want to reach, and the watch sends your position to them in real time. It needs a stable GPS signal and your phone connected through the TrailMaster app.", + minDelay: 1500, + maxDelay: 7500, + suggestions: [], + includeCitation: true) + .AddResponse( + static request => LastQuestionContains(request, "navigation tools", "route planning", "waypoint", "trail tracking"), + "Plenty to work with: preloaded topographic maps, trail tracking, waypoint management, and route planning, backed by an onboard compass, altimeter, and barometer for orientation and weather.", + minDelay: 1500, + maxDelay: 7500, + suggestions: [], + includeCitation: true) + .AddResponse( + static request => LastQuestionContains(request, "gps signal loss", "no gps signal", "lost gps", "gps troubleshooting"), + "If it shows \"No GPS Signal,\" start with your surroundings: dense foliage, buildings, or terrain can block satellites. Check for RF interference, run a signal diagnostics test, and if it keeps happening, reach out to ExpeditionTech support.", + minDelay: 1500, + maxDelay: 7500, + suggestions: [], + includeCitation: true) + .AddResponse( + static request => LastQuestionContains(request, "bluetooth", "cannot connect", "connection problem", "connectivity issues"), + "For pairing trouble, keep the watch within Bluetooth range, clear out nearby sources of interference, and update to the latest firmware. If it still won't connect, a Bluetooth signal analyzer can help track down the cause.", + minDelay: 1500, + maxDelay: 7500, + suggestions: [], + includeCitation: true) + .AddResponse( + static request => LastQuestionContains(request, "first aid"), + "The first aid supplies cover the basics for minor injuries: adhesive bandages, sterile gauze and tape, antiseptic wipes and antibiotic ointment, over-the-counter medications, and tools like tweezers, scissors, and a thermometer. A first aid guide is included too.", + minDelay: 1500, + maxDelay: 7500, + suggestions: [], + includeCitation: true) + .AddResponse( + static request => LastQuestionContains(request, "water purification", "water filter", "purify water", "clean water"), + "It comes with a portable water filter that removes 99.9999% of waterborne bacteria and 99.9% of protozoan parasites, so you can drink from rivers and lakes. Purification tablets are included too for an extra layer of protection.", + minDelay: 1500, + maxDelay: 7500, + suggestions: [], + includeCitation: true) + .AddResponse( + static request => LastQuestionContains(request, "signal for help", "signal mirror", "two-way radio", "signaling"), + "For signaling, the kit includes a whistle for short range, a signal mirror that reflects sunlight to catch a rescuer's eye, and a weather-resistant two-way radio with up to 20 miles of range to reach your group or emergency services.", + minDelay: 1500, + maxDelay: 7500, + suggestions: [], + includeCitation: true); + + internal static IEmbeddingGenerator> CreateEmbeddingGenerator() => + new LexicalMockEmbeddingGenerator(IngestedChunk.VectorDimensions); + + private static bool LastQuestionContains(MockChatClientRequest request, params string[] terms) + { + string? question = GetLastQuestion(request); + if (string.IsNullOrWhiteSpace(question)) + { + return false; + } + + foreach (string term in terms) + { + if (question.Contains(term, StringComparison.OrdinalIgnoreCase)) + { + return true; + } + } + + return false; + } + + private static string? GetLastQuestion(MockChatClientRequest request) + { + int startIndex = request.Options?.ResponseFormat is ChatResponseFormatJson + ? request.Messages.Count - 2 + : request.Messages.Count - 1; + + for (int i = startIndex; i >= 0; i--) + { + ChatMessage message = request.Messages[i]; + if (message.Role == ChatRole.User && !string.IsNullOrWhiteSpace(message.Text)) + { + return message.Text; + } + } + + return null; + } +} +#pragma warning restore MEAI001 diff --git a/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/templates/AIChatWeb-CSharp/README.Aspire.md b/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/templates/AIChatWeb-CSharp/README.Aspire.md index 0fff810489c..b4c9e3deb49 100644 --- a/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/templates/AIChatWeb-CSharp/README.Aspire.md +++ b/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/templates/AIChatWeb-CSharp/README.Aspire.md @@ -2,8 +2,10 @@ This project is an AI chat application that demonstrates how to chat with custom data using an AI language model. Please note that this template is currently in an early preview stage. If you have feedback, please take a [brief survey](https://aka.ms/dotnet-chat-templatePreview2-survey). +#### ---#if (!IsMock) >[!NOTE] > Before running this project you need to configure the API keys or endpoints for the providers you have chosen. See below for details specific to your choices. +#### ---#endif #### ---#if (IsAzure) ### Prerequisites @@ -20,31 +22,19 @@ This incompatibility can be addressed by upgrading to Docker Desktop 4.41.1. See # Configure the AI Model Provider -#### ---#if (IsGHModels) -## Using GitHub Models -To use models hosted by GitHub Models, you will need to create a GitHub personal access token. The token should not have any scopes or permissions. See [Managing your personal access tokens](https://docs.github.com/en/authentication/keeping-your-account-and-data-secure/managing-your-personal-access-tokens). +#### ---#if (IsMock) +## Using Mock provider -#### ---#if (hostIdentifier == "vs") -Configure your token for this project using .NET User Secrets: +The mock provider runs fully local with deterministic canned responses, so it requires no API keys, cloud credentials, or model downloads. -1. In Visual Studio, right-click on the AIChatWeb-CSharp.AppHost project in the Solution Explorer and select "Manage User Secrets". -2. This opens a `secrets.json` file where you can store your API keys without them being tracked in source control. Add the following key and value: +It is pre-seeded with roughly 20 TrailMaster GPS Watch responses (plus follow-up suggestions) that map to the sample documents under `wwwroot/Data`. - ```json - { - "ConnectionStrings:openai": "Endpoint=https://models.inference.ai.azure.com;Key=YOUR-API-KEY" - } - ``` -#### ---#else -From the command line, configure your token for this project using .NET User Secrets by running the following commands: - -```sh -cd AIChatWeb-CSharp.AppHost -dotnet user-secrets set ConnectionStrings:openai "Endpoint=https://models.inference.ai.azure.com;Key=YOUR-API-KEY" -``` -#### ---#endif +Good starter prompts: -Learn more about [prototyping with AI models using GitHub Models](https://docs.github.com/github-models/prototyping-with-ai-models). +- What does TrailMaster track? +- How rugged is TrailMaster? +- How does location sharing work? +- Why should I buy this watch? #### ---#endif #### ---#if (IsOpenAI) ## Using OpenAI diff --git a/src/ProjectTemplates/README.md b/src/ProjectTemplates/README.md index 69c052282ad..6e5da5dabcf 100644 --- a/src/ProjectTemplates/README.md +++ b/src/ProjectTemplates/README.md @@ -140,13 +140,13 @@ Finally, create a project from the template and run it: ```pwsh dotnet new aiagent-webapi ` - [--provider ] ` + --provider ` [--managed-identity] # or dotnet new aichatweb ` - [--provider ] ` + --provider ` [--vector-store ] ` [--aspire] ` [--managed-identity] diff --git a/src/Shared/DiagnosticIds/DiagnosticIds.cs b/src/Shared/DiagnosticIds/DiagnosticIds.cs index abddbf61c5a..17ad1211730 100644 --- a/src/Shared/DiagnosticIds/DiagnosticIds.cs +++ b/src/Shared/DiagnosticIds/DiagnosticIds.cs @@ -60,6 +60,7 @@ internal static class Experiments internal const string AIToolSearch = AIExperiments; internal const string AIRealTime = AIExperiments; internal const string AIFiles = AIExperiments; + internal const string AITesting = AIExperiments; internal const string AIOpenAIRequestPolicies = AIExperiments; // These diagnostic IDs are defined by the OpenAI package for its experimental APIs. diff --git a/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Embeddings/DelegatingEmbeddingGeneratorTests.cs b/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Embeddings/DelegatingEmbeddingGeneratorTests.cs index ededd588383..899d80f7420 100644 --- a/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Embeddings/DelegatingEmbeddingGeneratorTests.cs +++ b/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Embeddings/DelegatingEmbeddingGeneratorTests.cs @@ -26,7 +26,7 @@ public async Task GenerateEmbeddingsDefaultsToInnerServiceAsync() var expectedCancellationToken = cts.Token; var expectedResult = new TaskCompletionSource>>(); var expectedEmbedding = new GeneratedEmbeddings>([new(new float[] { 1.0f, 2.0f, 3.0f })]); - using var inner = new TestEmbeddingGenerator + using var inner = new MockEmbeddingGenerator { GenerateAsyncCallback = (input, options, cancellationToken) => { @@ -51,7 +51,7 @@ public async Task GenerateEmbeddingsDefaultsToInnerServiceAsync() [Fact] public void GetServiceThrowsForNullType() { - using var inner = new TestEmbeddingGenerator(); + using var inner = new MockEmbeddingGenerator(); using var delegating = new NoOpDelegatingEmbeddingGenerator(inner); Assert.Throws("serviceType", () => delegating.GetService(null!)); } @@ -60,7 +60,7 @@ public void GetServiceThrowsForNullType() public void GetServiceReturnsSelfIfCompatibleWithRequestAndKeyIsNull() { // Arrange - using var inner = new TestEmbeddingGenerator(); + using var inner = new MockEmbeddingGenerator(); using var delegating = new NoOpDelegatingEmbeddingGenerator(inner); // Act @@ -76,8 +76,8 @@ public void GetServiceDelegatesToInnerIfKeyIsNotNull() // Arrange var expectedParam = new object(); var expectedKey = new object(); - using var expectedResult = new TestEmbeddingGenerator(); - using var inner = new TestEmbeddingGenerator + using var expectedResult = new MockEmbeddingGenerator(); + using var inner = new MockEmbeddingGenerator { GetServiceCallback = (_, _) => expectedResult }; @@ -97,7 +97,7 @@ public void GetServiceDelegatesToInnerIfNotCompatibleWithRequest() var expectedParam = new object(); var expectedResult = TimeZoneInfo.Local; var expectedKey = new object(); - using var inner = new TestEmbeddingGenerator + using var inner = new MockEmbeddingGenerator { GetServiceCallback = (type, key) => type == expectedResult.GetType() && key == expectedKey ? expectedResult diff --git a/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Embeddings/EmbeddingGeneratorExtensionsTests.cs b/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Embeddings/EmbeddingGeneratorExtensionsTests.cs index 60e15848e0f..e7c77371a07 100644 --- a/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Embeddings/EmbeddingGeneratorExtensionsTests.cs +++ b/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Embeddings/EmbeddingGeneratorExtensionsTests.cs @@ -21,14 +21,14 @@ public void GetRequiredService_InvalidArgs_Throws() { Assert.Throws("generator", () => EmbeddingGeneratorExtensions.GetRequiredService(null!)); - using var generator = new TestEmbeddingGenerator(); + using var generator = new MockEmbeddingGenerator(); Assert.Throws("serviceType", () => generator.GetRequiredService(null!)); } [Fact] public void GetService_ValidService_Returned() { - using IEmbeddingGenerator> generator = new TestEmbeddingGenerator + using IEmbeddingGenerator> generator = new MockEmbeddingGenerator { GetServiceCallback = (serviceType, serviceKey) => { @@ -78,9 +78,9 @@ public void GetService_ValidService_Returned() [Fact] public async Task GenerateAsync_InvalidArgs_ThrowsAsync() { - await Assert.ThrowsAsync("generator", () => ((TestEmbeddingGenerator)null!).GenerateAsync("hello")); - await Assert.ThrowsAsync("generator", () => ((TestEmbeddingGenerator)null!).GenerateVectorAsync("hello")); - await Assert.ThrowsAsync("generator", () => ((TestEmbeddingGenerator)null!).GenerateAndZipAsync(["hello"])); + await Assert.ThrowsAsync("generator", () => ((MockEmbeddingGenerator)null!).GenerateAsync("hello")); + await Assert.ThrowsAsync("generator", () => ((MockEmbeddingGenerator)null!).GenerateVectorAsync("hello")); + await Assert.ThrowsAsync("generator", () => ((MockEmbeddingGenerator)null!).GenerateAndZipAsync(["hello"])); } [Fact] @@ -88,7 +88,7 @@ public async Task GenerateAsync_ReturnsSingleEmbeddingAsync() { Embedding result = new(new float[] { 1f, 2f, 3f }); - using TestEmbeddingGenerator service = new() + using MockEmbeddingGenerator service = new() { GenerateAsyncCallback = (values, options, cancellationToken) => Task.FromResult>>([result]) @@ -110,7 +110,7 @@ public async Task GenerateAndZipEmbeddingsAsync_ReturnsExpectedList(int count) .Select(i => new Embedding(Enumerable.Range(i, 4).Select(i => (float)i).ToArray())) .ToArray(); - using TestEmbeddingGenerator service = new() + using MockEmbeddingGenerator service = new() { GenerateAsyncCallback = (values, options, cancellationToken) => Task.FromResult>>(new(embeddings)) diff --git a/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Embeddings/GeneratedEmbeddingsTests.cs b/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Embeddings/GeneratedEmbeddingsTests.cs index a345af7b508..393fdd18634 100644 --- a/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Embeddings/GeneratedEmbeddingsTests.cs +++ b/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Embeddings/GeneratedEmbeddingsTests.cs @@ -250,7 +250,7 @@ public async Task Generator_SupportsCovariantInput() { var expectedGeneratedEmbeddings = new GeneratedEmbeddings>([new Embedding(new float[] { 1, 2, 3 })]); - using IEmbeddingGenerator> acceptsObject = new TestEmbeddingGenerator> + using IEmbeddingGenerator> acceptsObject = new MockEmbeddingGenerator { GenerateAsyncCallback = (values, options, cancellationToken) => Task.FromResult(expectedGeneratedEmbeddings), }; diff --git a/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Microsoft.Extensions.AI.Abstractions.Tests.csproj b/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Microsoft.Extensions.AI.Abstractions.Tests.csproj index f2b8c40018a..59b8c7f6dbb 100644 --- a/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Microsoft.Extensions.AI.Abstractions.Tests.csproj +++ b/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Microsoft.Extensions.AI.Abstractions.Tests.csproj @@ -37,5 +37,6 @@ + diff --git a/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/TestEmbeddingGenerator.cs b/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/TestEmbeddingGenerator.cs deleted file mode 100644 index dbb69304007..00000000000 --- a/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/TestEmbeddingGenerator.cs +++ /dev/null @@ -1,41 +0,0 @@ -// Licensed to the .NET Foundation under one or more agreements. -// The .NET Foundation licenses this file to you under the MIT license. - -using System; -using System.Collections.Generic; -using System.Threading; -using System.Threading.Tasks; - -#pragma warning disable SA1402 // File may only contain a single type -#pragma warning disable CA1816 // Dispose methods should call SuppressFinalize - -namespace Microsoft.Extensions.AI; - -public class TestEmbeddingGenerator : IEmbeddingGenerator - where TEmbedding : Embedding -{ - public TestEmbeddingGenerator() - { - GetServiceCallback = DefaultGetServiceCallback; - } - - public Func, EmbeddingGenerationOptions?, CancellationToken, Task>>? GenerateAsyncCallback { get; set; } - - public Func GetServiceCallback { get; set; } - - private object? DefaultGetServiceCallback(Type serviceType, object? serviceKey) => - serviceType is not null && serviceKey is null && serviceType.IsInstanceOfType(this) ? this : null; - - public Task> GenerateAsync(IEnumerable values, EmbeddingGenerationOptions? options = null, CancellationToken cancellationToken = default) - => GenerateAsyncCallback!.Invoke(values, options, cancellationToken); - - public object? GetService(Type serviceType, object? serviceKey = null) - => GetServiceCallback(serviceType, serviceKey); - - void IDisposable.Dispose() - { - // No resources to dispose - } -} - -public sealed class TestEmbeddingGenerator : TestEmbeddingGenerator>; diff --git a/test/Libraries/Microsoft.Extensions.AI.Testing.Tests/Microsoft.Extensions.AI.Testing.Tests.csproj b/test/Libraries/Microsoft.Extensions.AI.Testing.Tests/Microsoft.Extensions.AI.Testing.Tests.csproj new file mode 100644 index 00000000000..c9701755473 --- /dev/null +++ b/test/Libraries/Microsoft.Extensions.AI.Testing.Tests/Microsoft.Extensions.AI.Testing.Tests.csproj @@ -0,0 +1,11 @@ + + + Microsoft.Extensions.AI + Unit tests for Microsoft.Extensions.AI.Testing. + $(NoWarn);MEAI001;CA1063;VSTHRD003 + + + + + + diff --git a/test/Libraries/Microsoft.Extensions.AI.Testing.Tests/MockChatClientReadmeSamplesTests.cs b/test/Libraries/Microsoft.Extensions.AI.Testing.Tests/MockChatClientReadmeSamplesTests.cs new file mode 100644 index 00000000000..95123187e46 --- /dev/null +++ b/test/Libraries/Microsoft.Extensions.AI.Testing.Tests/MockChatClientReadmeSamplesTests.cs @@ -0,0 +1,364 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Net.Http; +using System.Runtime.CompilerServices; +using System.Text.Json; +using System.Threading; +using System.Threading.Tasks; +using Xunit; + +namespace Microsoft.Extensions.AI; + +public class MockChatClientReadmeSamplesTests +{ + [Fact] + public async Task QuickStart() + { + using var client = new MockChatClient(); + client + .AddResponse( + static _ => true, + static (_, _) => Task.FromResult( + new ChatResponse(new ChatMessage(ChatRole.Assistant, "Fallback response."))), + singleUse: true) + .AddResponse( + static request => request.LastUserText?.Contains("hello", StringComparison.OrdinalIgnoreCase) is true, + static (_, _) => Task.FromResult( + new ChatResponse(new ChatMessage(ChatRole.Assistant, "Hello from a deterministic mock.")))); + + ChatResponse response = await client.GetResponseAsync([new(ChatRole.User, "hello")]); + Console.WriteLine(response.Text); + + Assert.Equal("Hello from a deterministic mock.", response.Text); + } + + [Fact] + public async Task ResponseDictionary() + { + using var client = new MockChatClient(); + client.AddResponses(new() + { + ["hello"] = "Hello from a deterministic mock.", + ["goodbye"] = "Goodbye from a deterministic mock.", + }); + + Assert.Equal("Hello from a deterministic mock.", (await client.GetResponseAsync([new(ChatRole.User, "hello")])).Text); + Assert.Equal("Goodbye from a deterministic mock.", (await client.GetResponseAsync([new(ChatRole.User, "goodbye")])).Text); + } + + [Fact] + public async Task JsonResponseDictionary() + { + using var client = new MockChatClient(); + client.AddResponses( + JsonSerializer.Deserialize>( + """ + { + "hello": "Hello from a deterministic mock.", + "goodbye": "Goodbye from a deterministic mock." + } + """)!); + + Assert.Equal("Hello from a deterministic mock.", (await client.GetResponseAsync([new(ChatRole.User, "hello")])).Text); + Assert.Equal("Goodbye from a deterministic mock.", (await client.GetResponseAsync([new(ChatRole.User, "goodbye")])).Text); + } + + [Fact] + public async Task EnumerableResponseDictionary() + { + using var client = new MockChatClient(); + + client.AddResponses( + new KeyValuePair[] + { + new("hello", "Hello again. Nice to see you."), + new("hello", "Hello. Nice to meet you."), + }, + singleUse: true); + + Assert.Equal("Hello. Nice to meet you.", (await client.GetResponseAsync([new(ChatRole.User, "hello")])).Text); + Assert.Equal("Hello again. Nice to see you.", (await client.GetResponseAsync([new(ChatRole.User, "hello")])).Text); + } + + [Fact] + public async Task FunctionCallResponse() + { + using var client = new MockChatClient(); + client.AddResponse( + static request => request.LastUserText == "get-weather", + static (_, _) => Task.FromResult( + new ChatResponse( + new ChatMessage( + ChatRole.Assistant, + [new FunctionCallContent("weather-call", "GetWeather")])))); + + ChatResponse response = await client.GetResponseAsync([new(ChatRole.User, "get-weather")]); + FunctionCallContent functionCall = Assert.IsType( + Assert.Single(Assert.Single(response.Messages).Contents)); + + Assert.Equal("weather-call", functionCall.CallId); + Assert.Equal("GetWeather", functionCall.Name); + } + + [Fact] + public async Task CustomDictionaryPredicate() + { + using var client = new MockChatClient(); + + client.AddResponses( + new() + { + ["hello"] = "Hello from a deterministic mock.", + ["goodbye"] = "Goodbye from a deterministic mock.", + }, + static (request, key) => request.LastUserText?.StartsWith(key, StringComparison.OrdinalIgnoreCase) is true); + + Assert.Equal("Hello from a deterministic mock.", (await client.GetResponseAsync([new(ChatRole.User, "hello there")])).Text); + Assert.Equal("Goodbye from a deterministic mock.", (await client.GetResponseAsync([new(ChatRole.User, "goodbye for now")])).Text); + } + + [Fact] + public async Task SingleUseDictionaryResponses() + { + using var client = new MockChatClient(); + client.AddResponses( + new() + { + ["hello:2"] = "Hello again. Nice to see you.", + ["hello:1"] = "Hello. Nice to meet you.", + }, + static (request, key) => + { + string promptKey = key.Split(new[] { ':' }, 2)[0]; + return string.Equals(request.LastUserText, promptKey, StringComparison.OrdinalIgnoreCase); + }, + singleUse: true); + + Assert.Equal("Hello. Nice to meet you.", (await client.GetResponseAsync([new(ChatRole.User, "hello")])).Text); + Assert.Equal("Hello again. Nice to see you.", (await client.GetResponseAsync([new(ChatRole.User, "hello")])).Text); + } + + [Fact] + public async Task ImageResponseDictionary() + { + using var client = new MockChatClient(); + client.AddResponses( + new() + { + ["image/jpeg"] = "I see you shared a JPEG.", + ["image/png"] = "I see you shared a PNG.", + }, + static (request, mediaType) => request.Messages + .SelectMany(static message => message.Contents) + .OfType() + .Any(content => string.Equals(content.MediaType, mediaType, StringComparison.OrdinalIgnoreCase))); + + Assert.Equal( + "I see you shared a JPEG.", + (await client.GetResponseAsync([new(ChatRole.User, [new DataContent(new byte[] { 0xFF }, "image/jpeg")])])).Text); + Assert.Equal( + "I see you shared a PNG.", + (await client.GetResponseAsync([new(ChatRole.User, [new DataContent(new byte[] { 0x89 }, "image/png")])])).Text); + } + + [Fact] + public async Task ResponseDictionaryWrapper() + { + using var client = new MockChatClient(); + client.AddResponses( + new() + { + ["hello"] = "Hello from a deterministic mock.", + ["goodbye"] = "Goodbye from a deterministic mock.", + }, + getResponse: async (response, cancellationToken) => + { + await Task.Delay(TimeSpan.FromMilliseconds(100), cancellationToken); + return response; + }); + + Assert.Equal("Hello from a deterministic mock.", (await client.GetResponseAsync([new(ChatRole.User, "hello")])).Text); + } + + [Fact] + public async Task NonStreamingResponse() + { + using var client = new MockChatClient(); + + client.AddResponse( + static request => request.LastUserText == "explain", + static (_, _) => Task.FromResult( + new ChatResponse(new ChatMessage(ChatRole.Assistant, "Complete answer")))); + + Assert.Equal("Complete answer", (await client.GetResponseAsync([new(ChatRole.User, "explain")])).Text); + } + + [Fact] + public async Task StreamingResponse() + { + using var client = new MockChatClient(); + + client.AddStreamingResponse( + static request => request.LastUserText == "stream", + static (_, cancellationToken) => GetUpdatesAsync(cancellationToken)); + + static async IAsyncEnumerable GetUpdatesAsync( + [EnumeratorCancellation] CancellationToken cancellationToken) + { + cancellationToken.ThrowIfCancellationRequested(); + yield return new ChatResponseUpdate(ChatRole.Assistant, "Part 1 "); + yield return new ChatResponseUpdate(ChatRole.Assistant, "Part 2"); + } + + List updates = await ToListAsync(client.GetStreamingResponseAsync([new(ChatRole.User, "stream")])); + Assert.Equal("Part 1 ", updates[0].Text); + Assert.Equal("Part 2", updates[1].Text); + } + + [Fact] + public async Task DistinctStreamingAndNonStreamingResponses() + { + using var client = new MockChatClient(); + + client.AddResponse( + static request => request.LastUserText == "both", + static (_, _) => Task.FromResult( + new ChatResponse(new ChatMessage(ChatRole.Assistant, "Complete answer"))), + static (_, cancellationToken) => GetUpdatesAsync(cancellationToken)); + + static async IAsyncEnumerable GetUpdatesAsync( + [EnumeratorCancellation] CancellationToken cancellationToken) + { + cancellationToken.ThrowIfCancellationRequested(); + yield return new ChatResponseUpdate(ChatRole.Assistant, "Part 1 "); + yield return new ChatResponseUpdate(ChatRole.Assistant, "Part 2"); + } + + Assert.Equal("Complete answer", (await client.GetResponseAsync([new(ChatRole.User, "both")])).Text); + List updates = await ToListAsync(client.GetStreamingResponseAsync([new(ChatRole.User, "both")])); + Assert.Equal("Part 1 ", updates[0].Text); + Assert.Equal("Part 2", updates[1].Text); + } + + [Fact] + public async Task RichChatResponse() + { + using var client = new MockChatClient(); + + client.AddResponse( + static request => request.LastUserText == "sources", + static (_, _) => Task.FromResult( + new ChatResponse( + [ + new ChatMessage( + ChatRole.Assistant, + [ + new TextContent("TrailMaster tracks your progress.") + { + Annotations = + [ + new CitationAnnotation + { + Title = "Example_GPS_Watch.md", + Snippet = "track your progress", + } + ] + }, + new TextReasoningContent("The document describes tracking features.") + ]) + ]) + { + Usage = new UsageDetails { InputTokenCount = 3, OutputTokenCount = 7 } + })); + + ChatResponse response = await client.GetResponseAsync([new(ChatRole.User, "sources")]); + ChatMessage message = Assert.Single(response.Messages); + TextContent content = Assert.IsType(message.Contents[0]); + + Assert.Equal("TrailMaster tracks your progress.", content.Text); + Assert.Equal("Example_GPS_Watch.md", Assert.IsType(Assert.Single(content.Annotations!)).Title); + Assert.Equal("The document describes tracking features.", Assert.IsType(message.Contents[1]).Text); + Assert.Equal(3, response.Usage!.InputTokenCount); + Assert.Equal(7, response.Usage.OutputTokenCount); + } + + [Fact] + public async Task RequestAssertions() + { + using var client = new MockChatClient(); + client.AddResponse(static _ => true, static (_, _) => Task.FromResult( + new ChatResponse(new ChatMessage(ChatRole.Assistant, "response")))); + _ = await client.GetResponseAsync([new(ChatRole.User, "hello")]); + + MockChatClientRequest first = client.Requests[0]; + string? lastUserText = first.LastUserText; + bool wasStreaming = first.IsStreaming; + + Assert.Equal("hello", lastUserText); + Assert.False(wasStreaming); + } + + [Fact] + public async Task ClearAndReseed() + { + using var client = new MockChatClient(); + client.AddResponse(static _ => true, static (_, _) => Task.FromResult( + new ChatResponse(new ChatMessage(ChatRole.Assistant, "old scenario")))); + _ = await client.GetResponseAsync([new(ChatRole.User, "old")]); + + client.ClearResponses() + .AddResponse( + static _ => true, + static (_, _) => Task.FromResult( + new ChatResponse(new ChatMessage(ChatRole.Assistant, "New test scenario.")))); + + Assert.Equal("New test scenario.", (await client.GetResponseAsync([new(ChatRole.User, "new")])).Text); + Assert.Equal(2, client.Requests.Count); + } + + [Fact] + public async Task MockErrors() + { + using var client = new MockChatClient(); + + client.AddException( + static request => request.LastUserText == "UNAVAILABLE", + static () => new HttpRequestException("The provider is temporarily unavailable.")); + + HttpRequestException exception = await Assert.ThrowsAsync(() => + client.GetResponseAsync([new(ChatRole.User, "UNAVAILABLE")])); + Assert.Equal("The provider is temporarily unavailable.", exception.Message); + } + + [Fact] + public async Task MockEmbeddings() + { + using var embeddings = new MockEmbeddingGenerator + { + GenerateAsyncCallback = static (_, _, _) => + Task.FromResult>>( + [new(new float[] { 0.1f, 0.2f, 0.3f })]), + }; + + GeneratedEmbeddings> result = await embeddings.GenerateAsync(["trail"]); + Console.WriteLine(embeddings.CallCount); + + Assert.Equal(1, embeddings.CallCount); + Assert.Equal(new float[] { 0.1f, 0.2f, 0.3f }, Assert.Single(result).Vector.ToArray()); + } + + private static async Task> ToListAsync(IAsyncEnumerable updates) + { + var results = new List(); + await foreach (ChatResponseUpdate update in updates) + { + results.Add(update); + } + + return results; + } +} diff --git a/test/Libraries/Microsoft.Extensions.AI.Testing.Tests/MockChatClientTests.cs b/test/Libraries/Microsoft.Extensions.AI.Testing.Tests/MockChatClientTests.cs new file mode 100644 index 00000000000..c16ce81ee58 --- /dev/null +++ b/test/Libraries/Microsoft.Extensions.AI.Testing.Tests/MockChatClientTests.cs @@ -0,0 +1,706 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text.Json; +using System.Threading; +using System.Threading.Tasks; +using Xunit; + +namespace Microsoft.Extensions.AI; + +public class MockChatClientTests +{ + [Fact] + public async Task AddResponse_MatchesMostRecentlyAddedSeed_AndFallsBackAfterSingleUseSeed() + { + using var client = new MockChatClient(); + client + .AddResponse(static _ => true, static (_, _) => CreateResponseAsync("first"), singleUse: true) + .AddResponse(static _ => true, static (_, _) => CreateResponseAsync("second"), singleUse: true); + + Assert.Equal("second", (await client.GetResponseAsync([new(ChatRole.User, "hello")])).Text); + Assert.Equal("first", (await client.GetResponseAsync([new(ChatRole.User, "hello again")])).Text); + + var ex = await Assert.ThrowsAsync(() => + client.GetResponseAsync([new(ChatRole.User, "third request")])); + + Assert.Contains("third request", ex.Message); + } + + [Fact] + public async Task AddResponses_UsesOrdinalIgnoreCaseExactMatches() + { + using var client = new MockChatClient(); + client.AddResponses( + new() + { + ["hello"] = "Hello from a deterministic mock.", + ["goodbye"] = "Goodbye from a deterministic mock.", + }); + + Assert.Equal("Hello from a deterministic mock.", (await client.GetResponseAsync([new(ChatRole.User, "HELLO")])).Text); + + await Assert.ThrowsAsync(() => + client.GetResponseAsync([new(ChatRole.User, "hello there")])); + } + + [Fact] + public async Task AddResponses_SeedsJsonDeserializedResponsesInOrder() + { + using var client = new MockChatClient(); + client.AddResponses( + JsonSerializer.Deserialize>( + """ + { + "weather": "General weather response.", + "weather today": "Specific weather response." + } + """)!, + static (request, key) => request.LastUserText?.Contains(key, StringComparison.OrdinalIgnoreCase) is true); + + Assert.Equal("Specific weather response.", (await client.GetResponseAsync([new(ChatRole.User, "weather today")])).Text); + } + + [Fact] + public async Task AddResponses_AppliesSingleUseToDictionaryResponses() + { + using var client = new MockChatClient(); + client.AddResponses( + new() + { + ["hello:2"] = "Hello again. Nice to see you.", + ["hello:1"] = "Hello. Nice to meet you.", + }, + static (request, key) => string.Equals( + request.LastUserText, + key.Split(new[] { ':' }, 2)[0], + StringComparison.OrdinalIgnoreCase), + singleUse: true); + + Assert.Equal("Hello. Nice to meet you.", (await client.GetResponseAsync([new(ChatRole.User, "hello")])).Text); + Assert.Equal("Hello again. Nice to see you.", (await client.GetResponseAsync([new(ChatRole.User, "hello")])).Text); + + await Assert.ThrowsAsync(() => + client.GetResponseAsync([new(ChatRole.User, "hello")])); + } + + [Fact] + public async Task AddResponses_EnumerableResponsesSupportRepeatedKeys() + { + using var client = new MockChatClient(); + client.AddResponses( + new KeyValuePair[] + { + new("hello", "Hello again. Nice to see you."), + new("hello", "Hello. Nice to meet you."), + }, + singleUse: true); + + Assert.Equal("Hello. Nice to meet you.", (await client.GetResponseAsync([new(ChatRole.User, "hello")])).Text); + Assert.Equal("Hello again. Nice to see you.", (await client.GetResponseAsync([new(ChatRole.User, "hello")])).Text); + + await Assert.ThrowsAsync(() => + client.GetResponseAsync([new(ChatRole.User, "hello")])); + } + + [Fact] + public async Task AddResponses_AppliesResponseWrapperToStringResponses() + { + using var cancellationTokenSource = new CancellationTokenSource(); + CancellationToken observedToken = default; + using var client = new MockChatClient(); + client.AddResponses( + new() + { + ["hello"] = "Hello", + ["goodbye"] = "Goodbye", + }, + getResponse: (response, cancellationToken) => + { + observedToken = cancellationToken; + return Task.FromResult(new ChatResponse(new ChatMessage(ChatRole.Assistant, $"{response.Text} from wrapper"))); + }); + + ChatResponse result = await client.GetResponseAsync( + [new(ChatRole.User, "hello")], + cancellationToken: cancellationTokenSource.Token); + + Assert.Equal("Hello from wrapper", result.Text); + Assert.Equal(cancellationTokenSource.Token, observedToken); + } + + [Fact] + public async Task AddResponse_ReturnsNonTextChatResponse() + { + using var client = new MockChatClient(); + client.AddResponse( + static request => request.LastUserText == "get-weather", + static (_, _) => Task.FromResult( + new ChatResponse( + new ChatMessage( + ChatRole.Assistant, + [new FunctionCallContent("weather-call", "GetWeather")])))); + + ChatResponse response = await client.GetResponseAsync([new(ChatRole.User, "get-weather")]); + + FunctionCallContent functionCall = Assert.IsType( + Assert.Single(Assert.Single(response.Messages).Contents)); + Assert.Equal("weather-call", functionCall.CallId); + Assert.Equal("GetWeather", functionCall.Name); + } + + [Fact] + public async Task AddResponses_MatchesImageRequestsByMediaType() + { + const string JpegResponse = "I see you shared a JPEG."; + const string PngResponse = "I see you shared a PNG."; + using var client = new MockChatClient(); + client.AddResponses( + new() + { + ["image/jpeg"] = JpegResponse, + ["image/png"] = PngResponse, + }, + static (request, mediaType) => request.Messages + .SelectMany(static message => message.Contents) + .OfType() + .Any(content => string.Equals(content.MediaType, mediaType, StringComparison.OrdinalIgnoreCase))); + + ChatResponse jpegResponse = await client.GetResponseAsync( + [ + new ChatMessage( + ChatRole.User, + [new DataContent(new byte[] { 0xFF }, "image/jpeg")]) + ]); + ChatResponse pngResponse = await client.GetResponseAsync( + [ + new ChatMessage( + ChatRole.User, + [new DataContent(new byte[] { 0x89 }, "image/png")]) + ]); + + Assert.Equal(JpegResponse, jpegResponse.Text); + Assert.Equal(PngResponse, pngResponse.Text); + } + + [Fact] + public async Task AddResponses_ResponseWrapperReceivesCancellationToken() + { + CancellationToken observedToken = default; + using var client = new MockChatClient(); + client.AddResponses( + new() + { + ["cancel"] = "unreachable", + ["other"] = "Other response", + }, + getResponse: async (response, cancellationToken) => + { + observedToken = cancellationToken; + await Task.Delay(Timeout.Infinite, cancellationToken); + return response; + }); + using var cancellationTokenSource = new CancellationTokenSource(); + cancellationTokenSource.Cancel(); + + await Assert.ThrowsAnyAsync(() => + client.GetResponseAsync([new(ChatRole.User, "cancel")], cancellationToken: cancellationTokenSource.Token)); + + Assert.Equal(cancellationTokenSource.Token, observedToken); + } + + [Fact] + public async Task ExtensibilityHooks_AreOverridable() + { + using var client = new ExtensibleMockChatClient(); + client.AddResponses( + new() + { + ["hello"] = "Hello", + ["goodbye"] = "Goodbye", + }); + + Assert.Equal("Hello", (await client.GetResponseAsync([new(ChatRole.User, "hello")])).Text); + Assert.Equal(1, client.AddResponsesFromDictionaryCallCount); + Assert.Equal(1, client.AddResponsesFromEnumerableCallCount); + Assert.Equal(2, client.AddSeedCallCount); + Assert.Equal(1, client.MatchSeededResponseCallCount); + } + + [Fact] + public async Task AddResponse_IsReusableByDefault() + { + using var client = new MockChatClient(); + client.AddResponse(static _ => true, static (_, _) => CreateResponseAsync("stable")); + + Assert.Equal("stable", (await client.GetResponseAsync([new(ChatRole.User, "first")])).Text); + Assert.Equal("stable", (await client.GetResponseAsync([new(ChatRole.User, "second")])).Text); + } + + [Fact] + public async Task AddResponse_FactoryCanInspectRequestAndCancellationToken() + { + using var cancellationTokenSource = new CancellationTokenSource(); + CancellationToken observedToken = default; + using var client = new MockChatClient(); + client.AddResponse( + static request => request.LastUserText?.Contains("weather", StringComparison.OrdinalIgnoreCase) is true, + (request, cancellationToken) => + { + observedToken = cancellationToken; + return CreateResponseAsync($"{request.LastUserText} branch"); + }); + + ChatResponse response = await client.GetResponseAsync( + [new(ChatRole.User, "What's the weather today?")], + cancellationToken: cancellationTokenSource.Token); + + Assert.Equal("What's the weather today? branch", response.Text); + Assert.Equal(cancellationTokenSource.Token, observedToken); + } + + [Fact] + public async Task AddResponse_FactoryCanStageFunctionCallsUsingFunctionResults() + { + const string LoadDocumentsCallId = "load-documents"; + const string SearchCallId = "search"; + using var client = new MockChatClient(); + client.AddResponse(static _ => true, static (request, _) => Task.FromResult(CreateToolResponse(request))); + + ChatMessage userMessage = new(ChatRole.User, "What does TrailMaster track?"); + + ChatResponse loadDocuments = await client.GetResponseAsync([userMessage]); + FunctionCallContent loadDocumentsCall = Assert.IsType( + Assert.Single(Assert.Single(loadDocuments.Messages).Contents)); + Assert.Equal("LoadDocuments", loadDocumentsCall.Name); + + var loadDocumentsResult = new ChatMessage( + ChatRole.Tool, + [new FunctionResultContent(LoadDocumentsCallId, "Success: Function completed.")]); + ChatResponse search = await client.GetResponseAsync([userMessage, loadDocuments.Messages[0], loadDocumentsResult]); + FunctionCallContent searchCall = Assert.IsType( + Assert.Single(Assert.Single(search.Messages).Contents)); + Assert.Equal("Search", searchCall.Name); + Assert.Equal(userMessage.Text, searchCall.Arguments!["searchPhrase"]); + + var searchResult = new ChatMessage( + ChatRole.Tool, + [new FunctionResultContent(SearchCallId, new[] { "distance traveled speed elevation gain heart rate" })]); + ChatResponse final = await client.GetResponseAsync( + [userMessage, loadDocuments.Messages[0], loadDocumentsResult, search.Messages[0], searchResult]); + + Assert.Equal("Final answer", final.Text); + + static ChatResponse CreateToolResponse(MockChatClientRequest request) + { + if (HasFunctionResult(request, SearchCallId)) + { + return new ChatResponse(new ChatMessage(ChatRole.Assistant, "Final answer")); + } + + if (HasFunctionResult(request, LoadDocumentsCallId)) + { + return new ChatResponse( + new ChatMessage( + ChatRole.Assistant, + [new FunctionCallContent( + SearchCallId, + "Search", + new Dictionary + { + ["searchPhrase"] = request.LastUserText, + })])); + } + + return new ChatResponse( + new ChatMessage( + ChatRole.Assistant, + [new FunctionCallContent(LoadDocumentsCallId, "LoadDocuments")])); + } + + static bool HasFunctionResult(MockChatClientRequest request, string callId) => + request.Messages + .SelectMany(static message => message.Contents) + .OfType() + .Any(result => result.CallId == callId); + } + + [Fact] + public async Task AddResponse_PropagatesFactoryCancellation() + { + using var client = new MockChatClient(); + client.AddResponse( + static _ => true, + static async (_, cancellationToken) => + { + await Task.Delay(Timeout.Infinite, cancellationToken); + return new ChatResponse(new ChatMessage(ChatRole.Assistant, "unreachable")); + }); + using var cancellationTokenSource = new CancellationTokenSource(); + cancellationTokenSource.Cancel(); + + await Assert.ThrowsAnyAsync(() => + client.GetResponseAsync([new(ChatRole.User, "cancel")], cancellationToken: cancellationTokenSource.Token)); + } + + [Fact] + public async Task AddStreamingResponse_SupportsNonStreamingConversion_WithCitationsReasoningAndUsage() + { + var text = new TextContent("Final answer") + { + Annotations = + [ + new CitationAnnotation + { + Title = "Example_GPS_Watch.md", + Snippet = "track your progress", + } + ] + }; + + ChatResponseUpdate[] updates = + [ + new(ChatRole.Assistant, [text, new TextReasoningContent("Reasoning step")]) + { + ConversationId = "conversation-1", + ResponseId = "response-1", + ModelId = "mock-model", + FinishReason = ChatFinishReason.Stop, + }, + new() + { + Contents = [new UsageContent(new UsageDetails { InputTokenCount = 3, OutputTokenCount = 7 })] + } + ]; + + using var client = new MockChatClient(); + client.AddStreamingResponse( + static _ => true, + (_, _) => EnumerateUpdatesAsync(updates)); + + ChatResponse response = await client.GetResponseAsync([new(ChatRole.User, "question")]); + ChatMessage message = Assert.Single(response.Messages); + + var textContent = Assert.IsType(message.Contents[0]); + Assert.Equal("Final answer", textContent.Text); + var annotation = Assert.IsType(Assert.Single(textContent.Annotations!)); + Assert.Equal("Example_GPS_Watch.md", annotation.Title); + Assert.Equal("track your progress", annotation.Snippet); + + Assert.Equal("Reasoning step", Assert.IsType(message.Contents[1]).Text); + Assert.Equal("conversation-1", response.ConversationId); + Assert.Equal("response-1", response.ResponseId); + Assert.Equal("mock-model", response.ModelId); + Assert.Equal(ChatFinishReason.Stop, response.FinishReason); + Assert.Equal(3, response.Usage!.InputTokenCount); + Assert.Equal(7, response.Usage.OutputTokenCount); + } + + [Fact] + public async Task AddResponse_SupportsStreamingConversion() + { + var text = new TextContent("Streaming text") + { + Annotations = + [ + new CitationAnnotation + { + Title = "Example_Emergency_Survival_Kit.pdf", + Snippet = "water purification tablets", + } + ] + }; + + var response = new ChatResponse( + [ + new ChatMessage(ChatRole.Assistant, [text, new TextReasoningContent("Think")]) + ]) + { + Usage = new UsageDetails { OutputTokenCount = 5 }, + ConversationId = "conversation-2", + }; + + using var client = new MockChatClient(); + client.AddResponse( + static _ => true, + (_, _) => Task.FromResult(response)); + + List updates = await ToListAsync( + client.GetStreamingResponseAsync([new(ChatRole.User, "stream this")])); + + Assert.Equal(2, updates.Count); // message + usage + Assert.Equal("conversation-2", updates[0].ConversationId); + Assert.Equal("Streaming text", updates[0].Text); + + var streamedText = Assert.IsType(updates[0].Contents[0]); + Assert.Equal("Streaming text", streamedText.Text); + Assert.Equal("Example_Emergency_Survival_Kit.pdf", Assert.IsType(Assert.Single(streamedText.Annotations!)).Title); + Assert.Equal("Think", Assert.IsType(updates[0].Contents[1]).Text); + + Assert.IsType(Assert.Single(updates[1].Contents)); + } + + [Fact] + public async Task AddResponse_UsesDistinctStreamingFactory() + { + using var client = new MockChatClient(); + client.AddResponse( + static _ => true, + static (_, _) => CreateResponseAsync("non-streaming"), + static (_, _) => EnumerateUpdatesAsync([new(ChatRole.Assistant, "streaming")])); + + Assert.Equal("non-streaming", (await client.GetResponseAsync([new(ChatRole.User, "one")])).Text); + + List updates = await ToListAsync( + client.GetStreamingResponseAsync([new(ChatRole.User, "two")])); + Assert.Equal("streaming", Assert.Single(updates).Text); + } + + [Fact] + public async Task Requests_HistoryCapturesStreamingFlag() + { + using var client = new MockChatClient(); + client + .AddResponse(static _ => true, static (_, _) => CreateResponseAsync("non-streaming"), singleUse: true) + .AddResponse(static _ => true, static (_, _) => CreateResponseAsync("streaming"), singleUse: true); + + _ = await client.GetResponseAsync([new(ChatRole.User, "one")]); + await foreach (ChatResponseUpdate update in client.GetStreamingResponseAsync([new(ChatRole.User, "two")])) + { + Assert.NotNull(update); + } + + Assert.Equal(2, client.Requests.Count); + Assert.False(client.Requests[0].IsStreaming); + Assert.True(client.Requests[1].IsStreaming); + Assert.Equal("one", client.Requests[0].LastUserText); + Assert.Equal("two", client.Requests[1].LastUserText); + } + + [Fact] + public void GetService_ReturnsSelfWhenRequested() + { + using var client = new MockChatClient(); + + Assert.Same(client, client.GetService(typeof(MockChatClient))); + Assert.Same(client, client.GetService(typeof(IChatClient))); + Assert.Null(client.GetService(typeof(string))); + } + + [Fact] + public async Task Dispose_PreventsFurtherUse() + { + using var client = new MockChatClient(); + client.AddResponse(static _ => true, static (_, _) => CreateResponseAsync("before dispose")); + + client.Dispose(); + + await Assert.ThrowsAsync(() => + client.GetResponseAsync([new(ChatRole.User, "after dispose")])); + } + + [Fact] + public async Task ClearResponses_RemovesSeedsAndPreservesRequests() + { + using var client = new MockChatClient(); + client.AddResponse(static _ => true, static (_, _) => CreateResponseAsync("first")); + + _ = await client.GetResponseAsync([new(ChatRole.User, "first request")]); + Assert.Same(client, client.ClearResponses()); + + await Assert.ThrowsAsync(() => + client.GetResponseAsync([new(ChatRole.User, "second request")])); + + Assert.Equal(2, client.Requests.Count); + } + + [Fact] + public async Task AddException_UsesFreshExceptionForEachRequest() + { + using var client = new MockChatClient(); + client.AddException( + static request => request.LastUserText == "fail", + static () => new InvalidOperationException("expected")); + + InvalidOperationException first = await Assert.ThrowsAsync(() => + client.GetResponseAsync([new(ChatRole.User, "fail")])); + InvalidOperationException second = await Assert.ThrowsAsync(() => + client.GetResponseAsync([new(ChatRole.User, "fail")])); + + Assert.NotSame(first, second); + } + + [Fact] + public async Task AddException_StreamingRequestFaultsDuringEnumeration() + { + using var client = new MockChatClient(); + client.AddException(static _ => true, static () => new InvalidOperationException("expected")); + + await Assert.ThrowsAsync(async () => + { + await foreach (ChatResponseUpdate update in client.GetStreamingResponseAsync([new(ChatRole.User, "fail")])) + { + Assert.NotNull(update); + } + }); + } + + [Fact] + public void Registration_RequiresRequestPredicate() + { + using var client = new MockChatClient(); + + Assert.Throws(() => + client.AddResponse(null!, static (_, _) => CreateResponseAsync("response"))); + Assert.Throws(() => + client.AddStreamingResponse(null!, static (_, _) => EnumerateUpdatesAsync([]))); + Assert.Throws(() => + client.AddException(null!, static () => new InvalidOperationException())); + } + + [Fact] + public async Task MockEmbeddingGenerator_InvokesConfiguredCallbackAndRecordsCall() + { + var values = new List { "trail", "camp" }; + var options = new EmbeddingGenerationOptions(); + using var cancellationTokenSource = new CancellationTokenSource(); + var expected = new GeneratedEmbeddings>([new(new float[] { 1, 2, 3, 4 })]); + + using var generator = new MockEmbeddingGenerator + { + GenerateAsyncCallback = (actualValues, actualOptions, cancellationToken) => + { + Assert.Same(values, actualValues); + Assert.Same(options, actualOptions); + Assert.Equal(cancellationTokenSource.Token, cancellationToken); + return Task.FromResult(expected); + }, + }; + + Assert.Same(expected, await generator.GenerateAsync(values, options, cancellationTokenSource.Token)); + Assert.Equal(1, generator.CallCount); + } + + [Fact] + public async Task MockEmbeddingGenerator_SupportsGenericInputs() + { + var value = new object(); + var expected = new GeneratedEmbeddings>([new(new float[] { 1, 2, 3, 4 })]); + using var generator = new MockEmbeddingGenerator + { + GenerateAsyncCallback = (values, _, _) => + { + Assert.Same(value, Assert.Single(values)); + return Task.FromResult(expected); + }, + }; + + Assert.Same(expected, await generator.GenerateAsync([value])); + } + + [Fact] + public async Task MockEmbeddingGenerator_RecordsCallsForDerivedGenerators() + { + var expected = new GeneratedEmbeddings>([new(new float[] { 1, 2, 3, 4 })]); + using var generator = new DerivedMockEmbeddingGenerator(expected); + + Assert.Same(expected, await generator.GenerateAsync(["trail"])); + Assert.Equal(1, generator.CallCount); + } + + [Fact] + public void MockEmbeddingGenerator_ConfiguresServiceResolution() + { + using var generator = new MockEmbeddingGenerator(); + Assert.Same(generator, generator.GetService(typeof(MockEmbeddingGenerator))); + + var expected = new object(); + generator.GetServiceCallback = static (_, _) => null; + Assert.Null(generator.GetService(typeof(object))); + + generator.GetServiceCallback = (_, _) => expected; + Assert.Same(expected, generator.GetService(typeof(object))); + } + + private sealed class ExtensibleMockChatClient : MockChatClient + { + public int AddSeedCallCount { get; private set; } + + public int AddResponsesFromDictionaryCallCount { get; private set; } + + public int AddResponsesFromEnumerableCallCount { get; private set; } + + public int MatchSeededResponseCallCount { get; private set; } + + protected override MockChatClient AddSeed( + Func requestPredicate, + Func> getResponse, + Func> getStreamingResponse, + bool singleUse) + { + AddSeedCallCount++; + return base.AddSeed(requestPredicate, getResponse, getStreamingResponse, singleUse); + } + + protected override MockChatClient AddResponsesFromDictionary( + Dictionary responses, + Func? requestPredicate, + bool singleUse, + Func>? getResponse) + { + AddResponsesFromDictionaryCallCount++; + return base.AddResponsesFromDictionary(responses, requestPredicate, singleUse, getResponse); + } + + protected override MockChatClient AddResponsesFromEnumerable( + IEnumerable> responses, + Func? requestPredicate, + bool singleUse, + Func>? getResponse) + { + AddResponsesFromEnumerableCallCount++; + return base.AddResponsesFromEnumerable(responses, requestPredicate, singleUse, getResponse); + } + + protected override SeededResponse MatchSeededResponse(MockChatClientRequest request) + { + MatchSeededResponseCallCount++; + return base.MatchSeededResponse(request); + } + } + + private sealed class DerivedMockEmbeddingGenerator(GeneratedEmbeddings> expected) : MockEmbeddingGenerator + { + protected override Task>> GenerateCoreAsync( + IEnumerable values, + EmbeddingGenerationOptions? options, + CancellationToken cancellationToken) => + Task.FromResult(expected); + } + + private static Task CreateResponseAsync(string text) => + Task.FromResult(new ChatResponse(new ChatMessage(ChatRole.Assistant, text))); + + private static async IAsyncEnumerable EnumerateUpdatesAsync(IEnumerable updates) + { + foreach (ChatResponseUpdate update in updates) + { + yield return update; + await Task.Yield(); + } + } + + private static async Task> ToListAsync(IAsyncEnumerable updates) + { + var results = new List(); + await foreach (ChatResponseUpdate update in updates) + { + results.Add(update); + } + + return results; + } +} diff --git a/test/Libraries/Microsoft.Extensions.AI.Tests/Microsoft.Extensions.AI.Tests.csproj b/test/Libraries/Microsoft.Extensions.AI.Tests/Microsoft.Extensions.AI.Tests.csproj index 6c805e270c8..41bf468fcaf 100644 --- a/test/Libraries/Microsoft.Extensions.AI.Tests/Microsoft.Extensions.AI.Tests.csproj +++ b/test/Libraries/Microsoft.Extensions.AI.Tests/Microsoft.Extensions.AI.Tests.csproj @@ -25,7 +25,6 @@ - @@ -44,5 +43,6 @@ + diff --git a/test/Libraries/Microsoft.Extensions.AI.Tests/MockEmbeddingGeneratorAlias.cs b/test/Libraries/Microsoft.Extensions.AI.Tests/MockEmbeddingGeneratorAlias.cs new file mode 100644 index 00000000000..3ae0cecf65f --- /dev/null +++ b/test/Libraries/Microsoft.Extensions.AI.Tests/MockEmbeddingGeneratorAlias.cs @@ -0,0 +1,4 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +global using TestEmbeddingGenerator = Microsoft.Extensions.AI.MockEmbeddingGenerator; diff --git a/test/Libraries/Microsoft.Extensions.DataIngestion.Tests/Chunkers/SemanticSimilarityChunkerTests.cs b/test/Libraries/Microsoft.Extensions.DataIngestion.Tests/Chunkers/SemanticSimilarityChunkerTests.cs index 354cebf1565..8f64a87255d 100644 --- a/test/Libraries/Microsoft.Extensions.DataIngestion.Tests/Chunkers/SemanticSimilarityChunkerTests.cs +++ b/test/Libraries/Microsoft.Extensions.DataIngestion.Tests/Chunkers/SemanticSimilarityChunkerTests.cs @@ -9,6 +9,8 @@ using Microsoft.ML.Tokenizers; using Xunit; +#pragma warning disable MEAI001 // Tests use experimental mock embedding APIs + namespace Microsoft.Extensions.DataIngestion.Chunkers.Tests { public class SemanticSimilarityChunkerTests : DocumentChunkerTests @@ -16,12 +18,12 @@ public class SemanticSimilarityChunkerTests : DocumentChunkerTests protected override IngestionChunker CreateDocumentChunker(int maxTokensPerChunk = 2_000, int overlapTokens = 500) { #pragma warning disable CA2000 // Dispose objects before losing scope - TestEmbeddingGenerator embeddingClient = new(); + MockEmbeddingGenerator embeddingClient = CreateMockEmbeddingGenerator(); #pragma warning restore CA2000 // Dispose objects before losing scope return CreateSemanticSimilarityChunker(embeddingClient, maxTokensPerChunk, overlapTokens); } - private static IngestionChunker CreateSemanticSimilarityChunker(TestEmbeddingGenerator embeddingClient, int maxTokensPerChunk = 2_000, int overlapTokens = 500) + private static IngestionChunker CreateSemanticSimilarityChunker(IEmbeddingGenerator> embeddingClient, int maxTokensPerChunk = 2_000, int overlapTokens = 500) { Tokenizer tokenizer = TiktokenTokenizer.CreateForModel("gpt-4o"); return new SemanticSimilarityChunker(embeddingClient, @@ -42,7 +44,7 @@ public async Task SingleParagraph() new IngestionDocumentParagraph(text) } }); - using TestEmbeddingGenerator customGenerator = new() + using MockEmbeddingGenerator customGenerator = new() { GenerateAsyncCallback = static async (values, options, ct) => { @@ -77,7 +79,7 @@ public async Task TwoTopicsParagraphs() } }); - using var customGenerator = new TestEmbeddingGenerator + using var customGenerator = new MockEmbeddingGenerator { GenerateAsyncCallback = async (values, options, ct) => { @@ -154,7 +156,7 @@ public async Task TwoSeparateTopicsWithAllKindsOfElements() } }); - using var customGenerator = new TestEmbeddingGenerator + using var customGenerator = new MockEmbeddingGenerator { GenerateAsyncCallback = async (values, options, ct) => { @@ -236,6 +238,14 @@ These gods resided on Mount Olympus and ruled over different aspects of mortal a }; } + private static MockEmbeddingGenerator CreateMockEmbeddingGenerator() => + new() + { + GenerateAsyncCallback = static (values, _, _) => + Task.FromResult>>( + new(values.Select(_ => new Embedding(new float[] { 1, 2, 3, 4 })))), + }; + private static IngestionDocumentParagraph?[,] ToParagraphCells(string[,] cells) { int rows = cells.GetLength(0); @@ -253,3 +263,5 @@ These gods resided on Mount Olympus and ruled over different aspects of mortal a } } } + +#pragma warning restore MEAI001 diff --git a/test/Libraries/Microsoft.Extensions.DataIngestion.Tests/IngestionPipelineTests.cs b/test/Libraries/Microsoft.Extensions.DataIngestion.Tests/IngestionPipelineTests.cs index 7159cdc8718..99e35399eca 100644 --- a/test/Libraries/Microsoft.Extensions.DataIngestion.Tests/IngestionPipelineTests.cs +++ b/test/Libraries/Microsoft.Extensions.DataIngestion.Tests/IngestionPipelineTests.cs @@ -16,12 +16,16 @@ using OpenTelemetry.Trace; using Xunit; +#pragma warning disable MEAI001 // Tests use experimental mock embedding APIs + namespace Microsoft.Extensions.DataIngestion.Tests; #pragma warning disable S881 // Increment (++) and decrement (--) operators should not be used in a method call or mixed with other operators in an expression public sealed class IngestionPipelineTests : IDisposable { + private const int EmbeddingDimensionCount = 4; + private readonly FileInfo _withTable; private readonly FileInfo _withImage; private readonly IReadOnlyList _sampleFiles; @@ -82,9 +86,9 @@ public async Task CanProcessDocuments() List activities = []; using TracerProvider tracerProvider = CreateTraceProvider(activities); - TestEmbeddingGenerator embeddingGenerator = new(); + MockEmbeddingGenerator embeddingGenerator = CreateMockEmbeddingGenerator(); using InMemoryVectorStore testVectorStore = new(new() { EmbeddingGenerator = embeddingGenerator }); - using VectorStoreWriter vectorStoreWriter = new(testVectorStore, dimensionCount: TestEmbeddingGenerator.DimensionCount); + using VectorStoreWriter vectorStoreWriter = new(testVectorStore, dimensionCount: EmbeddingDimensionCount); using IngestionPipeline pipeline = new(CreateReader(), CreateChunker(), vectorStoreWriter); List ingestionResults = await pipeline.ProcessAsync(_sampleFiles).ToListAsync(); @@ -92,7 +96,7 @@ public async Task CanProcessDocuments() Assert.Equal(_sampleFiles.Count, ingestionResults.Count); AssertAllIngestionsSucceeded(ingestionResults); - Assert.True(embeddingGenerator.WasCalled, "Embedding generator should have been called."); + Assert.True(embeddingGenerator.CallCount > 0, "Embedding generator should have been called."); var retrieved = await vectorStoreWriter.VectorStoreCollection .GetAsync(record => _sampleFiles.Any(info => info.FullName == (string)record["documentid"]!), top: 1000) @@ -115,9 +119,9 @@ public async Task CanProcessDocumentsInDirectory() List activities = []; using TracerProvider tracerProvider = CreateTraceProvider(activities); - TestEmbeddingGenerator embeddingGenerator = new(); + MockEmbeddingGenerator embeddingGenerator = CreateMockEmbeddingGenerator(); using InMemoryVectorStore testVectorStore = new(new() { EmbeddingGenerator = embeddingGenerator }); - using VectorStoreWriter vectorStoreWriter = new(testVectorStore, dimensionCount: TestEmbeddingGenerator.DimensionCount); + using VectorStoreWriter vectorStoreWriter = new(testVectorStore, dimensionCount: EmbeddingDimensionCount); using IngestionPipeline pipeline = new(CreateReader(), CreateChunker(), vectorStoreWriter); @@ -126,7 +130,7 @@ public async Task CanProcessDocumentsInDirectory() Assert.Equal(directory.EnumerateFiles("*.md").Count(), ingestionResults.Count); AssertAllIngestionsSucceeded(ingestionResults); - Assert.True(embeddingGenerator.WasCalled, "Embedding generator should have been called."); + Assert.True(embeddingGenerator.CallCount > 0, "Embedding generator should have been called."); var retrieved = await vectorStoreWriter.VectorStoreCollection .GetAsync(record => ((string)record["documentid"]!).StartsWith(directory.FullName), top: 1000) @@ -149,12 +153,12 @@ public async Task ChunksCanBeMoreThanJustText() List activities = []; using TracerProvider tracerProvider = CreateTraceProvider(activities); - TestEmbeddingGenerator embeddingGenerator = new(); + MockEmbeddingGenerator embeddingGenerator = CreateMockEmbeddingGenerator(); using InMemoryVectorStore testVectorStore = new(new() { EmbeddingGenerator = embeddingGenerator }); - using VectorStoreWriter vectorStoreWriter = new(testVectorStore, dimensionCount: TestEmbeddingGenerator.DimensionCount); + using VectorStoreWriter vectorStoreWriter = new(testVectorStore, dimensionCount: EmbeddingDimensionCount); using IngestionPipeline pipeline = new(CreateReader(), new ImageChunker(), vectorStoreWriter); - Assert.False(embeddingGenerator.WasCalled); + Assert.Equal(0, embeddingGenerator.CallCount); var ingestionResults = await pipeline.ProcessAsync(_sampleFiles).ToListAsync(); AssertAllIngestionsSucceeded(ingestionResults); @@ -162,7 +166,7 @@ public async Task ChunksCanBeMoreThanJustText() .GetAsync(record => ((string)record["documentid"]!).EndsWith(_withImage.Name), top: 100) .ToListAsync(); - Assert.True(embeddingGenerator.WasCalled); + Assert.True(embeddingGenerator.CallCount > 0); Assert.NotEmpty(retrieved); for (int i = 0; i < retrieved.Count; i++) { @@ -197,9 +201,9 @@ public async Task SingleFailureDoesNotTearDownEntirePipeline() List activities = []; using TracerProvider tracerProvider = CreateTraceProvider(activities); - TestEmbeddingGenerator embeddingGenerator = new(); + MockEmbeddingGenerator embeddingGenerator = CreateMockEmbeddingGenerator(); using InMemoryVectorStore testVectorStore = new(new() { EmbeddingGenerator = embeddingGenerator }); - using VectorStoreWriter vectorStoreWriter = new(testVectorStore, dimensionCount: TestEmbeddingGenerator.DimensionCount); + using VectorStoreWriter vectorStoreWriter = new(testVectorStore, dimensionCount: EmbeddingDimensionCount); using IngestionPipeline pipeline = new(failingForFirstReader, CreateChunker(), vectorStoreWriter); @@ -225,6 +229,21 @@ async Task Verify(IAsyncEnumerable results) private static IngestionChunker CreateChunker() => new HeaderChunker(new(TiktokenTokenizer.CreateForModel("gpt-4"))); + private static MockEmbeddingGenerator CreateMockEmbeddingGenerator() => + new() + { + GenerateAsyncCallback = static (_, _, _) => CreateFixedEmbeddings(), + }; + + private static MockEmbeddingGenerator CreateMockEmbeddingGenerator() => + new() + { + GenerateAsyncCallback = static (_, _, _) => CreateFixedEmbeddings(), + }; + + private static Task>> CreateFixedEmbeddings() => + Task.FromResult>>([new(new float[] { 0, 1, 2, 3 })]); + private static TracerProvider CreateTraceProvider(List activities) => Sdk.CreateTracerProviderBuilder() .AddSource("Experimental.Microsoft.Extensions.DataIngestion") @@ -259,3 +278,5 @@ private static void AssertErrorActivities(List activities, int expecte Assert.All(failed, a => Assert.Equal(ExpectedException.ExceptionMessage, a.StatusDescription)); } } + +#pragma warning restore MEAI001 diff --git a/test/Libraries/Microsoft.Extensions.DataIngestion.Tests/Microsoft.Extensions.DataIngestion.Tests.csproj b/test/Libraries/Microsoft.Extensions.DataIngestion.Tests/Microsoft.Extensions.DataIngestion.Tests.csproj index 312ed567bd0..85f1fe81a31 100644 --- a/test/Libraries/Microsoft.Extensions.DataIngestion.Tests/Microsoft.Extensions.DataIngestion.Tests.csproj +++ b/test/Libraries/Microsoft.Extensions.DataIngestion.Tests/Microsoft.Extensions.DataIngestion.Tests.csproj @@ -15,6 +15,7 @@ + @@ -32,7 +33,6 @@ - diff --git a/test/Libraries/Microsoft.Extensions.DataIngestion.Tests/Utils/TestEmbeddingGenerator.cs b/test/Libraries/Microsoft.Extensions.DataIngestion.Tests/Utils/TestEmbeddingGenerator.cs deleted file mode 100644 index 611243684cd..00000000000 --- a/test/Libraries/Microsoft.Extensions.DataIngestion.Tests/Utils/TestEmbeddingGenerator.cs +++ /dev/null @@ -1,32 +0,0 @@ -// Licensed to the .NET Foundation under one or more agreements. -// The .NET Foundation licenses this file to you under the MIT license. - -using System; -using System.Collections.Generic; -using System.Threading; -using System.Threading.Tasks; -using Microsoft.Extensions.AI; - -namespace Microsoft.Extensions.DataIngestion; - -public sealed class TestEmbeddingGenerator : IEmbeddingGenerator> -{ - public const int DimensionCount = 4; - - public bool WasCalled { get; private set; } - - public void Dispose() - { - // No resources to dispose - } - - public Task>> GenerateAsync(IEnumerable values, EmbeddingGenerationOptions? options = null, CancellationToken cancellationToken = default) - { - WasCalled = true; - - return Task.FromResult(new GeneratedEmbeddings>( - [new(new float[] { 0, 1, 2, 3 })])); - } - - public object? GetService(Type serviceType, object? serviceKey = null) => null; -} diff --git a/test/Libraries/Microsoft.Extensions.DataIngestion.Tests/Writers/InMemoryVectorStoreWriterTests.cs b/test/Libraries/Microsoft.Extensions.DataIngestion.Tests/Writers/InMemoryVectorStoreWriterTests.cs index f9767414976..9550ae5d085 100644 --- a/test/Libraries/Microsoft.Extensions.DataIngestion.Tests/Writers/InMemoryVectorStoreWriterTests.cs +++ b/test/Libraries/Microsoft.Extensions.DataIngestion.Tests/Writers/InMemoryVectorStoreWriterTests.cs @@ -2,12 +2,17 @@ // The .NET Foundation licenses this file to you under the MIT license. using CommunityToolkit.VectorData.InMemory; +using Microsoft.Extensions.AI; using Microsoft.Extensions.VectorData; +#pragma warning disable MEAI001 // Tests use experimental mock embedding APIs + namespace Microsoft.Extensions.DataIngestion.Writers.Tests; public class InMemoryVectorStoreWriterTests : VectorStoreWriterTests { - protected override VectorStore CreateVectorStore(TestEmbeddingGenerator testEmbeddingGenerator) + protected override VectorStore CreateVectorStore(MockEmbeddingGenerator testEmbeddingGenerator) => new InMemoryVectorStore(new() { EmbeddingGenerator = testEmbeddingGenerator }); } + +#pragma warning restore MEAI001 diff --git a/test/Libraries/Microsoft.Extensions.DataIngestion.Tests/Writers/SqliteVectorStoreWriterTests.cs b/test/Libraries/Microsoft.Extensions.DataIngestion.Tests/Writers/SqliteVectorStoreWriterTests.cs index 7f29da9358d..23c186ee5c6 100644 --- a/test/Libraries/Microsoft.Extensions.DataIngestion.Tests/Writers/SqliteVectorStoreWriterTests.cs +++ b/test/Libraries/Microsoft.Extensions.DataIngestion.Tests/Writers/SqliteVectorStoreWriterTests.cs @@ -4,8 +4,11 @@ using System; using System.IO; using CommunityToolkit.VectorData.SqliteVec; +using Microsoft.Extensions.AI; using Microsoft.Extensions.VectorData; +#pragma warning disable MEAI001 // Tests use experimental mock embedding APIs + namespace Microsoft.Extensions.DataIngestion.Writers.Tests; public sealed class SqliteVectorStoreWriterTests : VectorStoreWriterTests, IDisposable @@ -14,6 +17,8 @@ public sealed class SqliteVectorStoreWriterTests : VectorStoreWriterTests, IDisp public void Dispose() => File.Delete(_tempFile); - protected override VectorStore CreateVectorStore(TestEmbeddingGenerator testEmbeddingGenerator) + protected override VectorStore CreateVectorStore(MockEmbeddingGenerator testEmbeddingGenerator) => new SqliteVectorStore($"Data Source={_tempFile};Pooling=false", new() { EmbeddingGenerator = testEmbeddingGenerator }); } + +#pragma warning restore MEAI001 diff --git a/test/Libraries/Microsoft.Extensions.DataIngestion.Tests/Writers/VectorStoreWriterTests.cs b/test/Libraries/Microsoft.Extensions.DataIngestion.Tests/Writers/VectorStoreWriterTests.cs index 0395c470ce0..b8fca6ae082 100644 --- a/test/Libraries/Microsoft.Extensions.DataIngestion.Tests/Writers/VectorStoreWriterTests.cs +++ b/test/Libraries/Microsoft.Extensions.DataIngestion.Tests/Writers/VectorStoreWriterTests.cs @@ -5,23 +5,28 @@ using System.Collections.Generic; using System.Linq; using System.Threading.Tasks; +using Microsoft.Extensions.AI; using Microsoft.Extensions.VectorData; using Xunit; +#pragma warning disable MEAI001 // Tests use experimental mock embedding APIs + namespace Microsoft.Extensions.DataIngestion.Writers.Tests; public abstract class VectorStoreWriterTests { + private const int EmbeddingDimensionCount = 4; + [Fact] public async Task CanGenerateDynamicSchema() { string documentId = Guid.NewGuid().ToString(); - using TestEmbeddingGenerator testEmbeddingGenerator = new(); + using MockEmbeddingGenerator testEmbeddingGenerator = CreateMockEmbeddingGenerator(); using VectorStore vectorStore = CreateVectorStore(testEmbeddingGenerator); using VectorStoreWriter writer = new( vectorStore, - dimensionCount: TestEmbeddingGenerator.DimensionCount); + dimensionCount: EmbeddingDimensionCount); IngestionDocument document = new(documentId); List> chunks = @@ -38,7 +43,7 @@ public async Task CanGenerateDynamicSchema() } ]; - Assert.False(testEmbeddingGenerator.WasCalled); + Assert.Equal(0, testEmbeddingGenerator.CallCount); await writer.WriteAsync(chunks.ToAsyncEnumerable()); Dictionary record = await writer.VectorStoreCollection @@ -49,7 +54,7 @@ public async Task CanGenerateDynamicSchema() Assert.NotNull(record["key"]); Assert.Equal(documentId, record["documentid"]); Assert.Equal(chunks[0].Content, record["content"]); - Assert.True(testEmbeddingGenerator.WasCalled); + Assert.True(testEmbeddingGenerator.CallCount > 0); foreach (var kvp in chunks[0].Metadata) { Assert.True(record.ContainsKey(kvp.Key), $"Record does not contain key '{kvp.Key}'"); @@ -62,11 +67,11 @@ public async Task DoesSupportIncrementalIngestion() { string documentId = Guid.NewGuid().ToString(); - using TestEmbeddingGenerator testEmbeddingGenerator = new(); + using MockEmbeddingGenerator testEmbeddingGenerator = CreateMockEmbeddingGenerator(); using VectorStore vectorStore = CreateVectorStore(testEmbeddingGenerator); using VectorStoreWriter writer = new( vectorStore, - dimensionCount: TestEmbeddingGenerator.DimensionCount, + dimensionCount: EmbeddingDimensionCount, options: new() { IncrementalIngestion = true, @@ -122,11 +127,11 @@ public async Task IncrementalIngestion_WithManyRecords_DeletesAllPreExistingChun { string documentId = Guid.NewGuid().ToString(); - using TestEmbeddingGenerator testEmbeddingGenerator = new(); + using MockEmbeddingGenerator testEmbeddingGenerator = CreateMockEmbeddingGenerator(); using VectorStore vectorStore = CreateVectorStore(testEmbeddingGenerator); using VectorStoreWriter writer = new( vectorStore, - dimensionCount: TestEmbeddingGenerator.DimensionCount, + dimensionCount: EmbeddingDimensionCount, options: new() { IncrementalIngestion = true, @@ -167,5 +172,14 @@ public async Task IncrementalIngestion_WithManyRecords_DeletesAllPreExistingChun Assert.Contains(records, r => (string)r["content"]! == "updated chunk 2"); } - protected abstract VectorStore CreateVectorStore(TestEmbeddingGenerator testEmbeddingGenerator); + protected abstract VectorStore CreateVectorStore(MockEmbeddingGenerator testEmbeddingGenerator); + + private static MockEmbeddingGenerator CreateMockEmbeddingGenerator() => + new() + { + GenerateAsyncCallback = static (_, _, _) => + Task.FromResult>>([new(new float[] { 0, 1, 2, 3 })]), + }; } + +#pragma warning restore MEAI001 diff --git a/test/ProjectTemplates/Infrastructure/TemplateSnapshotTestBase.cs b/test/ProjectTemplates/Infrastructure/TemplateSnapshotTestBase.cs index d716d0d5d8b..2c23b31b871 100644 --- a/test/ProjectTemplates/Infrastructure/TemplateSnapshotTestBase.cs +++ b/test/ProjectTemplates/Infrastructure/TemplateSnapshotTestBase.cs @@ -31,8 +31,8 @@ protected virtual TemplateVerifierOptions PrepareSnapshotVerifier( Directory.Delete(workingDir, recursive: true); } - // Get the template location from the template package. Use a wildcard for the version number in the file name. - string templateLocation = Path.Combine(WellKnownPaths.LocalShippingPackagesPath, $"{templatePackageName}.*.nupkg"); + // Resolve a concrete template package path so dotnet new install works cross-platform. + string templateLocation = ResolveTemplatePackagePath(templatePackageName); string[]? excludePatterns = Path.DirectorySeparatorChar is '/' ? verificationExcludePatterns @@ -50,4 +50,24 @@ protected virtual TemplateVerifierOptions PrepareSnapshotVerifier( VerificationExcludePatterns = excludePatterns }; } + + private static string ResolveTemplatePackagePath(string templatePackageName) + { + string packageSearchPattern = $"{templatePackageName}.*.nupkg"; + + FileInfo? latestPackage = new DirectoryInfo(WellKnownPaths.LocalShippingPackagesPath) + .EnumerateFiles(packageSearchPattern, SearchOption.TopDirectoryOnly) + .Where(file => !file.Name.EndsWith(".symbols.nupkg", StringComparison.OrdinalIgnoreCase)) + .OrderByDescending(file => file.LastWriteTimeUtc) + .ThenByDescending(file => file.Name, StringComparer.OrdinalIgnoreCase) + .FirstOrDefault(); + + if (latestPackage is null) + { + throw new FileNotFoundException( + $"Unable to find template package '{packageSearchPattern}' under '{WellKnownPaths.LocalShippingPackagesPath}'."); + } + + return latestPackage.FullName; + } } diff --git a/test/ProjectTemplates/Microsoft.Agents.AI.ProjectTemplates.IntegrationTests/AIAgentWebAPIExecutionTests.cs b/test/ProjectTemplates/Microsoft.Agents.AI.ProjectTemplates.IntegrationTests/AIAgentWebAPIExecutionTests.cs index 68674e9016e..5f5b972e04e 100644 --- a/test/ProjectTemplates/Microsoft.Agents.AI.ProjectTemplates.IntegrationTests/AIAgentWebAPIExecutionTests.cs +++ b/test/ProjectTemplates/Microsoft.Agents.AI.ProjectTemplates.IntegrationTests/AIAgentWebAPIExecutionTests.cs @@ -36,7 +36,7 @@ public AIAgentWebAPIExecutionTests(TemplateExecutionTestFixture fixture, ITestOu public static IEnumerable GetSupportedProjectConfigurations() { (string name, string[] values)[] allOptionValues = [ - ("--provider", ["azureopenai", "githubmodels", "ollama", "openai"]), + ("--provider", ["mock", "azureopenai", "ollama", "openai"]), ("--managed-identity", ["true", "false"]), ("--framework", ["net8.0", "net9.0", "net10.0"]) ]; diff --git a/test/ProjectTemplates/Microsoft.Agents.AI.ProjectTemplates.IntegrationTests/AIAgentWebAPISnapshotTests.cs b/test/ProjectTemplates/Microsoft.Agents.AI.ProjectTemplates.IntegrationTests/AIAgentWebAPISnapshotTests.cs index 8285ab383d7..238a2060ba0 100644 --- a/test/ProjectTemplates/Microsoft.Agents.AI.ProjectTemplates.IntegrationTests/AIAgentWebAPISnapshotTests.cs +++ b/test/ProjectTemplates/Microsoft.Agents.AI.ProjectTemplates.IntegrationTests/AIAgentWebAPISnapshotTests.cs @@ -22,9 +22,9 @@ public AIAgentWebAPISnapshotTests(ITestOutputHelper log) } [Theory] - [InlineData /* Defaults: --provider=githubmodels */] - [InlineData("--provider=ollama")] + [InlineData("--provider=mock")] [InlineData("--provider=openai")] + [InlineData("--provider=ollama")] [InlineData("--provider=azureopenai", "--managed-identity=true")] [InlineData("--provider=azureopenai", "--managed-identity=false")] public async Task RunSnapshotTests(params string[] templateArgs) diff --git a/test/ProjectTemplates/Microsoft.Agents.AI.ProjectTemplates.IntegrationTests/Snapshots/aiagent-webapi/aiagent-webapi.aoai_ID_F.verified/aiagent-webapi/Program.cs b/test/ProjectTemplates/Microsoft.Agents.AI.ProjectTemplates.IntegrationTests/Snapshots/aiagent-webapi/aiagent-webapi.aoai_ID_F.verified/aiagent-webapi/Program.cs index 3ece18c0d08..27f95ad43ff 100644 --- a/test/ProjectTemplates/Microsoft.Agents.AI.ProjectTemplates.IntegrationTests/Snapshots/aiagent-webapi/aiagent-webapi.aoai_ID_F.verified/aiagent-webapi/Program.cs +++ b/test/ProjectTemplates/Microsoft.Agents.AI.ProjectTemplates.IntegrationTests/Snapshots/aiagent-webapi/aiagent-webapi.aoai_ID_F.verified/aiagent-webapi/Program.cs @@ -46,6 +46,7 @@ // Register services for OpenAI responses and conversations (also required for DevUI) builder.Services.AddOpenAIResponses(); builder.Services.AddOpenAIConversations(); +builder.Services.AddDevUI(); var app = builder.Build(); app.UseHttpsRedirection(); diff --git a/test/ProjectTemplates/Microsoft.Agents.AI.ProjectTemplates.IntegrationTests/Snapshots/aiagent-webapi/aiagent-webapi.aoai_ID_F.verified/aiagent-webapi/README.md b/test/ProjectTemplates/Microsoft.Agents.AI.ProjectTemplates.IntegrationTests/Snapshots/aiagent-webapi/aiagent-webapi.aoai_ID_F.verified/aiagent-webapi/README.md index 222e83f1188..aefba461390 100644 --- a/test/ProjectTemplates/Microsoft.Agents.AI.ProjectTemplates.IntegrationTests/Snapshots/aiagent-webapi/aiagent-webapi.aoai_ID_F.verified/aiagent-webapi/README.md +++ b/test/ProjectTemplates/Microsoft.Agents.AI.ProjectTemplates.IntegrationTests/Snapshots/aiagent-webapi/aiagent-webapi.aoai_ID_F.verified/aiagent-webapi/README.md @@ -88,13 +88,13 @@ dotnet new aiagent-webapi --provider ollama --chat-model llama3.1 ### Available Parameters - **`--provider`**: Choose the AI service provider - - `githubmodels` (default) - GitHub Models + - `mock` - Deterministic mock provider - `azureopenai` - Azure OpenAI - `openai` - OpenAI Platform - `ollama` - Ollama (local development) - **`--chat-model`**: Specify the chat model/deployment name - - Default for OpenAI/Azure OpenAI/GitHub Models: `gpt-4o-mini` + - Default for OpenAI/Azure OpenAI: `gpt-4o-mini` - Default for Ollama: `llama3.2` - **`--managed-identity`**: Use managed identity for Azure services (default: `true`) diff --git a/test/ProjectTemplates/Microsoft.Agents.AI.ProjectTemplates.IntegrationTests/Snapshots/aiagent-webapi/aiagent-webapi.aoai_ID_T.verified/aiagent-webapi/Program.cs b/test/ProjectTemplates/Microsoft.Agents.AI.ProjectTemplates.IntegrationTests/Snapshots/aiagent-webapi/aiagent-webapi.aoai_ID_T.verified/aiagent-webapi/Program.cs index 037ac3deb7e..27315a315a4 100644 --- a/test/ProjectTemplates/Microsoft.Agents.AI.ProjectTemplates.IntegrationTests/Snapshots/aiagent-webapi/aiagent-webapi.aoai_ID_T.verified/aiagent-webapi/Program.cs +++ b/test/ProjectTemplates/Microsoft.Agents.AI.ProjectTemplates.IntegrationTests/Snapshots/aiagent-webapi/aiagent-webapi.aoai_ID_T.verified/aiagent-webapi/Program.cs @@ -48,6 +48,7 @@ // Register services for OpenAI responses and conversations (also required for DevUI) builder.Services.AddOpenAIResponses(); builder.Services.AddOpenAIConversations(); +builder.Services.AddDevUI(); var app = builder.Build(); app.UseHttpsRedirection(); diff --git a/test/ProjectTemplates/Microsoft.Agents.AI.ProjectTemplates.IntegrationTests/Snapshots/aiagent-webapi/aiagent-webapi.aoai_ID_T.verified/aiagent-webapi/README.md b/test/ProjectTemplates/Microsoft.Agents.AI.ProjectTemplates.IntegrationTests/Snapshots/aiagent-webapi/aiagent-webapi.aoai_ID_T.verified/aiagent-webapi/README.md index 99157e36906..7e8f5ac72f6 100644 --- a/test/ProjectTemplates/Microsoft.Agents.AI.ProjectTemplates.IntegrationTests/Snapshots/aiagent-webapi/aiagent-webapi.aoai_ID_T.verified/aiagent-webapi/README.md +++ b/test/ProjectTemplates/Microsoft.Agents.AI.ProjectTemplates.IntegrationTests/Snapshots/aiagent-webapi/aiagent-webapi.aoai_ID_T.verified/aiagent-webapi/README.md @@ -60,13 +60,13 @@ dotnet new aiagent-webapi --provider ollama --chat-model llama3.1 ### Available Parameters - **`--provider`**: Choose the AI service provider - - `githubmodels` (default) - GitHub Models + - `mock` - Deterministic mock provider - `azureopenai` - Azure OpenAI - `openai` - OpenAI Platform - `ollama` - Ollama (local development) - **`--chat-model`**: Specify the chat model/deployment name - - Default for OpenAI/Azure OpenAI/GitHub Models: `gpt-4o-mini` + - Default for OpenAI/Azure OpenAI: `gpt-4o-mini` - Default for Ollama: `llama3.2` - **`--managed-identity`**: Use managed identity for Azure services (default: `true`) diff --git a/test/ProjectTemplates/Microsoft.Agents.AI.ProjectTemplates.IntegrationTests/Snapshots/aiagent-webapi/aiagent-webapi.mock.verified/aiagent-webapi/MockChatClientExtensions.cs b/test/ProjectTemplates/Microsoft.Agents.AI.ProjectTemplates.IntegrationTests/Snapshots/aiagent-webapi/aiagent-webapi.mock.verified/aiagent-webapi/MockChatClientExtensions.cs new file mode 100644 index 00000000000..b8b9713b073 --- /dev/null +++ b/test/ProjectTemplates/Microsoft.Agents.AI.ProjectTemplates.IntegrationTests/Snapshots/aiagent-webapi/aiagent-webapi.mock.verified/aiagent-webapi/MockChatClientExtensions.cs @@ -0,0 +1,72 @@ +using Microsoft.Extensions.AI; + +#pragma warning disable MEAI001 // Mock provider uses experimental testing APIs + +internal static class MockChatClientExtensions +{ + /// Adds a canned response for matching requests. + /// The mock chat client to configure. + /// Predicate used to select whether the response applies to a request. + /// The assistant response text. + /// + /// The optional minimum simulated response delay in milliseconds. When supplied alone, it is the fixed delay. + /// + /// + /// The optional maximum simulated response delay in milliseconds. When supplied alone, a delay from zero up to this + /// value is selected. + /// + /// + /// to remove the response seed after its first matching request; otherwise it remains reusable. + /// + /// The configured . + internal static MockChatClient AddResponse( + this MockChatClient client, + Func requestPredicate, + string response, + int? minDelay = null, + int? maxDelay = null, + bool singleUse = false) + { + ArgumentNullException.ThrowIfNull(client); + ArgumentNullException.ThrowIfNull(requestPredicate); + ArgumentNullException.ThrowIfNull(response); + ValidateDelayRange(minDelay, maxDelay); + + return client.AddResponse( + requestPredicate, + async (_, cancellationToken) => + { + await Task.Delay(GetDelay(minDelay, maxDelay), cancellationToken); + return new ChatResponse(new ChatMessage(ChatRole.Assistant, response + "\n\n")); + }, + singleUse); + } + + private static int GetDelay(int? minDelay, int? maxDelay) => + (minDelay, maxDelay) switch + { + (null, null) => 0, + ({ } min, null) => min, + (null, { } max) => Random.Shared.Next(0, max), + ({ } min, { } max) => Random.Shared.Next(min, max), + }; + + private static void ValidateDelayRange(int? minDelay, int? maxDelay) + { + if (minDelay is { } minimumDelay) + { + ArgumentOutOfRangeException.ThrowIfLessThan(minimumDelay, 0, nameof(minDelay)); + } + + if (maxDelay is { } maximumDelay) + { + ArgumentOutOfRangeException.ThrowIfLessThan(maximumDelay, 0, nameof(maxDelay)); + + if (minDelay is { } configuredMinimumDelay) + { + ArgumentOutOfRangeException.ThrowIfLessThan(maximumDelay, configuredMinimumDelay, nameof(maxDelay)); + } + } + } +} +#pragma warning restore MEAI001 diff --git a/test/ProjectTemplates/Microsoft.Agents.AI.ProjectTemplates.IntegrationTests/Snapshots/aiagent-webapi/aiagent-webapi.mock.verified/aiagent-webapi/MockServices.cs b/test/ProjectTemplates/Microsoft.Agents.AI.ProjectTemplates.IntegrationTests/Snapshots/aiagent-webapi/aiagent-webapi.mock.verified/aiagent-webapi/MockServices.cs new file mode 100644 index 00000000000..21c685f58ae --- /dev/null +++ b/test/ProjectTemplates/Microsoft.Agents.AI.ProjectTemplates.IntegrationTests/Snapshots/aiagent-webapi/aiagent-webapi.mock.verified/aiagent-webapi/MockServices.cs @@ -0,0 +1,147 @@ +using Microsoft.Extensions.AI; + +#pragma warning disable MEAI001 // Mock provider uses experimental testing APIs + +internal static class MockServices +{ + internal static MockChatClient CreateChatClient() => + new MockChatClient() + .AddResponse( + static request => IsWriterStage(request), + """ + ## Welcome to the Mock Writing Room + + Meet The Writer, The Editor, and The Formatter. They are ready for their curtain call. + + Try: **Write a .NET story.**, **Edit this story.**, or **Format for publishing.** + """, + maxDelay: 2000) + .AddResponse( + static request => HasMessageContent(request, "Welcome to the Mock Writing Room"), + """ + The room is ready when you are. Bring a draft, and it will come back polished. + """, + maxDelay: 2000) + .AddResponse( + static request => IsWriterStage(request) && IsWritePrompt(request), + """ + Maya opened her first .NET console project during an afternoon study session. The program printed "Hello, World!", but she wanted it to ask for a number and tell her whether it was even. + + At first, compiler errors made every small change feel impossible. Instead of guessing, she read each message, used `int.TryParse`, and ran the app after every improvement. + + By the end of the session, Maya had a working program and a short list of ideas for her next project. She realized that learning .NET was not about knowing everything at once; it was about trying one small step, seeing what happened, and learning from it. + + """, + maxDelay: 2000) + .AddResponse( + static request => HasDraftContent(request, "Maya opened her first .NET console project during an afternoon study session."), + """ + **Title**: Maya's First Green Check + + Maya opened her first .NET console project during an afternoon study session. It printed "Hello, World!", but she wanted it to ask for a number and report whether it was even. + + Compiler errors made every small change feel impossible at first. Rather than guessing, Maya read each message, used `int.TryParse` to handle input safely, and ran the app after every improvement. + + By the end of the session, she had a working program and a short list of ideas for her next project. Maya learned that .NET did not require her to know everything at once: progress came from taking one small step, observing the result, and learning from it. + """, + maxDelay: 2000) + .AddResponse( + static request => IsWriterStage(request) && IsEditPrompt(request), + """ + maya opened her first .net console project and wanted it to check whether a number was even. compiler errors made her frustrated at first. + + she read the error messages, used int.tryparse, and kept running the program after each change. by the end, it worked and she was excited to build another project. + + """, + maxDelay: 2000) + .AddResponse( + static request => HasDraftContent(request, "maya opened her first .net console project and wanted it to check whether a number was even."), + """ + Maya opened her first .NET console project hoping to write a program that could identify even numbers. Compiler errors frustrated her at first, but she chose to slow down and read each message carefully. + + She used `int.TryParse` to make the input safer and ran the program after every change. By the end of the session, Maya had a working app and a new confidence that learning .NET happens one experiment at a time. + """, + maxDelay: 2000) + .AddResponse( + static request => IsWriterStage(request) && IsFormatPrompt(request), + """ + Maya learned to build her first .NET console app by reading compiler errors, trying `int.TryParse`, and testing each small change. + + """, + maxDelay: 2000) + .AddResponse( + static request => HasDraftContent(request, "Maya learned to build her first .NET console app by reading compiler errors, trying `int.TryParse`, and testing each small change."), + """ + **Title**: Maya's First .NET Project + + Maya began her first .NET console app with a simple goal: identify whether a number was even. When compiler errors appeared, she slowed down, read the messages, and made one change at a time. + + She tried `int.TryParse`, tested each improvement, and watched the program gradually come together. The project taught Maya that every small experiment is part of becoming a confident .NET developer. + """, + maxDelay: 2000); + + private static bool IsWritePrompt(MockChatClientRequest request) => + HasInteractivePrompt(request, "write"); + + private static bool IsEditPrompt(MockChatClientRequest request) => + HasInteractivePrompt(request, "edit"); + + private static bool IsFormatPrompt(MockChatClientRequest request) => + HasInteractivePrompt(request, "format"); + + private static bool IsWriterStage(MockChatClientRequest request) => + !HasAssistantMessage(request); + + private static bool HasAssistantMessage(MockChatClientRequest request) + { + foreach (ChatMessage message in request.Messages) + { + if (message.Role == ChatRole.Assistant && + !string.IsNullOrWhiteSpace(message.Text)) + { + return true; + } + } + + return false; + } + + private static bool HasDraftContent(MockChatClientRequest request, string text) => + request.LastUserText?.Contains(text, StringComparison.OrdinalIgnoreCase) is true || + HasAssistantContent(request, text); + + private static bool HasInteractivePrompt(MockChatClientRequest request, string text) + { + string? lastUserText = request.LastUserText; + return lastUserText?.Contains(text, StringComparison.OrdinalIgnoreCase) is true && + lastUserText.Contains("Welcome to the Mock Writing Room", StringComparison.OrdinalIgnoreCase) is false; + } + + private static bool HasMessageContent(MockChatClientRequest request, string text) + { + foreach (ChatMessage message in request.Messages) + { + if (message.Text?.Contains(text, StringComparison.OrdinalIgnoreCase) is true) + { + return true; + } + } + + return false; + } + + private static bool HasAssistantContent(MockChatClientRequest request, string text) + { + foreach (ChatMessage message in request.Messages) + { + if (message.Role == ChatRole.Assistant && + message.Text?.Contains(text, StringComparison.OrdinalIgnoreCase) is true) + { + return true; + } + } + + return false; + } +} +#pragma warning restore MEAI001 diff --git a/test/ProjectTemplates/Microsoft.Agents.AI.ProjectTemplates.IntegrationTests/Snapshots/aiagent-webapi/aiagent-webapi._defaults.verified/aiagent-webapi/Program.cs b/test/ProjectTemplates/Microsoft.Agents.AI.ProjectTemplates.IntegrationTests/Snapshots/aiagent-webapi/aiagent-webapi.mock.verified/aiagent-webapi/Program.cs similarity index 71% rename from test/ProjectTemplates/Microsoft.Agents.AI.ProjectTemplates.IntegrationTests/Snapshots/aiagent-webapi/aiagent-webapi._defaults.verified/aiagent-webapi/Program.cs rename to test/ProjectTemplates/Microsoft.Agents.AI.ProjectTemplates.IntegrationTests/Snapshots/aiagent-webapi/aiagent-webapi.mock.verified/aiagent-webapi/Program.cs index 2744b8bb8af..6ccb091734a 100644 --- a/test/ProjectTemplates/Microsoft.Agents.AI.ProjectTemplates.IntegrationTests/Snapshots/aiagent-webapi/aiagent-webapi._defaults.verified/aiagent-webapi/Program.cs +++ b/test/ProjectTemplates/Microsoft.Agents.AI.ProjectTemplates.IntegrationTests/Snapshots/aiagent-webapi/aiagent-webapi.mock.verified/aiagent-webapi/Program.cs @@ -1,24 +1,13 @@ -using System.ClientModel; -using System.ComponentModel; +using System.ComponentModel; using Microsoft.Agents.AI; using Microsoft.Agents.AI.DevUI; using Microsoft.Agents.AI.Hosting; using Microsoft.Agents.AI.Workflows; using Microsoft.Extensions.AI; -using OpenAI; -using OpenAI.Chat; var builder = WebApplication.CreateBuilder(args); -// You will need to set the token to your own value -// You can do this using Visual Studio's "Manage User Secrets" UI, or on the command line: -// cd this-project-directory -// dotnet user-secrets set "GITHUB_TOKEN" "your-github-models-token-here" -var chatClient = new ChatClient( - "gpt-4o-mini", - new ApiKeyCredential(builder.Configuration["GITHUB_TOKEN"] ?? throw new InvalidOperationException("Missing configuration: GITHUB_TOKEN")), - new OpenAIClientOptions { Endpoint = new Uri("https://models.inference.ai.azure.com") }) - .AsIChatClient(); +var chatClient = MockServices.CreateChatClient(); builder.Services.AddChatClient(chatClient); @@ -43,6 +32,7 @@ // Register services for OpenAI responses and conversations (also required for DevUI) builder.Services.AddOpenAIResponses(); builder.Services.AddOpenAIConversations(); +builder.Services.AddDevUI(); var app = builder.Build(); app.UseHttpsRedirection(); diff --git a/test/ProjectTemplates/Microsoft.Agents.AI.ProjectTemplates.IntegrationTests/Snapshots/aiagent-webapi/aiagent-webapi._defaults.verified/aiagent-webapi/Properties/launchSettings.json b/test/ProjectTemplates/Microsoft.Agents.AI.ProjectTemplates.IntegrationTests/Snapshots/aiagent-webapi/aiagent-webapi.mock.verified/aiagent-webapi/Properties/launchSettings.json similarity index 100% rename from test/ProjectTemplates/Microsoft.Agents.AI.ProjectTemplates.IntegrationTests/Snapshots/aiagent-webapi/aiagent-webapi._defaults.verified/aiagent-webapi/Properties/launchSettings.json rename to test/ProjectTemplates/Microsoft.Agents.AI.ProjectTemplates.IntegrationTests/Snapshots/aiagent-webapi/aiagent-webapi.mock.verified/aiagent-webapi/Properties/launchSettings.json diff --git a/test/ProjectTemplates/Microsoft.Agents.AI.ProjectTemplates.IntegrationTests/Snapshots/aiagent-webapi/aiagent-webapi._defaults.verified/aiagent-webapi/README.md b/test/ProjectTemplates/Microsoft.Agents.AI.ProjectTemplates.IntegrationTests/Snapshots/aiagent-webapi/aiagent-webapi.mock.verified/aiagent-webapi/README.md similarity index 66% rename from test/ProjectTemplates/Microsoft.Agents.AI.ProjectTemplates.IntegrationTests/Snapshots/aiagent-webapi/aiagent-webapi._defaults.verified/aiagent-webapi/README.md rename to test/ProjectTemplates/Microsoft.Agents.AI.ProjectTemplates.IntegrationTests/Snapshots/aiagent-webapi/aiagent-webapi.mock.verified/aiagent-webapi/README.md index 8ea2640ad89..8aaac3ba211 100644 --- a/test/ProjectTemplates/Microsoft.Agents.AI.ProjectTemplates.IntegrationTests/Snapshots/aiagent-webapi/aiagent-webapi._defaults.verified/aiagent-webapi/README.md +++ b/test/ProjectTemplates/Microsoft.Agents.AI.ProjectTemplates.IntegrationTests/Snapshots/aiagent-webapi/aiagent-webapi.mock.verified/aiagent-webapi/README.md @@ -4,43 +4,23 @@ This is an AI Agent Web API application created from the `aiagent-webapi` templa ## Prerequisites -- A GitHub Models API token (free to get started) +- No external AI provider prerequisites ## Getting Started ### 1. Configure Your AI Service -#### GitHub Models Configuration +#### Mock Configuration -This application uses GitHub Models (model: gpt-4o-mini) for AI functionality. You'll need to configure your GitHub Models API token: +This application uses a deterministic mock chat provider seeded for the writer/editor workflow. -**Option A: Using User Secrets (Recommended for Development)** +No API keys or model runtimes are required. -```bash -dotnet user-secrets set "GITHUB_TOKEN" "your-github-models-token-here" -``` - -**Option B: Using Environment Variables** - -Set the `GITHUB_TOKEN` environment variable: - -- **Windows (PowerShell)**: - ```powershell - $env:GITHUB_TOKEN = "your-github-models-token-here" - ``` +Try prompts such as: -- **Linux/macOS**: - ```bash - export GITHUB_TOKEN="your-github-models-token-here" - ``` - -#### Get a GitHub Models Token - -1. Visit [GitHub Models](https://github.com/marketplace/models) -2. Sign in with your GitHub account -3. Select a model (e.g., gpt-4o-mini) -4. Click "Get API Key" or follow the authentication instructions -5. Copy your personal access token +1. `Write a short story about a student learning .NET.` +2. `Edit this story for grammar and style.` +3. `Format the final story for publishing.` ### 2. Run the Application @@ -90,13 +70,13 @@ dotnet new aiagent-webapi --provider ollama --chat-model llama3.1 ### Available Parameters - **`--provider`**: Choose the AI service provider - - `githubmodels` (default) - GitHub Models + - `mock` - Deterministic mock provider - `azureopenai` - Azure OpenAI - `openai` - OpenAI Platform - `ollama` - Ollama (local development) - **`--chat-model`**: Specify the chat model/deployment name - - Default for OpenAI/Azure OpenAI/GitHub Models: `gpt-4o-mini` + - Default for OpenAI/Azure OpenAI: `gpt-4o-mini` - Default for Ollama: `llama3.2` - **`--managed-identity`**: Use managed identity for Azure services (default: `true`) @@ -115,15 +95,10 @@ dotnet new aiagent-webapi --provider ollama --chat-model llama3.1 - [AI apps for .NET developers](https://learn.microsoft.com/dotnet/ai) - [Microsoft Agent Framework Documentation](https://aka.ms/dotnet/agent-framework/docs) -- [GitHub Models](https://github.com/marketplace/models) ## Troubleshooting -**Problem**: Application fails with "Missing configuration: GITHUB_TOKEN" - -**Solution**: Make sure you've configured your GitHub Models API token using one of the methods described above. - -**Problem**: API requests fail with authentication errors +**Problem**: Responses seem repetitive. -**Solution**: Verify your GitHub Models token is valid and hasn't expired. You may need to regenerate it from the GitHub Models website. +**Solution**: The mock provider is intentionally deterministic. Use one of the three seeded workflow prompts listed above. diff --git a/test/ProjectTemplates/Microsoft.Agents.AI.ProjectTemplates.IntegrationTests/Snapshots/aiagent-webapi/aiagent-webapi._defaults.verified/aiagent-webapi/aiagent-webapi.csproj b/test/ProjectTemplates/Microsoft.Agents.AI.ProjectTemplates.IntegrationTests/Snapshots/aiagent-webapi/aiagent-webapi.mock.verified/aiagent-webapi/aiagent-webapi.csproj similarity index 90% rename from test/ProjectTemplates/Microsoft.Agents.AI.ProjectTemplates.IntegrationTests/Snapshots/aiagent-webapi/aiagent-webapi._defaults.verified/aiagent-webapi/aiagent-webapi.csproj rename to test/ProjectTemplates/Microsoft.Agents.AI.ProjectTemplates.IntegrationTests/Snapshots/aiagent-webapi/aiagent-webapi.mock.verified/aiagent-webapi/aiagent-webapi.csproj index ba6fd29474f..e8661283e17 100644 --- a/test/ProjectTemplates/Microsoft.Agents.AI.ProjectTemplates.IntegrationTests/Snapshots/aiagent-webapi/aiagent-webapi._defaults.verified/aiagent-webapi/aiagent-webapi.csproj +++ b/test/ProjectTemplates/Microsoft.Agents.AI.ProjectTemplates.IntegrationTests/Snapshots/aiagent-webapi/aiagent-webapi.mock.verified/aiagent-webapi/aiagent-webapi.csproj @@ -12,7 +12,7 @@ - + diff --git a/test/ProjectTemplates/Microsoft.Agents.AI.ProjectTemplates.IntegrationTests/Snapshots/aiagent-webapi/aiagent-webapi._defaults.verified/aiagent-webapi/appsettings.json b/test/ProjectTemplates/Microsoft.Agents.AI.ProjectTemplates.IntegrationTests/Snapshots/aiagent-webapi/aiagent-webapi.mock.verified/aiagent-webapi/appsettings.json similarity index 100% rename from test/ProjectTemplates/Microsoft.Agents.AI.ProjectTemplates.IntegrationTests/Snapshots/aiagent-webapi/aiagent-webapi._defaults.verified/aiagent-webapi/appsettings.json rename to test/ProjectTemplates/Microsoft.Agents.AI.ProjectTemplates.IntegrationTests/Snapshots/aiagent-webapi/aiagent-webapi.mock.verified/aiagent-webapi/appsettings.json diff --git a/test/ProjectTemplates/Microsoft.Agents.AI.ProjectTemplates.IntegrationTests/Snapshots/aiagent-webapi/aiagent-webapi.o.verified/aiagent-webapi/Program.cs b/test/ProjectTemplates/Microsoft.Agents.AI.ProjectTemplates.IntegrationTests/Snapshots/aiagent-webapi/aiagent-webapi.o.verified/aiagent-webapi/Program.cs index dfc311d6e17..ecbd90dc4ad 100644 --- a/test/ProjectTemplates/Microsoft.Agents.AI.ProjectTemplates.IntegrationTests/Snapshots/aiagent-webapi/aiagent-webapi.o.verified/aiagent-webapi/Program.cs +++ b/test/ProjectTemplates/Microsoft.Agents.AI.ProjectTemplates.IntegrationTests/Snapshots/aiagent-webapi/aiagent-webapi.o.verified/aiagent-webapi/Program.cs @@ -35,6 +35,7 @@ // Register services for OpenAI responses and conversations (also required for DevUI) builder.Services.AddOpenAIResponses(); builder.Services.AddOpenAIConversations(); +builder.Services.AddDevUI(); var app = builder.Build(); app.UseHttpsRedirection(); diff --git a/test/ProjectTemplates/Microsoft.Agents.AI.ProjectTemplates.IntegrationTests/Snapshots/aiagent-webapi/aiagent-webapi.o.verified/aiagent-webapi/README.md b/test/ProjectTemplates/Microsoft.Agents.AI.ProjectTemplates.IntegrationTests/Snapshots/aiagent-webapi/aiagent-webapi.o.verified/aiagent-webapi/README.md index 13f6f85451d..e2510b10a2f 100644 --- a/test/ProjectTemplates/Microsoft.Agents.AI.ProjectTemplates.IntegrationTests/Snapshots/aiagent-webapi/aiagent-webapi.o.verified/aiagent-webapi/README.md +++ b/test/ProjectTemplates/Microsoft.Agents.AI.ProjectTemplates.IntegrationTests/Snapshots/aiagent-webapi/aiagent-webapi.o.verified/aiagent-webapi/README.md @@ -71,13 +71,13 @@ dotnet new aiagent-webapi --provider ollama --chat-model llama3.1 ### Available Parameters - **`--provider`**: Choose the AI service provider - - `githubmodels` (default) - GitHub Models + - `mock` - Deterministic mock provider - `azureopenai` - Azure OpenAI - `openai` - OpenAI Platform - `ollama` - Ollama (local development) - **`--chat-model`**: Specify the chat model/deployment name - - Default for OpenAI/Azure OpenAI/GitHub Models: `gpt-4o-mini` + - Default for OpenAI/Azure OpenAI: `gpt-4o-mini` - Default for Ollama: `llama3.2` - **`--managed-identity`**: Use managed identity for Azure services (default: `true`) diff --git a/test/ProjectTemplates/Microsoft.Agents.AI.ProjectTemplates.IntegrationTests/Snapshots/aiagent-webapi/aiagent-webapi.oai.verified/aiagent-webapi/Program.cs b/test/ProjectTemplates/Microsoft.Agents.AI.ProjectTemplates.IntegrationTests/Snapshots/aiagent-webapi/aiagent-webapi.oai.verified/aiagent-webapi/Program.cs index efed4c96ad5..aed22f49d25 100644 --- a/test/ProjectTemplates/Microsoft.Agents.AI.ProjectTemplates.IntegrationTests/Snapshots/aiagent-webapi/aiagent-webapi.oai.verified/aiagent-webapi/Program.cs +++ b/test/ProjectTemplates/Microsoft.Agents.AI.ProjectTemplates.IntegrationTests/Snapshots/aiagent-webapi/aiagent-webapi.oai.verified/aiagent-webapi/Program.cs @@ -41,6 +41,7 @@ // Register services for OpenAI responses and conversations (also required for DevUI) builder.Services.AddOpenAIResponses(); builder.Services.AddOpenAIConversations(); +builder.Services.AddDevUI(); var app = builder.Build(); app.UseHttpsRedirection(); diff --git a/test/ProjectTemplates/Microsoft.Agents.AI.ProjectTemplates.IntegrationTests/Snapshots/aiagent-webapi/aiagent-webapi.oai.verified/aiagent-webapi/README.md b/test/ProjectTemplates/Microsoft.Agents.AI.ProjectTemplates.IntegrationTests/Snapshots/aiagent-webapi/aiagent-webapi.oai.verified/aiagent-webapi/README.md index dfd846707b6..b9533b75ce8 100644 --- a/test/ProjectTemplates/Microsoft.Agents.AI.ProjectTemplates.IntegrationTests/Snapshots/aiagent-webapi/aiagent-webapi.oai.verified/aiagent-webapi/README.md +++ b/test/ProjectTemplates/Microsoft.Agents.AI.ProjectTemplates.IntegrationTests/Snapshots/aiagent-webapi/aiagent-webapi.oai.verified/aiagent-webapi/README.md @@ -90,13 +90,13 @@ dotnet new aiagent-webapi --provider ollama --chat-model llama3.1 ### Available Parameters - **`--provider`**: Choose the AI service provider - - `githubmodels` (default) - GitHub Models + - `mock` - Deterministic mock provider - `azureopenai` - Azure OpenAI - `openai` - OpenAI Platform - `ollama` - Ollama (local development) - **`--chat-model`**: Specify the chat model/deployment name - - Default for OpenAI/Azure OpenAI/GitHub Models: `gpt-4o-mini` + - Default for OpenAI/Azure OpenAI: `gpt-4o-mini` - Default for Ollama: `llama3.2` - **`--managed-identity`**: Use managed identity for Azure services (default: `true`) diff --git a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/AIChatWebExecutionTests.cs b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/AIChatWebExecutionTests.cs index 74bfd3ffdd6..bdfa206e137 100644 --- a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/AIChatWebExecutionTests.cs +++ b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/AIChatWebExecutionTests.cs @@ -36,7 +36,7 @@ public AIChatWebExecutionTests(TemplateExecutionTestFixture fixture, ITestOutput public static IEnumerable GetSupportedProjectConfigurations() { (string name, string[] values)[] allOptionValues = [ - ("--provider", ["azureopenai", "githubmodels", "ollama", "openai" /*, "azureaifoundry" */]), + ("--provider", ["mock", "azureopenai", "ollama", "openai" /*, "azureaifoundry" */]), ("--vector-store", ["azureaisearch", "local", "qdrant"]), ("--aspire", ["true", "false"]), ("--managed-identity", ["true", "false"]), diff --git a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/AIChatWebSnapshotTests.cs b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/AIChatWebSnapshotTests.cs index ac02c466ea8..d1b7277507f 100644 --- a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/AIChatWebSnapshotTests.cs +++ b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/AIChatWebSnapshotTests.cs @@ -27,10 +27,11 @@ public AIChatWebSnapshotTests(ITestOutputHelper log) } [Theory] - [InlineData /* Defaults: --provider=githubmodels --vector-store=local */] + [InlineData("--provider=mock")] + [InlineData("--provider=openai")] [InlineData("--provider=ollama", "--vector-store=qdrant")] [InlineData("--provider=openai", "--vector-store=azureaisearch")] - [InlineData("--aspire")] + [InlineData("--aspire", "--provider=mock")] [InlineData("--aspire", "--provider=azureopenai", "--vector-store=azureaisearch")] public async Task RunSnapshotTests(params string[] templateArgs) { diff --git a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.A.verified/aichatweb/aichatweb.Web/Components/Layout/SurveyPrompt.razor b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.A.verified/aichatweb/aichatweb.Web/Components/Layout/SurveyPrompt.razor deleted file mode 100644 index 77557f20173..00000000000 --- a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.A.verified/aichatweb/aichatweb.Web/Components/Layout/SurveyPrompt.razor +++ /dev/null @@ -1,13 +0,0 @@ -
- - -
- How well is this template working for you? Please take a - brief survey - and tell us what you think. -
-
diff --git a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.A.verified/aichatweb/aichatweb.Web/Components/Layout/SurveyPrompt.razor.css b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.A.verified/aichatweb/aichatweb.Web/Components/Layout/SurveyPrompt.razor.css deleted file mode 100644 index c939b902afb..00000000000 --- a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.A.verified/aichatweb/aichatweb.Web/Components/Layout/SurveyPrompt.razor.css +++ /dev/null @@ -1,20 +0,0 @@ -.surveyContainer { - display: flex; - justify-content: center; - gap: 0.5rem; - font-size: 0.9em; - margin: 0.5rem auto -0.7rem auto; - max-width: 1024px; - color: #444; -} - - .surveyContainer a { - text-decoration: underline; - } - - .surveyContainer .tool-icon { - margin-top: 0.15rem; - width: 1.25rem; - height: 1.25rem; - flex-shrink: 0; - } diff --git a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.A_aoai_aais.verified/aichatweb/aichatweb.Web/Components/Layout/SurveyPrompt.razor b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.A_aoai_aais.verified/aichatweb/aichatweb.Web/Components/Layout/SurveyPrompt.razor deleted file mode 100644 index 77557f20173..00000000000 --- a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.A_aoai_aais.verified/aichatweb/aichatweb.Web/Components/Layout/SurveyPrompt.razor +++ /dev/null @@ -1,13 +0,0 @@ -
- - -
- How well is this template working for you? Please take a - brief survey - and tell us what you think. -
-
diff --git a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.A_aoai_aais.verified/aichatweb/aichatweb.Web/Components/Layout/SurveyPrompt.razor.css b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.A_aoai_aais.verified/aichatweb/aichatweb.Web/Components/Layout/SurveyPrompt.razor.css deleted file mode 100644 index c939b902afb..00000000000 --- a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.A_aoai_aais.verified/aichatweb/aichatweb.Web/Components/Layout/SurveyPrompt.razor.css +++ /dev/null @@ -1,20 +0,0 @@ -.surveyContainer { - display: flex; - justify-content: center; - gap: 0.5rem; - font-size: 0.9em; - margin: 0.5rem auto -0.7rem auto; - max-width: 1024px; - color: #444; -} - - .surveyContainer a { - text-decoration: underline; - } - - .surveyContainer .tool-icon { - margin-top: 0.15rem; - width: 1.25rem; - height: 1.25rem; - flex-shrink: 0; - } diff --git a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.A_aoai_aais.verified/aichatweb/aichatweb.Web/Components/Pages/Chat/Chat.razor b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.A_aoai_aais.verified/aichatweb/aichatweb.Web/Components/Pages/Chat/Chat.razor index 6fc5881c18f..9d33e3ff5ed 100644 --- a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.A_aoai_aais.verified/aichatweb/aichatweb.Web/Components/Pages/Chat/Chat.razor +++ b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.A_aoai_aais.verified/aichatweb/aichatweb.Web/Components/Pages/Chat/Chat.razor @@ -1,5 +1,6 @@ @page "/" @using System.ComponentModel +@using System.Xml.Linq @inject IChatClient ChatClient @inject NavigationManager Nav @inject SemanticSearch Search @@ -20,7 +21,6 @@
- @* Remove this line to eliminate the template survey message *@
@code { @@ -126,7 +126,10 @@ await InvokeAsync(StateHasChanged); var results = await Search.SearchAsync(searchPhrase, filenameFilter, maxResults: 5); return results.Select(result => - $"{result.Text}"); + new XElement( + "result", + new XAttribute("filename", result.DocumentId), + result.Text).ToString(SaveOptions.DisableFormatting)); } public void Dispose() diff --git a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.A_aoai_aais.verified/aichatweb/aichatweb.Web/Components/Pages/Chat/ChatMessageItem.razor b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.A_aoai_aais.verified/aichatweb/aichatweb.Web/Components/Pages/Chat/ChatMessageItem.razor index e45d92ab5f9..33367fa3974 100644 --- a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.A_aoai_aais.verified/aichatweb/aichatweb.Web/Components/Pages/Chat/ChatMessageItem.razor +++ b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.A_aoai_aais.verified/aichatweb/aichatweb.Web/Components/Pages/Chat/ChatMessageItem.razor @@ -1,6 +1,7 @@ @using System.Runtime.CompilerServices @using System.Text.RegularExpressions -@using System.Linq +@using System.Xml +@using System.Xml.Linq @if (Message.Role == ChatRole.User) { @@ -69,7 +70,7 @@ else if (Message.Role == ChatRole.Assistant) @code { private static readonly ConditionalWeakTable SubscribersLookup = new(); - private static readonly Regex CitationRegex = new(@"(?.*?)", RegexOptions.NonBacktracking); + private static readonly Regex CitationRegex = new(@"]*>.*?", RegexOptions.NonBacktracking | RegexOptions.Singleline); private List<(string File, string Quote)>? citations; @@ -99,9 +100,24 @@ else if (Message.Role == ChatRole.Assistant) private void ParseCitations(string text) { - var matches = CitationRegex.Matches(text); - citations = matches.Any() - ? matches.Select(m => (m.Groups["file"].Value, m.Groups["quote"].Value)).ToList() - : null; + var parsedCitations = new List<(string File, string Quote)>(); + foreach (Match match in CitationRegex.Matches(text)) + { + try + { + XElement citation = XElement.Parse(match.Value); + if (citation.Name.LocalName == "citation" && + citation.Attribute("filename")?.Value is { Length: > 0 } file) + { + parsedCitations.Add((file, citation.Value)); + } + } + catch (XmlException) + { + // Ignore malformed citation markup returned by the model. + } + } + + citations = parsedCitations.Count > 0 ? parsedCitations : null; } } diff --git a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.A.verified/aichatweb/README.md b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.A_mock.verified/aichatweb/README.md similarity index 72% rename from test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.A.verified/aichatweb/README.md rename to test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.A_mock.verified/aichatweb/README.md index 94c542fda7f..5d3b5afb2ca 100644 --- a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.A.verified/aichatweb/README.md +++ b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.A_mock.verified/aichatweb/README.md @@ -2,8 +2,6 @@ This project is an AI chat application that demonstrates how to chat with custom data using an AI language model. Please note that this template is currently in an early preview stage. If you have feedback, please take a [brief survey](https://aka.ms/dotnet-chat-templatePreview2-survey). ->[!NOTE] -> Before running this project you need to configure the API keys or endpoints for the providers you have chosen. See below for details specific to your choices. ### Known Issues @@ -15,17 +13,18 @@ This incompatibility can be addressed by upgrading to Docker Desktop 4.41.1. See # Configure the AI Model Provider -## Using GitHub Models -To use models hosted by GitHub Models, you will need to create a GitHub personal access token. The token should not have any scopes or permissions. See [Managing your personal access tokens](https://docs.github.com/en/authentication/keeping-your-account-and-data-secure/managing-your-personal-access-tokens). +## Using Mock provider -From the command line, configure your token for this project using .NET User Secrets by running the following commands: +The mock provider runs fully local with deterministic canned responses, so it requires no API keys, cloud credentials, or model downloads. -```sh -cd aichatweb.AppHost -dotnet user-secrets set ConnectionStrings:openai "Endpoint=https://models.inference.ai.azure.com;Key=YOUR-API-KEY" -``` +It is pre-seeded with roughly 20 TrailMaster GPS Watch responses (plus follow-up suggestions) that map to the sample documents under `wwwroot/Data`. -Learn more about [prototyping with AI models using GitHub Models](https://docs.github.com/github-models/prototyping-with-ai-models). +Good starter prompts: + +- What does TrailMaster track? +- How rugged is TrailMaster? +- How does location sharing work? +- Why should I buy this watch? # Running the application diff --git a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.A.verified/aichatweb/aichatweb.AppHost/AppHost.cs b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.A_mock.verified/aichatweb/aichatweb.AppHost/AppHost.cs similarity index 52% rename from test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.A.verified/aichatweb/aichatweb.AppHost/AppHost.cs rename to test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.A_mock.verified/aichatweb/aichatweb.AppHost/AppHost.cs index bf116a0c47e..7b8358c847a 100644 --- a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.A.verified/aichatweb/aichatweb.AppHost/AppHost.cs +++ b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.A_mock.verified/aichatweb/aichatweb.AppHost/AppHost.cs @@ -1,17 +1,10 @@ var builder = DistributedApplication.CreateBuilder(args); -// You will need to set the connection string to your own value -// You can do this using Visual Studio's "Manage User Secrets" UI, or on the command line: -// cd this-project-directory -// dotnet user-secrets set ConnectionStrings:openai "Endpoint=https://models.inference.ai.azure.com;Key=YOUR-API-KEY" -var openai = builder.AddConnectionString("openai"); - var markitdown = builder.AddContainer("markitdown", "mcp/markitdown") .WithArgs("--http", "--host", "0.0.0.0", "--port", "3001") .WithHttpEndpoint(targetPort: 3001, name: "http"); var webApp = builder.AddProject("aichatweb-app"); -webApp.WithReference(openai); webApp .WithEnvironment("MARKITDOWN_MCP_URL", markitdown.GetEndpoint("http")); diff --git a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.A.verified/aichatweb/aichatweb.AppHost/Properties/launchSettings.json b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.A_mock.verified/aichatweb/aichatweb.AppHost/Properties/launchSettings.json similarity index 100% rename from test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.A.verified/aichatweb/aichatweb.AppHost/Properties/launchSettings.json rename to test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.A_mock.verified/aichatweb/aichatweb.AppHost/Properties/launchSettings.json diff --git a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.A.verified/aichatweb/aichatweb.AppHost/aichatweb.AppHost.csproj b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.A_mock.verified/aichatweb/aichatweb.AppHost/aichatweb.AppHost.csproj similarity index 100% rename from test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.A.verified/aichatweb/aichatweb.AppHost/aichatweb.AppHost.csproj rename to test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.A_mock.verified/aichatweb/aichatweb.AppHost/aichatweb.AppHost.csproj diff --git a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.A.verified/aichatweb/aichatweb.AppHost/appsettings.Development.json b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.A_mock.verified/aichatweb/aichatweb.AppHost/appsettings.Development.json similarity index 100% rename from test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.A.verified/aichatweb/aichatweb.AppHost/appsettings.Development.json rename to test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.A_mock.verified/aichatweb/aichatweb.AppHost/appsettings.Development.json diff --git a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.A.verified/aichatweb/aichatweb.AppHost/appsettings.json b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.A_mock.verified/aichatweb/aichatweb.AppHost/appsettings.json similarity index 100% rename from test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.A.verified/aichatweb/aichatweb.AppHost/appsettings.json rename to test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.A_mock.verified/aichatweb/aichatweb.AppHost/appsettings.json diff --git a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.A.verified/aichatweb/aichatweb.ServiceDefaults/Extensions.cs b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.A_mock.verified/aichatweb/aichatweb.ServiceDefaults/Extensions.cs similarity index 100% rename from test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.A.verified/aichatweb/aichatweb.ServiceDefaults/Extensions.cs rename to test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.A_mock.verified/aichatweb/aichatweb.ServiceDefaults/Extensions.cs diff --git a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.A.verified/aichatweb/aichatweb.ServiceDefaults/aichatweb.ServiceDefaults.csproj b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.A_mock.verified/aichatweb/aichatweb.ServiceDefaults/aichatweb.ServiceDefaults.csproj similarity index 100% rename from test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.A.verified/aichatweb/aichatweb.ServiceDefaults/aichatweb.ServiceDefaults.csproj rename to test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.A_mock.verified/aichatweb/aichatweb.ServiceDefaults/aichatweb.ServiceDefaults.csproj diff --git a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.A.verified/aichatweb/aichatweb.Web/Components/App.razor b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.A_mock.verified/aichatweb/aichatweb.Web/Components/App.razor similarity index 100% rename from test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.A.verified/aichatweb/aichatweb.Web/Components/App.razor rename to test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.A_mock.verified/aichatweb/aichatweb.Web/Components/App.razor diff --git a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.A.verified/aichatweb/aichatweb.Web/Components/Layout/LoadingSpinner.razor b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.A_mock.verified/aichatweb/aichatweb.Web/Components/Layout/LoadingSpinner.razor similarity index 100% rename from test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.A.verified/aichatweb/aichatweb.Web/Components/Layout/LoadingSpinner.razor rename to test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.A_mock.verified/aichatweb/aichatweb.Web/Components/Layout/LoadingSpinner.razor diff --git a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.A.verified/aichatweb/aichatweb.Web/Components/Layout/LoadingSpinner.razor.css b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.A_mock.verified/aichatweb/aichatweb.Web/Components/Layout/LoadingSpinner.razor.css similarity index 100% rename from test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.A.verified/aichatweb/aichatweb.Web/Components/Layout/LoadingSpinner.razor.css rename to test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.A_mock.verified/aichatweb/aichatweb.Web/Components/Layout/LoadingSpinner.razor.css diff --git a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.A.verified/aichatweb/aichatweb.Web/Components/Layout/MainLayout.razor b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.A_mock.verified/aichatweb/aichatweb.Web/Components/Layout/MainLayout.razor similarity index 100% rename from test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.A.verified/aichatweb/aichatweb.Web/Components/Layout/MainLayout.razor rename to test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.A_mock.verified/aichatweb/aichatweb.Web/Components/Layout/MainLayout.razor diff --git a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.A.verified/aichatweb/aichatweb.Web/Components/Layout/MainLayout.razor.css b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.A_mock.verified/aichatweb/aichatweb.Web/Components/Layout/MainLayout.razor.css similarity index 100% rename from test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.A.verified/aichatweb/aichatweb.Web/Components/Layout/MainLayout.razor.css rename to test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.A_mock.verified/aichatweb/aichatweb.Web/Components/Layout/MainLayout.razor.css diff --git a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb._defaults.verified/aichatweb/Components/Pages/Chat/Chat.razor b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.A_mock.verified/aichatweb/aichatweb.Web/Components/Pages/Chat/Chat.razor similarity index 96% rename from test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb._defaults.verified/aichatweb/Components/Pages/Chat/Chat.razor rename to test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.A_mock.verified/aichatweb/aichatweb.Web/Components/Pages/Chat/Chat.razor index 6fc5881c18f..9d33e3ff5ed 100644 --- a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb._defaults.verified/aichatweb/Components/Pages/Chat/Chat.razor +++ b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.A_mock.verified/aichatweb/aichatweb.Web/Components/Pages/Chat/Chat.razor @@ -1,5 +1,6 @@ @page "/" @using System.ComponentModel +@using System.Xml.Linq @inject IChatClient ChatClient @inject NavigationManager Nav @inject SemanticSearch Search @@ -20,7 +21,6 @@
- @* Remove this line to eliminate the template survey message *@
@code { @@ -126,7 +126,10 @@ await InvokeAsync(StateHasChanged); var results = await Search.SearchAsync(searchPhrase, filenameFilter, maxResults: 5); return results.Select(result => - $"{result.Text}"); + new XElement( + "result", + new XAttribute("filename", result.DocumentId), + result.Text).ToString(SaveOptions.DisableFormatting)); } public void Dispose() diff --git a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.A.verified/aichatweb/aichatweb.Web/Components/Pages/Chat/Chat.razor.css b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.A_mock.verified/aichatweb/aichatweb.Web/Components/Pages/Chat/Chat.razor.css similarity index 100% rename from test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.A.verified/aichatweb/aichatweb.Web/Components/Pages/Chat/Chat.razor.css rename to test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.A_mock.verified/aichatweb/aichatweb.Web/Components/Pages/Chat/Chat.razor.css diff --git a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.A.verified/aichatweb/aichatweb.Web/Components/Pages/Chat/ChatCitation.razor b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.A_mock.verified/aichatweb/aichatweb.Web/Components/Pages/Chat/ChatCitation.razor similarity index 100% rename from test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.A.verified/aichatweb/aichatweb.Web/Components/Pages/Chat/ChatCitation.razor rename to test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.A_mock.verified/aichatweb/aichatweb.Web/Components/Pages/Chat/ChatCitation.razor diff --git a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.A.verified/aichatweb/aichatweb.Web/Components/Pages/Chat/ChatCitation.razor.css b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.A_mock.verified/aichatweb/aichatweb.Web/Components/Pages/Chat/ChatCitation.razor.css similarity index 100% rename from test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.A.verified/aichatweb/aichatweb.Web/Components/Pages/Chat/ChatCitation.razor.css rename to test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.A_mock.verified/aichatweb/aichatweb.Web/Components/Pages/Chat/ChatCitation.razor.css diff --git a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.A.verified/aichatweb/aichatweb.Web/Components/Pages/Chat/ChatHeader.razor b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.A_mock.verified/aichatweb/aichatweb.Web/Components/Pages/Chat/ChatHeader.razor similarity index 100% rename from test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.A.verified/aichatweb/aichatweb.Web/Components/Pages/Chat/ChatHeader.razor rename to test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.A_mock.verified/aichatweb/aichatweb.Web/Components/Pages/Chat/ChatHeader.razor diff --git a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.A.verified/aichatweb/aichatweb.Web/Components/Pages/Chat/ChatHeader.razor.css b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.A_mock.verified/aichatweb/aichatweb.Web/Components/Pages/Chat/ChatHeader.razor.css similarity index 100% rename from test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.A.verified/aichatweb/aichatweb.Web/Components/Pages/Chat/ChatHeader.razor.css rename to test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.A_mock.verified/aichatweb/aichatweb.Web/Components/Pages/Chat/ChatHeader.razor.css diff --git a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.A.verified/aichatweb/aichatweb.Web/Components/Pages/Chat/ChatInput.razor b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.A_mock.verified/aichatweb/aichatweb.Web/Components/Pages/Chat/ChatInput.razor similarity index 100% rename from test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.A.verified/aichatweb/aichatweb.Web/Components/Pages/Chat/ChatInput.razor rename to test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.A_mock.verified/aichatweb/aichatweb.Web/Components/Pages/Chat/ChatInput.razor diff --git a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.A.verified/aichatweb/aichatweb.Web/Components/Pages/Chat/ChatInput.razor.css b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.A_mock.verified/aichatweb/aichatweb.Web/Components/Pages/Chat/ChatInput.razor.css similarity index 100% rename from test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.A.verified/aichatweb/aichatweb.Web/Components/Pages/Chat/ChatInput.razor.css rename to test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.A_mock.verified/aichatweb/aichatweb.Web/Components/Pages/Chat/ChatInput.razor.css diff --git a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.A.verified/aichatweb/aichatweb.Web/Components/Pages/Chat/ChatInput.razor.js b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.A_mock.verified/aichatweb/aichatweb.Web/Components/Pages/Chat/ChatInput.razor.js similarity index 100% rename from test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.A.verified/aichatweb/aichatweb.Web/Components/Pages/Chat/ChatInput.razor.js rename to test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.A_mock.verified/aichatweb/aichatweb.Web/Components/Pages/Chat/ChatInput.razor.js diff --git a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb._defaults.verified/aichatweb/Components/Pages/Chat/ChatMessageItem.razor b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.A_mock.verified/aichatweb/aichatweb.Web/Components/Pages/Chat/ChatMessageItem.razor similarity index 84% rename from test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb._defaults.verified/aichatweb/Components/Pages/Chat/ChatMessageItem.razor rename to test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.A_mock.verified/aichatweb/aichatweb.Web/Components/Pages/Chat/ChatMessageItem.razor index e45d92ab5f9..33367fa3974 100644 --- a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb._defaults.verified/aichatweb/Components/Pages/Chat/ChatMessageItem.razor +++ b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.A_mock.verified/aichatweb/aichatweb.Web/Components/Pages/Chat/ChatMessageItem.razor @@ -1,6 +1,7 @@ @using System.Runtime.CompilerServices @using System.Text.RegularExpressions -@using System.Linq +@using System.Xml +@using System.Xml.Linq @if (Message.Role == ChatRole.User) { @@ -69,7 +70,7 @@ else if (Message.Role == ChatRole.Assistant) @code { private static readonly ConditionalWeakTable SubscribersLookup = new(); - private static readonly Regex CitationRegex = new(@"(?.*?)", RegexOptions.NonBacktracking); + private static readonly Regex CitationRegex = new(@"]*>.*?", RegexOptions.NonBacktracking | RegexOptions.Singleline); private List<(string File, string Quote)>? citations; @@ -99,9 +100,24 @@ else if (Message.Role == ChatRole.Assistant) private void ParseCitations(string text) { - var matches = CitationRegex.Matches(text); - citations = matches.Any() - ? matches.Select(m => (m.Groups["file"].Value, m.Groups["quote"].Value)).ToList() - : null; + var parsedCitations = new List<(string File, string Quote)>(); + foreach (Match match in CitationRegex.Matches(text)) + { + try + { + XElement citation = XElement.Parse(match.Value); + if (citation.Name.LocalName == "citation" && + citation.Attribute("filename")?.Value is { Length: > 0 } file) + { + parsedCitations.Add((file, citation.Value)); + } + } + catch (XmlException) + { + // Ignore malformed citation markup returned by the model. + } + } + + citations = parsedCitations.Count > 0 ? parsedCitations : null; } } diff --git a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.A.verified/aichatweb/aichatweb.Web/Components/Pages/Chat/ChatMessageItem.razor.css b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.A_mock.verified/aichatweb/aichatweb.Web/Components/Pages/Chat/ChatMessageItem.razor.css similarity index 100% rename from test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.A.verified/aichatweb/aichatweb.Web/Components/Pages/Chat/ChatMessageItem.razor.css rename to test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.A_mock.verified/aichatweb/aichatweb.Web/Components/Pages/Chat/ChatMessageItem.razor.css diff --git a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.A.verified/aichatweb/aichatweb.Web/Components/Pages/Chat/ChatMessageList.razor b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.A_mock.verified/aichatweb/aichatweb.Web/Components/Pages/Chat/ChatMessageList.razor similarity index 100% rename from test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.A.verified/aichatweb/aichatweb.Web/Components/Pages/Chat/ChatMessageList.razor rename to test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.A_mock.verified/aichatweb/aichatweb.Web/Components/Pages/Chat/ChatMessageList.razor diff --git a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.A.verified/aichatweb/aichatweb.Web/Components/Pages/Chat/ChatMessageList.razor.css b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.A_mock.verified/aichatweb/aichatweb.Web/Components/Pages/Chat/ChatMessageList.razor.css similarity index 100% rename from test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.A.verified/aichatweb/aichatweb.Web/Components/Pages/Chat/ChatMessageList.razor.css rename to test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.A_mock.verified/aichatweb/aichatweb.Web/Components/Pages/Chat/ChatMessageList.razor.css diff --git a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.A.verified/aichatweb/aichatweb.Web/Components/Pages/Chat/ChatMessageList.razor.js b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.A_mock.verified/aichatweb/aichatweb.Web/Components/Pages/Chat/ChatMessageList.razor.js similarity index 100% rename from test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.A.verified/aichatweb/aichatweb.Web/Components/Pages/Chat/ChatMessageList.razor.js rename to test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.A_mock.verified/aichatweb/aichatweb.Web/Components/Pages/Chat/ChatMessageList.razor.js diff --git a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.A.verified/aichatweb/aichatweb.Web/Components/Pages/Chat/ChatSuggestions.razor b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.A_mock.verified/aichatweb/aichatweb.Web/Components/Pages/Chat/ChatSuggestions.razor similarity index 100% rename from test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.A.verified/aichatweb/aichatweb.Web/Components/Pages/Chat/ChatSuggestions.razor rename to test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.A_mock.verified/aichatweb/aichatweb.Web/Components/Pages/Chat/ChatSuggestions.razor diff --git a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.A.verified/aichatweb/aichatweb.Web/Components/Pages/Chat/ChatSuggestions.razor.css b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.A_mock.verified/aichatweb/aichatweb.Web/Components/Pages/Chat/ChatSuggestions.razor.css similarity index 100% rename from test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.A.verified/aichatweb/aichatweb.Web/Components/Pages/Chat/ChatSuggestions.razor.css rename to test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.A_mock.verified/aichatweb/aichatweb.Web/Components/Pages/Chat/ChatSuggestions.razor.css diff --git a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.A.verified/aichatweb/aichatweb.Web/Components/Pages/Error.razor b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.A_mock.verified/aichatweb/aichatweb.Web/Components/Pages/Error.razor similarity index 100% rename from test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.A.verified/aichatweb/aichatweb.Web/Components/Pages/Error.razor rename to test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.A_mock.verified/aichatweb/aichatweb.Web/Components/Pages/Error.razor diff --git a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.A.verified/aichatweb/aichatweb.Web/Components/Routes.razor b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.A_mock.verified/aichatweb/aichatweb.Web/Components/Routes.razor similarity index 100% rename from test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.A.verified/aichatweb/aichatweb.Web/Components/Routes.razor rename to test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.A_mock.verified/aichatweb/aichatweb.Web/Components/Routes.razor diff --git a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.A.verified/aichatweb/aichatweb.Web/Components/_Imports.razor b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.A_mock.verified/aichatweb/aichatweb.Web/Components/_Imports.razor similarity index 100% rename from test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.A.verified/aichatweb/aichatweb.Web/Components/_Imports.razor rename to test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.A_mock.verified/aichatweb/aichatweb.Web/Components/_Imports.razor diff --git a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.A.verified/aichatweb/aichatweb.Web/Program.cs b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.A_mock.verified/aichatweb/aichatweb.Web/Program.cs similarity index 90% rename from test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.A.verified/aichatweb/aichatweb.Web/Program.cs rename to test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.A_mock.verified/aichatweb/aichatweb.Web/Program.cs index e47bad71cc8..f31aa5b1e8a 100644 --- a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.A.verified/aichatweb/aichatweb.Web/Program.cs +++ b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.A_mock.verified/aichatweb/aichatweb.Web/Program.cs @@ -1,5 +1,4 @@ using Microsoft.Extensions.AI; -using OpenAI; using aichatweb.Web.Components; using aichatweb.Web.Services; using aichatweb.Web.Services.Ingestion; @@ -8,12 +7,11 @@ builder.AddServiceDefaults(); builder.Services.AddRazorComponents().AddInteractiveServerComponents(); -var openai = builder.AddAzureOpenAIClient("openai"); -openai.AddChatClient("gpt-4o-mini") +builder.Services.AddChatClient(MockServices.CreateChatClient()) .UseFunctionInvocation() .UseOpenTelemetry(configure: c => c.EnableSensitiveData = builder.Environment.IsDevelopment()); -openai.AddEmbeddingGenerator("text-embedding-3-small"); +builder.Services.AddEmbeddingGenerator(MockServices.CreateEmbeddingGenerator()); var vectorStorePath = Path.Combine(AppContext.BaseDirectory, "vector-store.db"); var vectorStoreConnectionString = $"Data Source={vectorStorePath}"; diff --git a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.A.verified/aichatweb/aichatweb.Web/Properties/launchSettings.json b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.A_mock.verified/aichatweb/aichatweb.Web/Properties/launchSettings.json similarity index 100% rename from test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.A.verified/aichatweb/aichatweb.Web/Properties/launchSettings.json rename to test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.A_mock.verified/aichatweb/aichatweb.Web/Properties/launchSettings.json diff --git a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.A.verified/aichatweb/aichatweb.Web/Services/IngestedChunk.cs b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.A_mock.verified/aichatweb/aichatweb.Web/Services/IngestedChunk.cs similarity index 95% rename from test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.A.verified/aichatweb/aichatweb.Web/Services/IngestedChunk.cs rename to test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.A_mock.verified/aichatweb/aichatweb.Web/Services/IngestedChunk.cs index af609ea239e..2f1ca15090a 100644 --- a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.A.verified/aichatweb/aichatweb.Web/Services/IngestedChunk.cs +++ b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.A_mock.verified/aichatweb/aichatweb.Web/Services/IngestedChunk.cs @@ -11,7 +11,7 @@ public class IngestedChunk [VectorStoreKey(StorageName = "key")] [JsonPropertyName("key")] - public required Guid Key { get; set; } + public required string Key { get; set; } [VectorStoreData(StorageName = "documentid")] [JsonPropertyName("documentid")] diff --git a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.A.verified/aichatweb/aichatweb.Web/Services/Ingestion/DataIngestor.cs b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.A_mock.verified/aichatweb/aichatweb.Web/Services/Ingestion/DataIngestor.cs similarity index 100% rename from test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.A.verified/aichatweb/aichatweb.Web/Services/Ingestion/DataIngestor.cs rename to test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.A_mock.verified/aichatweb/aichatweb.Web/Services/Ingestion/DataIngestor.cs diff --git a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.A.verified/aichatweb/aichatweb.Web/Services/Ingestion/DocumentReader.cs b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.A_mock.verified/aichatweb/aichatweb.Web/Services/Ingestion/DocumentReader.cs similarity index 100% rename from test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.A.verified/aichatweb/aichatweb.Web/Services/Ingestion/DocumentReader.cs rename to test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.A_mock.verified/aichatweb/aichatweb.Web/Services/Ingestion/DocumentReader.cs diff --git a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.A_mock.verified/aichatweb/aichatweb.Web/Services/LexicalMockEmbeddingGenerator.cs b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.A_mock.verified/aichatweb/aichatweb.Web/Services/LexicalMockEmbeddingGenerator.cs new file mode 100644 index 00000000000..8f9e483bc9d --- /dev/null +++ b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.A_mock.verified/aichatweb/aichatweb.Web/Services/LexicalMockEmbeddingGenerator.cs @@ -0,0 +1,107 @@ +using System; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Extensions.AI; + +namespace aichatweb.Web.Services; + +#pragma warning disable MEAI001 // Mock provider uses experimental testing APIs + +internal sealed class LexicalMockEmbeddingGenerator : MockEmbeddingGenerator +{ + private readonly int _dimensions; + + internal LexicalMockEmbeddingGenerator(int dimensions) + { + ArgumentOutOfRangeException.ThrowIfLessThan(dimensions, 1); + _dimensions = dimensions; + } + + protected override Task>> GenerateCoreAsync( + IEnumerable values, + EmbeddingGenerationOptions? options, + CancellationToken cancellationToken) + { + if (GenerateAsyncCallback is not null) + { + return base.GenerateCoreAsync(values, options, cancellationToken); + } + + GeneratedEmbeddings> embeddings = []; + foreach (string value in values) + { + cancellationToken.ThrowIfCancellationRequested(); + embeddings.Add(new Embedding(CreateVector(value, _dimensions))); + } + + return Task.FromResult(embeddings); + } + + private static float[] CreateVector(string? value, int dimensions) + { + var vector = new float[dimensions]; + if (string.IsNullOrEmpty(value)) + { + return vector; + } + + // Hash tokens and three-character shingles so shared words and word fragments have matching dimensions. + int tokenStart = -1; + for (int i = 0; i <= value.Length; i++) + { + if (i < value.Length && char.IsLetterOrDigit(value[i])) + { + tokenStart = tokenStart < 0 ? i : tokenStart; + } + else if (tokenStart >= 0) + { + AddTokenFeatures(value, tokenStart, i - tokenStart, vector); + tokenStart = -1; + } + } + + // Normalize to unit length so cosine similarity favors lexical overlap rather than document length. + float squaredMagnitude = 0; + foreach (float component in vector) + { + squaredMagnitude += component * component; + } + + if (squaredMagnitude > 0) + { + float scale = 1 / (float)Math.Sqrt(squaredMagnitude); + for (int i = 0; i < vector.Length; i++) + { + vector[i] *= scale; + } + } + + return vector; + } + + private static void AddTokenFeatures(string value, int tokenStart, int tokenLength, float[] vector) + { + AddFeature(value, tokenStart, tokenLength, vector); + + for (int i = tokenStart; i <= tokenStart + tokenLength - 3; i++) + { + AddFeature(value, i, 3, vector); + } + } + + private static void AddFeature(string value, int start, int length, float[] vector) + { + uint hash = 2_166_136_261; + unchecked + { + for (int i = start; i < start + length; i++) + { + hash = (hash ^ char.ToLowerInvariant(value[i])) * 16_777_619; + } + } + + vector[(int)(hash % (uint)vector.Length)] += 1; + } +} +#pragma warning restore MEAI001 diff --git a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.A_mock.verified/aichatweb/aichatweb.Web/Services/MockChatClientExtensions.cs b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.A_mock.verified/aichatweb/aichatweb.Web/Services/MockChatClientExtensions.cs new file mode 100644 index 00000000000..45ed824b906 --- /dev/null +++ b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.A_mock.verified/aichatweb/aichatweb.Web/Services/MockChatClientExtensions.cs @@ -0,0 +1,297 @@ +using System.Linq; +using System.Text.Json; +using System.Xml; +using System.Xml.Linq; +using Microsoft.Extensions.AI; + +namespace aichatweb.Web.Services; + +#pragma warning disable MEAI001 // Mock provider uses experimental testing APIs + +internal static class MockChatClientExtensions +{ + private const string LoadDocumentsCallId = "mock-load-documents"; + private const string SearchCallId = "mock-search"; + private const string MockConversationId = "mock-conversation"; + + /// Adds a canned response for matching requests. + /// The mock chat client to configure. + /// Predicate used to select whether the response applies to a request. + /// The assistant response text. + /// + /// The optional minimum simulated response delay in milliseconds. When supplied alone, it is the fixed delay. + /// + /// + /// The optional maximum simulated response delay in milliseconds. When supplied alone, a delay from zero up to this + /// value is selected. + /// + /// + /// Optional aichatweb follow-up suggestions returned through its structured-output request. Supply an empty + /// collection when the response is a conversation dead end. + /// + /// + /// to invoke the document-search tools and append a citation from the search result. + /// + /// + /// to remove the response seed after its first matching request; otherwise it remains reusable. + /// + /// The configured . + internal static MockChatClient AddResponse( + this MockChatClient client, + Func requestPredicate, + string response, + int? minDelay = null, + int? maxDelay = null, + IEnumerable? suggestions = null, + bool includeCitation = false, + bool singleUse = false) + { + ArgumentNullException.ThrowIfNull(client); + ArgumentNullException.ThrowIfNull(requestPredicate); + ArgumentNullException.ThrowIfNull(response); + ValidateDelayRange(minDelay, maxDelay); + + if (suggestions is null) + { + return AddResponse(client, requestPredicate, response, minDelay, maxDelay, includeCitation, singleUse); + } + + string structuredResponse = JsonSerializer.Serialize(new { data = suggestions.ToArray() }); + + MockChatClient configuredClient = AddResponse( + client, + request => request.Options?.ResponseFormat is not ChatResponseFormatJson && requestPredicate(request), + response, + minDelay, + maxDelay, + includeCitation, + singleUse); + + return AddResponse( + configuredClient, + request => request.Options?.ResponseFormat is ChatResponseFormatJson && requestPredicate(request), + structuredResponse, + minDelay, + maxDelay, + includeCitation, + singleUse); + } + + internal static string Citation(string filename, string quote) + { + ArgumentNullException.ThrowIfNull(filename); + ArgumentNullException.ThrowIfNull(quote); + + return new XElement( + "citation", + new XAttribute("filename", filename), + quote).ToString(SaveOptions.DisableFormatting); + } + + private static MockChatClient AddResponse( + MockChatClient client, + Func requestPredicate, + string response, + int? minDelay, + int? maxDelay, + bool includeCitation, + bool singleUse) => + client.AddResponse( + requestPredicate, + async (request, cancellationToken) => + { + await Task.Delay(GetDelay(minDelay, maxDelay), cancellationToken); + return CreateResponse(request, response, includeCitation); + }, + singleUse); + + private static ChatResponse CreateResponse(MockChatClientRequest request, string response, bool includeCitation) + { + if (request.Options?.ResponseFormat is ChatResponseFormatJson) + { + return new ChatResponse(new ChatMessage(ChatRole.Assistant, response)); + } + + if (TryGetFunctionResult(request, SearchCallId, out FunctionResultContent? searchResult) && searchResult is not null) + { + return CreateConversationResponse($"{response}{GetCitation(searchResult, request.LastUserText)}"); + } + + if (TryGetFunctionResult(request, LoadDocumentsCallId, out _)) + { + return CreateSearchFunctionCallResponse(request); + } + + if (includeCitation) + { + return request.Options?.ConversationId is null + ? CreateFunctionCallResponse(LoadDocumentsCallId, "LoadDocuments") + : CreateSearchFunctionCallResponse(request); + } + + return CreateConversationResponse(response); + } + + private static ChatResponse CreateConversationResponse(string response) => + new(new ChatMessage(ChatRole.Assistant, response)) + { + ConversationId = MockConversationId, + }; + + private static ChatResponse CreateFunctionCallResponse( + string callId, + string name, + IDictionary? arguments = null) => + new(new ChatMessage(ChatRole.Assistant, [new FunctionCallContent(callId, name, arguments)])); + + private static ChatResponse CreateSearchFunctionCallResponse(MockChatClientRequest request) => + CreateFunctionCallResponse( + SearchCallId, + "Search", + new Dictionary + { + ["searchPhrase"] = request.LastUserText ?? string.Empty, + }); + + private static string GetCitation(FunctionResultContent searchResult, string? searchPhrase) + { + IEnumerable results = searchResult.Result switch + { + IEnumerable result => result, + JsonElement { ValueKind: JsonValueKind.String } result when result.GetString() is { } resultText => [resultText], + JsonElement { ValueKind: JsonValueKind.Array } result => result + .EnumerateArray() + .Where(static element => element.ValueKind is JsonValueKind.String) + .Select(static element => element.GetString()!), + _ => [], + }; + + foreach (string result in results) + { + try + { + XElement element = XElement.Parse(result); + if (element.Name.LocalName != "result" || + element.Attribute("filename")?.Value is not { Length: > 0 } filename) + { + continue; + } + + string quote = GetQuote(element.Value, searchPhrase); + if (quote.Length > 0) + { + return Citation(filename, quote); + } + } + catch (XmlException) + { + // Ignore malformed search results. + } + } + + return string.Empty; + } + + private static string GetQuote(string text, string? searchPhrase) + { + string[] words = text.Split((char[]?)null, StringSplitOptions.RemoveEmptyEntries); + if (words.Length == 0) + { + return string.Empty; + } + + int start = 0; + if (!string.IsNullOrWhiteSpace(searchPhrase)) + { + int fewestOccurrences = int.MaxValue; + foreach (string queryWord in searchPhrase.Split((char[]?)null, StringSplitOptions.RemoveEmptyEntries)) + { + string term = NormalizeWord(queryWord); + if (term.Length < 4) + { + continue; + } + + int occurrences = 0; + int firstMatch = -1; + for (int i = 0; i < words.Length; i++) + { + if (IsTermMatch(NormalizeWord(words[i]), term)) + { + occurrences++; + firstMatch = firstMatch < 0 ? i : firstMatch; + } + } + + if (occurrences > 0 && occurrences < fewestOccurrences) + { + fewestOccurrences = occurrences; + start = firstMatch; + } + } + } + + return string.Join(" ", words, start, Math.Min(5, words.Length - start)); + } + + private static bool IsTermMatch(string word, string term) => + word.Length > 0 && (word.Contains(term, StringComparison.Ordinal) || term.Contains(word, StringComparison.Ordinal)); + + private static string NormalizeWord(string value) => + string.Concat(value.Where(static character => char.IsLetterOrDigit(character)).Select(static character => char.ToLowerInvariant(character))); + + private static bool TryGetFunctionResult( + MockChatClientRequest request, + string callId, + out FunctionResultContent? functionResult) + { + for (int i = request.Messages.Count - 1; i >= 0; i--) + { + ChatMessage message = request.Messages[i]; + if (message.Role == ChatRole.User) + { + break; + } + + foreach (AIContent content in message.Contents) + { + if (content is FunctionResultContent result && result.CallId == callId) + { + functionResult = result; + return true; + } + } + } + + functionResult = null; + return false; + } + + private static int GetDelay(int? minDelay, int? maxDelay) => + (minDelay, maxDelay) switch + { + (null, null) => 0, + ({ } min, null) => min, + (null, { } max) => Random.Shared.Next(0, max), + ({ } min, { } max) => Random.Shared.Next(min, max), + }; + + private static void ValidateDelayRange(int? minDelay, int? maxDelay) + { + if (minDelay is { } minimumDelayMilliseconds) + { + ArgumentOutOfRangeException.ThrowIfLessThan(minimumDelayMilliseconds, 0, nameof(minDelay)); + } + + if (maxDelay is { } maximumDelayMilliseconds) + { + ArgumentOutOfRangeException.ThrowIfLessThan(maximumDelayMilliseconds, 0, nameof(maxDelay)); + + if (minDelay is { } minimumDelayMillisecondsForMaximum) + { + ArgumentOutOfRangeException.ThrowIfLessThan(maximumDelayMilliseconds, minimumDelayMillisecondsForMaximum, nameof(maxDelay)); + } + } + } +} +#pragma warning restore MEAI001 diff --git a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.A_mock.verified/aichatweb/aichatweb.Web/Services/MockServices.cs b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.A_mock.verified/aichatweb/aichatweb.Web/Services/MockServices.cs new file mode 100644 index 00000000000..a9b682a296d --- /dev/null +++ b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.A_mock.verified/aichatweb/aichatweb.Web/Services/MockServices.cs @@ -0,0 +1,161 @@ +using Microsoft.Extensions.AI; + +namespace aichatweb.Web.Services; + +#pragma warning disable MEAI001 // Mock provider uses experimental testing APIs + +internal static class MockServices +{ + internal static MockChatClient CreateChatClient() => + new MockChatClient() + .AddResponse( + static _ => true, + "Hi! I can help you plan an outdoor trip or find the right gear for it. What would you like to know?", + minDelay: 500, + maxDelay: 500, + suggestions: ["What kind of adventures?", "What kinds of products?"]) + .AddResponse( + static request => LastQuestionContains(request, "what kind of adventures", "outdoor activities"), + "Mostly off-grid trips: hiking, backpacking, trail running, and mountaineering are the classics, and plenty of people use the same gear for camping and long day hikes. Anywhere you lose cell service, the right gear makes the trip safer and easier.", + minDelay: 750, + maxDelay: 1500, + suggestions: ["Tell me about hiking adventures.", "Tell me about trail safety."]) + .AddResponse( + static request => LastQuestionContains(request, "what kinds of products"), + "Two main categories: adventure GPS watches for navigation and tracking, and emergency survival kits for backcountry safety. The GPS watch is the flagship, though a lot of people carry both. Want to dig into either one?", + minDelay: 750, + maxDelay: 1500, + suggestions: ["Tell me about GPS watches.", "Tell me about survival kits."]) + .AddResponse( + static request => LastQuestionContains(request, "hiking adventures", "hiking"), + "Hiking is right in the TrailMaster GPS Watch's wheelhouse. It's made for hikers, backpackers, and trail runners heading into remote terrain, with offline topographic maps and real-time location sharing to keep you on route and reachable.", + minDelay: 1000, + maxDelay: 3000, + suggestions: ["What does TrailMaster track?", "How rugged is TrailMaster?", "How does location sharing work?"], + includeCitation: true) + .AddResponse( + static request => LastQuestionContains(request, "gps watches", "gps watch"), + "The TrailMaster GPS Watch pairs precise positioning with a rugged build. It runs a multi-constellation receiver for GPS, GLONASS, and Galileo, plus an altimeter, barometer, and compass, so navigation holds up even where satellite coverage is thin.", + minDelay: 1000, + maxDelay: 3000, + suggestions: ["What does TrailMaster track?", "What navigation tools are built in?", "How rugged is TrailMaster?"], + includeCitation: true) + .AddResponse( + static request => LastQuestionContains(request, "trail safety", "safety"), + "Trail safety really comes down to preparation. Before heading out, make sure the watch is charged and calibrated and that you know its controls, then use real-time location sharing so someone always knows where you are. For longer trips, an emergency survival kit is a smart backup.", + minDelay: 1000, + maxDelay: 3000, + suggestions: ["How does location sharing work?", "How do I fix GPS signal loss?", "Tell me about survival kits."], + includeCitation: true) + .AddResponse( + static request => LastQuestionContains(request, "survival kits", "survival kit", "emergency kit"), + "The Life Guard X Emergency Survival Kit is built for when a trip goes sideways. It packs first aid supplies, high-calorie emergency food, a water purification system, an emergency shelter, and signaling tools so you can stay safe and reach help.", + minDelay: 1000, + maxDelay: 3000, + suggestions: ["What's in the first aid supplies?", "How does water purification work?", "How do I signal for help?"], + includeCitation: true) + .AddResponse( + static request => LastQuestionContains(request, "what does trailmaster track", "what does it track", "performance metrics", "heart rate", "elevation gain"), + "It tracks the numbers that matter for training and pacing: distance traveled, speed, elevation gain, and heart rate. You can watch them live in tracking mode or review them after the activity.", + minDelay: 1500, + maxDelay: 7500, + suggestions: [], + includeCitation: true) + .AddResponse( + static request => LastQuestionContains(request, "how rugged", "rugged", "durable", "shock-resistant", "harsh conditions"), + "It's built for abuse. The casing is durable and shock-resistant, and the reinforced strap and rugged design shrug off extreme temperatures, water, and impact out in the field.", + minDelay: 1500, + maxDelay: 7500, + suggestions: [], + includeCitation: true) + .AddResponse( + static request => LastQuestionContains(request, "location sharing", "share location", "share my location"), + "Press the share button and pick the contacts you want to reach, and the watch sends your position to them in real time. It needs a stable GPS signal and your phone connected through the TrailMaster app.", + minDelay: 1500, + maxDelay: 7500, + suggestions: [], + includeCitation: true) + .AddResponse( + static request => LastQuestionContains(request, "navigation tools", "route planning", "waypoint", "trail tracking"), + "Plenty to work with: preloaded topographic maps, trail tracking, waypoint management, and route planning, backed by an onboard compass, altimeter, and barometer for orientation and weather.", + minDelay: 1500, + maxDelay: 7500, + suggestions: [], + includeCitation: true) + .AddResponse( + static request => LastQuestionContains(request, "gps signal loss", "no gps signal", "lost gps", "gps troubleshooting"), + "If it shows \"No GPS Signal,\" start with your surroundings: dense foliage, buildings, or terrain can block satellites. Check for RF interference, run a signal diagnostics test, and if it keeps happening, reach out to ExpeditionTech support.", + minDelay: 1500, + maxDelay: 7500, + suggestions: [], + includeCitation: true) + .AddResponse( + static request => LastQuestionContains(request, "bluetooth", "cannot connect", "connection problem", "connectivity issues"), + "For pairing trouble, keep the watch within Bluetooth range, clear out nearby sources of interference, and update to the latest firmware. If it still won't connect, a Bluetooth signal analyzer can help track down the cause.", + minDelay: 1500, + maxDelay: 7500, + suggestions: [], + includeCitation: true) + .AddResponse( + static request => LastQuestionContains(request, "first aid"), + "The first aid supplies cover the basics for minor injuries: adhesive bandages, sterile gauze and tape, antiseptic wipes and antibiotic ointment, over-the-counter medications, and tools like tweezers, scissors, and a thermometer. A first aid guide is included too.", + minDelay: 1500, + maxDelay: 7500, + suggestions: [], + includeCitation: true) + .AddResponse( + static request => LastQuestionContains(request, "water purification", "water filter", "purify water", "clean water"), + "It comes with a portable water filter that removes 99.9999% of waterborne bacteria and 99.9% of protozoan parasites, so you can drink from rivers and lakes. Purification tablets are included too for an extra layer of protection.", + minDelay: 1500, + maxDelay: 7500, + suggestions: [], + includeCitation: true) + .AddResponse( + static request => LastQuestionContains(request, "signal for help", "signal mirror", "two-way radio", "signaling"), + "For signaling, the kit includes a whistle for short range, a signal mirror that reflects sunlight to catch a rescuer's eye, and a weather-resistant two-way radio with up to 20 miles of range to reach your group or emergency services.", + minDelay: 1500, + maxDelay: 7500, + suggestions: [], + includeCitation: true); + + internal static IEmbeddingGenerator> CreateEmbeddingGenerator() => + new LexicalMockEmbeddingGenerator(IngestedChunk.VectorDimensions); + + private static bool LastQuestionContains(MockChatClientRequest request, params string[] terms) + { + string? question = GetLastQuestion(request); + if (string.IsNullOrWhiteSpace(question)) + { + return false; + } + + foreach (string term in terms) + { + if (question.Contains(term, StringComparison.OrdinalIgnoreCase)) + { + return true; + } + } + + return false; + } + + private static string? GetLastQuestion(MockChatClientRequest request) + { + int startIndex = request.Options?.ResponseFormat is ChatResponseFormatJson + ? request.Messages.Count - 2 + : request.Messages.Count - 1; + + for (int i = startIndex; i >= 0; i--) + { + ChatMessage message = request.Messages[i]; + if (message.Role == ChatRole.User && !string.IsNullOrWhiteSpace(message.Text)) + { + return message.Text; + } + } + + return null; + } +} +#pragma warning restore MEAI001 diff --git a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.A.verified/aichatweb/aichatweb.Web/Services/SemanticSearch.cs b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.A_mock.verified/aichatweb/aichatweb.Web/Services/SemanticSearch.cs similarity index 100% rename from test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.A.verified/aichatweb/aichatweb.Web/Services/SemanticSearch.cs rename to test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.A_mock.verified/aichatweb/aichatweb.Web/Services/SemanticSearch.cs diff --git a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.A.verified/aichatweb/aichatweb.Web/aichatweb.Web.csproj b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.A_mock.verified/aichatweb/aichatweb.Web/aichatweb.Web.csproj similarity index 86% rename from test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.A.verified/aichatweb/aichatweb.Web/aichatweb.Web.csproj rename to test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.A_mock.verified/aichatweb/aichatweb.Web/aichatweb.Web.csproj index 3325bff88d9..1d3b3e6cf38 100644 --- a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.A.verified/aichatweb/aichatweb.Web/aichatweb.Web.csproj +++ b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.A_mock.verified/aichatweb/aichatweb.Web/aichatweb.Web.csproj @@ -8,8 +8,7 @@ - - + diff --git a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.A.verified/aichatweb/aichatweb.Web/appsettings.Development.json b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.A_mock.verified/aichatweb/aichatweb.Web/appsettings.Development.json similarity index 100% rename from test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.A.verified/aichatweb/aichatweb.Web/appsettings.Development.json rename to test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.A_mock.verified/aichatweb/aichatweb.Web/appsettings.Development.json diff --git a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.A.verified/aichatweb/aichatweb.Web/appsettings.json b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.A_mock.verified/aichatweb/aichatweb.Web/appsettings.json similarity index 100% rename from test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.A.verified/aichatweb/aichatweb.Web/appsettings.json rename to test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.A_mock.verified/aichatweb/aichatweb.Web/appsettings.json diff --git a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.A.verified/aichatweb/aichatweb.sln b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.A_mock.verified/aichatweb/aichatweb.sln similarity index 100% rename from test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.A.verified/aichatweb/aichatweb.sln rename to test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.A_mock.verified/aichatweb/aichatweb.sln diff --git a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb._defaults.verified/aichatweb/Components/Layout/SurveyPrompt.razor b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb._defaults.verified/aichatweb/Components/Layout/SurveyPrompt.razor deleted file mode 100644 index 77557f20173..00000000000 --- a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb._defaults.verified/aichatweb/Components/Layout/SurveyPrompt.razor +++ /dev/null @@ -1,13 +0,0 @@ -
- - -
- How well is this template working for you? Please take a - brief survey - and tell us what you think. -
-
diff --git a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb._defaults.verified/aichatweb/Components/Layout/SurveyPrompt.razor.css b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb._defaults.verified/aichatweb/Components/Layout/SurveyPrompt.razor.css deleted file mode 100644 index c939b902afb..00000000000 --- a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb._defaults.verified/aichatweb/Components/Layout/SurveyPrompt.razor.css +++ /dev/null @@ -1,20 +0,0 @@ -.surveyContainer { - display: flex; - justify-content: center; - gap: 0.5rem; - font-size: 0.9em; - margin: 0.5rem auto -0.7rem auto; - max-width: 1024px; - color: #444; -} - - .surveyContainer a { - text-decoration: underline; - } - - .surveyContainer .tool-icon { - margin-top: 0.15rem; - width: 1.25rem; - height: 1.25rem; - flex-shrink: 0; - } diff --git a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb._defaults.verified/aichatweb/README.md b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb._defaults.verified/aichatweb/README.md deleted file mode 100644 index cf7deff5878..00000000000 --- a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb._defaults.verified/aichatweb/README.md +++ /dev/null @@ -1,19 +0,0 @@ -# AI Chat with Custom Data - -This project is an AI chat application that demonstrates how to chat with custom data using an AI language model. Please note that this template is currently in an early preview stage. If you have feedback, please take a [brief survey](https://aka.ms/dotnet-chat-templatePreview2-survey). - ->[!NOTE] -> Before running this project you need to configure the API keys or endpoints for the providers you have chosen. See below for details specific to your choices. - -# Configure the AI Model Provider -To use models hosted by GitHub Models, you will need to create a GitHub personal access token with `models:read` permissions, but no other scopes or permissions. See [Prototyping with AI models](https://docs.github.com/github-models/prototyping-with-ai-models) and [Managing your personal access tokens](https://docs.github.com/authentication/keeping-your-account-and-data-secure/managing-your-personal-access-tokens) in the GitHub Docs for more information. - -From the command line, configure your token for this project using .NET User Secrets by running the following commands: - -```sh -cd <> -dotnet user-secrets set GitHubModels:Token YOUR-TOKEN -``` - -Learn more about [prototyping with AI models using GitHub Models](https://docs.github.com/github-models/prototyping-with-ai-models). - diff --git a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb._defaults.verified/aichatweb/Components/App.razor b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.mock.verified/aichatweb/Components/App.razor similarity index 100% rename from test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb._defaults.verified/aichatweb/Components/App.razor rename to test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.mock.verified/aichatweb/Components/App.razor diff --git a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb._defaults.verified/aichatweb/Components/Layout/LoadingSpinner.razor b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.mock.verified/aichatweb/Components/Layout/LoadingSpinner.razor similarity index 100% rename from test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb._defaults.verified/aichatweb/Components/Layout/LoadingSpinner.razor rename to test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.mock.verified/aichatweb/Components/Layout/LoadingSpinner.razor diff --git a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb._defaults.verified/aichatweb/Components/Layout/LoadingSpinner.razor.css b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.mock.verified/aichatweb/Components/Layout/LoadingSpinner.razor.css similarity index 100% rename from test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb._defaults.verified/aichatweb/Components/Layout/LoadingSpinner.razor.css rename to test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.mock.verified/aichatweb/Components/Layout/LoadingSpinner.razor.css diff --git a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb._defaults.verified/aichatweb/Components/Layout/MainLayout.razor b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.mock.verified/aichatweb/Components/Layout/MainLayout.razor similarity index 100% rename from test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb._defaults.verified/aichatweb/Components/Layout/MainLayout.razor rename to test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.mock.verified/aichatweb/Components/Layout/MainLayout.razor diff --git a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb._defaults.verified/aichatweb/Components/Layout/MainLayout.razor.css b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.mock.verified/aichatweb/Components/Layout/MainLayout.razor.css similarity index 100% rename from test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb._defaults.verified/aichatweb/Components/Layout/MainLayout.razor.css rename to test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.mock.verified/aichatweb/Components/Layout/MainLayout.razor.css diff --git a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.A.verified/aichatweb/aichatweb.Web/Components/Pages/Chat/Chat.razor b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.mock.verified/aichatweb/Components/Pages/Chat/Chat.razor similarity index 96% rename from test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.A.verified/aichatweb/aichatweb.Web/Components/Pages/Chat/Chat.razor rename to test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.mock.verified/aichatweb/Components/Pages/Chat/Chat.razor index 6fc5881c18f..9d33e3ff5ed 100644 --- a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.A.verified/aichatweb/aichatweb.Web/Components/Pages/Chat/Chat.razor +++ b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.mock.verified/aichatweb/Components/Pages/Chat/Chat.razor @@ -1,5 +1,6 @@ @page "/" @using System.ComponentModel +@using System.Xml.Linq @inject IChatClient ChatClient @inject NavigationManager Nav @inject SemanticSearch Search @@ -20,7 +21,6 @@
- @* Remove this line to eliminate the template survey message *@
@code { @@ -126,7 +126,10 @@ await InvokeAsync(StateHasChanged); var results = await Search.SearchAsync(searchPhrase, filenameFilter, maxResults: 5); return results.Select(result => - $"{result.Text}"); + new XElement( + "result", + new XAttribute("filename", result.DocumentId), + result.Text).ToString(SaveOptions.DisableFormatting)); } public void Dispose() diff --git a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb._defaults.verified/aichatweb/Components/Pages/Chat/Chat.razor.css b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.mock.verified/aichatweb/Components/Pages/Chat/Chat.razor.css similarity index 100% rename from test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb._defaults.verified/aichatweb/Components/Pages/Chat/Chat.razor.css rename to test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.mock.verified/aichatweb/Components/Pages/Chat/Chat.razor.css diff --git a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb._defaults.verified/aichatweb/Components/Pages/Chat/ChatCitation.razor b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.mock.verified/aichatweb/Components/Pages/Chat/ChatCitation.razor similarity index 100% rename from test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb._defaults.verified/aichatweb/Components/Pages/Chat/ChatCitation.razor rename to test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.mock.verified/aichatweb/Components/Pages/Chat/ChatCitation.razor diff --git a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb._defaults.verified/aichatweb/Components/Pages/Chat/ChatCitation.razor.css b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.mock.verified/aichatweb/Components/Pages/Chat/ChatCitation.razor.css similarity index 100% rename from test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb._defaults.verified/aichatweb/Components/Pages/Chat/ChatCitation.razor.css rename to test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.mock.verified/aichatweb/Components/Pages/Chat/ChatCitation.razor.css diff --git a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb._defaults.verified/aichatweb/Components/Pages/Chat/ChatHeader.razor b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.mock.verified/aichatweb/Components/Pages/Chat/ChatHeader.razor similarity index 100% rename from test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb._defaults.verified/aichatweb/Components/Pages/Chat/ChatHeader.razor rename to test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.mock.verified/aichatweb/Components/Pages/Chat/ChatHeader.razor diff --git a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb._defaults.verified/aichatweb/Components/Pages/Chat/ChatHeader.razor.css b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.mock.verified/aichatweb/Components/Pages/Chat/ChatHeader.razor.css similarity index 100% rename from test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb._defaults.verified/aichatweb/Components/Pages/Chat/ChatHeader.razor.css rename to test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.mock.verified/aichatweb/Components/Pages/Chat/ChatHeader.razor.css diff --git a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb._defaults.verified/aichatweb/Components/Pages/Chat/ChatInput.razor b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.mock.verified/aichatweb/Components/Pages/Chat/ChatInput.razor similarity index 100% rename from test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb._defaults.verified/aichatweb/Components/Pages/Chat/ChatInput.razor rename to test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.mock.verified/aichatweb/Components/Pages/Chat/ChatInput.razor diff --git a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb._defaults.verified/aichatweb/Components/Pages/Chat/ChatInput.razor.css b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.mock.verified/aichatweb/Components/Pages/Chat/ChatInput.razor.css similarity index 100% rename from test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb._defaults.verified/aichatweb/Components/Pages/Chat/ChatInput.razor.css rename to test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.mock.verified/aichatweb/Components/Pages/Chat/ChatInput.razor.css diff --git a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb._defaults.verified/aichatweb/Components/Pages/Chat/ChatInput.razor.js b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.mock.verified/aichatweb/Components/Pages/Chat/ChatInput.razor.js similarity index 100% rename from test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb._defaults.verified/aichatweb/Components/Pages/Chat/ChatInput.razor.js rename to test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.mock.verified/aichatweb/Components/Pages/Chat/ChatInput.razor.js diff --git a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.A.verified/aichatweb/aichatweb.Web/Components/Pages/Chat/ChatMessageItem.razor b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.mock.verified/aichatweb/Components/Pages/Chat/ChatMessageItem.razor similarity index 84% rename from test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.A.verified/aichatweb/aichatweb.Web/Components/Pages/Chat/ChatMessageItem.razor rename to test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.mock.verified/aichatweb/Components/Pages/Chat/ChatMessageItem.razor index e45d92ab5f9..33367fa3974 100644 --- a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.A.verified/aichatweb/aichatweb.Web/Components/Pages/Chat/ChatMessageItem.razor +++ b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.mock.verified/aichatweb/Components/Pages/Chat/ChatMessageItem.razor @@ -1,6 +1,7 @@ @using System.Runtime.CompilerServices @using System.Text.RegularExpressions -@using System.Linq +@using System.Xml +@using System.Xml.Linq @if (Message.Role == ChatRole.User) { @@ -69,7 +70,7 @@ else if (Message.Role == ChatRole.Assistant) @code { private static readonly ConditionalWeakTable SubscribersLookup = new(); - private static readonly Regex CitationRegex = new(@"(?.*?)", RegexOptions.NonBacktracking); + private static readonly Regex CitationRegex = new(@"]*>.*?", RegexOptions.NonBacktracking | RegexOptions.Singleline); private List<(string File, string Quote)>? citations; @@ -99,9 +100,24 @@ else if (Message.Role == ChatRole.Assistant) private void ParseCitations(string text) { - var matches = CitationRegex.Matches(text); - citations = matches.Any() - ? matches.Select(m => (m.Groups["file"].Value, m.Groups["quote"].Value)).ToList() - : null; + var parsedCitations = new List<(string File, string Quote)>(); + foreach (Match match in CitationRegex.Matches(text)) + { + try + { + XElement citation = XElement.Parse(match.Value); + if (citation.Name.LocalName == "citation" && + citation.Attribute("filename")?.Value is { Length: > 0 } file) + { + parsedCitations.Add((file, citation.Value)); + } + } + catch (XmlException) + { + // Ignore malformed citation markup returned by the model. + } + } + + citations = parsedCitations.Count > 0 ? parsedCitations : null; } } diff --git a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb._defaults.verified/aichatweb/Components/Pages/Chat/ChatMessageItem.razor.css b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.mock.verified/aichatweb/Components/Pages/Chat/ChatMessageItem.razor.css similarity index 100% rename from test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb._defaults.verified/aichatweb/Components/Pages/Chat/ChatMessageItem.razor.css rename to test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.mock.verified/aichatweb/Components/Pages/Chat/ChatMessageItem.razor.css diff --git a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb._defaults.verified/aichatweb/Components/Pages/Chat/ChatMessageList.razor b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.mock.verified/aichatweb/Components/Pages/Chat/ChatMessageList.razor similarity index 100% rename from test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb._defaults.verified/aichatweb/Components/Pages/Chat/ChatMessageList.razor rename to test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.mock.verified/aichatweb/Components/Pages/Chat/ChatMessageList.razor diff --git a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb._defaults.verified/aichatweb/Components/Pages/Chat/ChatMessageList.razor.css b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.mock.verified/aichatweb/Components/Pages/Chat/ChatMessageList.razor.css similarity index 100% rename from test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb._defaults.verified/aichatweb/Components/Pages/Chat/ChatMessageList.razor.css rename to test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.mock.verified/aichatweb/Components/Pages/Chat/ChatMessageList.razor.css diff --git a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb._defaults.verified/aichatweb/Components/Pages/Chat/ChatMessageList.razor.js b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.mock.verified/aichatweb/Components/Pages/Chat/ChatMessageList.razor.js similarity index 100% rename from test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb._defaults.verified/aichatweb/Components/Pages/Chat/ChatMessageList.razor.js rename to test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.mock.verified/aichatweb/Components/Pages/Chat/ChatMessageList.razor.js diff --git a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb._defaults.verified/aichatweb/Components/Pages/Chat/ChatSuggestions.razor b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.mock.verified/aichatweb/Components/Pages/Chat/ChatSuggestions.razor similarity index 100% rename from test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb._defaults.verified/aichatweb/Components/Pages/Chat/ChatSuggestions.razor rename to test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.mock.verified/aichatweb/Components/Pages/Chat/ChatSuggestions.razor diff --git a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb._defaults.verified/aichatweb/Components/Pages/Chat/ChatSuggestions.razor.css b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.mock.verified/aichatweb/Components/Pages/Chat/ChatSuggestions.razor.css similarity index 100% rename from test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb._defaults.verified/aichatweb/Components/Pages/Chat/ChatSuggestions.razor.css rename to test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.mock.verified/aichatweb/Components/Pages/Chat/ChatSuggestions.razor.css diff --git a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb._defaults.verified/aichatweb/Components/Pages/Error.razor b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.mock.verified/aichatweb/Components/Pages/Error.razor similarity index 100% rename from test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb._defaults.verified/aichatweb/Components/Pages/Error.razor rename to test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.mock.verified/aichatweb/Components/Pages/Error.razor diff --git a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb._defaults.verified/aichatweb/Components/Routes.razor b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.mock.verified/aichatweb/Components/Routes.razor similarity index 100% rename from test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb._defaults.verified/aichatweb/Components/Routes.razor rename to test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.mock.verified/aichatweb/Components/Routes.razor diff --git a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb._defaults.verified/aichatweb/Components/_Imports.razor b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.mock.verified/aichatweb/Components/_Imports.razor similarity index 100% rename from test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb._defaults.verified/aichatweb/Components/_Imports.razor rename to test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.mock.verified/aichatweb/Components/_Imports.razor diff --git a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.mock.verified/aichatweb/Program.cs b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.mock.verified/aichatweb/Program.cs new file mode 100644 index 00000000000..91bc130378b --- /dev/null +++ b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.mock.verified/aichatweb/Program.cs @@ -0,0 +1,41 @@ +using Microsoft.Extensions.AI; +using aichatweb.Components; +using aichatweb.Services; +using aichatweb.Services.Ingestion; + +var builder = WebApplication.CreateBuilder(args); +builder.Services.AddRazorComponents().AddInteractiveServerComponents(); + +var chatClient = MockServices.CreateChatClient(); +IEmbeddingGenerator> embeddingGenerator = MockServices.CreateEmbeddingGenerator(); + + +var vectorStorePath = Path.Combine(AppContext.BaseDirectory, "vector-store.db"); +var vectorStoreConnectionString = $"Data Source={vectorStorePath}"; +builder.Services.AddSqliteVectorStore(_ => vectorStoreConnectionString); +builder.Services.AddSqliteCollection(IngestedChunk.CollectionName, vectorStoreConnectionString); + +builder.Services.AddSingleton(); +builder.Services.AddSingleton(); +builder.Services.AddKeyedSingleton("ingestion_directory", new DirectoryInfo(Path.Combine(builder.Environment.WebRootPath, "Data"))); +builder.Services.AddChatClient(chatClient).UseFunctionInvocation().UseLogging(); +builder.Services.AddEmbeddingGenerator(embeddingGenerator); + +var app = builder.Build(); + +// Configure the HTTP request pipeline. +if (!app.Environment.IsDevelopment()) +{ + app.UseExceptionHandler("/Error", createScopeForErrors: true); + // The default HSTS value is 30 days. You may want to change this for production scenarios, see https://aka.ms/aspnetcore-hsts. + app.UseHsts(); +} + +app.UseHttpsRedirection(); +app.UseAntiforgery(); + +app.UseStaticFiles(); +app.MapRazorComponents() + .AddInteractiveServerRenderMode(); + +app.Run(); diff --git a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb._defaults.verified/aichatweb/Properties/launchSettings.json b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.mock.verified/aichatweb/Properties/launchSettings.json similarity index 100% rename from test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb._defaults.verified/aichatweb/Properties/launchSettings.json rename to test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.mock.verified/aichatweb/Properties/launchSettings.json diff --git a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.mock.verified/aichatweb/README.md b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.mock.verified/aichatweb/README.md new file mode 100644 index 00000000000..cc993cc5d58 --- /dev/null +++ b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.mock.verified/aichatweb/README.md @@ -0,0 +1,18 @@ +# AI Chat with Custom Data + +This project is an AI chat application that demonstrates how to chat with custom data using an AI language model. Please note that this template is currently in an early preview stage. If you have feedback, please take a [brief survey](https://aka.ms/dotnet-chat-templatePreview2-survey). + + +# Configure the AI Model Provider +## Using Mock provider + +The mock provider runs fully local with deterministic canned responses, so it requires no API keys, cloud credentials, or model downloads. + +It is pre-seeded with roughly 20 TrailMaster GPS Watch responses (plus follow-up suggestions) that map to the sample documents under `wwwroot/Data`. + +Good starter prompts: + +- What does TrailMaster track? +- How rugged is TrailMaster? +- How does location sharing work? +- Why should I buy this watch? diff --git a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb._defaults.verified/aichatweb/Services/IngestedChunk.cs b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.mock.verified/aichatweb/Services/IngestedChunk.cs similarity index 95% rename from test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb._defaults.verified/aichatweb/Services/IngestedChunk.cs rename to test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.mock.verified/aichatweb/Services/IngestedChunk.cs index 68af3ef20fb..a7136d1988d 100644 --- a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb._defaults.verified/aichatweb/Services/IngestedChunk.cs +++ b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.mock.verified/aichatweb/Services/IngestedChunk.cs @@ -11,7 +11,7 @@ public class IngestedChunk [VectorStoreKey(StorageName = "key")] [JsonPropertyName("key")] - public required Guid Key { get; set; } + public required string Key { get; set; } [VectorStoreData(StorageName = "documentid")] [JsonPropertyName("documentid")] diff --git a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb._defaults.verified/aichatweb/Services/Ingestion/DataIngestor.cs b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.mock.verified/aichatweb/Services/Ingestion/DataIngestor.cs similarity index 100% rename from test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb._defaults.verified/aichatweb/Services/Ingestion/DataIngestor.cs rename to test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.mock.verified/aichatweb/Services/Ingestion/DataIngestor.cs diff --git a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb._defaults.verified/aichatweb/Services/Ingestion/DocumentReader.cs b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.mock.verified/aichatweb/Services/Ingestion/DocumentReader.cs similarity index 100% rename from test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb._defaults.verified/aichatweb/Services/Ingestion/DocumentReader.cs rename to test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.mock.verified/aichatweb/Services/Ingestion/DocumentReader.cs diff --git a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb._defaults.verified/aichatweb/Services/Ingestion/PdfPigReader.cs b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.mock.verified/aichatweb/Services/Ingestion/PdfPigReader.cs similarity index 100% rename from test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb._defaults.verified/aichatweb/Services/Ingestion/PdfPigReader.cs rename to test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.mock.verified/aichatweb/Services/Ingestion/PdfPigReader.cs diff --git a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.mock.verified/aichatweb/Services/LexicalMockEmbeddingGenerator.cs b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.mock.verified/aichatweb/Services/LexicalMockEmbeddingGenerator.cs new file mode 100644 index 00000000000..c3e411aa47c --- /dev/null +++ b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.mock.verified/aichatweb/Services/LexicalMockEmbeddingGenerator.cs @@ -0,0 +1,107 @@ +using System; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Extensions.AI; + +namespace aichatweb.Services; + +#pragma warning disable MEAI001 // Mock provider uses experimental testing APIs + +internal sealed class LexicalMockEmbeddingGenerator : MockEmbeddingGenerator +{ + private readonly int _dimensions; + + internal LexicalMockEmbeddingGenerator(int dimensions) + { + ArgumentOutOfRangeException.ThrowIfLessThan(dimensions, 1); + _dimensions = dimensions; + } + + protected override Task>> GenerateCoreAsync( + IEnumerable values, + EmbeddingGenerationOptions? options, + CancellationToken cancellationToken) + { + if (GenerateAsyncCallback is not null) + { + return base.GenerateCoreAsync(values, options, cancellationToken); + } + + GeneratedEmbeddings> embeddings = []; + foreach (string value in values) + { + cancellationToken.ThrowIfCancellationRequested(); + embeddings.Add(new Embedding(CreateVector(value, _dimensions))); + } + + return Task.FromResult(embeddings); + } + + private static float[] CreateVector(string? value, int dimensions) + { + var vector = new float[dimensions]; + if (string.IsNullOrEmpty(value)) + { + return vector; + } + + // Hash tokens and three-character shingles so shared words and word fragments have matching dimensions. + int tokenStart = -1; + for (int i = 0; i <= value.Length; i++) + { + if (i < value.Length && char.IsLetterOrDigit(value[i])) + { + tokenStart = tokenStart < 0 ? i : tokenStart; + } + else if (tokenStart >= 0) + { + AddTokenFeatures(value, tokenStart, i - tokenStart, vector); + tokenStart = -1; + } + } + + // Normalize to unit length so cosine similarity favors lexical overlap rather than document length. + float squaredMagnitude = 0; + foreach (float component in vector) + { + squaredMagnitude += component * component; + } + + if (squaredMagnitude > 0) + { + float scale = 1 / (float)Math.Sqrt(squaredMagnitude); + for (int i = 0; i < vector.Length; i++) + { + vector[i] *= scale; + } + } + + return vector; + } + + private static void AddTokenFeatures(string value, int tokenStart, int tokenLength, float[] vector) + { + AddFeature(value, tokenStart, tokenLength, vector); + + for (int i = tokenStart; i <= tokenStart + tokenLength - 3; i++) + { + AddFeature(value, i, 3, vector); + } + } + + private static void AddFeature(string value, int start, int length, float[] vector) + { + uint hash = 2_166_136_261; + unchecked + { + for (int i = start; i < start + length; i++) + { + hash = (hash ^ char.ToLowerInvariant(value[i])) * 16_777_619; + } + } + + vector[(int)(hash % (uint)vector.Length)] += 1; + } +} +#pragma warning restore MEAI001 diff --git a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.mock.verified/aichatweb/Services/MockChatClientExtensions.cs b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.mock.verified/aichatweb/Services/MockChatClientExtensions.cs new file mode 100644 index 00000000000..56aa94a3735 --- /dev/null +++ b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.mock.verified/aichatweb/Services/MockChatClientExtensions.cs @@ -0,0 +1,297 @@ +using System.Linq; +using System.Text.Json; +using System.Xml; +using System.Xml.Linq; +using Microsoft.Extensions.AI; + +namespace aichatweb.Services; + +#pragma warning disable MEAI001 // Mock provider uses experimental testing APIs + +internal static class MockChatClientExtensions +{ + private const string LoadDocumentsCallId = "mock-load-documents"; + private const string SearchCallId = "mock-search"; + private const string MockConversationId = "mock-conversation"; + + /// Adds a canned response for matching requests. + /// The mock chat client to configure. + /// Predicate used to select whether the response applies to a request. + /// The assistant response text. + /// + /// The optional minimum simulated response delay in milliseconds. When supplied alone, it is the fixed delay. + /// + /// + /// The optional maximum simulated response delay in milliseconds. When supplied alone, a delay from zero up to this + /// value is selected. + /// + /// + /// Optional aichatweb follow-up suggestions returned through its structured-output request. Supply an empty + /// collection when the response is a conversation dead end. + /// + /// + /// to invoke the document-search tools and append a citation from the search result. + /// + /// + /// to remove the response seed after its first matching request; otherwise it remains reusable. + /// + /// The configured . + internal static MockChatClient AddResponse( + this MockChatClient client, + Func requestPredicate, + string response, + int? minDelay = null, + int? maxDelay = null, + IEnumerable? suggestions = null, + bool includeCitation = false, + bool singleUse = false) + { + ArgumentNullException.ThrowIfNull(client); + ArgumentNullException.ThrowIfNull(requestPredicate); + ArgumentNullException.ThrowIfNull(response); + ValidateDelayRange(minDelay, maxDelay); + + if (suggestions is null) + { + return AddResponse(client, requestPredicate, response, minDelay, maxDelay, includeCitation, singleUse); + } + + string structuredResponse = JsonSerializer.Serialize(new { data = suggestions.ToArray() }); + + MockChatClient configuredClient = AddResponse( + client, + request => request.Options?.ResponseFormat is not ChatResponseFormatJson && requestPredicate(request), + response, + minDelay, + maxDelay, + includeCitation, + singleUse); + + return AddResponse( + configuredClient, + request => request.Options?.ResponseFormat is ChatResponseFormatJson && requestPredicate(request), + structuredResponse, + minDelay, + maxDelay, + includeCitation, + singleUse); + } + + internal static string Citation(string filename, string quote) + { + ArgumentNullException.ThrowIfNull(filename); + ArgumentNullException.ThrowIfNull(quote); + + return new XElement( + "citation", + new XAttribute("filename", filename), + quote).ToString(SaveOptions.DisableFormatting); + } + + private static MockChatClient AddResponse( + MockChatClient client, + Func requestPredicate, + string response, + int? minDelay, + int? maxDelay, + bool includeCitation, + bool singleUse) => + client.AddResponse( + requestPredicate, + async (request, cancellationToken) => + { + await Task.Delay(GetDelay(minDelay, maxDelay), cancellationToken); + return CreateResponse(request, response, includeCitation); + }, + singleUse); + + private static ChatResponse CreateResponse(MockChatClientRequest request, string response, bool includeCitation) + { + if (request.Options?.ResponseFormat is ChatResponseFormatJson) + { + return new ChatResponse(new ChatMessage(ChatRole.Assistant, response)); + } + + if (TryGetFunctionResult(request, SearchCallId, out FunctionResultContent? searchResult) && searchResult is not null) + { + return CreateConversationResponse($"{response}{GetCitation(searchResult, request.LastUserText)}"); + } + + if (TryGetFunctionResult(request, LoadDocumentsCallId, out _)) + { + return CreateSearchFunctionCallResponse(request); + } + + if (includeCitation) + { + return request.Options?.ConversationId is null + ? CreateFunctionCallResponse(LoadDocumentsCallId, "LoadDocuments") + : CreateSearchFunctionCallResponse(request); + } + + return CreateConversationResponse(response); + } + + private static ChatResponse CreateConversationResponse(string response) => + new(new ChatMessage(ChatRole.Assistant, response)) + { + ConversationId = MockConversationId, + }; + + private static ChatResponse CreateFunctionCallResponse( + string callId, + string name, + IDictionary? arguments = null) => + new(new ChatMessage(ChatRole.Assistant, [new FunctionCallContent(callId, name, arguments)])); + + private static ChatResponse CreateSearchFunctionCallResponse(MockChatClientRequest request) => + CreateFunctionCallResponse( + SearchCallId, + "Search", + new Dictionary + { + ["searchPhrase"] = request.LastUserText ?? string.Empty, + }); + + private static string GetCitation(FunctionResultContent searchResult, string? searchPhrase) + { + IEnumerable results = searchResult.Result switch + { + IEnumerable result => result, + JsonElement { ValueKind: JsonValueKind.String } result when result.GetString() is { } resultText => [resultText], + JsonElement { ValueKind: JsonValueKind.Array } result => result + .EnumerateArray() + .Where(static element => element.ValueKind is JsonValueKind.String) + .Select(static element => element.GetString()!), + _ => [], + }; + + foreach (string result in results) + { + try + { + XElement element = XElement.Parse(result); + if (element.Name.LocalName != "result" || + element.Attribute("filename")?.Value is not { Length: > 0 } filename) + { + continue; + } + + string quote = GetQuote(element.Value, searchPhrase); + if (quote.Length > 0) + { + return Citation(filename, quote); + } + } + catch (XmlException) + { + // Ignore malformed search results. + } + } + + return string.Empty; + } + + private static string GetQuote(string text, string? searchPhrase) + { + string[] words = text.Split((char[]?)null, StringSplitOptions.RemoveEmptyEntries); + if (words.Length == 0) + { + return string.Empty; + } + + int start = 0; + if (!string.IsNullOrWhiteSpace(searchPhrase)) + { + int fewestOccurrences = int.MaxValue; + foreach (string queryWord in searchPhrase.Split((char[]?)null, StringSplitOptions.RemoveEmptyEntries)) + { + string term = NormalizeWord(queryWord); + if (term.Length < 4) + { + continue; + } + + int occurrences = 0; + int firstMatch = -1; + for (int i = 0; i < words.Length; i++) + { + if (IsTermMatch(NormalizeWord(words[i]), term)) + { + occurrences++; + firstMatch = firstMatch < 0 ? i : firstMatch; + } + } + + if (occurrences > 0 && occurrences < fewestOccurrences) + { + fewestOccurrences = occurrences; + start = firstMatch; + } + } + } + + return string.Join(" ", words, start, Math.Min(5, words.Length - start)); + } + + private static bool IsTermMatch(string word, string term) => + word.Length > 0 && (word.Contains(term, StringComparison.Ordinal) || term.Contains(word, StringComparison.Ordinal)); + + private static string NormalizeWord(string value) => + string.Concat(value.Where(static character => char.IsLetterOrDigit(character)).Select(static character => char.ToLowerInvariant(character))); + + private static bool TryGetFunctionResult( + MockChatClientRequest request, + string callId, + out FunctionResultContent? functionResult) + { + for (int i = request.Messages.Count - 1; i >= 0; i--) + { + ChatMessage message = request.Messages[i]; + if (message.Role == ChatRole.User) + { + break; + } + + foreach (AIContent content in message.Contents) + { + if (content is FunctionResultContent result && result.CallId == callId) + { + functionResult = result; + return true; + } + } + } + + functionResult = null; + return false; + } + + private static int GetDelay(int? minDelay, int? maxDelay) => + (minDelay, maxDelay) switch + { + (null, null) => 0, + ({ } min, null) => min, + (null, { } max) => Random.Shared.Next(0, max), + ({ } min, { } max) => Random.Shared.Next(min, max), + }; + + private static void ValidateDelayRange(int? minDelay, int? maxDelay) + { + if (minDelay is { } minimumDelayMilliseconds) + { + ArgumentOutOfRangeException.ThrowIfLessThan(minimumDelayMilliseconds, 0, nameof(minDelay)); + } + + if (maxDelay is { } maximumDelayMilliseconds) + { + ArgumentOutOfRangeException.ThrowIfLessThan(maximumDelayMilliseconds, 0, nameof(maxDelay)); + + if (minDelay is { } minimumDelayMillisecondsForMaximum) + { + ArgumentOutOfRangeException.ThrowIfLessThan(maximumDelayMilliseconds, minimumDelayMillisecondsForMaximum, nameof(maxDelay)); + } + } + } +} +#pragma warning restore MEAI001 diff --git a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.mock.verified/aichatweb/Services/MockServices.cs b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.mock.verified/aichatweb/Services/MockServices.cs new file mode 100644 index 00000000000..4c883e6865c --- /dev/null +++ b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.mock.verified/aichatweb/Services/MockServices.cs @@ -0,0 +1,161 @@ +using Microsoft.Extensions.AI; + +namespace aichatweb.Services; + +#pragma warning disable MEAI001 // Mock provider uses experimental testing APIs + +internal static class MockServices +{ + internal static MockChatClient CreateChatClient() => + new MockChatClient() + .AddResponse( + static _ => true, + "Hi! I can help you plan an outdoor trip or find the right gear for it. What would you like to know?", + minDelay: 500, + maxDelay: 500, + suggestions: ["What kind of adventures?", "What kinds of products?"]) + .AddResponse( + static request => LastQuestionContains(request, "what kind of adventures", "outdoor activities"), + "Mostly off-grid trips: hiking, backpacking, trail running, and mountaineering are the classics, and plenty of people use the same gear for camping and long day hikes. Anywhere you lose cell service, the right gear makes the trip safer and easier.", + minDelay: 750, + maxDelay: 1500, + suggestions: ["Tell me about hiking adventures.", "Tell me about trail safety."]) + .AddResponse( + static request => LastQuestionContains(request, "what kinds of products"), + "Two main categories: adventure GPS watches for navigation and tracking, and emergency survival kits for backcountry safety. The GPS watch is the flagship, though a lot of people carry both. Want to dig into either one?", + minDelay: 750, + maxDelay: 1500, + suggestions: ["Tell me about GPS watches.", "Tell me about survival kits."]) + .AddResponse( + static request => LastQuestionContains(request, "hiking adventures", "hiking"), + "Hiking is right in the TrailMaster GPS Watch's wheelhouse. It's made for hikers, backpackers, and trail runners heading into remote terrain, with offline topographic maps and real-time location sharing to keep you on route and reachable.", + minDelay: 1000, + maxDelay: 3000, + suggestions: ["What does TrailMaster track?", "How rugged is TrailMaster?", "How does location sharing work?"], + includeCitation: true) + .AddResponse( + static request => LastQuestionContains(request, "gps watches", "gps watch"), + "The TrailMaster GPS Watch pairs precise positioning with a rugged build. It runs a multi-constellation receiver for GPS, GLONASS, and Galileo, plus an altimeter, barometer, and compass, so navigation holds up even where satellite coverage is thin.", + minDelay: 1000, + maxDelay: 3000, + suggestions: ["What does TrailMaster track?", "What navigation tools are built in?", "How rugged is TrailMaster?"], + includeCitation: true) + .AddResponse( + static request => LastQuestionContains(request, "trail safety", "safety"), + "Trail safety really comes down to preparation. Before heading out, make sure the watch is charged and calibrated and that you know its controls, then use real-time location sharing so someone always knows where you are. For longer trips, an emergency survival kit is a smart backup.", + minDelay: 1000, + maxDelay: 3000, + suggestions: ["How does location sharing work?", "How do I fix GPS signal loss?", "Tell me about survival kits."], + includeCitation: true) + .AddResponse( + static request => LastQuestionContains(request, "survival kits", "survival kit", "emergency kit"), + "The Life Guard X Emergency Survival Kit is built for when a trip goes sideways. It packs first aid supplies, high-calorie emergency food, a water purification system, an emergency shelter, and signaling tools so you can stay safe and reach help.", + minDelay: 1000, + maxDelay: 3000, + suggestions: ["What's in the first aid supplies?", "How does water purification work?", "How do I signal for help?"], + includeCitation: true) + .AddResponse( + static request => LastQuestionContains(request, "what does trailmaster track", "what does it track", "performance metrics", "heart rate", "elevation gain"), + "It tracks the numbers that matter for training and pacing: distance traveled, speed, elevation gain, and heart rate. You can watch them live in tracking mode or review them after the activity.", + minDelay: 1500, + maxDelay: 7500, + suggestions: [], + includeCitation: true) + .AddResponse( + static request => LastQuestionContains(request, "how rugged", "rugged", "durable", "shock-resistant", "harsh conditions"), + "It's built for abuse. The casing is durable and shock-resistant, and the reinforced strap and rugged design shrug off extreme temperatures, water, and impact out in the field.", + minDelay: 1500, + maxDelay: 7500, + suggestions: [], + includeCitation: true) + .AddResponse( + static request => LastQuestionContains(request, "location sharing", "share location", "share my location"), + "Press the share button and pick the contacts you want to reach, and the watch sends your position to them in real time. It needs a stable GPS signal and your phone connected through the TrailMaster app.", + minDelay: 1500, + maxDelay: 7500, + suggestions: [], + includeCitation: true) + .AddResponse( + static request => LastQuestionContains(request, "navigation tools", "route planning", "waypoint", "trail tracking"), + "Plenty to work with: preloaded topographic maps, trail tracking, waypoint management, and route planning, backed by an onboard compass, altimeter, and barometer for orientation and weather.", + minDelay: 1500, + maxDelay: 7500, + suggestions: [], + includeCitation: true) + .AddResponse( + static request => LastQuestionContains(request, "gps signal loss", "no gps signal", "lost gps", "gps troubleshooting"), + "If it shows \"No GPS Signal,\" start with your surroundings: dense foliage, buildings, or terrain can block satellites. Check for RF interference, run a signal diagnostics test, and if it keeps happening, reach out to ExpeditionTech support.", + minDelay: 1500, + maxDelay: 7500, + suggestions: [], + includeCitation: true) + .AddResponse( + static request => LastQuestionContains(request, "bluetooth", "cannot connect", "connection problem", "connectivity issues"), + "For pairing trouble, keep the watch within Bluetooth range, clear out nearby sources of interference, and update to the latest firmware. If it still won't connect, a Bluetooth signal analyzer can help track down the cause.", + minDelay: 1500, + maxDelay: 7500, + suggestions: [], + includeCitation: true) + .AddResponse( + static request => LastQuestionContains(request, "first aid"), + "The first aid supplies cover the basics for minor injuries: adhesive bandages, sterile gauze and tape, antiseptic wipes and antibiotic ointment, over-the-counter medications, and tools like tweezers, scissors, and a thermometer. A first aid guide is included too.", + minDelay: 1500, + maxDelay: 7500, + suggestions: [], + includeCitation: true) + .AddResponse( + static request => LastQuestionContains(request, "water purification", "water filter", "purify water", "clean water"), + "It comes with a portable water filter that removes 99.9999% of waterborne bacteria and 99.9% of protozoan parasites, so you can drink from rivers and lakes. Purification tablets are included too for an extra layer of protection.", + minDelay: 1500, + maxDelay: 7500, + suggestions: [], + includeCitation: true) + .AddResponse( + static request => LastQuestionContains(request, "signal for help", "signal mirror", "two-way radio", "signaling"), + "For signaling, the kit includes a whistle for short range, a signal mirror that reflects sunlight to catch a rescuer's eye, and a weather-resistant two-way radio with up to 20 miles of range to reach your group or emergency services.", + minDelay: 1500, + maxDelay: 7500, + suggestions: [], + includeCitation: true); + + internal static IEmbeddingGenerator> CreateEmbeddingGenerator() => + new LexicalMockEmbeddingGenerator(IngestedChunk.VectorDimensions); + + private static bool LastQuestionContains(MockChatClientRequest request, params string[] terms) + { + string? question = GetLastQuestion(request); + if (string.IsNullOrWhiteSpace(question)) + { + return false; + } + + foreach (string term in terms) + { + if (question.Contains(term, StringComparison.OrdinalIgnoreCase)) + { + return true; + } + } + + return false; + } + + private static string? GetLastQuestion(MockChatClientRequest request) + { + int startIndex = request.Options?.ResponseFormat is ChatResponseFormatJson + ? request.Messages.Count - 2 + : request.Messages.Count - 1; + + for (int i = startIndex; i >= 0; i--) + { + ChatMessage message = request.Messages[i]; + if (message.Role == ChatRole.User && !string.IsNullOrWhiteSpace(message.Text)) + { + return message.Text; + } + } + + return null; + } +} +#pragma warning restore MEAI001 diff --git a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb._defaults.verified/aichatweb/Services/SemanticSearch.cs b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.mock.verified/aichatweb/Services/SemanticSearch.cs similarity index 100% rename from test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb._defaults.verified/aichatweb/Services/SemanticSearch.cs rename to test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.mock.verified/aichatweb/Services/SemanticSearch.cs diff --git a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.mock.verified/aichatweb/aichatweb.csproj b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.mock.verified/aichatweb/aichatweb.csproj new file mode 100644 index 00000000000..6b47e9d5c43 --- /dev/null +++ b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.mock.verified/aichatweb/aichatweb.csproj @@ -0,0 +1,21 @@ + + + + net10.0 + enable + enable + {00000000-0000-0000-0000-000000000000} + + + + + + + + + + + + + + diff --git a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb._defaults.verified/aichatweb/appsettings.Development.json b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.mock.verified/aichatweb/appsettings.Development.json similarity index 100% rename from test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb._defaults.verified/aichatweb/appsettings.Development.json rename to test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.mock.verified/aichatweb/appsettings.Development.json diff --git a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb._defaults.verified/aichatweb/appsettings.json b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.mock.verified/aichatweb/appsettings.json similarity index 100% rename from test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb._defaults.verified/aichatweb/appsettings.json rename to test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.mock.verified/aichatweb/appsettings.json diff --git a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.o_q.verified/aichatweb/aichatweb.Web/Components/Layout/SurveyPrompt.razor b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.o_q.verified/aichatweb/aichatweb.Web/Components/Layout/SurveyPrompt.razor deleted file mode 100644 index 77557f20173..00000000000 --- a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.o_q.verified/aichatweb/aichatweb.Web/Components/Layout/SurveyPrompt.razor +++ /dev/null @@ -1,13 +0,0 @@ -
- - -
- How well is this template working for you? Please take a - brief survey - and tell us what you think. -
-
diff --git a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.o_q.verified/aichatweb/aichatweb.Web/Components/Layout/SurveyPrompt.razor.css b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.o_q.verified/aichatweb/aichatweb.Web/Components/Layout/SurveyPrompt.razor.css deleted file mode 100644 index c939b902afb..00000000000 --- a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.o_q.verified/aichatweb/aichatweb.Web/Components/Layout/SurveyPrompt.razor.css +++ /dev/null @@ -1,20 +0,0 @@ -.surveyContainer { - display: flex; - justify-content: center; - gap: 0.5rem; - font-size: 0.9em; - margin: 0.5rem auto -0.7rem auto; - max-width: 1024px; - color: #444; -} - - .surveyContainer a { - text-decoration: underline; - } - - .surveyContainer .tool-icon { - margin-top: 0.15rem; - width: 1.25rem; - height: 1.25rem; - flex-shrink: 0; - } diff --git a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.o_q.verified/aichatweb/aichatweb.Web/Components/Pages/Chat/Chat.razor b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.o_q.verified/aichatweb/aichatweb.Web/Components/Pages/Chat/Chat.razor index 6fc5881c18f..9d33e3ff5ed 100644 --- a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.o_q.verified/aichatweb/aichatweb.Web/Components/Pages/Chat/Chat.razor +++ b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.o_q.verified/aichatweb/aichatweb.Web/Components/Pages/Chat/Chat.razor @@ -1,5 +1,6 @@ @page "/" @using System.ComponentModel +@using System.Xml.Linq @inject IChatClient ChatClient @inject NavigationManager Nav @inject SemanticSearch Search @@ -20,7 +21,6 @@
- @* Remove this line to eliminate the template survey message *@
@code { @@ -126,7 +126,10 @@ await InvokeAsync(StateHasChanged); var results = await Search.SearchAsync(searchPhrase, filenameFilter, maxResults: 5); return results.Select(result => - $"{result.Text}"); + new XElement( + "result", + new XAttribute("filename", result.DocumentId), + result.Text).ToString(SaveOptions.DisableFormatting)); } public void Dispose() diff --git a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.o_q.verified/aichatweb/aichatweb.Web/Components/Pages/Chat/ChatMessageItem.razor b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.o_q.verified/aichatweb/aichatweb.Web/Components/Pages/Chat/ChatMessageItem.razor index e45d92ab5f9..33367fa3974 100644 --- a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.o_q.verified/aichatweb/aichatweb.Web/Components/Pages/Chat/ChatMessageItem.razor +++ b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.o_q.verified/aichatweb/aichatweb.Web/Components/Pages/Chat/ChatMessageItem.razor @@ -1,6 +1,7 @@ @using System.Runtime.CompilerServices @using System.Text.RegularExpressions -@using System.Linq +@using System.Xml +@using System.Xml.Linq @if (Message.Role == ChatRole.User) { @@ -69,7 +70,7 @@ else if (Message.Role == ChatRole.Assistant) @code { private static readonly ConditionalWeakTable SubscribersLookup = new(); - private static readonly Regex CitationRegex = new(@"(?.*?)", RegexOptions.NonBacktracking); + private static readonly Regex CitationRegex = new(@"]*>.*?", RegexOptions.NonBacktracking | RegexOptions.Singleline); private List<(string File, string Quote)>? citations; @@ -99,9 +100,24 @@ else if (Message.Role == ChatRole.Assistant) private void ParseCitations(string text) { - var matches = CitationRegex.Matches(text); - citations = matches.Any() - ? matches.Select(m => (m.Groups["file"].Value, m.Groups["quote"].Value)).ToList() - : null; + var parsedCitations = new List<(string File, string Quote)>(); + foreach (Match match in CitationRegex.Matches(text)) + { + try + { + XElement citation = XElement.Parse(match.Value); + if (citation.Name.LocalName == "citation" && + citation.Attribute("filename")?.Value is { Length: > 0 } file) + { + parsedCitations.Add((file, citation.Value)); + } + } + catch (XmlException) + { + // Ignore malformed citation markup returned by the model. + } + } + + citations = parsedCitations.Count > 0 ? parsedCitations : null; } } diff --git a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.oai.verified/aichatweb/Components/App.razor b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.oai.verified/aichatweb/Components/App.razor new file mode 100644 index 00000000000..40862eea8e7 --- /dev/null +++ b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.oai.verified/aichatweb/Components/App.razor @@ -0,0 +1,24 @@ + + + + + + + + + + + + + + + + + + + + + +@code { + private readonly IComponentRenderMode renderMode = new InteractiveServerRenderMode(prerender: false); +} diff --git a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.oai.verified/aichatweb/Components/Layout/LoadingSpinner.razor b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.oai.verified/aichatweb/Components/Layout/LoadingSpinner.razor new file mode 100644 index 00000000000..116455ce45b --- /dev/null +++ b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.oai.verified/aichatweb/Components/Layout/LoadingSpinner.razor @@ -0,0 +1 @@ +
diff --git a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.oai.verified/aichatweb/Components/Layout/LoadingSpinner.razor.css b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.oai.verified/aichatweb/Components/Layout/LoadingSpinner.razor.css new file mode 100644 index 00000000000..d85b851a679 --- /dev/null +++ b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.oai.verified/aichatweb/Components/Layout/LoadingSpinner.razor.css @@ -0,0 +1,89 @@ +/* Used under CC0 license */ + +.lds-ellipsis { + color: #666; + animation: fade-in 1s; +} + +@keyframes fade-in { + 0% { + opacity: 0; + } + + 100% { + opacity: 1; + } +} + + .lds-ellipsis, + .lds-ellipsis div { + box-sizing: border-box; + } + +.lds-ellipsis { + margin: auto; + display: block; + position: relative; + width: 80px; + height: 80px; +} + + .lds-ellipsis div { + position: absolute; + top: 33.33333px; + width: 10px; + height: 10px; + border-radius: 50%; + background: currentColor; + animation-timing-function: cubic-bezier(0, 1, 1, 0); + } + + .lds-ellipsis div:nth-child(1) { + left: 8px; + animation: lds-ellipsis1 0.6s infinite; + } + + .lds-ellipsis div:nth-child(2) { + left: 8px; + animation: lds-ellipsis2 0.6s infinite; + } + + .lds-ellipsis div:nth-child(3) { + left: 32px; + animation: lds-ellipsis2 0.6s infinite; + } + + .lds-ellipsis div:nth-child(4) { + left: 56px; + animation: lds-ellipsis3 0.6s infinite; + } + +@keyframes lds-ellipsis1 { + 0% { + transform: scale(0); + } + + 100% { + transform: scale(1); + } +} + +@keyframes lds-ellipsis3 { + 0% { + transform: scale(1); + } + + 100% { + transform: scale(0); + } +} + +@keyframes lds-ellipsis2 { + 0% { + transform: translate(0, 0); + } + + 100% { + transform: translate(24px, 0); + } +} diff --git a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.oai.verified/aichatweb/Components/Layout/MainLayout.razor b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.oai.verified/aichatweb/Components/Layout/MainLayout.razor new file mode 100644 index 00000000000..96fbbe6cc42 --- /dev/null +++ b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.oai.verified/aichatweb/Components/Layout/MainLayout.razor @@ -0,0 +1,9 @@ +@inherits LayoutComponentBase + +@Body + +
+ An unhandled error has occurred. + Reload + 🗙 +
diff --git a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.oai.verified/aichatweb/Components/Layout/MainLayout.razor.css b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.oai.verified/aichatweb/Components/Layout/MainLayout.razor.css new file mode 100644 index 00000000000..cda2020dcb0 --- /dev/null +++ b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.oai.verified/aichatweb/Components/Layout/MainLayout.razor.css @@ -0,0 +1,20 @@ +#blazor-error-ui { + color-scheme: light only; + background: lightyellow; + bottom: 0; + box-shadow: 0 -1px 2px rgba(0, 0, 0, 0.2); + box-sizing: border-box; + display: none; + left: 0; + padding: 0.6rem 1.25rem 0.7rem 1.25rem; + position: fixed; + width: 100%; + z-index: 1000; +} + + #blazor-error-ui .dismiss { + cursor: pointer; + position: absolute; + right: 0.75rem; + top: 0.5rem; + } diff --git a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.oai.verified/aichatweb/Components/Pages/Chat/Chat.razor b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.oai.verified/aichatweb/Components/Pages/Chat/Chat.razor new file mode 100644 index 00000000000..9d33e3ff5ed --- /dev/null +++ b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.oai.verified/aichatweb/Components/Pages/Chat/Chat.razor @@ -0,0 +1,137 @@ +@page "/" +@using System.ComponentModel +@using System.Xml.Linq +@inject IChatClient ChatClient +@inject NavigationManager Nav +@inject SemanticSearch Search +@implements IDisposable + +Chat + + + + + +
To get started, try asking about these example documents. You can replace these with your own data and replace this message.
+ + +
+
+ +
+ + +
+ +@code { + private const string SystemPrompt = @" + You are an assistant who answers questions about information you retrieve. + Do not answer questions about anything else. + Use only simple markdown to format your responses. + + Use the LoadDocuments tool to prepare for searches before answering any questions. + + Use the Search tool to find relevant information. When you do this, end your + reply with citations in the special XML format: + + exact quote here + + Always include the citation in your response if there are results. + + The quote must be max 5 words, taken word-for-word from the search result, and is the basis for why the citation is relevant. + Don't refer to the presence of citations; just emit these tags right at the end, with no surrounding text. + "; + + private int statefulMessageCount; + private readonly ChatOptions chatOptions = new(); + private readonly List messages = new(); + private CancellationTokenSource? currentResponseCancellation; + private ChatMessage? currentResponseMessage; + private ChatInput? chatInput; + private ChatSuggestions? chatSuggestions; + + protected override void OnInitialized() + { + statefulMessageCount = 0; + messages.Add(new(ChatRole.System, SystemPrompt)); + chatOptions.Tools = [ + AIFunctionFactory.Create(LoadDocumentsAsync), + AIFunctionFactory.Create(SearchAsync) + ]; + } + + private async Task AddUserMessageAsync(ChatMessage userMessage) + { + CancelAnyCurrentResponse(); + + // Add the user message to the conversation + messages.Add(userMessage); + chatSuggestions?.Clear(); + await chatInput!.FocusAsync(); + + // Stream and display a new response from the IChatClient + var responseText = new TextContent(""); + currentResponseMessage = new ChatMessage(ChatRole.Assistant, [responseText]); + currentResponseCancellation = new(); + await foreach (var update in ChatClient.GetStreamingResponseAsync(messages.Skip(statefulMessageCount), chatOptions, currentResponseCancellation.Token)) + { + messages.AddMessages(update, filter: c => c is not TextContent); + responseText.Text += update.Text; + chatOptions.ConversationId = update.ConversationId; + ChatMessageItem.NotifyChanged(currentResponseMessage); + } + + // Store the final response in the conversation, and begin getting suggestions + messages.Add(currentResponseMessage!); + statefulMessageCount = chatOptions.ConversationId is not null ? messages.Count : 0; + currentResponseMessage = null; + chatSuggestions?.Update(messages); + } + + private void CancelAnyCurrentResponse() + { + // If a response was cancelled while streaming, include it in the conversation so it's not lost + if (currentResponseMessage is not null) + { + messages.Add(currentResponseMessage); + } + + currentResponseCancellation?.Cancel(); + currentResponseMessage = null; + } + + private async Task ResetConversationAsync() + { + CancelAnyCurrentResponse(); + messages.Clear(); + messages.Add(new(ChatRole.System, SystemPrompt)); + chatOptions.ConversationId = null; + statefulMessageCount = 0; + chatSuggestions?.Clear(); + await chatInput!.FocusAsync(); + } + + [Description("Loads the documents needed for performing searches. Must be completed before a search can be executed, but only needs to be completed once.")] + private async Task LoadDocumentsAsync() + { + await InvokeAsync(StateHasChanged); + await Search.LoadDocumentsAsync(); + } + + [Description("Searches for information using a phrase or keyword. Relies on documents already being loaded.")] + private async Task> SearchAsync( + [Description("The phrase to search for.")] string searchPhrase, + [Description("If possible, specify the filename to search that file only. If not provided or empty, the search includes all files.")] string? filenameFilter = null) + { + await InvokeAsync(StateHasChanged); + var results = await Search.SearchAsync(searchPhrase, filenameFilter, maxResults: 5); + return results.Select(result => + new XElement( + "result", + new XAttribute("filename", result.DocumentId), + result.Text).ToString(SaveOptions.DisableFormatting)); + } + + public void Dispose() + => currentResponseCancellation?.Cancel(); +} diff --git a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.oai.verified/aichatweb/Components/Pages/Chat/Chat.razor.css b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.oai.verified/aichatweb/Components/Pages/Chat/Chat.razor.css new file mode 100644 index 00000000000..98ed1ba7d1e --- /dev/null +++ b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.oai.verified/aichatweb/Components/Pages/Chat/Chat.razor.css @@ -0,0 +1,11 @@ +.chat-container { + position: sticky; + bottom: 0; + padding-left: 1.5rem; + padding-right: 1.5rem; + padding-top: 0.75rem; + padding-bottom: 1.5rem; + border-top-width: 1px; + background-color: #F3F4F6; + border-color: #E5E7EB; +} diff --git a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.oai.verified/aichatweb/Components/Pages/Chat/ChatCitation.razor b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.oai.verified/aichatweb/Components/Pages/Chat/ChatCitation.razor new file mode 100644 index 00000000000..667189beabd --- /dev/null +++ b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.oai.verified/aichatweb/Components/Pages/Chat/ChatCitation.razor @@ -0,0 +1,39 @@ +@using System.Web +@if (!string.IsNullOrWhiteSpace(viewerUrl)) +{ + + + + +
+
@File
+
@Quote
+
+
+} + +@code { + [Parameter] + public required string File { get; set; } + + [Parameter] + public string? Quote { get; set; } + + private string? viewerUrl; + + protected override void OnParametersSet() + { + viewerUrl = null; + + // If you ingest other types of content besides Markdown or PDF files, construct a URL to an appropriate viewer here + if (File.EndsWith(".md")) + { + viewerUrl = $"lib/markdown_viewer/viewer.html?file=/Data/{HttpUtility.UrlEncode(File)}#:~:text={Uri.EscapeDataString(Quote ?? "")}"; + } + else if (File.EndsWith(".pdf")) + { + var search = Quote?.Trim('.', ',', ' ', '\n', '\r', '\t', '"', '\''); + viewerUrl = $"lib/pdf_viewer/viewer.html?file=/Data/{HttpUtility.UrlEncode(File)}#search={HttpUtility.UrlEncode(search)}&phrase=true"; + } + } +} diff --git a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.oai.verified/aichatweb/Components/Pages/Chat/ChatCitation.razor.css b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.oai.verified/aichatweb/Components/Pages/Chat/ChatCitation.razor.css new file mode 100644 index 00000000000..0ca029b7e64 --- /dev/null +++ b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.oai.verified/aichatweb/Components/Pages/Chat/ChatCitation.razor.css @@ -0,0 +1,37 @@ +.citation { + display: inline-flex; + padding-top: 0.5rem; + padding-bottom: 0.5rem; + padding-left: 0.75rem; + padding-right: 0.75rem; + margin-top: 1rem; + margin-right: 1rem; + border-bottom: 2px solid #a770de; + gap: 0.5rem; + border-radius: 0.25rem; + font-size: 0.875rem; + line-height: 1.25rem; + background-color: #ffffff; +} + + .citation[href]:hover { + outline: 1px solid #865cb1; + } + + .citation svg { + width: 1.5rem; + height: 1.5rem; + } + + .citation:active { + background-color: rgba(0,0,0,0.05); + } + +.citation-content { + display: flex; + flex-direction: column; +} + +.citation-file { + font-weight: 600; +} diff --git a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.oai.verified/aichatweb/Components/Pages/Chat/ChatHeader.razor b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.oai.verified/aichatweb/Components/Pages/Chat/ChatHeader.razor new file mode 100644 index 00000000000..0e9d9c8894a --- /dev/null +++ b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.oai.verified/aichatweb/Components/Pages/Chat/ChatHeader.razor @@ -0,0 +1,17 @@ +
+
+ +
+ +

aichatweb

+
+ +@code { + [Parameter] + public EventCallback OnNewChat { get; set; } +} diff --git a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.oai.verified/aichatweb/Components/Pages/Chat/ChatHeader.razor.css b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.oai.verified/aichatweb/Components/Pages/Chat/ChatHeader.razor.css new file mode 100644 index 00000000000..6adcc414540 --- /dev/null +++ b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.oai.verified/aichatweb/Components/Pages/Chat/ChatHeader.razor.css @@ -0,0 +1,25 @@ +.chat-header-container { + top: 0; + padding: 1.5rem; +} + +.chat-header-controls { + margin-bottom: 1.5rem; +} + +h1 { + overflow: hidden; + text-overflow: ellipsis; +} + +.new-chat-icon { + width: 1.25rem; + height: 1.25rem; + color: rgb(55, 65, 81); +} + +@media (min-width: 768px) { + .chat-header-container { + position: sticky; + } +} diff --git a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.oai.verified/aichatweb/Components/Pages/Chat/ChatInput.razor b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.oai.verified/aichatweb/Components/Pages/Chat/ChatInput.razor new file mode 100644 index 00000000000..e87ac6ccf47 --- /dev/null +++ b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.oai.verified/aichatweb/Components/Pages/Chat/ChatInput.razor @@ -0,0 +1,51 @@ +@inject IJSRuntime JS + + + + + +@code { + private ElementReference textArea; + private string? messageText; + + [Parameter] + public EventCallback OnSend { get; set; } + + public ValueTask FocusAsync() + => textArea.FocusAsync(); + + private async Task SendMessageAsync() + { + if (messageText is { Length: > 0 } text) + { + messageText = null; + await OnSend.InvokeAsync(new ChatMessage(ChatRole.User, text)); + } + } + + protected override async Task OnAfterRenderAsync(bool firstRender) + { + if (firstRender) + { + try + { + var module = await JS.InvokeAsync("import", "./Components/Pages/Chat/ChatInput.razor.js"); + await module.InvokeVoidAsync("init", textArea); + await module.DisposeAsync(); + } + catch (JSDisconnectedException) + { + } + } + } +} diff --git a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.oai.verified/aichatweb/Components/Pages/Chat/ChatInput.razor.css b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.oai.verified/aichatweb/Components/Pages/Chat/ChatInput.razor.css new file mode 100644 index 00000000000..3b26c9af316 --- /dev/null +++ b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.oai.verified/aichatweb/Components/Pages/Chat/ChatInput.razor.css @@ -0,0 +1,57 @@ +.input-box { + display: flex; + flex-direction: column; + background: white; + border: 1px solid rgb(229, 231, 235); + border-radius: 8px; + padding: 0.5rem 0.75rem; + margin-top: 0.75rem; +} + + .input-box:focus-within { + outline: 2px solid #4152d5; + } + +textarea { + resize: none; + border: none; + outline: none; + flex-grow: 1; +} + + textarea:placeholder-shown + .tools { + --send-button-color: #aaa; + } + +.tools { + display: flex; + margin-top: 1rem; + align-items: center; +} + +.tool-icon { + width: 1.25rem; + height: 1.25rem; +} + +.send-button { + color: var(--send-button-color); + margin-left: auto; +} + + .send-button:hover { + color: black; + } + +.attach { + background-color: white; + border-style: dashed; + color: #888; + border-color: #888; + padding: 3px 8px; +} + + .attach:hover { + background-color: #f0f0f0; + color: black; + } diff --git a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.oai.verified/aichatweb/Components/Pages/Chat/ChatInput.razor.js b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.oai.verified/aichatweb/Components/Pages/Chat/ChatInput.razor.js new file mode 100644 index 00000000000..39e18ac7b74 --- /dev/null +++ b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.oai.verified/aichatweb/Components/Pages/Chat/ChatInput.razor.js @@ -0,0 +1,43 @@ +export function init(elem) { + elem.focus(); + + // Auto-resize whenever the user types or if the value is set programmatically + elem.addEventListener('input', () => resizeToFit(elem)); + afterPropertyWritten(elem, 'value', () => resizeToFit(elem)); + + // Auto-submit the form on 'enter' keypress + elem.addEventListener('keydown', (e) => { + if (e.key === 'Enter' && !e.shiftKey) { + e.preventDefault(); + elem.dispatchEvent(new CustomEvent('change', { bubbles: true })); + elem.closest('form').dispatchEvent(new CustomEvent('submit', { bubbles: true, cancelable: true })); + } + }); +} + +function resizeToFit(elem) { + const lineHeight = parseFloat(getComputedStyle(elem).lineHeight); + + elem.rows = 1; + const numLines = Math.ceil(elem.scrollHeight / lineHeight); + elem.rows = Math.min(5, Math.max(1, numLines)); +} + +function afterPropertyWritten(target, propName, callback) { + const descriptor = getPropertyDescriptor(target, propName); + Object.defineProperty(target, propName, { + get: function () { + return descriptor.get.apply(this, arguments); + }, + set: function () { + const result = descriptor.set.apply(this, arguments); + callback(); + return result; + } + }); +} + +function getPropertyDescriptor(target, propertyName) { + return Object.getOwnPropertyDescriptor(target, propertyName) + || getPropertyDescriptor(Object.getPrototypeOf(target), propertyName); +} diff --git a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.oai.verified/aichatweb/Components/Pages/Chat/ChatMessageItem.razor b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.oai.verified/aichatweb/Components/Pages/Chat/ChatMessageItem.razor new file mode 100644 index 00000000000..33367fa3974 --- /dev/null +++ b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.oai.verified/aichatweb/Components/Pages/Chat/ChatMessageItem.razor @@ -0,0 +1,123 @@ +@using System.Runtime.CompilerServices +@using System.Text.RegularExpressions +@using System.Xml +@using System.Xml.Linq + +@if (Message.Role == ChatRole.User) +{ +
+ @Message.Text +
+} +else if (Message.Role == ChatRole.Assistant) +{ + foreach (var content in Message.Contents) + { + if (content is TextContent { Text: { Length: > 0 } text }) + { +
+
+
+ + + +
+
+
Assistant
+
+ + + @foreach (var citation in citations ?? []) + { + + } +
+
+ } + else if (content is FunctionCallContent { Name: "LoadDocuments" }) + { + + } + else if (content is FunctionCallContent { Name: "Search" } fcc && fcc.Arguments?.TryGetValue("searchPhrase", out var searchPhrase) is true) + { + + } + } +} + +@code { + private static readonly ConditionalWeakTable SubscribersLookup = new(); + private static readonly Regex CitationRegex = new(@"]*>.*?", RegexOptions.NonBacktracking | RegexOptions.Singleline); + + private List<(string File, string Quote)>? citations; + + [Parameter, EditorRequired] + public required ChatMessage Message { get; set; } + + [Parameter] + public bool InProgress { get; set;} + + protected override void OnInitialized() + { + SubscribersLookup.AddOrUpdate(Message, this); + + if (!InProgress && Message.Role == ChatRole.Assistant && Message.Text is { Length: > 0 } text) + { + ParseCitations(text); + } + } + + public static void NotifyChanged(ChatMessage source) + { + if (SubscribersLookup.TryGetValue(source, out var subscriber)) + { + subscriber.StateHasChanged(); + } + } + + private void ParseCitations(string text) + { + var parsedCitations = new List<(string File, string Quote)>(); + foreach (Match match in CitationRegex.Matches(text)) + { + try + { + XElement citation = XElement.Parse(match.Value); + if (citation.Name.LocalName == "citation" && + citation.Attribute("filename")?.Value is { Length: > 0 } file) + { + parsedCitations.Add((file, citation.Value)); + } + } + catch (XmlException) + { + // Ignore malformed citation markup returned by the model. + } + } + + citations = parsedCitations.Count > 0 ? parsedCitations : null; + } +} diff --git a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.oai.verified/aichatweb/Components/Pages/Chat/ChatMessageItem.razor.css b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.oai.verified/aichatweb/Components/Pages/Chat/ChatMessageItem.razor.css new file mode 100644 index 00000000000..10453454be8 --- /dev/null +++ b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.oai.verified/aichatweb/Components/Pages/Chat/ChatMessageItem.razor.css @@ -0,0 +1,120 @@ +.user-message { + background: rgb(182 215 232); + align-self: flex-end; + min-width: 25%; + max-width: calc(100% - 5rem); + padding: 0.5rem 1.25rem; + border-radius: 0.25rem; + color: #1F2937; + white-space: pre-wrap; +} + +.assistant-message, .assistant-search { + display: grid; + grid-template-rows: min-content; + grid-template-columns: 2rem minmax(0, 1fr); + gap: 0.25rem; +} + +.assistant-message-header { + font-weight: 600; +} + +.assistant-message-text { + grid-column-start: 2; +} + +.assistant-message-icon { + display: flex; + justify-content: center; + align-items: center; + border-radius: 9999px; + width: 1.5rem; + height: 1.5rem; + color: #ffffff; + background: #9b72ce; +} + + .assistant-message-icon svg { + width: 1rem; + height: 1rem; + } + +.assistant-search { + font-size: 0.875rem; + line-height: 1.25rem; +} + +.assistant-search-icon { + display: flex; + justify-content: center; + align-items: center; + width: 1.5rem; + height: 1.5rem; +} + + .assistant-search-icon svg { + width: 1rem; + height: 1rem; + } + +.assistant-search-content { + align-content: center; +} + +.assistant-search-phrase { + font-weight: 600; +} + +/* Default styling for markdown-formatted assistant messages */ +::deep ul { + list-style-type: disc; + margin-left: 1.5rem; +} + +::deep ol { + list-style-type: decimal; + margin-left: 1.5rem; +} + +::deep li { + margin: 0.5rem 0; +} + +::deep strong { + font-weight: 600; +} + +::deep h3 { + margin: 1rem 0; + font-weight: 600; +} + +::deep p + p { + margin-top: 1rem; +} + +::deep table { + margin: 1rem 0; +} + +::deep th { + text-align: left; + border-bottom: 1px solid silver; +} + +::deep th, ::deep td { + padding: 0.1rem 0.5rem; +} + +::deep th, ::deep tr:nth-child(even) { + background-color: rgba(0, 0, 0, 0.05); +} + +::deep pre > code { + background-color: white; + display: block; + padding: 0.5rem 1rem; + margin: 1rem 0; + overflow-x: auto; +} diff --git a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.oai.verified/aichatweb/Components/Pages/Chat/ChatMessageList.razor b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.oai.verified/aichatweb/Components/Pages/Chat/ChatMessageList.razor new file mode 100644 index 00000000000..d245f455f11 --- /dev/null +++ b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.oai.verified/aichatweb/Components/Pages/Chat/ChatMessageList.razor @@ -0,0 +1,42 @@ +@inject IJSRuntime JS + +
+ + @foreach (var message in Messages) + { + + } + + @if (InProgressMessage is not null) + { + + + } + else if (IsEmpty) + { +
@NoMessagesContent
+ } +
+
+ +@code { + [Parameter] + public required IEnumerable Messages { get; set; } + + [Parameter] + public ChatMessage? InProgressMessage { get; set; } + + [Parameter] + public RenderFragment? NoMessagesContent { get; set; } + + private bool IsEmpty => !Messages.Any(m => (m.Role == ChatRole.User || m.Role == ChatRole.Assistant) && !string.IsNullOrEmpty(m.Text)); + + protected override async Task OnAfterRenderAsync(bool firstRender) + { + if (firstRender) + { + // Activates the auto-scrolling behavior + await JS.InvokeVoidAsync("import", "./Components/Pages/Chat/ChatMessageList.razor.js"); + } + } +} diff --git a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.oai.verified/aichatweb/Components/Pages/Chat/ChatMessageList.razor.css b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.oai.verified/aichatweb/Components/Pages/Chat/ChatMessageList.razor.css new file mode 100644 index 00000000000..6fbf083c7fa --- /dev/null +++ b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.oai.verified/aichatweb/Components/Pages/Chat/ChatMessageList.razor.css @@ -0,0 +1,22 @@ +.message-list-container { + margin: 2rem 1.5rem; + flex-grow: 1; +} + +.message-list { + display: flex; + flex-direction: column; + gap: 1.25rem; +} + +.no-messages { + text-align: center; + font-size: 1.25rem; + color: #999; + margin-top: calc(40vh - 18rem); +} + +chat-messages > ::deep div:last-of-type { + /* Adds some vertical buffer to so that suggestions don't overlap the output when they appear */ + margin-bottom: 2rem; +} diff --git a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.oai.verified/aichatweb/Components/Pages/Chat/ChatMessageList.razor.js b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.oai.verified/aichatweb/Components/Pages/Chat/ChatMessageList.razor.js new file mode 100644 index 00000000000..3de8de273b8 --- /dev/null +++ b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.oai.verified/aichatweb/Components/Pages/Chat/ChatMessageList.razor.js @@ -0,0 +1,34 @@ +// The following logic provides auto-scroll behavior for the chat messages list. +// If you don't want that behavior, you can simply not load this module. + +window.customElements.define('chat-messages', class ChatMessages extends HTMLElement { + static _isFirstAutoScroll = true; + + connectedCallback() { + this._observer = new MutationObserver(mutations => this._scheduleAutoScroll(mutations)); + this._observer.observe(this, { childList: true, attributes: true }); + } + + disconnectedCallback() { + this._observer.disconnect(); + } + + _scheduleAutoScroll(mutations) { + // Debounce the calls in case multiple DOM updates occur together + cancelAnimationFrame(this._nextAutoScroll); + this._nextAutoScroll = requestAnimationFrame(() => { + const addedUserMessage = mutations.some(m => Array.from(m.addedNodes).some(n => n.parentElement === this && n.classList?.contains('user-message'))); + const elem = this.lastElementChild; + if (ChatMessages._isFirstAutoScroll || addedUserMessage || this._elemIsNearScrollBoundary(elem, 300)) { + elem.scrollIntoView({ behavior: ChatMessages._isFirstAutoScroll ? 'instant' : 'smooth' }); + ChatMessages._isFirstAutoScroll = false; + } + }); + } + + _elemIsNearScrollBoundary(elem, threshold) { + const maxScrollPos = document.body.scrollHeight - window.innerHeight; + const remainingScrollDistance = maxScrollPos - window.scrollY; + return remainingScrollDistance < elem.offsetHeight + threshold; + } +}); diff --git a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.oai.verified/aichatweb/Components/Pages/Chat/ChatSuggestions.razor b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.oai.verified/aichatweb/Components/Pages/Chat/ChatSuggestions.razor new file mode 100644 index 00000000000..69ca922a8ce --- /dev/null +++ b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.oai.verified/aichatweb/Components/Pages/Chat/ChatSuggestions.razor @@ -0,0 +1,78 @@ +@inject IChatClient ChatClient + +@if (suggestions is not null) +{ +
+ @foreach (var suggestion in suggestions) + { + + } +
+} + +@code { + private static string Prompt = @" + Suggest up to 3 follow-up questions that I could ask you to help me complete my task. + Each suggestion must be a complete sentence, maximum 6 words. + Each suggestion must be phrased as something that I (the user) would ask you (the assistant) in response to your previous message, + for example 'How do I do that?' or 'Explain ...'. + If there are no suggestions, reply with an empty list. + "; + + private string[]? suggestions; + private CancellationTokenSource? cancellation; + + [Parameter] + public EventCallback OnSelected { get; set; } + + public void Clear() + { + suggestions = null; + cancellation?.Cancel(); + } + + public void Update(IReadOnlyList messages) + { + // Runs in the background and handles its own cancellation/errors + _ = UpdateSuggestionsAsync(messages); + } + + private async Task UpdateSuggestionsAsync(IReadOnlyList messages) + { + cancellation?.Cancel(); + cancellation = new CancellationTokenSource(); + + try + { + var response = await ChatClient.GetResponseAsync( + [.. ReduceMessages(messages), new(ChatRole.User, Prompt)], + cancellationToken: cancellation.Token); + if (!response.TryGetResult(out suggestions)) + { + suggestions = null; + } + + StateHasChanged(); + } + catch (Exception ex) when (ex is not OperationCanceledException) + { + await DispatchExceptionAsync(ex); + } + } + + private async Task AddSuggestionAsync(string text) + { + await OnSelected.InvokeAsync(new(ChatRole.User, text)); + } + + private IEnumerable ReduceMessages(IReadOnlyList messages) + { + // Get any leading system messages, plus up to 5 user/assistant messages + // This should be enough context to generate suggestions without unnecessarily resending entire conversations when long + var systemMessages = messages.TakeWhile(m => m.Role == ChatRole.System); + var otherMessages = messages.Where((m, index) => m.Role == ChatRole.User || m.Role == ChatRole.Assistant).Where(m => !string.IsNullOrEmpty(m.Text)).TakeLast(5); + return systemMessages.Concat(otherMessages); + } +} diff --git a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.oai.verified/aichatweb/Components/Pages/Chat/ChatSuggestions.razor.css b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.oai.verified/aichatweb/Components/Pages/Chat/ChatSuggestions.razor.css new file mode 100644 index 00000000000..b291042c6d4 --- /dev/null +++ b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.oai.verified/aichatweb/Components/Pages/Chat/ChatSuggestions.razor.css @@ -0,0 +1,9 @@ +.suggestions { + text-align: right; + white-space: nowrap; + gap: 0.5rem; + justify-content: flex-end; + flex-wrap: wrap; + display: flex; + margin-bottom: 0.75rem; +} diff --git a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.oai.verified/aichatweb/Components/Pages/Error.razor b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.oai.verified/aichatweb/Components/Pages/Error.razor new file mode 100644 index 00000000000..576cc2d2f4d --- /dev/null +++ b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.oai.verified/aichatweb/Components/Pages/Error.razor @@ -0,0 +1,36 @@ +@page "/Error" +@using System.Diagnostics + +Error + +

Error.

+

An error occurred while processing your request.

+ +@if (ShowRequestId) +{ +

+ Request ID: @RequestId +

+} + +

Development Mode

+

+ Swapping to Development environment will display more detailed information about the error that occurred. +

+

+ The Development environment shouldn't be enabled for deployed applications. + It can result in displaying sensitive information from exceptions to end users. + For local debugging, enable the Development environment by setting the ASPNETCORE_ENVIRONMENT environment variable to Development + and restarting the app. +

+ +@code{ + [CascadingParameter] + private HttpContext? HttpContext { get; set; } + + private string? RequestId { get; set; } + private bool ShowRequestId => !string.IsNullOrEmpty(RequestId); + + protected override void OnInitialized() => + RequestId = Activity.Current?.Id ?? HttpContext?.TraceIdentifier; +} diff --git a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.oai.verified/aichatweb/Components/Routes.razor b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.oai.verified/aichatweb/Components/Routes.razor new file mode 100644 index 00000000000..f756e19dfbc --- /dev/null +++ b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.oai.verified/aichatweb/Components/Routes.razor @@ -0,0 +1,6 @@ + + + + + + diff --git a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.oai.verified/aichatweb/Components/_Imports.razor b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.oai.verified/aichatweb/Components/_Imports.razor new file mode 100644 index 00000000000..9a70e8453ef --- /dev/null +++ b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.oai.verified/aichatweb/Components/_Imports.razor @@ -0,0 +1,13 @@ +@using System.Net.Http +@using System.Net.Http.Json +@using Microsoft.AspNetCore.Components.Forms +@using Microsoft.AspNetCore.Components.Routing +@using Microsoft.AspNetCore.Components.Web +@using static Microsoft.AspNetCore.Components.Web.RenderMode +@using Microsoft.AspNetCore.Components.Web.Virtualization +@using Microsoft.Extensions.AI +@using Microsoft.JSInterop +@using aichatweb +@using aichatweb.Components +@using aichatweb.Components.Layout +@using aichatweb.Services diff --git a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb._defaults.verified/aichatweb/Program.cs b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.oai.verified/aichatweb/Program.cs similarity index 72% rename from test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb._defaults.verified/aichatweb/Program.cs rename to test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.oai.verified/aichatweb/Program.cs index 27e50372647..429840c1d69 100644 --- a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb._defaults.verified/aichatweb/Program.cs +++ b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.oai.verified/aichatweb/Program.cs @@ -11,16 +11,16 @@ // You will need to set the endpoint and key to your own values // You can do this using Visual Studio's "Manage User Secrets" UI, or on the command line: // cd this-project-directory -// dotnet user-secrets set GitHubModels:Token YOUR-GITHUB-TOKEN -var credential = new ApiKeyCredential(builder.Configuration["GitHubModels:Token"] ?? throw new InvalidOperationException("Missing configuration: GitHubModels:Token. See the README for details.")); -var openAIOptions = new OpenAIClientOptions() -{ - Endpoint = new Uri("https://models.inference.ai.azure.com") -}; +// dotnet user-secrets set OpenAI:Key YOUR-API-KEY + +var openAIClient = new OpenAIClient( + new ApiKeyCredential(builder.Configuration["OpenAI:Key"] ?? throw new InvalidOperationException("Missing configuration: OpenAI:Key. See the README for details."))); + +#pragma warning disable OPENAI001 // GetResponsesClient() is experimental and subject to change or removal in future updates. +var chatClient = openAIClient.GetResponsesClient().AsIChatClient("gpt-4o-mini"); +#pragma warning restore OPENAI001 -var ghModelsClient = new OpenAIClient(credential, openAIOptions); -var chatClient = ghModelsClient.GetChatClient("gpt-4o-mini").AsIChatClient(); -var embeddingGenerator = ghModelsClient.GetEmbeddingClient("text-embedding-3-small").AsIEmbeddingGenerator(); +var embeddingGenerator = openAIClient.GetEmbeddingClient("text-embedding-3-small").AsIEmbeddingGenerator(); var vectorStorePath = Path.Combine(AppContext.BaseDirectory, "vector-store.db"); var vectorStoreConnectionString = $"Data Source={vectorStorePath}"; diff --git a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.oai.verified/aichatweb/Properties/launchSettings.json b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.oai.verified/aichatweb/Properties/launchSettings.json new file mode 100644 index 00000000000..6e300d8a802 --- /dev/null +++ b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.oai.verified/aichatweb/Properties/launchSettings.json @@ -0,0 +1,23 @@ +{ + "$schema": "https://json.schemastore.org/launchsettings.json", + "profiles": { + "http": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": true, + "applicationUrl": "http://localhost:9996", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + }, + "https": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": true, + "applicationUrl": "https://localhost:9995;http://localhost:9996", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + } + } +} diff --git a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.oai.verified/aichatweb/README.md b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.oai.verified/aichatweb/README.md new file mode 100644 index 00000000000..9119f1965f6 --- /dev/null +++ b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.oai.verified/aichatweb/README.md @@ -0,0 +1,19 @@ +# AI Chat with Custom Data + +This project is an AI chat application that demonstrates how to chat with custom data using an AI language model. Please note that this template is currently in an early preview stage. If you have feedback, please take a [brief survey](https://aka.ms/dotnet-chat-templatePreview2-survey). + +>[!NOTE] +> Before running this project you need to configure the API keys or endpoints for the providers you have chosen. See below for details specific to your choices. + +# Configure the AI Model Provider +## Using OpenAI + +To call the OpenAI REST API, you will need an API key. To obtain one, first [create a new OpenAI account](https://platform.openai.com/signup) or [log in](https://platform.openai.com/login). Next, navigate to the API key page and select "Create new secret key", optionally naming the key. Make sure to save your API key somewhere safe and do not share it with anyone. + +From the command line, configure your API key for this project using .NET User Secrets by running the following commands: + +```sh +cd <> +dotnet user-secrets set OpenAI:Key YOUR-API-KEY +``` + diff --git a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.oai.verified/aichatweb/Services/IngestedChunk.cs b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.oai.verified/aichatweb/Services/IngestedChunk.cs new file mode 100644 index 00000000000..a7136d1988d --- /dev/null +++ b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.oai.verified/aichatweb/Services/IngestedChunk.cs @@ -0,0 +1,31 @@ +using System.Text.Json.Serialization; +using Microsoft.Extensions.VectorData; + +namespace aichatweb.Services; + +public class IngestedChunk +{ + public const int VectorDimensions = 1536; // 1536 is the default vector size for the OpenAI text-embedding-3-small model + public const string VectorDistanceFunction = DistanceFunction.CosineDistance; + public const string CollectionName = "data-aichatweb-chunks"; + + [VectorStoreKey(StorageName = "key")] + [JsonPropertyName("key")] + public required string Key { get; set; } + + [VectorStoreData(StorageName = "documentid")] + [JsonPropertyName("documentid")] + public required string DocumentId { get; set; } + + [VectorStoreData(StorageName = "content")] + [JsonPropertyName("content")] + public required string Text { get; set; } + + [VectorStoreData(StorageName = "context")] + [JsonPropertyName("context")] + public string? Context { get; set; } + + [VectorStoreVector(VectorDimensions, DistanceFunction = VectorDistanceFunction, StorageName = "embedding")] + [JsonPropertyName("embedding")] + public string? Vector => Text; +} diff --git a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.oai.verified/aichatweb/Services/Ingestion/DataIngestor.cs b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.oai.verified/aichatweb/Services/Ingestion/DataIngestor.cs new file mode 100644 index 00000000000..d97b986b694 --- /dev/null +++ b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.oai.verified/aichatweb/Services/Ingestion/DataIngestor.cs @@ -0,0 +1,35 @@ +using Microsoft.Extensions.AI; +using Microsoft.Extensions.DataIngestion; +using Microsoft.Extensions.DataIngestion.Chunkers; +using Microsoft.Extensions.VectorData; +using Microsoft.ML.Tokenizers; + +namespace aichatweb.Services.Ingestion; + +public class DataIngestor( + ILogger logger, + ILoggerFactory loggerFactory, + VectorStore vectorStore, + IEmbeddingGenerator> embeddingGenerator) +{ + public async Task IngestDataAsync(DirectoryInfo directory, string searchPattern) + { + using var writer = new VectorStoreWriter(vectorStore, dimensionCount: IngestedChunk.VectorDimensions, new() + { + CollectionName = IngestedChunk.CollectionName, + DistanceFunction = IngestedChunk.VectorDistanceFunction, + IncrementalIngestion = false, + }); + + using var pipeline = new IngestionPipeline( + reader: new DocumentReader(directory), + chunker: new SemanticSimilarityChunker(embeddingGenerator, new(TiktokenTokenizer.CreateForModel("gpt-4o"))), + writer: writer, + loggerFactory: loggerFactory); + + await foreach (var result in pipeline.ProcessAsync(directory, searchPattern)) + { + logger.LogInformation("Completed processing '{id}'. Succeeded: '{succeeded}'.", result.DocumentId, result.Succeeded); + } + } +} diff --git a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.oai.verified/aichatweb/Services/Ingestion/DocumentReader.cs b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.oai.verified/aichatweb/Services/Ingestion/DocumentReader.cs new file mode 100644 index 00000000000..315a6ad3d53 --- /dev/null +++ b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.oai.verified/aichatweb/Services/Ingestion/DocumentReader.cs @@ -0,0 +1,36 @@ +using Microsoft.Extensions.DataIngestion; + +namespace aichatweb.Services.Ingestion; + +internal sealed class DocumentReader(DirectoryInfo rootDirectory) : IngestionDocumentReader +{ + private readonly MarkdownReader _markdownReader = new(); + private readonly PdfPigReader _pdfReader = new(); + + public override Task ReadAsync(FileInfo source, string identifier, string? mediaType = null, CancellationToken cancellationToken = default) + { + if (Path.IsPathFullyQualified(identifier)) + { + // Normalize the identifier to its relative path + identifier = Path.GetRelativePath(rootDirectory.FullName, identifier); + } + + mediaType = GetCustomMediaType(source) ?? mediaType; + return base.ReadAsync(source, identifier, mediaType, cancellationToken); + } + + public override Task ReadAsync(Stream source, string identifier, string mediaType, CancellationToken cancellationToken = default) + => mediaType switch + { + "application/pdf" => _pdfReader.ReadAsync(source, identifier, mediaType, cancellationToken), + "text/markdown" => _markdownReader.ReadAsync(source, identifier, mediaType, cancellationToken), + _ => throw new InvalidOperationException($"Unsupported media type '{mediaType}'"), + }; + + private static string? GetCustomMediaType(FileInfo source) + => source.Extension switch + { + ".md" => "text/markdown", + _ => null + }; +} diff --git a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.oai.verified/aichatweb/Services/Ingestion/PdfPigReader.cs b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.oai.verified/aichatweb/Services/Ingestion/PdfPigReader.cs new file mode 100644 index 00000000000..f6de539eb22 --- /dev/null +++ b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.oai.verified/aichatweb/Services/Ingestion/PdfPigReader.cs @@ -0,0 +1,42 @@ +using Microsoft.Extensions.DataIngestion; +using UglyToad.PdfPig; +using UglyToad.PdfPig.Content; +using UglyToad.PdfPig.DocumentLayoutAnalysis.PageSegmenter; +using UglyToad.PdfPig.DocumentLayoutAnalysis.WordExtractor; + +namespace aichatweb.Services.Ingestion; + +internal sealed class PdfPigReader : IngestionDocumentReader +{ + public override Task ReadAsync(Stream source, string identifier, string mediaType, CancellationToken cancellationToken = default) + { + using var pdf = PdfDocument.Open(source); + var document = new IngestionDocument(identifier); + foreach (var page in pdf.GetPages()) + { + document.Sections.Add(GetPageSection(page)); + } + return Task.FromResult(document); + } + + private static IngestionDocumentSection GetPageSection(Page pdfPage) + { + var section = new IngestionDocumentSection + { + PageNumber = pdfPage.Number, + }; + + var letters = pdfPage.Letters; + var words = NearestNeighbourWordExtractor.Instance.GetWords(letters); + + foreach (var textBlock in DocstrumBoundingBoxes.Instance.GetBlocks(words)) + { + section.Elements.Add(new IngestionDocumentParagraph(textBlock.Text) + { + Text = textBlock.Text + }); + } + + return section; + } +} diff --git a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.oai.verified/aichatweb/Services/SemanticSearch.cs b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.oai.verified/aichatweb/Services/SemanticSearch.cs new file mode 100644 index 00000000000..8072f8bcddb --- /dev/null +++ b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.oai.verified/aichatweb/Services/SemanticSearch.cs @@ -0,0 +1,27 @@ +using aichatweb.Services.Ingestion; +using Microsoft.Extensions.VectorData; + +namespace aichatweb.Services; + +public class SemanticSearch( + VectorStoreCollection vectorCollection, + [FromKeyedServices("ingestion_directory")] DirectoryInfo ingestionDirectory, + DataIngestor dataIngestor) +{ + private Task? _ingestionTask; + + public async Task LoadDocumentsAsync() => await ( _ingestionTask ??= dataIngestor.IngestDataAsync(ingestionDirectory, searchPattern: "*.*")); + + public async Task> SearchAsync(string text, string? documentIdFilter, int maxResults) + { + // Ensure documents have been loaded before searching + await LoadDocumentsAsync(); + + var nearest = vectorCollection.SearchAsync(text, maxResults, new VectorSearchOptions + { + Filter = documentIdFilter is { Length: > 0 } ? record => record.DocumentId == documentIdFilter : null, + }); + + return await nearest.Select(result => result.Record).ToListAsync(); + } +} diff --git a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb._defaults.verified/aichatweb/aichatweb.csproj b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.oai.verified/aichatweb/aichatweb.csproj similarity index 100% rename from test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb._defaults.verified/aichatweb/aichatweb.csproj rename to test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.oai.verified/aichatweb/aichatweb.csproj diff --git a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.oai.verified/aichatweb/appsettings.Development.json b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.oai.verified/aichatweb/appsettings.Development.json new file mode 100644 index 00000000000..e22bd83cf3a --- /dev/null +++ b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.oai.verified/aichatweb/appsettings.Development.json @@ -0,0 +1,9 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning", + "Microsoft.EntityFrameworkCore": "Warning" + } + } +} diff --git a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.oai.verified/aichatweb/appsettings.json b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.oai.verified/aichatweb/appsettings.json new file mode 100644 index 00000000000..d286041f99d --- /dev/null +++ b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.oai.verified/aichatweb/appsettings.json @@ -0,0 +1,10 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning", + "Microsoft.EntityFrameworkCore": "Warning" + } + }, + "AllowedHosts": "*" +} diff --git a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.oai_aais.verified/aichatweb/Components/Layout/SurveyPrompt.razor b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.oai_aais.verified/aichatweb/Components/Layout/SurveyPrompt.razor deleted file mode 100644 index 77557f20173..00000000000 --- a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.oai_aais.verified/aichatweb/Components/Layout/SurveyPrompt.razor +++ /dev/null @@ -1,13 +0,0 @@ -
- - -
- How well is this template working for you? Please take a - brief survey - and tell us what you think. -
-
diff --git a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.oai_aais.verified/aichatweb/Components/Layout/SurveyPrompt.razor.css b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.oai_aais.verified/aichatweb/Components/Layout/SurveyPrompt.razor.css deleted file mode 100644 index c939b902afb..00000000000 --- a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.oai_aais.verified/aichatweb/Components/Layout/SurveyPrompt.razor.css +++ /dev/null @@ -1,20 +0,0 @@ -.surveyContainer { - display: flex; - justify-content: center; - gap: 0.5rem; - font-size: 0.9em; - margin: 0.5rem auto -0.7rem auto; - max-width: 1024px; - color: #444; -} - - .surveyContainer a { - text-decoration: underline; - } - - .surveyContainer .tool-icon { - margin-top: 0.15rem; - width: 1.25rem; - height: 1.25rem; - flex-shrink: 0; - } diff --git a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.oai_aais.verified/aichatweb/Components/Pages/Chat/Chat.razor b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.oai_aais.verified/aichatweb/Components/Pages/Chat/Chat.razor index 6fc5881c18f..9d33e3ff5ed 100644 --- a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.oai_aais.verified/aichatweb/Components/Pages/Chat/Chat.razor +++ b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.oai_aais.verified/aichatweb/Components/Pages/Chat/Chat.razor @@ -1,5 +1,6 @@ @page "/" @using System.ComponentModel +@using System.Xml.Linq @inject IChatClient ChatClient @inject NavigationManager Nav @inject SemanticSearch Search @@ -20,7 +21,6 @@
- @* Remove this line to eliminate the template survey message *@
@code { @@ -126,7 +126,10 @@ await InvokeAsync(StateHasChanged); var results = await Search.SearchAsync(searchPhrase, filenameFilter, maxResults: 5); return results.Select(result => - $"{result.Text}"); + new XElement( + "result", + new XAttribute("filename", result.DocumentId), + result.Text).ToString(SaveOptions.DisableFormatting)); } public void Dispose() diff --git a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.oai_aais.verified/aichatweb/Components/Pages/Chat/ChatMessageItem.razor b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.oai_aais.verified/aichatweb/Components/Pages/Chat/ChatMessageItem.razor index e45d92ab5f9..33367fa3974 100644 --- a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.oai_aais.verified/aichatweb/Components/Pages/Chat/ChatMessageItem.razor +++ b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.oai_aais.verified/aichatweb/Components/Pages/Chat/ChatMessageItem.razor @@ -1,6 +1,7 @@ @using System.Runtime.CompilerServices @using System.Text.RegularExpressions -@using System.Linq +@using System.Xml +@using System.Xml.Linq @if (Message.Role == ChatRole.User) { @@ -69,7 +70,7 @@ else if (Message.Role == ChatRole.Assistant) @code { private static readonly ConditionalWeakTable SubscribersLookup = new(); - private static readonly Regex CitationRegex = new(@"(?.*?)", RegexOptions.NonBacktracking); + private static readonly Regex CitationRegex = new(@"]*>.*?", RegexOptions.NonBacktracking | RegexOptions.Singleline); private List<(string File, string Quote)>? citations; @@ -99,9 +100,24 @@ else if (Message.Role == ChatRole.Assistant) private void ParseCitations(string text) { - var matches = CitationRegex.Matches(text); - citations = matches.Any() - ? matches.Select(m => (m.Groups["file"].Value, m.Groups["quote"].Value)).ToList() - : null; + var parsedCitations = new List<(string File, string Quote)>(); + foreach (Match match in CitationRegex.Matches(text)) + { + try + { + XElement citation = XElement.Parse(match.Value); + if (citation.Name.LocalName == "citation" && + citation.Attribute("filename")?.Value is { Length: > 0 } file) + { + parsedCitations.Add((file, citation.Value)); + } + } + catch (XmlException) + { + // Ignore malformed citation markup returned by the model. + } + } + + citations = parsedCitations.Count > 0 ? parsedCitations : null; } }