From 55582bff9800cda299c4d598484f7c6e72c34ff0 Mon Sep 17 00:00:00 2001 From: Jeff Handley Date: Thu, 23 Jul 2026 12:29:33 -0700 Subject: [PATCH 1/7] Incorporate 10.8.2 servicing release feedback into release-manager playbooks - Add startup stage-tree overview and per-stage progress rail to the agent's operating rules so each session opens with a process snapshot. - Require concrete next-step guidance after every stage so the user always knows the exact prompt or command needed to advance. - Add explicit run-location discipline: privileged and auth-sensitive commands use an editor-in-wait prompt for API keys and a terminal handoff block for AzDO/artifact/publish operations. - Add conditional Stage 7 (support-page) policy: skip for servicing releases that touch only existing packages; release-manager authors the dotnet/website PR when new packages are introduced; monthly releases review a partner-team PR. - Prefer the source main PR over the bot-authored backport PR in servicing release notes, with correct human author attribution. - Require a tag preflight check before creating a draft GitHub release. - Add Step 2 to the servicing preparation flow: survey the release branch and open PRs for existing backport activity; inform the user that the release-manager agent can efficiently cherry-pick from main as part of the servicing release process; pre-highlight identified source PRs as candidates in the selection table; classify already-released backports separately and surface them in a grounding table rather than omitting them. - Add release wrap-up rule: present a celebratory closing message with per-stage wall-clock timing and an estimate of active user-interaction time. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .github/agents/release-manager.agent.md | 21 +++++ .../references/stages-1-2-servicing-branch.md | 81 +++++++++++++++---- .../references/stage-4-publish-and-promote.md | 20 ++++- .../validate-release/README.md | 10 ++- .../references/stage-7-support-page.md | 42 +++++++++- .../write-release-notes/README.md | 8 ++ .../references/collect-prs.md | 12 +++ 7 files changed, 172 insertions(+), 22 deletions(-) diff --git a/.github/agents/release-manager.agent.md b/.github/agents/release-manager.agent.md index e5c5d315f67..723f18b0c3e 100644 --- a/.github/agents/release-manager.agent.md +++ b/.github/agents/release-manager.agent.md @@ -34,6 +34,10 @@ every gate. ## Starting a session +When a new release-manager session begins and a release activity is in scope, first present a compact +process overview as a tree that shows the active track and all stages/sub-stages (monthly vs +servicing), then state which stage is current. + When the user asks where the release process stands, assess the current release state without relying on this session's history: inspect the available branch, commit, pull-request, and build information; identify what is complete and what remains; and state any missing context. Earlier stages may have @@ -84,10 +88,20 @@ verification script lives at `release-manager/validate-release/scripts/Test-Sour into sub-stages); complete them strictly in order. Pause for the user to review and approve before each commit, and before every push, pipeline queue, publish, channel promotion, or merge-commit completion. **Never push until the user explicitly instructs it.** +- **Progress visibility.** At each gating prompt, include a concise progress rail that shows completed + stages, the current stage/sub-step, and remaining stages. Keep it compact and update it every time + stage state changes. +- **Concrete next-step guidance.** After completing each stage/sub-stage, tell the user the exact next + action to advance (for example, a specific approval cue or command handoff) so they do not need to + guess or repeatedly send generic "proceed" prompts. - **Irreversibility.** Publishing to nuget.org, promoting a build to a public channel (its symbols and packages flow to msdl and downstream consumers), and pushing tags cannot be cleanly undone. Prepare and review first, then act only on explicit user confirmation. Never run `dotnet nuget push` yourself and never handle nuget.org API keys. +- **Run-location discipline.** Orchestrate in-session, but for privileged/auth-sensitive operational + commands (for example AzDO artifact access, `dotnet nuget push`, and BAR promotion checks), prefer + explicit terminal handoff blocks for the user to run outside the session, then continue based on + their reported output. - **Commit hygiene.** Every stage and every sub-stage is its own commit -- never combine them. Commits that land in public `dotnet/extensions` history keep the `Co-authored-by: Copilot` trailer but **omit** the `Copilot-Session` trailer. Write commit subjects that describe what @@ -97,6 +111,13 @@ verification script lives at `release-manager/validate-release/scripts/Test-Sour branches. Resolve each by its **URL**, not by remote name -- names vary by machine. Do not rely on absolute on-disk clone paths. - **Release notes** are never published to a GitHub release without explicit user confirmation. +- **Release wrap-up.** When the full release process is complete (GitHub release published, any + required branch reconciliation done), present a short celebratory closing message that: + - Confirms the release version and packages shipped. + - Includes a timing summary: wall-clock elapsed time for each stage completed this session, total + elapsed time for the session, and an estimate of active user-interaction time (time the user + spent responding to prompts, as distinct from wait time while the agent or CI worked). + - Track stage start/end times throughout the session so this summary is accurate. ## Release process at a glance diff --git a/.github/agents/release-manager/prepare-release/references/stages-1-2-servicing-branch.md b/.github/agents/release-manager/prepare-release/references/stages-1-2-servicing-branch.md index a20a150fcb6..244e6f98336 100644 --- a/.github/agents/release-manager/prepare-release/references/stages-1-2-servicing-branch.md +++ b/.github/agents/release-manager/prepare-release/references/stages-1-2-servicing-branch.md @@ -20,17 +20,60 @@ Then stop for merge/approval before publish steps. 2. Confirm the target patch version (`..`). 3. Create a working branch from `origin/release/.`. -## Step 2: Build the candidate backport list from `main` +## Step 2: Survey the release branch for existing backport activity + +Before building the full candidate list, check for evidence of existing backport work targeting this +release. This step is informational -- it seeds the candidate list and reminds the user that the release-manager agent can efficiently +cherry-pick from `main` as part of the servicing release process. + +### 2a) Identify backport PRs + +1. **Open PRs** targeting `release/.`: find any with titles starting with + `[release/.]` or bodies containing `Backport of #NNNN` / `backport of #NNNN`. +2. **Recently merged PRs** into `release/.` since the previous release tag: apply the + same pattern match. +3. For each backport PR found, extract the source `main` PR number from its title or body. + +### 2b) Classify backport PRs by release status + +For each source `main` PR number identified, check whether it already shipped in a previous release: + +- Run `git merge-base --is-ancestor ` to check ancestry. +- Or check whether the PR number appears in a prior release's release notes body. + +Classify each backport PR as either: + +- **Already released** -- source PR shipped in a prior release tag; carry this into Step 3d for the + already-released grounding table (do not present it as a selectable candidate or comment on it). +- **Not yet released** -- source PR has not shipped; carry it into the Step 3 candidate list as a + pre-highlighted row. + +### 2c) Inform the user if open or unshipped backport PRs exist + +If any open or recently-merged (but not yet incorporated) backport PRs remain after filtering: + +> "I see backport PRs already open/merged targeting `release/.`. The release manager +> can cherry-pick those changes directly from `main` -- this is typically more efficient than +> managing individual backport PRs. I'll include the corresponding `main` PRs as pre-highlighted +> candidates in the selection below." + +Carry the source `main` PR numbers into the Step 3 candidate list as pre-highlighted rows. + +If no relevant backport activity is found, proceed directly to Step 3 without comment. + +--- + +## Step 3: Build the candidate backport list from `main` The user needs a clear **selection list** of commits from `main` that are not yet included in `release/.`. For grounding, also present a separate view of what is already merged into `release/.` (whether those merges happened before or after the target patch-version bump point). -### 2a) Gather candidate PRs/commits +### 3a) Gather candidate PRs/commits Build candidates from commits in `main` that are not in `release/.`, then map them to PR numbers where possible. -### 2b) Build grounding for already-merged release-branch items +### 3b) Build grounding for already-merged release-branch items Identify items that already shipped on the release branch by checking merged PRs into `release/.` and harvesting referenced `#NNNN` slugs from their titles/bodies. @@ -41,7 +84,7 @@ Present these as **grounding only** (not selectable candidates). If a patch-bump If no patch-bump commit exists yet on the release branch, label rows as `before planned patch bump`. -### 2c) Enrich each candidate with affected libraries +### 3c) Enrich each candidate with affected libraries For each candidate PR: @@ -49,33 +92,41 @@ For each candidate PR: 2. Derive affected libraries from touched paths (for example, `src/Libraries//...`). 3. Keep the list unique and sorted. -### 2d) Present the selection table +### 3d) Present the selection table -Show a selection table with one row per **selectable** candidate item: +Show a selection table with one row per **selectable** candidate item. Pre-highlight any rows +seeded from Step 2 (existing backport PRs), so the user can quickly confirm or adjust those: -| SHA (short) | Title | PR | Libraries affected | -|---|---|---|---| -| `abc1234` | Commit/PR title | `#1234` link | `Microsoft.Extensions.AI`, ... | +| SHA (short) | Title | PR | Libraries affected | Pre-selected? | +|---|---|---|---|---| +| `abc1234` | Commit/PR title | `#1234` link | `Microsoft.Extensions.AI`, ... | ✔ (backport PR open) | -Then show a separate grounding table for already-merged release-branch items: +Then show a separate grounding table for items already merged into the release branch for this +release cycle: | Timing vs patch bump | SHA (short) | Title | PR | Libraries affected | |---|---|---|---|---| | before patch bump / after patch bump / before planned patch bump | `def5678` | Commit/PR title | `#5678` link | `Microsoft.Extensions.AI.Abstractions`, ... | +Then show a third table for items identified in Step 2 as already shipped in a **prior** release: + +| Prior release tag | Source PR | Title | Libraries affected | +|---|---|---|---| +| `v10.8.1` | `#5432` link | Commit/PR title | `Microsoft.Extensions.AI`, ... | + Requirements: - Keep already-merged release-branch rows out of the selectable table. - Prefer PR-number rows when available. - If a commit has no PR number, mark it and ask the user whether to include it explicitly. -## Step 3: Ask the user to choose items (multi-select) +## Step 4: Ask the user to choose items (multi-select) Prompt the user to select rows from the **selectable candidate table only** (for example by PR numbers or row numbers, comma-separated). Do not cherry-pick until selection is explicit. -## Step 4: Confirm package scope and template inclusion +## Step 5: Confirm package scope and template inclusion Before committing: @@ -85,7 +136,7 @@ Before committing: Record this confirmed scope; it is the source of truth for publish/validate/release-notes stages. -## Step 5: Apply commits in servicing order +## Step 6: Apply commits in servicing order Apply commits in this exact order: @@ -104,7 +155,7 @@ For each cherry-pick, record whether it was: - Commit message format: `Bump version to ..`. - This matches recent 10.* servicing examples (`10.4.1`, `10.5.1`, `10.5.2`, `10.8.1`). -## Step 6: Create the servicing-prep PR +## Step 7: Create the servicing-prep PR Do not push or open the PR until the user explicitly says to do so. @@ -139,7 +190,7 @@ Notes: - Keep the `DO-NOT-SQUASH` label on the PR from creation through merge. - For the merge method into `release/.`, prefer **Rebase and merge** (never squash). -## Step 7: Preserve scope for downstream stages +## Step 8: Preserve scope for downstream stages After PR creation, treat the merged servicing-prep PR description as authoritative for: diff --git a/.github/agents/release-manager/publish-release/references/stage-4-publish-and-promote.md b/.github/agents/release-manager/publish-release/references/stage-4-publish-and-promote.md index b5d51f12aa1..626c3e3d8e8 100644 --- a/.github/agents/release-manager/publish-release/references/stage-4-publish-and-promote.md +++ b/.github/agents/release-manager/publish-release/references/stage-4-publish-and-promote.md @@ -16,11 +16,19 @@ This stage is operational and produces no commit. Both actions are **irreversibl For servicing releases, also require the merged servicing-prep PR description; it is the source of truth for package scope. +## Execution context check + +Before running artifact/publish operations, confirm whether this session can access AzDO/BAR with the +required auth context. If access is limited in-session, switch to a terminal handoff flow: provide +exact commands for the user to run outside the session, then continue based on their output. + ## Sub-stage 1 - Prepare and publish packages Publishing is irreversible (a published version cannot be overwritten or truly deleted) and requires nuget.org API keys, so the agent only prepares the set; the user runs the push. 1. Download and extract the `PackageArtifacts` from the official build. + - If in-session artifact download/auth fails, hand off this step to the user's terminal and ask + them to return the local extracted package folder path. 2. Stage the packages to publish into a clean folder: - Always exclude `Microsoft.Internal.*`. - For **servicing releases**, start from the package list in the merged servicing-prep PR description; treat that list as canonical unless the user explicitly changes it. @@ -28,7 +36,8 @@ Publishing is irreversible (a published version cannot be overwritten or truly d - In both tracks, flag template/tooling packages (for example `*.ProjectTemplates`) for an explicit include/exclude decision. 3. Present the excluded and to-publish lists for the user to review. 4. Two nuget.org accounts are involved: almost all packages publish from the **dotnetframework** account; **`Microsoft.Agents.AI.ProjectTemplates`** publishes from the **MicrosoftAgentFramework** account, so it must be pushed separately with that account's key. -5. The **user** runs `dotnet nuget push` with the appropriate API key(s). Never run the push, and never handle the API keys. +5. The **user** runs `dotnet nuget push` with the appropriate API key(s) in their terminal (outside + the agent session). Never run the push, and never handle the API keys. ### Secure API key entry (user-run helper) @@ -102,6 +111,15 @@ Notes: After a successful push, **delete or regenerate the API key on nuget.org** ([nuget.org/account/apikeys](https://www.nuget.org/account/apikeys)) so the key used for this release is not left valid. The helper shreds the temporary key *file* locally, but only deleting/regenerating the key on nuget.org invalidates it server-side. Guide the user to do this as the final step of Sub-stage 1. +### Terminal handoff expectation + +When handing off Sub-stage 1 commands, provide a copy/paste-ready command block and ask the user to +run it in terminal, then report: + +- staged package path(s), +- push output summary, +- confirmation that the nuget.org API key was deleted/regenerated. + ## Sub-stage 2 - Ensure the official release build is on the public channel Determine whether manual promotion is required: diff --git a/.github/agents/release-manager/validate-release/README.md b/.github/agents/release-manager/validate-release/README.md index 77ac7938429..9f473c95f5a 100644 --- a/.github/agents/release-manager/validate-release/README.md +++ b/.github/agents/release-manager/validate-release/README.md @@ -6,9 +6,9 @@ Run this playbook after **publish-release**. - Stage 5 is automated symbol verification. - Stage 6 stages reconciliation merges when needed (pushing and PR completion are left to the user). -- Stage 7 confirms the support-page listing. +- Stage 7 handles support-page follow-up based on release type and package novelty. -For servicing releases prepared directly on public `release/.`, Stage 6 is often unnecessary because commits were backported from `main` into the release branch up front. In that case, run Stage 5 and Stage 7, and run Stage 6 only if the user explicitly asks for additional branch-flow follow-up. +For servicing releases prepared directly on public `release/.`, Stage 6 is often unnecessary because commits were backported from `main` into the release branch up front. In that case, run Stage 5 and run Stage 6 only if the user explicitly asks for additional branch-flow follow-up. ## Stage 5 - Verify Source Link and Symbols @@ -24,7 +24,11 @@ Read and follow [references/stage-6-reconcile-branches.md](references/stage-6-re ## Stage 7 - Confirm the Support-Page Update -The .NET Platform Extensions support page is maintained by a partner team. Confirm that a pull request updating it has been opened for this release, review it, and flag any newly published or recently-stabilized packages that are missing from the list. +Stage 7 is conditional: + +- **Monthly releases**: the support page is maintained by a partner team; review their PR and confirm completeness. +- **Servicing releases that only service existing packages**: Stage 7 can be skipped. +- **Servicing releases that introduce newly shipped packages**: the release manager must author and submit the `dotnet/website` PR that adds those packages to the support page. Read and follow [references/stage-7-support-page.md](references/stage-7-support-page.md). diff --git a/.github/agents/release-manager/validate-release/references/stage-7-support-page.md b/.github/agents/release-manager/validate-release/references/stage-7-support-page.md index b244a695b6f..a2dc12b42fe 100644 --- a/.github/agents/release-manager/validate-release/references/stage-7-support-page.md +++ b/.github/agents/release-manager/validate-release/references/stage-7-support-page.md @@ -1,6 +1,22 @@ -# Stage 7 - Confirm the Support-Page Update +# Stage 7 - Support-Page Follow-up -The [.NET Platform Extensions support policy page](https://dotnet.microsoft.com/en-us/platform/support/policy/extensions) lists the supported packages and their current versions. It is maintained by a **partner team**, so this stage is a **review**, not an edit: confirm their update lands and is complete. +The [.NET Platform Extensions support policy page](https://dotnet.microsoft.com/en-us/platform/support/policy/extensions) +lists supported packages and current versions. This stage differs by release type and package novelty. + +## Decision gate + +1. Determine whether the release is **monthly** or **servicing**. +2. Determine whether the shipped scope includes any **newly published packages**. + +Then follow one path: + +- **Monthly release** -> review partner-team PR (Path A). +- **Servicing release, existing packages only** -> skip Stage 7 (Path B). +- **Servicing release with new package(s)** -> release manager authors `dotnet/website` PR (Path C). + +## Path A - Monthly release (review partner-team PR) + +The support page is maintained by a partner team in the monthly flow; this path is a review. ## Steps @@ -15,6 +31,26 @@ The [.NET Platform Extensions support policy page](https://dotnet.microsoft.com/ Exclude preview-only packages, which are not listed. 4. If the pull request is missing or incomplete, raise it with the partner team. +## Path B - Servicing release with existing packages only + +No support-page PR is required. Record that Stage 7 is intentionally skipped because the servicing +release did not introduce new package entries. + +## Path C - Servicing release with newly published package(s) + +In servicing flow, the release manager owns the website update when a new package is introduced. + +1. Create a PR in [`dotnet/website`](https://github.com/dotnet/website) updating + `website/src/netlandingpage/Pages/platform/support/policy/extensions.cshtml`. +2. Update: + - supported version and release date for this release, + - package list entries for newly published packages, + - any required out-of-support table changes. +3. Ensure preview-only packages are not listed. +4. Submit the PR and track it to completion as part of release sign-off. + ## After the stage -This is the final validation step. The release is complete once its symbols are public (Stage 5), the branches are reconciled (Stage 6), and the support-page listing is confirmed. +This is the final validation step when applicable. The release is complete once symbols are public +(Stage 5), branch reconciliation is complete when required (Stage 6), and Stage 7 obligations for the +selected path are satisfied. diff --git a/.github/agents/release-manager/write-release-notes/README.md b/.github/agents/release-manager/write-release-notes/README.md index f3330d1927a..80ce8e974f1 100644 --- a/.github/agents/release-manager/write-release-notes/README.md +++ b/.github/agents/release-manager/write-release-notes/README.md @@ -24,6 +24,7 @@ The repository uses `release/` branches (e.g. `release/10.4`) where release tags - **Maximize parallel tool calls.** Fetch multiple PR and issue details in a single response. - **Package assignment is file-path-driven.** Determine which packages a PR affects by examining which `src/Libraries/{PackageName}/` paths it touches. See [references/package-areas.md](references/package-areas.md) for the mapping. Use `area-*` labels only as a fallback. - **For servicing releases, use the servicing-prep PR description as scope input.** If a merged PR titled `Prepare .. Servicing Release` exists, treat its package list and commit list as the default source of truth for publish/validate/release-notes scope unless the user explicitly overrides it. +- **For servicing backports, prefer source PR entries.** When a servicing `release/*` PR backports a `main` PR, use the source `main` PR as the release-notes entry and attribution source unless the backport PR adds distinct release-only user-facing changes. ## Process @@ -111,10 +112,17 @@ After the user has reviewed and approved the draft, present the finalization opt - **Save to private gist** — save the draft notes to a private GitHub gist for later use - **Cancel** — discard the draft without creating anything +Before creating a draft release, run a required tag preflight: + +1. Check whether the expected `v` tag already exists. +2. If missing, ask for explicit confirmation to create the tag at the selected release commit. +3. Only then create the draft release. + ## Edge Cases - **PR spans categories**: Categorize by primary intent; read the title and description. - **PR spans multiple areas**: Place under the most central area; mention cross-cutting nature in the description. +- **Servicing backport pair appears in range**: keep the source `main` PR and exclude the `release/*` wrapper PR unless the wrapper carries distinct release-only user-facing changes. - **Copilot-authored PRs**: If the PR author is Copilot or a bot, check the `copilot_work_started` timeline event for the triggering user, then assignees, then the merger. See [references/editorial-rules.md](references/editorial-rules.md) for the full fallback chain. Never fabricate an attribution — always derive it from the PR data. - **No breaking changes**: Omit the Breaking Changes section entirely. - **No experimental changes**: Omit the Experimental API Changes section entirely. diff --git a/.github/agents/release-manager/write-release-notes/references/collect-prs.md b/.github/agents/release-manager/write-release-notes/references/collect-prs.md index 7b3419990fb..7c1c3a14754 100644 --- a/.github/agents/release-manager/write-release-notes/references/collect-prs.md +++ b/.github/agents/release-manager/write-release-notes/references/collect-prs.md @@ -8,6 +8,18 @@ Gather all merged PRs between the previous release tag and the target for this r 2. **Target**: The user provides a target (commit SHA, branch, or tag). If none is specified, use the `HEAD` of the default branch (`main`). 3. Verify both refs exist: `git rev-parse ` and `git rev-parse `. +## Servicing override for backports + +For servicing releases, treat the merged servicing-prep PR as canonical scope input. + +1. Collect the `release/*` PRs listed or implied by that servicing-prep scope. +2. For each `release/*` backport PR, resolve its source `main` PR from title/body references + (for example `Backport of #NNNN` or trailing `(#NNNN)`). +3. Use the source `main` PR as the release-notes entry and attribution source, even when the source + PR merge date falls outside the tag-to-tag range. +4. Keep the `release/*` wrapper PR excluded unless it introduces distinct release-only user-facing + behavior not present in the source PR. + ## Search for merged PRs ### Primary — GitHub MCP server From f0f409f8ded39dd2f6923bb42667b5dba83785e5 Mon Sep 17 00:00:00 2001 From: Jeff Handley Date: Thu, 23 Jul 2026 05:30:36 -0700 Subject: [PATCH 2/7] Remove GitHub Models from aichatweb template Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: dc263661-1b95-4f80-a7f4-3a4d3cd275ff --- .../.template.config/template.json | 10 -- .../AIChatWeb-CSharp.AppHost/AppHost.cs | 10 +- .../AIChatWeb-CSharp.Web.csproj-in | 4 +- .../AIChatWeb-CSharp.Web/Program.Aspire.cs | 4 +- .../AIChatWeb-CSharp.Web/Program.cs | 20 +-- .../AIChatWeb-CSharp.Web/README.md | 26 ---- .../AIChatWeb-CSharp/README.Aspire.md | 26 ---- .../AIChatWebExecutionTests.cs | 2 +- .../AIChatWebSnapshotTests.cs | 4 +- .../aichatweb.A.verified/aichatweb/README.md | 57 -------- .../aichatweb/aichatweb.AppHost/AppHost.cs | 18 --- .../Properties/launchSettings.json | 29 ---- .../aichatweb.AppHost.csproj | 21 --- .../appsettings.Development.json | 8 -- .../aichatweb.AppHost/appsettings.json | 9 -- .../aichatweb.ServiceDefaults/Extensions.cs | 134 ------------------ .../aichatweb.ServiceDefaults.csproj | 22 --- .../aichatweb.Web/Components/App.razor | 24 ---- .../Components/Layout/LoadingSpinner.razor | 1 - .../Layout/LoadingSpinner.razor.css | 89 ------------ .../Components/Layout/MainLayout.razor | 9 -- .../Components/Layout/MainLayout.razor.css | 20 --- .../Components/Layout/SurveyPrompt.razor | 13 -- .../Components/Layout/SurveyPrompt.razor.css | 20 --- .../Components/Pages/Chat/Chat.razor | 134 ------------------ .../Components/Pages/Chat/Chat.razor.css | 11 -- .../Components/Pages/Chat/ChatCitation.razor | 39 ----- .../Pages/Chat/ChatCitation.razor.css | 37 ----- .../Components/Pages/Chat/ChatHeader.razor | 17 --- .../Pages/Chat/ChatHeader.razor.css | 25 ---- .../Components/Pages/Chat/ChatInput.razor | 51 ------- .../Components/Pages/Chat/ChatInput.razor.css | 57 -------- .../Components/Pages/Chat/ChatInput.razor.js | 43 ------ .../Pages/Chat/ChatMessageItem.razor | 107 -------------- .../Pages/Chat/ChatMessageItem.razor.css | 120 ---------------- .../Pages/Chat/ChatMessageList.razor | 42 ------ .../Pages/Chat/ChatMessageList.razor.css | 22 --- .../Pages/Chat/ChatMessageList.razor.js | 34 ----- .../Pages/Chat/ChatSuggestions.razor | 78 ---------- .../Pages/Chat/ChatSuggestions.razor.css | 9 -- .../Components/Pages/Error.razor | 36 ----- .../aichatweb.Web/Components/Routes.razor | 6 - .../aichatweb.Web/Components/_Imports.razor | 13 -- .../aichatweb/aichatweb.Web/Program.cs | 45 ------ .../Properties/launchSettings.json | 23 --- .../aichatweb.Web/Services/IngestedChunk.cs | 31 ---- .../Services/Ingestion/DataIngestor.cs | 35 ----- .../Services/Ingestion/DocumentReader.cs | 42 ------ .../aichatweb.Web/Services/SemanticSearch.cs | 27 ---- .../aichatweb.Web/aichatweb.Web.csproj | 26 ---- .../appsettings.Development.json | 9 -- .../aichatweb/aichatweb.Web/appsettings.json | 10 -- .../aichatweb/aichatweb.sln | 34 ----- .../aichatweb/Components/App.razor | 24 ---- .../Components/Layout/LoadingSpinner.razor | 1 - .../Layout/LoadingSpinner.razor.css | 89 ------------ .../Components/Layout/MainLayout.razor | 9 -- .../Components/Layout/MainLayout.razor.css | 20 --- .../Components/Layout/SurveyPrompt.razor | 13 -- .../Components/Layout/SurveyPrompt.razor.css | 20 --- .../Components/Pages/Chat/Chat.razor | 134 ------------------ .../Components/Pages/Chat/Chat.razor.css | 11 -- .../Components/Pages/Chat/ChatCitation.razor | 39 ----- .../Pages/Chat/ChatCitation.razor.css | 37 ----- .../Components/Pages/Chat/ChatHeader.razor | 17 --- .../Pages/Chat/ChatHeader.razor.css | 25 ---- .../Components/Pages/Chat/ChatInput.razor | 51 ------- .../Components/Pages/Chat/ChatInput.razor.css | 57 -------- .../Components/Pages/Chat/ChatInput.razor.js | 43 ------ .../Pages/Chat/ChatMessageItem.razor | 107 -------------- .../Pages/Chat/ChatMessageItem.razor.css | 120 ---------------- .../Pages/Chat/ChatMessageList.razor | 42 ------ .../Pages/Chat/ChatMessageList.razor.css | 22 --- .../Pages/Chat/ChatMessageList.razor.js | 34 ----- .../Pages/Chat/ChatSuggestions.razor | 78 ---------- .../Pages/Chat/ChatSuggestions.razor.css | 9 -- .../aichatweb/Components/Pages/Error.razor | 36 ----- .../aichatweb/Components/Routes.razor | 6 - .../aichatweb/Components/_Imports.razor | 13 -- .../aichatweb/Program.cs | 53 ------- .../aichatweb/Properties/launchSettings.json | 23 --- .../aichatweb/README.md | 19 --- .../aichatweb/Services/IngestedChunk.cs | 31 ---- .../Services/Ingestion/DataIngestor.cs | 35 ----- .../Services/Ingestion/DocumentReader.cs | 36 ----- .../Services/Ingestion/PdfPigReader.cs | 42 ------ .../aichatweb/Services/SemanticSearch.cs | 27 ---- .../aichatweb/aichatweb.csproj | 21 --- .../aichatweb/appsettings.Development.json | 9 -- .../aichatweb/appsettings.json | 10 -- 90 files changed, 12 insertions(+), 3094 deletions(-) delete mode 100644 test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.A.verified/aichatweb/README.md delete mode 100644 test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.A.verified/aichatweb/aichatweb.AppHost/AppHost.cs delete mode 100644 test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.A.verified/aichatweb/aichatweb.AppHost/Properties/launchSettings.json delete mode 100644 test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.A.verified/aichatweb/aichatweb.AppHost/aichatweb.AppHost.csproj delete mode 100644 test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.A.verified/aichatweb/aichatweb.AppHost/appsettings.Development.json delete mode 100644 test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.A.verified/aichatweb/aichatweb.AppHost/appsettings.json delete mode 100644 test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.A.verified/aichatweb/aichatweb.ServiceDefaults/Extensions.cs delete mode 100644 test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.A.verified/aichatweb/aichatweb.ServiceDefaults/aichatweb.ServiceDefaults.csproj delete mode 100644 test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.A.verified/aichatweb/aichatweb.Web/Components/App.razor delete mode 100644 test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.A.verified/aichatweb/aichatweb.Web/Components/Layout/LoadingSpinner.razor delete mode 100644 test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.A.verified/aichatweb/aichatweb.Web/Components/Layout/LoadingSpinner.razor.css delete mode 100644 test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.A.verified/aichatweb/aichatweb.Web/Components/Layout/MainLayout.razor delete mode 100644 test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.A.verified/aichatweb/aichatweb.Web/Components/Layout/MainLayout.razor.css delete mode 100644 test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.A.verified/aichatweb/aichatweb.Web/Components/Layout/SurveyPrompt.razor delete mode 100644 test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.A.verified/aichatweb/aichatweb.Web/Components/Layout/SurveyPrompt.razor.css delete mode 100644 test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.A.verified/aichatweb/aichatweb.Web/Components/Pages/Chat/Chat.razor delete mode 100644 test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.A.verified/aichatweb/aichatweb.Web/Components/Pages/Chat/Chat.razor.css delete mode 100644 test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.A.verified/aichatweb/aichatweb.Web/Components/Pages/Chat/ChatCitation.razor delete mode 100644 test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.A.verified/aichatweb/aichatweb.Web/Components/Pages/Chat/ChatCitation.razor.css delete mode 100644 test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.A.verified/aichatweb/aichatweb.Web/Components/Pages/Chat/ChatHeader.razor delete mode 100644 test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.A.verified/aichatweb/aichatweb.Web/Components/Pages/Chat/ChatHeader.razor.css delete mode 100644 test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.A.verified/aichatweb/aichatweb.Web/Components/Pages/Chat/ChatInput.razor delete mode 100644 test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.A.verified/aichatweb/aichatweb.Web/Components/Pages/Chat/ChatInput.razor.css delete mode 100644 test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.A.verified/aichatweb/aichatweb.Web/Components/Pages/Chat/ChatInput.razor.js delete mode 100644 test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.A.verified/aichatweb/aichatweb.Web/Components/Pages/Chat/ChatMessageItem.razor delete mode 100644 test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.A.verified/aichatweb/aichatweb.Web/Components/Pages/Chat/ChatMessageItem.razor.css delete mode 100644 test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.A.verified/aichatweb/aichatweb.Web/Components/Pages/Chat/ChatMessageList.razor delete mode 100644 test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.A.verified/aichatweb/aichatweb.Web/Components/Pages/Chat/ChatMessageList.razor.css delete mode 100644 test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.A.verified/aichatweb/aichatweb.Web/Components/Pages/Chat/ChatMessageList.razor.js delete mode 100644 test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.A.verified/aichatweb/aichatweb.Web/Components/Pages/Chat/ChatSuggestions.razor delete mode 100644 test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.A.verified/aichatweb/aichatweb.Web/Components/Pages/Chat/ChatSuggestions.razor.css delete mode 100644 test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.A.verified/aichatweb/aichatweb.Web/Components/Pages/Error.razor delete mode 100644 test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.A.verified/aichatweb/aichatweb.Web/Components/Routes.razor delete mode 100644 test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.A.verified/aichatweb/aichatweb.Web/Components/_Imports.razor delete mode 100644 test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.A.verified/aichatweb/aichatweb.Web/Program.cs delete mode 100644 test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.A.verified/aichatweb/aichatweb.Web/Properties/launchSettings.json delete mode 100644 test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.A.verified/aichatweb/aichatweb.Web/Services/IngestedChunk.cs delete mode 100644 test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.A.verified/aichatweb/aichatweb.Web/Services/Ingestion/DataIngestor.cs delete mode 100644 test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.A.verified/aichatweb/aichatweb.Web/Services/Ingestion/DocumentReader.cs delete mode 100644 test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.A.verified/aichatweb/aichatweb.Web/Services/SemanticSearch.cs delete mode 100644 test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.A.verified/aichatweb/aichatweb.Web/aichatweb.Web.csproj delete mode 100644 test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.A.verified/aichatweb/aichatweb.Web/appsettings.Development.json delete mode 100644 test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.A.verified/aichatweb/aichatweb.Web/appsettings.json delete mode 100644 test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.A.verified/aichatweb/aichatweb.sln delete mode 100644 test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb._defaults.verified/aichatweb/Components/App.razor delete mode 100644 test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb._defaults.verified/aichatweb/Components/Layout/LoadingSpinner.razor delete mode 100644 test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb._defaults.verified/aichatweb/Components/Layout/LoadingSpinner.razor.css delete mode 100644 test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb._defaults.verified/aichatweb/Components/Layout/MainLayout.razor delete mode 100644 test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb._defaults.verified/aichatweb/Components/Layout/MainLayout.razor.css delete mode 100644 test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb._defaults.verified/aichatweb/Components/Layout/SurveyPrompt.razor delete mode 100644 test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb._defaults.verified/aichatweb/Components/Layout/SurveyPrompt.razor.css delete mode 100644 test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb._defaults.verified/aichatweb/Components/Pages/Chat/Chat.razor delete mode 100644 test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb._defaults.verified/aichatweb/Components/Pages/Chat/Chat.razor.css delete mode 100644 test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb._defaults.verified/aichatweb/Components/Pages/Chat/ChatCitation.razor delete mode 100644 test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb._defaults.verified/aichatweb/Components/Pages/Chat/ChatCitation.razor.css delete mode 100644 test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb._defaults.verified/aichatweb/Components/Pages/Chat/ChatHeader.razor delete mode 100644 test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb._defaults.verified/aichatweb/Components/Pages/Chat/ChatHeader.razor.css delete mode 100644 test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb._defaults.verified/aichatweb/Components/Pages/Chat/ChatInput.razor delete mode 100644 test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb._defaults.verified/aichatweb/Components/Pages/Chat/ChatInput.razor.css delete mode 100644 test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb._defaults.verified/aichatweb/Components/Pages/Chat/ChatInput.razor.js delete mode 100644 test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb._defaults.verified/aichatweb/Components/Pages/Chat/ChatMessageItem.razor delete mode 100644 test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb._defaults.verified/aichatweb/Components/Pages/Chat/ChatMessageItem.razor.css delete mode 100644 test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb._defaults.verified/aichatweb/Components/Pages/Chat/ChatMessageList.razor delete mode 100644 test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb._defaults.verified/aichatweb/Components/Pages/Chat/ChatMessageList.razor.css delete mode 100644 test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb._defaults.verified/aichatweb/Components/Pages/Chat/ChatMessageList.razor.js delete mode 100644 test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb._defaults.verified/aichatweb/Components/Pages/Chat/ChatSuggestions.razor delete mode 100644 test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb._defaults.verified/aichatweb/Components/Pages/Chat/ChatSuggestions.razor.css delete mode 100644 test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb._defaults.verified/aichatweb/Components/Pages/Error.razor delete mode 100644 test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb._defaults.verified/aichatweb/Components/Routes.razor delete mode 100644 test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb._defaults.verified/aichatweb/Components/_Imports.razor delete mode 100644 test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb._defaults.verified/aichatweb/Program.cs delete mode 100644 test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb._defaults.verified/aichatweb/Properties/launchSettings.json delete mode 100644 test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb._defaults.verified/aichatweb/README.md delete mode 100644 test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb._defaults.verified/aichatweb/Services/IngestedChunk.cs delete mode 100644 test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb._defaults.verified/aichatweb/Services/Ingestion/DataIngestor.cs delete mode 100644 test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb._defaults.verified/aichatweb/Services/Ingestion/DocumentReader.cs delete mode 100644 test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb._defaults.verified/aichatweb/Services/Ingestion/PdfPigReader.cs delete mode 100644 test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb._defaults.verified/aichatweb/Services/SemanticSearch.cs delete mode 100644 test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb._defaults.verified/aichatweb/aichatweb.csproj delete mode 100644 test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb._defaults.verified/aichatweb/appsettings.Development.json delete mode 100644 test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb._defaults.verified/aichatweb/appsettings.json 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..c769d2e9aca 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 @@ -128,18 +128,12 @@ "type": "parameter", "displayName": "_AI service provider", "datatype": "choice", - "defaultValue": "githubmodels", "choices": [ { "choice": "azureopenai", "displayName": "Azure OpenAI", "description": "Uses Azure OpenAI service" }, - { - "choice": "githubmodels", - "displayName": "GitHub Models", - "description": "Uses GitHub Models" - }, { "choice": "ollama", "displayName": "Ollama (for local development)", @@ -215,10 +209,6 @@ "type": "computed", "value": "(AiServiceProvider == \"openai\")" }, - "IsGHModels": { - "type": "computed", - "value": "(AiServiceProvider == \"githubmodels\")" - }, "IsOllama": { "type": "computed", "value": "(AiServiceProvider == \"ollama\")" 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..fd577fedc2c 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,17 +1,11 @@ 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"); #else // IsAzureOpenAI @@ -62,7 +56,7 @@ .WithReference(embeddings) .WaitFor(chat) .WaitFor(embeddings); -#elif (IsOpenAI || IsGHModels) +#elif (IsOpenAI) webApp.WithReference(openai); #else // IsAzureOpenAI webApp 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..9b4f08998f4 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 --> - + 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..66b017af999 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,7 @@ builder.AddOllamaApiClient("embeddings") .AddEmbeddingGenerator(); #elif (IsAzureAIFoundry) -#else // (IsOpenAI || IsAzureOpenAI || IsGHModels) +#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..ae7ccc9d0ae 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"), 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..29552b95212 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 @@ -21,32 +21,6 @@ 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: - -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: - -```sh -cd <> -dotnet user-secrets set GitHubModels:Token YOUR-TOKEN -``` -#### ---#endif - -Learn more about [prototyping with AI models using GitHub Models](https://docs.github.com/github-models/prototyping-with-ai-models). - -#### ---#endif #### ---#if (IsOpenAI) ## Using OpenAI 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..fc3071a5e80 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 @@ -20,32 +20,6 @@ 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 (hostIdentifier == "vs") -Configure your token for this project using .NET User Secrets: - -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: - - ```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 - -Learn more about [prototyping with AI models using GitHub Models](https://docs.github.com/github-models/prototyping-with-ai-models). -#### ---#endif #### ---#if (IsOpenAI) ## Using OpenAI diff --git a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/AIChatWebExecutionTests.cs b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/AIChatWebExecutionTests.cs index 74bfd3ffdd6..4d4ceb4073c 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", ["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..c94354aa0cd 100644 --- a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/AIChatWebSnapshotTests.cs +++ b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/AIChatWebSnapshotTests.cs @@ -27,10 +27,10 @@ public AIChatWebSnapshotTests(ITestOutputHelper log) } [Theory] - [InlineData /* Defaults: --provider=githubmodels --vector-store=local */] + [InlineData("--provider=openai")] [InlineData("--provider=ollama", "--vector-store=qdrant")] [InlineData("--provider=openai", "--vector-store=azureaisearch")] - [InlineData("--aspire")] + [InlineData("--aspire", "--provider=openai")] [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/README.md b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.A.verified/aichatweb/README.md deleted file mode 100644 index 94c542fda7f..00000000000 --- a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.A.verified/aichatweb/README.md +++ /dev/null @@ -1,57 +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. - -### Known Issues - -#### Errors running Ollama or Docker - -A recent incompatibility was found between Ollama and Docker Desktop. This issue results in runtime errors when connecting to Ollama, and the workaround for that can lead to Docker not working for Aspire projects. - -This incompatibility can be addressed by upgrading to Docker Desktop 4.41.1. See [ollama/ollama#9509](https://github.com/ollama/ollama/issues/9509#issuecomment-2842461831) for more information and a link to install the version of Docker Desktop with the fix. - -# 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). - -From the command line, configure your token for this project using .NET User Secrets by running the following commands: - -```sh -cd aichatweb.AppHost -dotnet user-secrets set ConnectionStrings:openai "Endpoint=https://models.inference.ai.azure.com;Key=YOUR-API-KEY" -``` - -Learn more about [prototyping with AI models using GitHub Models](https://docs.github.com/github-models/prototyping-with-ai-models). - -# Running the application - -## Using Visual Studio - -1. Open the `.sln` file in Visual Studio. -2. Press `Ctrl+F5` or click the "Start" button in the toolbar to run the project. - -## Using Visual Studio Code - -1. Open the project folder in Visual Studio Code. -2. Install the [C# Dev Kit extension](https://marketplace.visualstudio.com/items?itemName=ms-dotnettools.csdevkit) for Visual Studio Code. -3. Once installed, Open the `Program.cs` file in the aichatweb.AppHost project. -4. Run the project by clicking the "Run" button in the Debug view. - -## Trust the localhost certificate - -Several Aspire templates include ASP.NET Core projects that are configured to use HTTPS by default. If this is the first time you're running the project, an exception might occur when loading the Aspire dashboard. This error can be resolved by trusting the self-signed development certificate with the .NET CLI. - -See [Troubleshoot untrusted localhost certificate in Aspire](https://learn.microsoft.com/dotnet/aspire/troubleshooting/untrusted-localhost-certificate) for more information. - -# Updating JavaScript dependencies - -This template leverages JavaScript libraries to provide essential functionality. These libraries are located in the wwwroot/lib folder of the aichatweb.Web project. For instructions on updating each dependency, please refer to the README.md file in each respective folder. - -# Learn More -To learn more about development with .NET and AI, check out the following links: - -* [AI for .NET Developers](https://learn.microsoft.com/dotnet/ai/) 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.verified/aichatweb/aichatweb.AppHost/AppHost.cs deleted file mode 100644 index bf116a0c47e..00000000000 --- a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.A.verified/aichatweb/aichatweb.AppHost/AppHost.cs +++ /dev/null @@ -1,18 +0,0 @@ -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")); - -builder.Build().Run(); 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.verified/aichatweb/aichatweb.AppHost/Properties/launchSettings.json deleted file mode 100644 index f08691f26f1..00000000000 --- a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.A.verified/aichatweb/aichatweb.AppHost/Properties/launchSettings.json +++ /dev/null @@ -1,29 +0,0 @@ -{ - "$schema": "https://json.schemastore.org/launchsettings.json", - "profiles": { - "https": { - "commandName": "Project", - "dotnetRunMessages": true, - "launchBrowser": true, - "applicationUrl": "https://localhost:9995;http://localhost:9996", - "environmentVariables": { - "ASPNETCORE_ENVIRONMENT": "Development", - "DOTNET_ENVIRONMENT": "Development", - "ASPIRE_DASHBOARD_OTLP_ENDPOINT_URL": "https://localhost:9995", - "ASPIRE_RESOURCE_SERVICE_ENDPOINT_URL": "https://localhost:9995" - } - }, - "http": { - "commandName": "Project", - "dotnetRunMessages": true, - "launchBrowser": true, - "applicationUrl": "http://localhost:9996", - "environmentVariables": { - "ASPNETCORE_ENVIRONMENT": "Development", - "DOTNET_ENVIRONMENT": "Development", - "ASPIRE_DASHBOARD_OTLP_ENDPOINT_URL": "http://localhost:9996", - "ASPIRE_RESOURCE_SERVICE_ENDPOINT_URL": "http://localhost:9996" - } - } - } -} 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.verified/aichatweb/aichatweb.AppHost/aichatweb.AppHost.csproj deleted file mode 100644 index c9340b988a1..00000000000 --- a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.A.verified/aichatweb/aichatweb.AppHost/aichatweb.AppHost.csproj +++ /dev/null @@ -1,21 +0,0 @@ - - - - - - Exe - net10.0 - enable - enable - {00000000-0000-0000-0000-000000000000} - - - - - - - - - - - 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.verified/aichatweb/aichatweb.AppHost/appsettings.Development.json deleted file mode 100644 index b0bacf42851..00000000000 --- a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.A.verified/aichatweb/aichatweb.AppHost/appsettings.Development.json +++ /dev/null @@ -1,8 +0,0 @@ -{ - "Logging": { - "LogLevel": { - "Default": "Information", - "Microsoft.AspNetCore": "Warning" - } - } -} 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.verified/aichatweb/aichatweb.AppHost/appsettings.json deleted file mode 100644 index bfad98588cd..00000000000 --- a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.A.verified/aichatweb/aichatweb.AppHost/appsettings.json +++ /dev/null @@ -1,9 +0,0 @@ -{ - "Logging": { - "LogLevel": { - "Default": "Information", - "Microsoft.AspNetCore": "Warning", - "Aspire.Hosting.Dcp": "Warning" - } - } -} 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.verified/aichatweb/aichatweb.ServiceDefaults/Extensions.cs deleted file mode 100644 index 8d0b0cd5d67..00000000000 --- a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.A.verified/aichatweb/aichatweb.ServiceDefaults/Extensions.cs +++ /dev/null @@ -1,134 +0,0 @@ -using Microsoft.AspNetCore.Builder; -using Microsoft.AspNetCore.Diagnostics.HealthChecks; -using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Diagnostics.HealthChecks; -using Microsoft.Extensions.Logging; -using Microsoft.Extensions.ServiceDiscovery; -using OpenTelemetry; -using OpenTelemetry.Metrics; -using OpenTelemetry.Trace; - -namespace Microsoft.Extensions.Hosting; - -// Adds common Aspire services: service discovery, resilience, health checks, and OpenTelemetry. -// This project should be referenced by each service project in your solution. -// To learn more about using this project, see https://aka.ms/dotnet/aspire/service-defaults -public static class Extensions -{ - private const string HealthEndpointPath = "/health"; - private const string AlivenessEndpointPath = "/alive"; - - public static TBuilder AddServiceDefaults(this TBuilder builder) where TBuilder : IHostApplicationBuilder - { - builder.ConfigureOpenTelemetry(); - - builder.AddDefaultHealthChecks(); - - builder.Services.AddServiceDiscovery(); - - builder.Services.ConfigureHttpClientDefaults(http => - { -#pragma warning disable EXTEXP0001 // RemoveAllResilienceHandlers is experimental - http.RemoveAllResilienceHandlers(); -#pragma warning restore EXTEXP0001 - - // Turn on resilience by default - http.AddStandardResilienceHandler(); - - // Turn on service discovery by default - http.AddServiceDiscovery(); - }); - - // Uncomment the following to restrict the allowed schemes for service discovery. - // builder.Services.Configure(options => - // { - // options.AllowedSchemes = ["https"]; - // }); - - return builder; - } - - public static TBuilder ConfigureOpenTelemetry(this TBuilder builder) where TBuilder : IHostApplicationBuilder - { - builder.Logging.AddOpenTelemetry(logging => - { - logging.IncludeFormattedMessage = true; - logging.IncludeScopes = true; - }); - - builder.Services.AddOpenTelemetry() - .WithMetrics(metrics => - { - metrics.AddAspNetCoreInstrumentation() - .AddHttpClientInstrumentation() - .AddRuntimeInstrumentation() - .AddMeter("Experimental.Microsoft.Extensions.AI"); - }) - .WithTracing(tracing => - { - tracing.AddSource(builder.Environment.ApplicationName) - .AddAspNetCoreInstrumentation(tracing => - // Exclude health check requests from tracing - tracing.Filter = context => - !context.Request.Path.StartsWithSegments(HealthEndpointPath) - && !context.Request.Path.StartsWithSegments(AlivenessEndpointPath) - ) - // Uncomment the following line to enable gRPC instrumentation (requires the OpenTelemetry.Instrumentation.GrpcNetClient package) - //.AddGrpcClientInstrumentation() - .AddHttpClientInstrumentation() - .AddSource("Experimental.Microsoft.Extensions.AI") - .AddSource("Experimental.Microsoft.Extensions.DataIngestion"); - }); - - builder.AddOpenTelemetryExporters(); - - return builder; - } - - private static TBuilder AddOpenTelemetryExporters(this TBuilder builder) where TBuilder : IHostApplicationBuilder - { - var useOtlpExporter = !string.IsNullOrWhiteSpace(builder.Configuration["OTEL_EXPORTER_OTLP_ENDPOINT"]); - - if (useOtlpExporter) - { - builder.Services.AddOpenTelemetry().UseOtlpExporter(); - } - - // Uncomment the following lines to enable the Azure Monitor exporter (requires the Azure.Monitor.OpenTelemetry.AspNetCore package) - //if (!string.IsNullOrEmpty(builder.Configuration["APPLICATIONINSIGHTS_CONNECTION_STRING"])) - //{ - // builder.Services.AddOpenTelemetry() - // .UseAzureMonitor(); - //} - - return builder; - } - - public static TBuilder AddDefaultHealthChecks(this TBuilder builder) where TBuilder : IHostApplicationBuilder - { - builder.Services.AddHealthChecks() - // Add a default liveness check to ensure app is responsive - .AddCheck("self", () => HealthCheckResult.Healthy(), ["live"]); - - return builder; - } - - public static WebApplication MapDefaultEndpoints(this WebApplication app) - { - // Adding health checks endpoints to applications in non-development environments has security implications. - // See https://aka.ms/dotnet/aspire/healthchecks for details before enabling these endpoints in non-development environments. - if (app.Environment.IsDevelopment()) - { - // All health checks must pass for app to be considered ready to accept traffic after starting - app.MapHealthChecks(HealthEndpointPath); - - // Only health checks tagged with the "live" tag must pass for app to be considered alive - app.MapHealthChecks(AlivenessEndpointPath, new HealthCheckOptions - { - Predicate = r => r.Tags.Contains("live") - }); - } - - return app; - } -} 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.verified/aichatweb/aichatweb.ServiceDefaults/aichatweb.ServiceDefaults.csproj deleted file mode 100644 index b3853baa2d2..00000000000 --- a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.A.verified/aichatweb/aichatweb.ServiceDefaults/aichatweb.ServiceDefaults.csproj +++ /dev/null @@ -1,22 +0,0 @@ - - - - net10.0 - enable - enable - true - - - - - - - - - - - - - - - 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.verified/aichatweb/aichatweb.Web/Components/App.razor deleted file mode 100644 index 262359d5f5a..00000000000 --- a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.A.verified/aichatweb/aichatweb.Web/Components/App.razor +++ /dev/null @@ -1,24 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - -@code { - private readonly IComponentRenderMode renderMode = new InteractiveServerRenderMode(prerender: false); -} 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.verified/aichatweb/aichatweb.Web/Components/Layout/LoadingSpinner.razor deleted file mode 100644 index 116455ce45b..00000000000 --- a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.A.verified/aichatweb/aichatweb.Web/Components/Layout/LoadingSpinner.razor +++ /dev/null @@ -1 +0,0 @@ -
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.verified/aichatweb/aichatweb.Web/Components/Layout/LoadingSpinner.razor.css deleted file mode 100644 index d85b851a679..00000000000 --- a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.A.verified/aichatweb/aichatweb.Web/Components/Layout/LoadingSpinner.razor.css +++ /dev/null @@ -1,89 +0,0 @@ -/* 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.A.verified/aichatweb/aichatweb.Web/Components/Layout/MainLayout.razor b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.A.verified/aichatweb/aichatweb.Web/Components/Layout/MainLayout.razor deleted file mode 100644 index 96fbbe6cc42..00000000000 --- a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.A.verified/aichatweb/aichatweb.Web/Components/Layout/MainLayout.razor +++ /dev/null @@ -1,9 +0,0 @@ -@inherits LayoutComponentBase - -@Body - -
- An unhandled error has occurred. - Reload - 🗙 -
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.verified/aichatweb/aichatweb.Web/Components/Layout/MainLayout.razor.css deleted file mode 100644 index cda2020dcb0..00000000000 --- a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.A.verified/aichatweb/aichatweb.Web/Components/Layout/MainLayout.razor.css +++ /dev/null @@ -1,20 +0,0 @@ -#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.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.verified/aichatweb/aichatweb.Web/Components/Pages/Chat/Chat.razor b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.A.verified/aichatweb/aichatweb.Web/Components/Pages/Chat/Chat.razor deleted file mode 100644 index 6fc5881c18f..00000000000 --- a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.A.verified/aichatweb/aichatweb.Web/Components/Pages/Chat/Chat.razor +++ /dev/null @@ -1,134 +0,0 @@ -@page "/" -@using System.ComponentModel -@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.
- - -
-
- -
- - - @* Remove this line to eliminate the template survey 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 => - $"{result.Text}"); - } - - public void Dispose() - => currentResponseCancellation?.Cancel(); -} 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.verified/aichatweb/aichatweb.Web/Components/Pages/Chat/Chat.razor.css deleted file mode 100644 index 98ed1ba7d1e..00000000000 --- a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.A.verified/aichatweb/aichatweb.Web/Components/Pages/Chat/Chat.razor.css +++ /dev/null @@ -1,11 +0,0 @@ -.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.A.verified/aichatweb/aichatweb.Web/Components/Pages/Chat/ChatCitation.razor b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.A.verified/aichatweb/aichatweb.Web/Components/Pages/Chat/ChatCitation.razor deleted file mode 100644 index 667189beabd..00000000000 --- a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.A.verified/aichatweb/aichatweb.Web/Components/Pages/Chat/ChatCitation.razor +++ /dev/null @@ -1,39 +0,0 @@ -@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.A.verified/aichatweb/aichatweb.Web/Components/Pages/Chat/ChatCitation.razor.css b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.A.verified/aichatweb/aichatweb.Web/Components/Pages/Chat/ChatCitation.razor.css deleted file mode 100644 index 0ca029b7e64..00000000000 --- a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.A.verified/aichatweb/aichatweb.Web/Components/Pages/Chat/ChatCitation.razor.css +++ /dev/null @@ -1,37 +0,0 @@ -.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.A.verified/aichatweb/aichatweb.Web/Components/Pages/Chat/ChatHeader.razor b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.A.verified/aichatweb/aichatweb.Web/Components/Pages/Chat/ChatHeader.razor deleted file mode 100644 index 12b1d524e23..00000000000 --- a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.A.verified/aichatweb/aichatweb.Web/Components/Pages/Chat/ChatHeader.razor +++ /dev/null @@ -1,17 +0,0 @@ -
-
- -
- -

aichatweb.Web

-
- -@code { - [Parameter] - public EventCallback OnNewChat { get; set; } -} 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.verified/aichatweb/aichatweb.Web/Components/Pages/Chat/ChatHeader.razor.css deleted file mode 100644 index 6adcc414540..00000000000 --- a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.A.verified/aichatweb/aichatweb.Web/Components/Pages/Chat/ChatHeader.razor.css +++ /dev/null @@ -1,25 +0,0 @@ -.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.A.verified/aichatweb/aichatweb.Web/Components/Pages/Chat/ChatInput.razor b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.A.verified/aichatweb/aichatweb.Web/Components/Pages/Chat/ChatInput.razor deleted file mode 100644 index e87ac6ccf47..00000000000 --- a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.A.verified/aichatweb/aichatweb.Web/Components/Pages/Chat/ChatInput.razor +++ /dev/null @@ -1,51 +0,0 @@ -@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.A.verified/aichatweb/aichatweb.Web/Components/Pages/Chat/ChatInput.razor.css b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.A.verified/aichatweb/aichatweb.Web/Components/Pages/Chat/ChatInput.razor.css deleted file mode 100644 index 3b26c9af316..00000000000 --- a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.A.verified/aichatweb/aichatweb.Web/Components/Pages/Chat/ChatInput.razor.css +++ /dev/null @@ -1,57 +0,0 @@ -.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.A.verified/aichatweb/aichatweb.Web/Components/Pages/Chat/ChatInput.razor.js b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.A.verified/aichatweb/aichatweb.Web/Components/Pages/Chat/ChatInput.razor.js deleted file mode 100644 index 39e18ac7b74..00000000000 --- a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.A.verified/aichatweb/aichatweb.Web/Components/Pages/Chat/ChatInput.razor.js +++ /dev/null @@ -1,43 +0,0 @@ -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.A.verified/aichatweb/aichatweb.Web/Components/Pages/Chat/ChatMessageItem.razor b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.A.verified/aichatweb/aichatweb.Web/Components/Pages/Chat/ChatMessageItem.razor deleted file mode 100644 index e45d92ab5f9..00000000000 --- a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.A.verified/aichatweb/aichatweb.Web/Components/Pages/Chat/ChatMessageItem.razor +++ /dev/null @@ -1,107 +0,0 @@ -@using System.Runtime.CompilerServices -@using System.Text.RegularExpressions -@using System.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); - - 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 matches = CitationRegex.Matches(text); - citations = matches.Any() - ? matches.Select(m => (m.Groups["file"].Value, m.Groups["quote"].Value)).ToList() - : 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.verified/aichatweb/aichatweb.Web/Components/Pages/Chat/ChatMessageItem.razor.css deleted file mode 100644 index 10453454be8..00000000000 --- a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.A.verified/aichatweb/aichatweb.Web/Components/Pages/Chat/ChatMessageItem.razor.css +++ /dev/null @@ -1,120 +0,0 @@ -.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.A.verified/aichatweb/aichatweb.Web/Components/Pages/Chat/ChatMessageList.razor b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.A.verified/aichatweb/aichatweb.Web/Components/Pages/Chat/ChatMessageList.razor deleted file mode 100644 index d245f455f11..00000000000 --- a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.A.verified/aichatweb/aichatweb.Web/Components/Pages/Chat/ChatMessageList.razor +++ /dev/null @@ -1,42 +0,0 @@ -@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.A.verified/aichatweb/aichatweb.Web/Components/Pages/Chat/ChatMessageList.razor.css b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.A.verified/aichatweb/aichatweb.Web/Components/Pages/Chat/ChatMessageList.razor.css deleted file mode 100644 index 6fbf083c7fa..00000000000 --- a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.A.verified/aichatweb/aichatweb.Web/Components/Pages/Chat/ChatMessageList.razor.css +++ /dev/null @@ -1,22 +0,0 @@ -.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.A.verified/aichatweb/aichatweb.Web/Components/Pages/Chat/ChatMessageList.razor.js b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.A.verified/aichatweb/aichatweb.Web/Components/Pages/Chat/ChatMessageList.razor.js deleted file mode 100644 index 3de8de273b8..00000000000 --- a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.A.verified/aichatweb/aichatweb.Web/Components/Pages/Chat/ChatMessageList.razor.js +++ /dev/null @@ -1,34 +0,0 @@ -// 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.A.verified/aichatweb/aichatweb.Web/Components/Pages/Chat/ChatSuggestions.razor b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.A.verified/aichatweb/aichatweb.Web/Components/Pages/Chat/ChatSuggestions.razor deleted file mode 100644 index 69ca922a8ce..00000000000 --- a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.A.verified/aichatweb/aichatweb.Web/Components/Pages/Chat/ChatSuggestions.razor +++ /dev/null @@ -1,78 +0,0 @@ -@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.A.verified/aichatweb/aichatweb.Web/Components/Pages/Chat/ChatSuggestions.razor.css b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.A.verified/aichatweb/aichatweb.Web/Components/Pages/Chat/ChatSuggestions.razor.css deleted file mode 100644 index b291042c6d4..00000000000 --- a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.A.verified/aichatweb/aichatweb.Web/Components/Pages/Chat/ChatSuggestions.razor.css +++ /dev/null @@ -1,9 +0,0 @@ -.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.A.verified/aichatweb/aichatweb.Web/Components/Pages/Error.razor b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.A.verified/aichatweb/aichatweb.Web/Components/Pages/Error.razor deleted file mode 100644 index 576cc2d2f4d..00000000000 --- a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.A.verified/aichatweb/aichatweb.Web/Components/Pages/Error.razor +++ /dev/null @@ -1,36 +0,0 @@ -@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.A.verified/aichatweb/aichatweb.Web/Components/Routes.razor b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.A.verified/aichatweb/aichatweb.Web/Components/Routes.razor deleted file mode 100644 index f756e19dfbc..00000000000 --- a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.A.verified/aichatweb/aichatweb.Web/Components/Routes.razor +++ /dev/null @@ -1,6 +0,0 @@ - - - - - - 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.verified/aichatweb/aichatweb.Web/Components/_Imports.razor deleted file mode 100644 index fa7cadef6ea..00000000000 --- a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.A.verified/aichatweb/aichatweb.Web/Components/_Imports.razor +++ /dev/null @@ -1,13 +0,0 @@ -@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.Web -@using aichatweb.Web.Components -@using aichatweb.Web.Components.Layout -@using aichatweb.Web.Services 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.verified/aichatweb/aichatweb.Web/Program.cs deleted file mode 100644 index e47bad71cc8..00000000000 --- a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.A.verified/aichatweb/aichatweb.Web/Program.cs +++ /dev/null @@ -1,45 +0,0 @@ -using Microsoft.Extensions.AI; -using OpenAI; -using aichatweb.Web.Components; -using aichatweb.Web.Services; -using aichatweb.Web.Services.Ingestion; - -var builder = WebApplication.CreateBuilder(args); -builder.AddServiceDefaults(); -builder.Services.AddRazorComponents().AddInteractiveServerComponents(); - -var openai = builder.AddAzureOpenAIClient("openai"); -openai.AddChatClient("gpt-4o-mini") - .UseFunctionInvocation() - .UseOpenTelemetry(configure: c => - c.EnableSensitiveData = builder.Environment.IsDevelopment()); -openai.AddEmbeddingGenerator("text-embedding-3-small"); - -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"))); - -var app = builder.Build(); - -app.MapDefaultEndpoints(); - -// 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.A.verified/aichatweb/aichatweb.Web/Properties/launchSettings.json b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.A.verified/aichatweb/aichatweb.Web/Properties/launchSettings.json deleted file mode 100644 index 6e300d8a802..00000000000 --- a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.A.verified/aichatweb/aichatweb.Web/Properties/launchSettings.json +++ /dev/null @@ -1,23 +0,0 @@ -{ - "$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.A.verified/aichatweb/aichatweb.Web/Services/IngestedChunk.cs b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.A.verified/aichatweb/aichatweb.Web/Services/IngestedChunk.cs deleted file mode 100644 index af609ea239e..00000000000 --- a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.A.verified/aichatweb/aichatweb.Web/Services/IngestedChunk.cs +++ /dev/null @@ -1,31 +0,0 @@ -using System.Text.Json.Serialization; -using Microsoft.Extensions.VectorData; - -namespace aichatweb.Web.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 Guid 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.A.verified/aichatweb/aichatweb.Web/Services/Ingestion/DataIngestor.cs b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.A.verified/aichatweb/aichatweb.Web/Services/Ingestion/DataIngestor.cs deleted file mode 100644 index 9dd366a03a5..00000000000 --- a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.A.verified/aichatweb/aichatweb.Web/Services/Ingestion/DataIngestor.cs +++ /dev/null @@ -1,35 +0,0 @@ -using Microsoft.Extensions.AI; -using Microsoft.Extensions.DataIngestion; -using Microsoft.Extensions.DataIngestion.Chunkers; -using Microsoft.Extensions.VectorData; -using Microsoft.ML.Tokenizers; - -namespace aichatweb.Web.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.A.verified/aichatweb/aichatweb.Web/Services/Ingestion/DocumentReader.cs b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.A.verified/aichatweb/aichatweb.Web/Services/Ingestion/DocumentReader.cs deleted file mode 100644 index 60fcdbdc128..00000000000 --- a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.A.verified/aichatweb/aichatweb.Web/Services/Ingestion/DocumentReader.cs +++ /dev/null @@ -1,42 +0,0 @@ -using Microsoft.Extensions.DataIngestion; - -namespace aichatweb.Web.Services.Ingestion; - -internal sealed class DocumentReader(DirectoryInfo rootDirectory) : IngestionDocumentReader -{ - private readonly MarkdownReader _markdownReader = new(); - private readonly MarkItDownMcpReader _pdfReader = new(mcpServerUri: GetMarkItDownMcpServerUrl()); - - 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 - }; - - private static Uri GetMarkItDownMcpServerUrl() - { - var markItDownMcpUrl = $"{Environment.GetEnvironmentVariable("MARKITDOWN_MCP_URL")}/mcp"; - return new Uri(markItDownMcpUrl); - } -} 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.verified/aichatweb/aichatweb.Web/Services/SemanticSearch.cs deleted file mode 100644 index d043c8efb84..00000000000 --- a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.A.verified/aichatweb/aichatweb.Web/Services/SemanticSearch.cs +++ /dev/null @@ -1,27 +0,0 @@ -using aichatweb.Web.Services.Ingestion; -using Microsoft.Extensions.VectorData; - -namespace aichatweb.Web.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.A.verified/aichatweb/aichatweb.Web/aichatweb.Web.csproj b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.A.verified/aichatweb/aichatweb.Web/aichatweb.Web.csproj deleted file mode 100644 index 3325bff88d9..00000000000 --- a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.A.verified/aichatweb/aichatweb.Web/aichatweb.Web.csproj +++ /dev/null @@ -1,26 +0,0 @@ - - - - net10.0 - enable - enable - {00000000-0000-0000-0000-000000000000} - - - - - - - - - - - - - - - - - - - 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.verified/aichatweb/aichatweb.Web/appsettings.Development.json deleted file mode 100644 index e22bd83cf3a..00000000000 --- a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.A.verified/aichatweb/aichatweb.Web/appsettings.Development.json +++ /dev/null @@ -1,9 +0,0 @@ -{ - "Logging": { - "LogLevel": { - "Default": "Information", - "Microsoft.AspNetCore": "Warning", - "Microsoft.EntityFrameworkCore": "Warning" - } - } -} 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.verified/aichatweb/aichatweb.Web/appsettings.json deleted file mode 100644 index d286041f99d..00000000000 --- a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.A.verified/aichatweb/aichatweb.Web/appsettings.json +++ /dev/null @@ -1,10 +0,0 @@ -{ - "Logging": { - "LogLevel": { - "Default": "Information", - "Microsoft.AspNetCore": "Warning", - "Microsoft.EntityFrameworkCore": "Warning" - } - }, - "AllowedHosts": "*" -} 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.verified/aichatweb/aichatweb.sln deleted file mode 100644 index 67d2a3cad3c..00000000000 --- a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.A.verified/aichatweb/aichatweb.sln +++ /dev/null @@ -1,34 +0,0 @@ - -Microsoft Visual Studio Solution File, Format Version 12.00 -# Visual Studio Version 17 -VisualStudioVersion = 17.0.31903.59 -MinimumVisualStudioVersion = 10.0.40219.1 -Project("{00000000-0000-0000-0000-000000000000}") = "aichatweb.AppHost", "aichatweb.AppHost\aichatweb.AppHost.csproj", "{00000000-0000-0000-0000-000000000000}" -EndProject -Project("{00000000-0000-0000-0000-000000000000}") = "aichatweb.ServiceDefaults", "aichatweb.ServiceDefaults\aichatweb.ServiceDefaults.csproj", "{00000000-0000-0000-0000-000000000000}" -EndProject -Project("{00000000-0000-0000-0000-000000000000}") = "aichatweb.Web", "aichatweb.Web\aichatweb.Web.csproj", "{00000000-0000-0000-0000-000000000000}" -EndProject -Global - GlobalSection(SolutionConfigurationPlatforms) = preSolution - Debug|Any CPU = Debug|Any CPU - Release|Any CPU = Release|Any CPU - EndGlobalSection - GlobalSection(SolutionProperties) = preSolution - HideSolutionNode = FALSE - EndGlobalSection - GlobalSection(ProjectConfigurationPlatforms) = postSolution - {00000000-0000-0000-0000-000000000000}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {00000000-0000-0000-0000-000000000000}.Debug|Any CPU.Build.0 = Debug|Any CPU - {00000000-0000-0000-0000-000000000000}.Release|Any CPU.ActiveCfg = Release|Any CPU - {00000000-0000-0000-0000-000000000000}.Release|Any CPU.Build.0 = Release|Any CPU - {00000000-0000-0000-0000-000000000000}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {00000000-0000-0000-0000-000000000000}.Debug|Any CPU.Build.0 = Debug|Any CPU - {00000000-0000-0000-0000-000000000000}.Release|Any CPU.ActiveCfg = Release|Any CPU - {00000000-0000-0000-0000-000000000000}.Release|Any CPU.Build.0 = Release|Any CPU - {00000000-0000-0000-0000-000000000000}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {00000000-0000-0000-0000-000000000000}.Debug|Any CPU.Build.0 = Debug|Any CPU - {00000000-0000-0000-0000-000000000000}.Release|Any CPU.ActiveCfg = Release|Any CPU - {00000000-0000-0000-0000-000000000000}.Release|Any CPU.Build.0 = Release|Any CPU - EndGlobalSection -EndGlobal 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._defaults.verified/aichatweb/Components/App.razor deleted file mode 100644 index 40862eea8e7..00000000000 --- a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb._defaults.verified/aichatweb/Components/App.razor +++ /dev/null @@ -1,24 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - -@code { - private readonly IComponentRenderMode renderMode = new InteractiveServerRenderMode(prerender: false); -} 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._defaults.verified/aichatweb/Components/Layout/LoadingSpinner.razor deleted file mode 100644 index 116455ce45b..00000000000 --- a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb._defaults.verified/aichatweb/Components/Layout/LoadingSpinner.razor +++ /dev/null @@ -1 +0,0 @@ -
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._defaults.verified/aichatweb/Components/Layout/LoadingSpinner.razor.css deleted file mode 100644 index d85b851a679..00000000000 --- a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb._defaults.verified/aichatweb/Components/Layout/LoadingSpinner.razor.css +++ /dev/null @@ -1,89 +0,0 @@ -/* 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._defaults.verified/aichatweb/Components/Layout/MainLayout.razor b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb._defaults.verified/aichatweb/Components/Layout/MainLayout.razor deleted file mode 100644 index 96fbbe6cc42..00000000000 --- a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb._defaults.verified/aichatweb/Components/Layout/MainLayout.razor +++ /dev/null @@ -1,9 +0,0 @@ -@inherits LayoutComponentBase - -@Body - -
- An unhandled error has occurred. - Reload - 🗙 -
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._defaults.verified/aichatweb/Components/Layout/MainLayout.razor.css deleted file mode 100644 index cda2020dcb0..00000000000 --- a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb._defaults.verified/aichatweb/Components/Layout/MainLayout.razor.css +++ /dev/null @@ -1,20 +0,0 @@ -#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._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/Components/Pages/Chat/Chat.razor b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb._defaults.verified/aichatweb/Components/Pages/Chat/Chat.razor deleted file mode 100644 index 6fc5881c18f..00000000000 --- a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb._defaults.verified/aichatweb/Components/Pages/Chat/Chat.razor +++ /dev/null @@ -1,134 +0,0 @@ -@page "/" -@using System.ComponentModel -@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.
- - -
-
- -
- - - @* Remove this line to eliminate the template survey 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 => - $"{result.Text}"); - } - - public void Dispose() - => currentResponseCancellation?.Cancel(); -} 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._defaults.verified/aichatweb/Components/Pages/Chat/Chat.razor.css deleted file mode 100644 index 98ed1ba7d1e..00000000000 --- a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb._defaults.verified/aichatweb/Components/Pages/Chat/Chat.razor.css +++ /dev/null @@ -1,11 +0,0 @@ -.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._defaults.verified/aichatweb/Components/Pages/Chat/ChatCitation.razor b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb._defaults.verified/aichatweb/Components/Pages/Chat/ChatCitation.razor deleted file mode 100644 index 667189beabd..00000000000 --- a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb._defaults.verified/aichatweb/Components/Pages/Chat/ChatCitation.razor +++ /dev/null @@ -1,39 +0,0 @@ -@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._defaults.verified/aichatweb/Components/Pages/Chat/ChatCitation.razor.css b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb._defaults.verified/aichatweb/Components/Pages/Chat/ChatCitation.razor.css deleted file mode 100644 index 0ca029b7e64..00000000000 --- a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb._defaults.verified/aichatweb/Components/Pages/Chat/ChatCitation.razor.css +++ /dev/null @@ -1,37 +0,0 @@ -.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._defaults.verified/aichatweb/Components/Pages/Chat/ChatHeader.razor b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb._defaults.verified/aichatweb/Components/Pages/Chat/ChatHeader.razor deleted file mode 100644 index 0e9d9c8894a..00000000000 --- a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb._defaults.verified/aichatweb/Components/Pages/Chat/ChatHeader.razor +++ /dev/null @@ -1,17 +0,0 @@ -
-
- -
- -

aichatweb

-
- -@code { - [Parameter] - public EventCallback OnNewChat { get; set; } -} 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._defaults.verified/aichatweb/Components/Pages/Chat/ChatHeader.razor.css deleted file mode 100644 index 6adcc414540..00000000000 --- a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb._defaults.verified/aichatweb/Components/Pages/Chat/ChatHeader.razor.css +++ /dev/null @@ -1,25 +0,0 @@ -.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._defaults.verified/aichatweb/Components/Pages/Chat/ChatInput.razor b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb._defaults.verified/aichatweb/Components/Pages/Chat/ChatInput.razor deleted file mode 100644 index e87ac6ccf47..00000000000 --- a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb._defaults.verified/aichatweb/Components/Pages/Chat/ChatInput.razor +++ /dev/null @@ -1,51 +0,0 @@ -@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._defaults.verified/aichatweb/Components/Pages/Chat/ChatInput.razor.css b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb._defaults.verified/aichatweb/Components/Pages/Chat/ChatInput.razor.css deleted file mode 100644 index 3b26c9af316..00000000000 --- a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb._defaults.verified/aichatweb/Components/Pages/Chat/ChatInput.razor.css +++ /dev/null @@ -1,57 +0,0 @@ -.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._defaults.verified/aichatweb/Components/Pages/Chat/ChatInput.razor.js b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb._defaults.verified/aichatweb/Components/Pages/Chat/ChatInput.razor.js deleted file mode 100644 index 39e18ac7b74..00000000000 --- a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb._defaults.verified/aichatweb/Components/Pages/Chat/ChatInput.razor.js +++ /dev/null @@ -1,43 +0,0 @@ -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._defaults.verified/aichatweb/Components/Pages/Chat/ChatMessageItem.razor b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb._defaults.verified/aichatweb/Components/Pages/Chat/ChatMessageItem.razor deleted file mode 100644 index e45d92ab5f9..00000000000 --- a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb._defaults.verified/aichatweb/Components/Pages/Chat/ChatMessageItem.razor +++ /dev/null @@ -1,107 +0,0 @@ -@using System.Runtime.CompilerServices -@using System.Text.RegularExpressions -@using System.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); - - 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 matches = CitationRegex.Matches(text); - citations = matches.Any() - ? matches.Select(m => (m.Groups["file"].Value, m.Groups["quote"].Value)).ToList() - : 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._defaults.verified/aichatweb/Components/Pages/Chat/ChatMessageItem.razor.css deleted file mode 100644 index 10453454be8..00000000000 --- a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb._defaults.verified/aichatweb/Components/Pages/Chat/ChatMessageItem.razor.css +++ /dev/null @@ -1,120 +0,0 @@ -.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._defaults.verified/aichatweb/Components/Pages/Chat/ChatMessageList.razor b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb._defaults.verified/aichatweb/Components/Pages/Chat/ChatMessageList.razor deleted file mode 100644 index d245f455f11..00000000000 --- a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb._defaults.verified/aichatweb/Components/Pages/Chat/ChatMessageList.razor +++ /dev/null @@ -1,42 +0,0 @@ -@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._defaults.verified/aichatweb/Components/Pages/Chat/ChatMessageList.razor.css b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb._defaults.verified/aichatweb/Components/Pages/Chat/ChatMessageList.razor.css deleted file mode 100644 index 6fbf083c7fa..00000000000 --- a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb._defaults.verified/aichatweb/Components/Pages/Chat/ChatMessageList.razor.css +++ /dev/null @@ -1,22 +0,0 @@ -.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._defaults.verified/aichatweb/Components/Pages/Chat/ChatMessageList.razor.js b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb._defaults.verified/aichatweb/Components/Pages/Chat/ChatMessageList.razor.js deleted file mode 100644 index 3de8de273b8..00000000000 --- a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb._defaults.verified/aichatweb/Components/Pages/Chat/ChatMessageList.razor.js +++ /dev/null @@ -1,34 +0,0 @@ -// 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._defaults.verified/aichatweb/Components/Pages/Chat/ChatSuggestions.razor b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb._defaults.verified/aichatweb/Components/Pages/Chat/ChatSuggestions.razor deleted file mode 100644 index 69ca922a8ce..00000000000 --- a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb._defaults.verified/aichatweb/Components/Pages/Chat/ChatSuggestions.razor +++ /dev/null @@ -1,78 +0,0 @@ -@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._defaults.verified/aichatweb/Components/Pages/Chat/ChatSuggestions.razor.css b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb._defaults.verified/aichatweb/Components/Pages/Chat/ChatSuggestions.razor.css deleted file mode 100644 index b291042c6d4..00000000000 --- a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb._defaults.verified/aichatweb/Components/Pages/Chat/ChatSuggestions.razor.css +++ /dev/null @@ -1,9 +0,0 @@ -.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._defaults.verified/aichatweb/Components/Pages/Error.razor b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb._defaults.verified/aichatweb/Components/Pages/Error.razor deleted file mode 100644 index 576cc2d2f4d..00000000000 --- a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb._defaults.verified/aichatweb/Components/Pages/Error.razor +++ /dev/null @@ -1,36 +0,0 @@ -@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._defaults.verified/aichatweb/Components/Routes.razor b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb._defaults.verified/aichatweb/Components/Routes.razor deleted file mode 100644 index f756e19dfbc..00000000000 --- a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb._defaults.verified/aichatweb/Components/Routes.razor +++ /dev/null @@ -1,6 +0,0 @@ - - - - - - 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._defaults.verified/aichatweb/Components/_Imports.razor deleted file mode 100644 index 9a70e8453ef..00000000000 --- a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb._defaults.verified/aichatweb/Components/_Imports.razor +++ /dev/null @@ -1,13 +0,0 @@ -@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._defaults.verified/aichatweb/Program.cs deleted file mode 100644 index 27e50372647..00000000000 --- a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb._defaults.verified/aichatweb/Program.cs +++ /dev/null @@ -1,53 +0,0 @@ -using System.ClientModel; -using Microsoft.Extensions.AI; -using OpenAI; -using aichatweb.Components; -using aichatweb.Services; -using aichatweb.Services.Ingestion; - -var builder = WebApplication.CreateBuilder(args); -builder.Services.AddRazorComponents().AddInteractiveServerComponents(); - -// 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(); - -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._defaults.verified/aichatweb/Properties/launchSettings.json deleted file mode 100644 index 6e300d8a802..00000000000 --- a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb._defaults.verified/aichatweb/Properties/launchSettings.json +++ /dev/null @@ -1,23 +0,0 @@ -{ - "$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._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/Services/IngestedChunk.cs b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb._defaults.verified/aichatweb/Services/IngestedChunk.cs deleted file mode 100644 index 68af3ef20fb..00000000000 --- a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb._defaults.verified/aichatweb/Services/IngestedChunk.cs +++ /dev/null @@ -1,31 +0,0 @@ -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 Guid 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._defaults.verified/aichatweb/Services/Ingestion/DataIngestor.cs b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb._defaults.verified/aichatweb/Services/Ingestion/DataIngestor.cs deleted file mode 100644 index d97b986b694..00000000000 --- a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb._defaults.verified/aichatweb/Services/Ingestion/DataIngestor.cs +++ /dev/null @@ -1,35 +0,0 @@ -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._defaults.verified/aichatweb/Services/Ingestion/DocumentReader.cs b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb._defaults.verified/aichatweb/Services/Ingestion/DocumentReader.cs deleted file mode 100644 index 315a6ad3d53..00000000000 --- a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb._defaults.verified/aichatweb/Services/Ingestion/DocumentReader.cs +++ /dev/null @@ -1,36 +0,0 @@ -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._defaults.verified/aichatweb/Services/Ingestion/PdfPigReader.cs b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb._defaults.verified/aichatweb/Services/Ingestion/PdfPigReader.cs deleted file mode 100644 index f6de539eb22..00000000000 --- a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb._defaults.verified/aichatweb/Services/Ingestion/PdfPigReader.cs +++ /dev/null @@ -1,42 +0,0 @@ -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._defaults.verified/aichatweb/Services/SemanticSearch.cs b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb._defaults.verified/aichatweb/Services/SemanticSearch.cs deleted file mode 100644 index 8072f8bcddb..00000000000 --- a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb._defaults.verified/aichatweb/Services/SemanticSearch.cs +++ /dev/null @@ -1,27 +0,0 @@ -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._defaults.verified/aichatweb/aichatweb.csproj deleted file mode 100644 index a4536e86ee6..00000000000 --- a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb._defaults.verified/aichatweb/aichatweb.csproj +++ /dev/null @@ -1,21 +0,0 @@ - - - - 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._defaults.verified/aichatweb/appsettings.Development.json deleted file mode 100644 index e22bd83cf3a..00000000000 --- a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb._defaults.verified/aichatweb/appsettings.Development.json +++ /dev/null @@ -1,9 +0,0 @@ -{ - "Logging": { - "LogLevel": { - "Default": "Information", - "Microsoft.AspNetCore": "Warning", - "Microsoft.EntityFrameworkCore": "Warning" - } - } -} 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._defaults.verified/aichatweb/appsettings.json deleted file mode 100644 index d286041f99d..00000000000 --- a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb._defaults.verified/aichatweb/appsettings.json +++ /dev/null @@ -1,10 +0,0 @@ -{ - "Logging": { - "LogLevel": { - "Default": "Information", - "Microsoft.AspNetCore": "Warning", - "Microsoft.EntityFrameworkCore": "Warning" - } - }, - "AllowedHosts": "*" -} From a2d962d1a2fd2848564f90507191f2623e945f4d Mon Sep 17 00:00:00 2001 From: Jeff Handley Date: Thu, 23 Jul 2026 05:30:59 -0700 Subject: [PATCH 3/7] Remove GitHub Models from aiagent-webapi template Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: dc263661-1b95-4f80-a7f4-3a4d3cd275ff --- .../.template.config/template.json | 10 -- .../AIAgentWebApi-CSharp.csproj-in | 2 +- .../templates/AIAgentWebApi-CSharp/Program.cs | 18 +-- .../templates/AIAgentWebApi-CSharp/README.md | 57 +------- src/ProjectTemplates/README.md | 4 +- .../AIAgentWebAPIExecutionTests.cs | 2 +- .../AIAgentWebAPISnapshotTests.cs | 3 +- .../aiagent-webapi/Program.cs | 67 --------- .../Properties/launchSettings.json | 25 ---- .../aiagent-webapi/README.md | 129 ------------------ .../aiagent-webapi/aiagent-webapi.csproj | 20 --- .../aiagent-webapi/appsettings.json | 9 -- 12 files changed, 14 insertions(+), 332 deletions(-) delete mode 100644 test/ProjectTemplates/Microsoft.Agents.AI.ProjectTemplates.IntegrationTests/Snapshots/aiagent-webapi/aiagent-webapi._defaults.verified/aiagent-webapi/Program.cs delete mode 100644 test/ProjectTemplates/Microsoft.Agents.AI.ProjectTemplates.IntegrationTests/Snapshots/aiagent-webapi/aiagent-webapi._defaults.verified/aiagent-webapi/Properties/launchSettings.json delete mode 100644 test/ProjectTemplates/Microsoft.Agents.AI.ProjectTemplates.IntegrationTests/Snapshots/aiagent-webapi/aiagent-webapi._defaults.verified/aiagent-webapi/README.md delete mode 100644 test/ProjectTemplates/Microsoft.Agents.AI.ProjectTemplates.IntegrationTests/Snapshots/aiagent-webapi/aiagent-webapi._defaults.verified/aiagent-webapi/aiagent-webapi.csproj delete mode 100644 test/ProjectTemplates/Microsoft.Agents.AI.ProjectTemplates.IntegrationTests/Snapshots/aiagent-webapi/aiagent-webapi._defaults.verified/aiagent-webapi/appsettings.json diff --git a/src/ProjectTemplates/Microsoft.Agents.AI.ProjectTemplates/templates/AIAgentWebApi-CSharp/.template.config/template.json b/src/ProjectTemplates/Microsoft.Agents.AI.ProjectTemplates/templates/AIAgentWebApi-CSharp/.template.config/template.json index b534bdc52be..3db05881b08 100644 --- a/src/ProjectTemplates/Microsoft.Agents.AI.ProjectTemplates/templates/AIAgentWebApi-CSharp/.template.config/template.json +++ b/src/ProjectTemplates/Microsoft.Agents.AI.ProjectTemplates/templates/AIAgentWebApi-CSharp/.template.config/template.json @@ -61,18 +61,12 @@ "type": "parameter", "displayName": "_AI service provider", "datatype": "choice", - "defaultValue": "githubmodels", "choices": [ { "choice": "azureopenai", "displayName": "Azure OpenAI", "description": "Uses Azure OpenAI service" }, - { - "choice": "githubmodels", - "displayName": "GitHub Models", - "description": "Uses GitHub Models" - }, { "choice": "ollama", "displayName": "Ollama (for local development)", @@ -110,10 +104,6 @@ "type": "computed", "value": "(AiServiceProvider == \"openai\")" }, - "IsGHModels": { - "type": "computed", - "value": "(AiServiceProvider == \"githubmodels\")" - }, "IsOllama": { "type": "computed", "value": "(AiServiceProvider == \"ollama\")" diff --git a/src/ProjectTemplates/Microsoft.Agents.AI.ProjectTemplates/templates/AIAgentWebApi-CSharp/AIAgentWebApi-CSharp.csproj-in b/src/ProjectTemplates/Microsoft.Agents.AI.ProjectTemplates/templates/AIAgentWebApi-CSharp/AIAgentWebApi-CSharp.csproj-in index b808f360513..6c1fff69daa 100644 --- a/src/ProjectTemplates/Microsoft.Agents.AI.ProjectTemplates/templates/AIAgentWebApi-CSharp/AIAgentWebApi-CSharp.csproj-in +++ b/src/ProjectTemplates/Microsoft.Agents.AI.ProjectTemplates/templates/AIAgentWebApi-CSharp/AIAgentWebApi-CSharp.csproj-in @@ -15,7 +15,7 @@ - + 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..7bc6241dcd9 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 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..8dd93d1ecca 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,9 +4,7 @@ This is an AI Agent Web API application created from the `aiagent-webapi` templa ## Prerequisites - -- A GitHub Models API token (free to get started) - + - An OpenAI API key - An Azure OpenAI service deployment @@ -18,40 +16,7 @@ This is an AI Agent Web API application created from the `aiagent-webapi` templa ### 1. Configure Your AI Service - -#### GitHub Models Configuration - -This application uses GitHub Models (model: gpt-4o-mini) for AI functionality. You'll need to configure your GitHub Models API token: - -**Option A: Using User Secrets (Recommended for Development)** - -```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" - ``` - -- **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 - - + #### OpenAI Configuration This application uses the OpenAI Platform (model: gpt-4o-mini). You'll need to configure your OpenAI API key: @@ -198,13 +163,12 @@ dotnet new aiagent-webapi --provider ollama --chat-model llama3.1 ### Available Parameters - **`--provider`**: Choose the AI service provider - - `githubmodels` (default) - GitHub Models - `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 +187,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,16 +197,7 @@ 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 - -**Solution**: Verify your GitHub Models token is valid and hasn't expired. You may need to regenerate it from the GitHub Models website. - - + **Problem**: Application fails with "Missing configuration: OPENAI_KEY" **Solution**: Make sure you've configured your OpenAI API key using one of the methods described above. diff --git a/src/ProjectTemplates/README.md b/src/ProjectTemplates/README.md index 69c052282ad..d4dad3ab88a 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/test/ProjectTemplates/Microsoft.Agents.AI.ProjectTemplates.IntegrationTests/AIAgentWebAPIExecutionTests.cs b/test/ProjectTemplates/Microsoft.Agents.AI.ProjectTemplates.IntegrationTests/AIAgentWebAPIExecutionTests.cs index 68674e9016e..dca4e7769f8 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", ["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..1cb35bf7167 100644 --- a/test/ProjectTemplates/Microsoft.Agents.AI.ProjectTemplates.IntegrationTests/AIAgentWebAPISnapshotTests.cs +++ b/test/ProjectTemplates/Microsoft.Agents.AI.ProjectTemplates.IntegrationTests/AIAgentWebAPISnapshotTests.cs @@ -22,9 +22,8 @@ public AIAgentWebAPISnapshotTests(ITestOutputHelper log) } [Theory] - [InlineData /* Defaults: --provider=githubmodels */] - [InlineData("--provider=ollama")] [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._defaults.verified/aiagent-webapi/Program.cs b/test/ProjectTemplates/Microsoft.Agents.AI.ProjectTemplates.IntegrationTests/Snapshots/aiagent-webapi/aiagent-webapi._defaults.verified/aiagent-webapi/Program.cs deleted file mode 100644 index 2744b8bb8af..00000000000 --- a/test/ProjectTemplates/Microsoft.Agents.AI.ProjectTemplates.IntegrationTests/Snapshots/aiagent-webapi/aiagent-webapi._defaults.verified/aiagent-webapi/Program.cs +++ /dev/null @@ -1,67 +0,0 @@ -using System.ClientModel; -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(); - -builder.Services.AddChatClient(chatClient); - -builder.AddAIAgent("writer", "You write short stories (300 words or less) about the specified topic."); - -builder.AddAIAgent("editor", (sp, key) => new ChatClientAgent( - chatClient, - name: key, - instructions: "You edit short stories to improve grammar and style, ensuring the stories are less than 300 words. Once finished editing, you select a title and format the story for publishing.", - tools: [AIFunctionFactory.Create(FormatStory)] -)); - -builder.AddWorkflow("publisher", (sp, key) => AgentWorkflowBuilder.BuildSequential( - workflowName: key, - agents: - [ - sp.GetRequiredKeyedService("writer"), - sp.GetRequiredKeyedService("editor") - ] -)).AddAsAIAgent("publisher-agent"); - -// Register services for OpenAI responses and conversations (also required for DevUI) -builder.Services.AddOpenAIResponses(); -builder.Services.AddOpenAIConversations(); - -var app = builder.Build(); -app.UseHttpsRedirection(); - -// Map endpoints for OpenAI responses and conversations (also required for DevUI) -app.MapOpenAIResponses(); -app.MapOpenAIConversations(); - -if (builder.Environment.IsDevelopment()) -{ - // Map DevUI endpoint to /devui - app.MapDevUI(); -} - -app.Run(); - -[Description("Formats the story for publication, revealing its title.")] -string FormatStory(string title, string story) => $""" - **Title**: {title} - - {story} - """; 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._defaults.verified/aiagent-webapi/Properties/launchSettings.json deleted file mode 100644 index d71b1876d35..00000000000 --- a/test/ProjectTemplates/Microsoft.Agents.AI.ProjectTemplates.IntegrationTests/Snapshots/aiagent-webapi/aiagent-webapi._defaults.verified/aiagent-webapi/Properties/launchSettings.json +++ /dev/null @@ -1,25 +0,0 @@ -{ - "$schema": "https://json.schemastore.org/launchsettings.json", - "profiles": { - "http": { - "commandName": "Project", - "dotnetRunMessages": true, - "launchBrowser": true, - "launchUrl": "devui/", - "applicationUrl": "http://localhost:9996", - "environmentVariables": { - "ASPNETCORE_ENVIRONMENT": "Development" - } - }, - "https": { - "commandName": "Project", - "dotnetRunMessages": true, - "launchBrowser": true, - "launchUrl": "devui/", - "applicationUrl": "https://localhost:9995;http://localhost:9996", - "environmentVariables": { - "ASPNETCORE_ENVIRONMENT": "Development" - } - } - } -} 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._defaults.verified/aiagent-webapi/README.md deleted file mode 100644 index 8ea2640ad89..00000000000 --- a/test/ProjectTemplates/Microsoft.Agents.AI.ProjectTemplates.IntegrationTests/Snapshots/aiagent-webapi/aiagent-webapi._defaults.verified/aiagent-webapi/README.md +++ /dev/null @@ -1,129 +0,0 @@ -# AI Agent Web API - -This is an AI Agent Web API application created from the `aiagent-webapi` template. This template is currently in a preview stage. If you have feedback, please take a [brief survey](https://aka.ms/dotnet/aiagent-webapi/preview1/survey). - -## Prerequisites - -- A GitHub Models API token (free to get started) - -## Getting Started - -### 1. Configure Your AI Service - -#### GitHub Models Configuration - -This application uses GitHub Models (model: gpt-4o-mini) for AI functionality. You'll need to configure your GitHub Models API token: - -**Option A: Using User Secrets (Recommended for Development)** - -```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" - ``` - -- **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 - - -### 2. Run the Application - -```bash -dotnet run -lp https -``` - -The application will start and listen on: -- HTTP: `http://localhost:9996` -- HTTPS: `https://localhost:9995` - -### 3. Test the Application - -The application exposes OpenAI-compatible API endpoints. You can interact with the AI agents using any OpenAI-compatible client or tools. - -In development environments, a `/devui/` route is mapped to the Agent Framework development UI (DevUI), and when the app is launched through an IDE a browser will open to this URL. DevUI provides a web-based UI for interacting with agents and workflows. DevUI operates as an OpenAI-compatible client using the Responses and Conversations endpoints. - -## How It Works - -This application demonstrates Agent Framework with: - -1. **Writer Agent**: Writes short stories (300 words or less) about specified topics -2. **Editor Agent**: Edits stories to improve grammar and style, ensuring they stay under 300 words -3. **Publisher Workflow Agent**: A sequential workflow agent that combines the writer and editor agents - -The agents are exposed through OpenAI-compatible API endpoints, making them easy to integrate with existing tools and applications. - -## Template Parameters - -When creating a new project, you can customize it using template parameters: - -```bash -# Specify AI service provider -dotnet new aiagent-webapi --provider azureopenai - -# Specify a custom chat model -dotnet new aiagent-webapi --chat-model gpt-4o - -# Use API key authentication for Azure OpenAI -dotnet new aiagent-webapi --provider azureopenai --managed-identity false - -# Use Ollama with a different model -dotnet new aiagent-webapi --provider ollama --chat-model llama3.1 -``` - -### Available Parameters - -- **`--provider`**: Choose the AI service provider - - `githubmodels` (default) - GitHub Models - - `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 Ollama: `llama3.2` - -- **`--managed-identity`**: Use managed identity for Azure services (default: `true`) - - Only applicable when `--provider azureopenai` - -- **`--framework`**: Target framework (default: `net10.0`) - - Options: `net10.0`, `net9.0`, `net8.0` - -## Project Structure - -- `Program.cs` - Application entry point and configuration -- `appsettings.json` - Application configuration -- `Properties/launchSettings.json` - Launch profiles for development - -## Learn More - -- [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 - -**Solution**: Verify your GitHub Models token is valid and hasn't expired. You may need to regenerate it from the GitHub Models website. - 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._defaults.verified/aiagent-webapi/aiagent-webapi.csproj deleted file mode 100644 index ba6fd29474f..00000000000 --- a/test/ProjectTemplates/Microsoft.Agents.AI.ProjectTemplates.IntegrationTests/Snapshots/aiagent-webapi/aiagent-webapi._defaults.verified/aiagent-webapi/aiagent-webapi.csproj +++ /dev/null @@ -1,20 +0,0 @@ - - - - net10.0 - enable - enable - {00000000-0000-0000-0000-000000000000} - - - - - - - - - - - - - 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._defaults.verified/aiagent-webapi/appsettings.json deleted file mode 100644 index 223027717b4..00000000000 --- a/test/ProjectTemplates/Microsoft.Agents.AI.ProjectTemplates.IntegrationTests/Snapshots/aiagent-webapi/aiagent-webapi._defaults.verified/aiagent-webapi/appsettings.json +++ /dev/null @@ -1,9 +0,0 @@ -{ - "Logging": { - "LogLevel": { - "Default": "Information", - "Microsoft.AspNetCore": "Warning" - } - }, - "AllowedHosts": "*" -} From 92b3a94d99446f2050219370aa98e7ee4b058498 Mon Sep 17 00:00:00 2001 From: Jeff Handley Date: Thu, 23 Jul 2026 05:31:27 -0700 Subject: [PATCH 4/7] Introduce Microsoft.Extensions.AI.Testing library Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: dc263661-1b95-4f80-a7f4-3a4d3cd275ff --- .../references/package-areas.md | 1 + .../Microsoft.Extensions.AI.Testing.csproj | 37 ++ .../MockChatClient.cs | 422 +++++++++++++++++ .../MockChatClientRequest.cs | 59 +++ .../MockEmbeddingGenerator.cs | 128 ++++++ .../Microsoft.Extensions.AI.Testing/README.md | 184 ++++++++ ...crosoft.Extensions.AI.Testing.Tests.csproj | 11 + .../MockChatClientTests.cs | 431 ++++++++++++++++++ 8 files changed, 1273 insertions(+) create mode 100644 src/Libraries/Microsoft.Extensions.AI.Testing/Microsoft.Extensions.AI.Testing.csproj create mode 100644 src/Libraries/Microsoft.Extensions.AI.Testing/MockChatClient.cs create mode 100644 src/Libraries/Microsoft.Extensions.AI.Testing/MockChatClientRequest.cs create mode 100644 src/Libraries/Microsoft.Extensions.AI.Testing/MockEmbeddingGenerator.cs create mode 100644 src/Libraries/Microsoft.Extensions.AI.Testing/README.md create mode 100644 test/Libraries/Microsoft.Extensions.AI.Testing.Tests/Microsoft.Extensions.AI.Testing.Tests.csproj create mode 100644 test/Libraries/Microsoft.Extensions.AI.Testing.Tests/MockChatClientTests.cs diff --git a/.github/agents/release-manager/write-release-notes/references/package-areas.md b/.github/agents/release-manager/write-release-notes/references/package-areas.md index 9d8203951da..bdbce611e8f 100644 --- a/.github/agents/release-manager/write-release-notes/references/package-areas.md +++ b/.github/agents/release-manager/write-release-notes/references/package-areas.md @@ -10,6 +10,7 @@ Packages: - `Microsoft.Extensions.AI` - `Microsoft.Extensions.AI.Abstractions` - `Microsoft.Extensions.AI.OpenAI` +- `Microsoft.Extensions.AI.Testing` ### AI Evaluation 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..c13459e7461 --- /dev/null +++ b/src/Libraries/Microsoft.Extensions.AI.Testing/Microsoft.Extensions.AI.Testing.csproj @@ -0,0 +1,37 @@ + + + + Microsoft.Extensions.AI + Deterministic mock implementations for testing applications built on Microsoft.Extensions.AI. + AI + Testing + $(PackageTags);Testing;AI;Chat + true + + + + dev + EXTEXP0019 + false + n/a + n/a + + + + $(TargetFrameworks);netstandard2.0 + $(NoWarn);MEAI001 + true + true + + + + true + true + true + + + + + + + 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..5d78f06f5ef --- /dev/null +++ b/src/Libraries/Microsoft.Extensions.AI.Testing/MockChatClient.cs @@ -0,0 +1,422 @@ +// 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.Runtime.CompilerServices; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Shared.Diagnostics; + +namespace Microsoft.Extensions.AI; + +/// +/// A deterministic implementation for tests and local mock scenarios. +/// +/// +/// +/// 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. +/// +/// +public 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 virtual void Dispose() + { + lock (_sync) + { + _disposed = true; + _seededResponses.Clear(); + _requests.Clear(); + } + } + + private 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; + } + + 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 SeededResponse MatchSeededResponse(MockChatClientRequest 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 ?? ""}'."); + } + + private void ThrowIfDisposed() + { + if (_disposed) + { + throw new ObjectDisposedException(nameof(MockChatClient)); + } + } + + private sealed class SeededResponse + { + public Func RequestPredicate { get; init; } = default!; + + public Func> ResponseFactory { get; init; } = default!; + + public Func> StreamingFactory { get; init; } = default!; + + public bool SingleUse { get; init; } + } +} 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..b7d4c51e228 --- /dev/null +++ b/src/Libraries/Microsoft.Extensions.AI.Testing/MockChatClientRequest.cs @@ -0,0 +1,59 @@ +// 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.Linq; +using Microsoft.Shared.Diagnostics; + +namespace Microsoft.Extensions.AI; + +/// +/// Represents one request invocation captured by . +/// +/// +/// Instances are created by when +/// or +/// +/// is called. +/// +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..ebed6d55b90 --- /dev/null +++ b/src/Libraries/Microsoft.Extensions.AI.Testing/MockEmbeddingGenerator.cs @@ -0,0 +1,128 @@ +// 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; + +/// +/// A deterministic for tests and local mock scenarios. +/// +/// +/// The generated vectors are repeatable pseudo-embeddings. They use lexical overlap to exercise vector data flows, +/// but they do not represent semantic similarity. +/// +public class MockEmbeddingGenerator : IEmbeddingGenerator> +{ + private static float[] CreateVector(string? value, int dimensions) + { + var vector = new float[dimensions]; + if (value is null || value.Length == 0) + { + return vector; + } + + // Hash each case-insensitive token and each three-character shingle into the vector. Shared words and word + // fragments therefore contribute to the same dimensions, providing deterministic lexical similarity. + 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 compares 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; + } + + private readonly int _dimensions; + + /// Initializes a new instance of the class. + /// The number of values in each generated vector. + /// is less than one. + public MockEmbeddingGenerator(int dimensions) + { + _dimensions = Throw.IfLessThan(dimensions, 1, nameof(dimensions)); + } + + /// + public virtual Task>> GenerateAsync( + IEnumerable values, + EmbeddingGenerationOptions? options = null, + CancellationToken cancellationToken = default) + { + _ = Throw.IfNull(values); + + GeneratedEmbeddings> embeddings = []; + foreach (string value in values) + { + cancellationToken.ThrowIfCancellationRequested(); + embeddings.Add(new Embedding(CreateVector(value, _dimensions))); + } + + return Task.FromResult(embeddings); + } + + /// + public virtual object? GetService(Type serviceType, object? serviceKey = null) + { + _ = Throw.IfNull(serviceType); + return serviceKey is null && serviceType.IsInstanceOfType(this) ? this : null; + } + + /// + public virtual void Dispose() + { + } + +} 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..f7368b0f067 --- /dev/null +++ b/src/Libraries/Microsoft.Extensions.AI.Testing/README.md @@ -0,0 +1,184 @@ +# Microsoft.Extensions.AI.Testing + +Deterministic test doubles for applications built on `Microsoft.Extensions.AI`. + +> [!IMPORTANT] +> This package is for **mocking and deterministic testing** of chat and embedding behavior. + +## 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 repeatable pseudo-embeddings with lexical similarity. + +## MockChatClient behavior + +| Behavior | Details | +| --- | --- | +| Starts empty | No responses are predefined. | +| Match order | Responses are checked in reverse insertion order; the most recently added matching response wins. | +| 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); +``` + +## 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 + +Construct `MockEmbeddingGenerator` with the required vector dimension. It produces a repeatable vector for every input, making it useful for deterministic vector-data tests and local demos. The vectors model lexical overlap, not semantic meaning. + +```csharp +using var embeddings = new MockEmbeddingGenerator(dimensions: 384); +GeneratedEmbeddings> result = await embeddings.GenerateAsync(["trail", "navigation"]); +``` + +## Feedback & Contributing + +We welcome feedback and contributions in [our GitHub repo](https://github.com/dotnet/extensions). 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/MockChatClientTests.cs b/test/Libraries/Microsoft.Extensions.AI.Testing.Tests/MockChatClientTests.cs new file mode 100644 index 00000000000..8bde504e7ae --- /dev/null +++ b/test/Libraries/Microsoft.Extensions.AI.Testing.Tests/MockChatClientTests.cs @@ -0,0 +1,431 @@ +// 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.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 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_GeneratesRepeatableVectors() + { + using var generator = new MockEmbeddingGenerator(4); + + GeneratedEmbeddings> embeddings = await generator.GenerateAsync(["trail", "trail", "camp"]); + + Assert.Equal(3, embeddings.Count); + Assert.Equal(4, embeddings[0].Vector.Length); + Assert.Equal(embeddings[0].Vector.ToArray(), embeddings[1].Vector.ToArray()); + Assert.NotEqual(embeddings[0].Vector.ToArray(), embeddings[2].Vector.ToArray()); + } + + [Fact] + public async Task MockEmbeddingGenerator_PrefersLexicallySimilarText() + { + using var generator = new MockEmbeddingGenerator(64); + + GeneratedEmbeddings> embeddings = await generator.GenerateAsync( + [ + "TrailMaster GPS Watch supports offline maps", + "Emergency survival kits include water treatment", + "TrailMaster GPS Watch provides location tracking", + ]); + + Assert.True( + CosineSimilarity(embeddings[0], embeddings[2]) > CosineSimilarity(embeddings[0], embeddings[1])); + } + + [Fact] + public void MockEmbeddingGenerator_RequiresPositiveDimensions() + { + Assert.Throws(() => new MockEmbeddingGenerator(0)); + } + + private static Task CreateResponseAsync(string text) => + Task.FromResult(new ChatResponse(new ChatMessage(ChatRole.Assistant, text))); + + private static float CosineSimilarity(Embedding left, Embedding right) + { + float similarity = 0; + for (int i = 0; i < left.Vector.Length; i++) + { + similarity += left.Vector.Span[i] * right.Vector.Span[i]; + } + + return similarity; + } + + 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; + } +} From e869d81d85b2da4efc31dd19cbffef3262ade6d0 Mon Sep 17 00:00:00 2001 From: Jeff Handley Date: Thu, 23 Jul 2026 12:23:49 -0700 Subject: [PATCH 5/7] Add mock provider to aichatweb template with canned responses Introduces the 'mock' provider option (--provider mock) to the aichatweb project template. The mock provider uses Microsoft.Extensions.AI.Testing's MockChatClient seeded with ~20 canned responses about the TrailMaster X4 GPS Watch, allowing the template to be used without any external AI provider credentials while still demonstrating the full chat experience with citations, suggestions, and vector-based semantic search. Also updates the Aspire test scenario to use --provider=mock instead of --provider=openai, and adds a test for the plain --provider=openai scenario. Updates the TemplateSnapshotTestBase to resolve template package paths using file timestamps rather than wildcard paths, improving cross-platform reliability. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: dc263661-1b95-4f80-a7f4-3a4d3cd275ff --- .../Microsoft.Extensions.AI.Templates.csproj | 1 + .../.template.config/template.json | 17 + .../AIChatWeb-CSharp.AppHost/AppHost.cs | 2 + .../AIChatWeb-CSharp.Web.csproj-in | 3 + .../Components/Layout/SurveyPrompt.razor | 13 - .../Components/Layout/SurveyPrompt.razor.css | 20 -- .../Components/Pages/Chat/Chat.razor | 7 +- .../Pages/Chat/ChatMessageItem.razor | 28 +- .../AIChatWeb-CSharp.Web/Program.Aspire.cs | 6 + .../AIChatWeb-CSharp.Web/Program.cs | 4 + .../AIChatWeb-CSharp.Web/README.md | 16 + .../Services/IngestedChunk.cs | 4 + .../Services/MockChatClientExtensions.cs | 294 ++++++++++++++++++ .../Services/MockServices.cs | 157 ++++++++++ .../AIChatWeb-CSharp/README.Aspire.md | 16 + src/ProjectTemplates/README.md | 4 +- .../TemplateSnapshotTestBase.cs | 24 +- .../AIChatWebExecutionTests.cs | 2 +- .../AIChatWebSnapshotTests.cs | 3 +- .../Components/Layout/SurveyPrompt.razor | 13 - .../Components/Layout/SurveyPrompt.razor.css | 20 -- .../Components/Pages/Chat/Chat.razor | 7 +- .../Pages/Chat/ChatMessageItem.razor | 28 +- .../aichatweb/README.md | 56 ++++ .../aichatweb/aichatweb.AppHost/AppHost.cs | 11 + .../Properties/launchSettings.json | 29 ++ .../aichatweb.AppHost.csproj | 21 ++ .../appsettings.Development.json | 8 + .../aichatweb.AppHost/appsettings.json | 9 + .../aichatweb.ServiceDefaults/Extensions.cs | 134 ++++++++ .../aichatweb.ServiceDefaults.csproj | 22 ++ .../aichatweb.Web/Components/App.razor | 24 ++ .../Components/Layout/LoadingSpinner.razor | 1 + .../Layout/LoadingSpinner.razor.css | 89 ++++++ .../Components/Layout/MainLayout.razor | 9 + .../Components/Layout/MainLayout.razor.css | 20 ++ .../Components/Pages/Chat/Chat.razor | 137 ++++++++ .../Components/Pages/Chat/Chat.razor.css | 11 + .../Components/Pages/Chat/ChatCitation.razor | 39 +++ .../Pages/Chat/ChatCitation.razor.css | 37 +++ .../Components/Pages/Chat/ChatHeader.razor | 17 + .../Pages/Chat/ChatHeader.razor.css | 25 ++ .../Components/Pages/Chat/ChatInput.razor | 51 +++ .../Components/Pages/Chat/ChatInput.razor.css | 57 ++++ .../Components/Pages/Chat/ChatInput.razor.js | 43 +++ .../Pages/Chat/ChatMessageItem.razor | 123 ++++++++ .../Pages/Chat/ChatMessageItem.razor.css | 120 +++++++ .../Pages/Chat/ChatMessageList.razor | 42 +++ .../Pages/Chat/ChatMessageList.razor.css | 22 ++ .../Pages/Chat/ChatMessageList.razor.js | 34 ++ .../Pages/Chat/ChatSuggestions.razor | 78 +++++ .../Pages/Chat/ChatSuggestions.razor.css | 9 + .../Components/Pages/Error.razor | 36 +++ .../aichatweb.Web/Components/Routes.razor | 6 + .../aichatweb.Web/Components/_Imports.razor | 13 + .../aichatweb/aichatweb.Web/Program.cs | 43 +++ .../Properties/launchSettings.json | 23 ++ .../aichatweb.Web/Services/IngestedChunk.cs | 31 ++ .../Services/Ingestion/DataIngestor.cs | 35 +++ .../Services/Ingestion/DocumentReader.cs | 42 +++ .../Services/MockChatClientExtensions.cs | 294 ++++++++++++++++++ .../aichatweb.Web/Services/MockServices.cs | 157 ++++++++++ .../aichatweb.Web/Services/SemanticSearch.cs | 27 ++ .../aichatweb.Web/aichatweb.Web.csproj | 25 ++ .../appsettings.Development.json | 9 + .../aichatweb/aichatweb.Web/appsettings.json | 10 + .../aichatweb/aichatweb.sln | 34 ++ .../aichatweb/Components/App.razor | 24 ++ .../Components/Layout/LoadingSpinner.razor | 1 + .../Layout/LoadingSpinner.razor.css | 89 ++++++ .../Components/Layout/MainLayout.razor | 9 + .../Components/Layout/MainLayout.razor.css | 20 ++ .../Components/Pages/Chat/Chat.razor | 137 ++++++++ .../Components/Pages/Chat/Chat.razor.css | 11 + .../Components/Pages/Chat/ChatCitation.razor | 39 +++ .../Pages/Chat/ChatCitation.razor.css | 37 +++ .../Components/Pages/Chat/ChatHeader.razor | 17 + .../Pages/Chat/ChatHeader.razor.css | 25 ++ .../Components/Pages/Chat/ChatInput.razor | 51 +++ .../Components/Pages/Chat/ChatInput.razor.css | 57 ++++ .../Components/Pages/Chat/ChatInput.razor.js | 43 +++ .../Pages/Chat/ChatMessageItem.razor | 123 ++++++++ .../Pages/Chat/ChatMessageItem.razor.css | 120 +++++++ .../Pages/Chat/ChatMessageList.razor | 42 +++ .../Pages/Chat/ChatMessageList.razor.css | 22 ++ .../Pages/Chat/ChatMessageList.razor.js | 34 ++ .../Pages/Chat/ChatSuggestions.razor | 78 +++++ .../Pages/Chat/ChatSuggestions.razor.css | 9 + .../aichatweb/Components/Pages/Error.razor | 36 +++ .../aichatweb/Components/Routes.razor | 6 + .../aichatweb/Components/_Imports.razor | 13 + .../aichatweb/Program.cs | 41 +++ .../aichatweb/Properties/launchSettings.json | 23 ++ .../aichatweb/README.md | 18 ++ .../aichatweb/Services/IngestedChunk.cs | 31 ++ .../Services/Ingestion/DataIngestor.cs | 35 +++ .../Services/Ingestion/DocumentReader.cs | 36 +++ .../Services/Ingestion/PdfPigReader.cs | 42 +++ .../Services/MockChatClientExtensions.cs | 294 ++++++++++++++++++ .../aichatweb/Services/MockServices.cs | 157 ++++++++++ .../aichatweb/Services/SemanticSearch.cs | 27 ++ .../aichatweb/aichatweb.csproj | 21 ++ .../aichatweb/appsettings.Development.json | 9 + .../aichatweb/appsettings.json | 10 + .../Components/Layout/SurveyPrompt.razor | 13 - .../Components/Layout/SurveyPrompt.razor.css | 20 -- .../Components/Pages/Chat/Chat.razor | 7 +- .../Pages/Chat/ChatMessageItem.razor | 28 +- .../aichatweb/Components/App.razor | 24 ++ .../Components/Layout/LoadingSpinner.razor | 1 + .../Layout/LoadingSpinner.razor.css | 89 ++++++ .../Components/Layout/MainLayout.razor | 9 + .../Components/Layout/MainLayout.razor.css | 20 ++ .../Components/Pages/Chat/Chat.razor | 137 ++++++++ .../Components/Pages/Chat/Chat.razor.css | 11 + .../Components/Pages/Chat/ChatCitation.razor | 39 +++ .../Pages/Chat/ChatCitation.razor.css | 37 +++ .../Components/Pages/Chat/ChatHeader.razor | 17 + .../Pages/Chat/ChatHeader.razor.css | 25 ++ .../Components/Pages/Chat/ChatInput.razor | 51 +++ .../Components/Pages/Chat/ChatInput.razor.css | 57 ++++ .../Components/Pages/Chat/ChatInput.razor.js | 43 +++ .../Pages/Chat/ChatMessageItem.razor | 123 ++++++++ .../Pages/Chat/ChatMessageItem.razor.css | 120 +++++++ .../Pages/Chat/ChatMessageList.razor | 42 +++ .../Pages/Chat/ChatMessageList.razor.css | 22 ++ .../Pages/Chat/ChatMessageList.razor.js | 34 ++ .../Pages/Chat/ChatSuggestions.razor | 78 +++++ .../Pages/Chat/ChatSuggestions.razor.css | 9 + .../aichatweb/Components/Pages/Error.razor | 36 +++ .../aichatweb/Components/Routes.razor | 6 + .../aichatweb/Components/_Imports.razor | 13 + .../aichatweb/Program.cs | 53 ++++ .../aichatweb/Properties/launchSettings.json | 23 ++ .../aichatweb/README.md | 19 ++ .../aichatweb/Services/IngestedChunk.cs | 31 ++ .../Services/Ingestion/DataIngestor.cs | 35 +++ .../Services/Ingestion/DocumentReader.cs | 36 +++ .../Services/Ingestion/PdfPigReader.cs | 42 +++ .../aichatweb/Services/SemanticSearch.cs | 27 ++ .../aichatweb/aichatweb.csproj | 21 ++ .../aichatweb/appsettings.Development.json | 9 + .../aichatweb/appsettings.json | 10 + .../Components/Layout/SurveyPrompt.razor | 13 - .../Components/Layout/SurveyPrompt.razor.css | 20 -- .../Components/Pages/Chat/Chat.razor | 7 +- .../Pages/Chat/ChatMessageItem.razor | 28 +- 147 files changed, 5854 insertions(+), 170 deletions(-) delete mode 100644 src/ProjectTemplates/Microsoft.Extensions.AI.Templates/templates/AIChatWeb-CSharp/AIChatWeb-CSharp.Web/Components/Layout/SurveyPrompt.razor delete mode 100644 src/ProjectTemplates/Microsoft.Extensions.AI.Templates/templates/AIChatWeb-CSharp/AIChatWeb-CSharp.Web/Components/Layout/SurveyPrompt.razor.css create mode 100644 src/ProjectTemplates/Microsoft.Extensions.AI.Templates/templates/AIChatWeb-CSharp/AIChatWeb-CSharp.Web/Services/MockChatClientExtensions.cs create mode 100644 src/ProjectTemplates/Microsoft.Extensions.AI.Templates/templates/AIChatWeb-CSharp/AIChatWeb-CSharp.Web/Services/MockServices.cs delete mode 100644 test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.A_aoai_aais.verified/aichatweb/aichatweb.Web/Components/Layout/SurveyPrompt.razor delete mode 100644 test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.A_aoai_aais.verified/aichatweb/aichatweb.Web/Components/Layout/SurveyPrompt.razor.css create mode 100644 test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.A_mock.verified/aichatweb/README.md create mode 100644 test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.A_mock.verified/aichatweb/aichatweb.AppHost/AppHost.cs create mode 100644 test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.A_mock.verified/aichatweb/aichatweb.AppHost/Properties/launchSettings.json create mode 100644 test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.A_mock.verified/aichatweb/aichatweb.AppHost/aichatweb.AppHost.csproj create mode 100644 test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.A_mock.verified/aichatweb/aichatweb.AppHost/appsettings.Development.json create mode 100644 test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.A_mock.verified/aichatweb/aichatweb.AppHost/appsettings.json create mode 100644 test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.A_mock.verified/aichatweb/aichatweb.ServiceDefaults/Extensions.cs create mode 100644 test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.A_mock.verified/aichatweb/aichatweb.ServiceDefaults/aichatweb.ServiceDefaults.csproj create mode 100644 test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.A_mock.verified/aichatweb/aichatweb.Web/Components/App.razor create mode 100644 test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.A_mock.verified/aichatweb/aichatweb.Web/Components/Layout/LoadingSpinner.razor create mode 100644 test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.A_mock.verified/aichatweb/aichatweb.Web/Components/Layout/LoadingSpinner.razor.css create mode 100644 test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.A_mock.verified/aichatweb/aichatweb.Web/Components/Layout/MainLayout.razor create mode 100644 test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.A_mock.verified/aichatweb/aichatweb.Web/Components/Layout/MainLayout.razor.css create mode 100644 test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.A_mock.verified/aichatweb/aichatweb.Web/Components/Pages/Chat/Chat.razor create mode 100644 test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.A_mock.verified/aichatweb/aichatweb.Web/Components/Pages/Chat/Chat.razor.css create mode 100644 test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.A_mock.verified/aichatweb/aichatweb.Web/Components/Pages/Chat/ChatCitation.razor create mode 100644 test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.A_mock.verified/aichatweb/aichatweb.Web/Components/Pages/Chat/ChatCitation.razor.css create mode 100644 test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.A_mock.verified/aichatweb/aichatweb.Web/Components/Pages/Chat/ChatHeader.razor create mode 100644 test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.A_mock.verified/aichatweb/aichatweb.Web/Components/Pages/Chat/ChatHeader.razor.css create mode 100644 test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.A_mock.verified/aichatweb/aichatweb.Web/Components/Pages/Chat/ChatInput.razor create mode 100644 test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.A_mock.verified/aichatweb/aichatweb.Web/Components/Pages/Chat/ChatInput.razor.css create mode 100644 test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.A_mock.verified/aichatweb/aichatweb.Web/Components/Pages/Chat/ChatInput.razor.js create mode 100644 test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.A_mock.verified/aichatweb/aichatweb.Web/Components/Pages/Chat/ChatMessageItem.razor create mode 100644 test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.A_mock.verified/aichatweb/aichatweb.Web/Components/Pages/Chat/ChatMessageItem.razor.css create mode 100644 test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.A_mock.verified/aichatweb/aichatweb.Web/Components/Pages/Chat/ChatMessageList.razor create mode 100644 test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.A_mock.verified/aichatweb/aichatweb.Web/Components/Pages/Chat/ChatMessageList.razor.css create mode 100644 test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.A_mock.verified/aichatweb/aichatweb.Web/Components/Pages/Chat/ChatMessageList.razor.js create mode 100644 test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.A_mock.verified/aichatweb/aichatweb.Web/Components/Pages/Chat/ChatSuggestions.razor create mode 100644 test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.A_mock.verified/aichatweb/aichatweb.Web/Components/Pages/Chat/ChatSuggestions.razor.css create mode 100644 test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.A_mock.verified/aichatweb/aichatweb.Web/Components/Pages/Error.razor create mode 100644 test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.A_mock.verified/aichatweb/aichatweb.Web/Components/Routes.razor create mode 100644 test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.A_mock.verified/aichatweb/aichatweb.Web/Components/_Imports.razor create mode 100644 test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.A_mock.verified/aichatweb/aichatweb.Web/Program.cs create mode 100644 test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.A_mock.verified/aichatweb/aichatweb.Web/Properties/launchSettings.json create mode 100644 test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.A_mock.verified/aichatweb/aichatweb.Web/Services/IngestedChunk.cs create mode 100644 test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.A_mock.verified/aichatweb/aichatweb.Web/Services/Ingestion/DataIngestor.cs create mode 100644 test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.A_mock.verified/aichatweb/aichatweb.Web/Services/Ingestion/DocumentReader.cs create mode 100644 test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.A_mock.verified/aichatweb/aichatweb.Web/Services/MockChatClientExtensions.cs create mode 100644 test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.A_mock.verified/aichatweb/aichatweb.Web/Services/MockServices.cs create mode 100644 test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.A_mock.verified/aichatweb/aichatweb.Web/Services/SemanticSearch.cs create mode 100644 test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.A_mock.verified/aichatweb/aichatweb.Web/aichatweb.Web.csproj create mode 100644 test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.A_mock.verified/aichatweb/aichatweb.Web/appsettings.Development.json create mode 100644 test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.A_mock.verified/aichatweb/aichatweb.Web/appsettings.json create mode 100644 test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.A_mock.verified/aichatweb/aichatweb.sln create mode 100644 test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.mock.verified/aichatweb/Components/App.razor create mode 100644 test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.mock.verified/aichatweb/Components/Layout/LoadingSpinner.razor create mode 100644 test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.mock.verified/aichatweb/Components/Layout/LoadingSpinner.razor.css create mode 100644 test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.mock.verified/aichatweb/Components/Layout/MainLayout.razor create mode 100644 test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.mock.verified/aichatweb/Components/Layout/MainLayout.razor.css create mode 100644 test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.mock.verified/aichatweb/Components/Pages/Chat/Chat.razor create mode 100644 test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.mock.verified/aichatweb/Components/Pages/Chat/Chat.razor.css create mode 100644 test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.mock.verified/aichatweb/Components/Pages/Chat/ChatCitation.razor create mode 100644 test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.mock.verified/aichatweb/Components/Pages/Chat/ChatCitation.razor.css create mode 100644 test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.mock.verified/aichatweb/Components/Pages/Chat/ChatHeader.razor create mode 100644 test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.mock.verified/aichatweb/Components/Pages/Chat/ChatHeader.razor.css create mode 100644 test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.mock.verified/aichatweb/Components/Pages/Chat/ChatInput.razor create mode 100644 test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.mock.verified/aichatweb/Components/Pages/Chat/ChatInput.razor.css create mode 100644 test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.mock.verified/aichatweb/Components/Pages/Chat/ChatInput.razor.js create mode 100644 test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.mock.verified/aichatweb/Components/Pages/Chat/ChatMessageItem.razor create mode 100644 test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.mock.verified/aichatweb/Components/Pages/Chat/ChatMessageItem.razor.css create mode 100644 test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.mock.verified/aichatweb/Components/Pages/Chat/ChatMessageList.razor create mode 100644 test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.mock.verified/aichatweb/Components/Pages/Chat/ChatMessageList.razor.css create mode 100644 test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.mock.verified/aichatweb/Components/Pages/Chat/ChatMessageList.razor.js create mode 100644 test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.mock.verified/aichatweb/Components/Pages/Chat/ChatSuggestions.razor create mode 100644 test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.mock.verified/aichatweb/Components/Pages/Chat/ChatSuggestions.razor.css create mode 100644 test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.mock.verified/aichatweb/Components/Pages/Error.razor create mode 100644 test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.mock.verified/aichatweb/Components/Routes.razor create mode 100644 test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.mock.verified/aichatweb/Components/_Imports.razor create mode 100644 test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.mock.verified/aichatweb/Program.cs create mode 100644 test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.mock.verified/aichatweb/Properties/launchSettings.json create mode 100644 test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.mock.verified/aichatweb/README.md create mode 100644 test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.mock.verified/aichatweb/Services/IngestedChunk.cs create mode 100644 test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.mock.verified/aichatweb/Services/Ingestion/DataIngestor.cs create mode 100644 test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.mock.verified/aichatweb/Services/Ingestion/DocumentReader.cs create mode 100644 test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.mock.verified/aichatweb/Services/Ingestion/PdfPigReader.cs create mode 100644 test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.mock.verified/aichatweb/Services/MockChatClientExtensions.cs create mode 100644 test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.mock.verified/aichatweb/Services/MockServices.cs create mode 100644 test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.mock.verified/aichatweb/Services/SemanticSearch.cs create mode 100644 test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.mock.verified/aichatweb/aichatweb.csproj create mode 100644 test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.mock.verified/aichatweb/appsettings.Development.json create mode 100644 test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.mock.verified/aichatweb/appsettings.json delete mode 100644 test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.o_q.verified/aichatweb/aichatweb.Web/Components/Layout/SurveyPrompt.razor delete mode 100644 test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.o_q.verified/aichatweb/aichatweb.Web/Components/Layout/SurveyPrompt.razor.css create mode 100644 test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.oai.verified/aichatweb/Components/App.razor create mode 100644 test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.oai.verified/aichatweb/Components/Layout/LoadingSpinner.razor create mode 100644 test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.oai.verified/aichatweb/Components/Layout/LoadingSpinner.razor.css create mode 100644 test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.oai.verified/aichatweb/Components/Layout/MainLayout.razor create mode 100644 test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.oai.verified/aichatweb/Components/Layout/MainLayout.razor.css create mode 100644 test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.oai.verified/aichatweb/Components/Pages/Chat/Chat.razor create mode 100644 test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.oai.verified/aichatweb/Components/Pages/Chat/Chat.razor.css create mode 100644 test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.oai.verified/aichatweb/Components/Pages/Chat/ChatCitation.razor create mode 100644 test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.oai.verified/aichatweb/Components/Pages/Chat/ChatCitation.razor.css create mode 100644 test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.oai.verified/aichatweb/Components/Pages/Chat/ChatHeader.razor create mode 100644 test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.oai.verified/aichatweb/Components/Pages/Chat/ChatHeader.razor.css create mode 100644 test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.oai.verified/aichatweb/Components/Pages/Chat/ChatInput.razor create mode 100644 test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.oai.verified/aichatweb/Components/Pages/Chat/ChatInput.razor.css create mode 100644 test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.oai.verified/aichatweb/Components/Pages/Chat/ChatInput.razor.js create mode 100644 test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.oai.verified/aichatweb/Components/Pages/Chat/ChatMessageItem.razor create mode 100644 test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.oai.verified/aichatweb/Components/Pages/Chat/ChatMessageItem.razor.css create mode 100644 test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.oai.verified/aichatweb/Components/Pages/Chat/ChatMessageList.razor create mode 100644 test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.oai.verified/aichatweb/Components/Pages/Chat/ChatMessageList.razor.css create mode 100644 test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.oai.verified/aichatweb/Components/Pages/Chat/ChatMessageList.razor.js create mode 100644 test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.oai.verified/aichatweb/Components/Pages/Chat/ChatSuggestions.razor create mode 100644 test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.oai.verified/aichatweb/Components/Pages/Chat/ChatSuggestions.razor.css create mode 100644 test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.oai.verified/aichatweb/Components/Pages/Error.razor create mode 100644 test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.oai.verified/aichatweb/Components/Routes.razor create mode 100644 test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.oai.verified/aichatweb/Components/_Imports.razor create mode 100644 test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.oai.verified/aichatweb/Program.cs create mode 100644 test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.oai.verified/aichatweb/Properties/launchSettings.json create mode 100644 test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.oai.verified/aichatweb/README.md create mode 100644 test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.oai.verified/aichatweb/Services/IngestedChunk.cs create mode 100644 test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.oai.verified/aichatweb/Services/Ingestion/DataIngestor.cs create mode 100644 test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.oai.verified/aichatweb/Services/Ingestion/DocumentReader.cs create mode 100644 test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.oai.verified/aichatweb/Services/Ingestion/PdfPigReader.cs create mode 100644 test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.oai.verified/aichatweb/Services/SemanticSearch.cs create mode 100644 test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.oai.verified/aichatweb/aichatweb.csproj create mode 100644 test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.oai.verified/aichatweb/appsettings.Development.json create mode 100644 test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.oai.verified/aichatweb/appsettings.json delete mode 100644 test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.oai_aais.verified/aichatweb/Components/Layout/SurveyPrompt.razor delete mode 100644 test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.oai_aais.verified/aichatweb/Components/Layout/SurveyPrompt.razor.css 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 c769d2e9aca..a7f4ccce926 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,13 @@ "exclude": [ "AIChatWeb-CSharp.Web/Services/JsonVectorStore.cs" ] + }, + { + "condition": "(!IsMock)", + "exclude": [ + "AIChatWeb-CSharp.Web/Services/MockServices.cs", + "AIChatWeb-CSharp.Web/Services/MockChatClientExtensions.cs" + ] } ] }], @@ -128,12 +135,18 @@ "type": "parameter", "displayName": "_AI service provider", "datatype": "choice", + "isRequired": true, "choices": [ { "choice": "azureopenai", "displayName": "Azure OpenAI", "description": "Uses Azure OpenAI service" }, + { + "choice": "mock", + "displayName": "Mock (for local development and testing)", + "description": "Uses deterministic, pre-seeded responses" + }, { "choice": "ollama", "displayName": "Ollama (for local development)", @@ -213,6 +226,10 @@ "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 fd577fedc2c..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 @@ -7,6 +7,7 @@ // cd this-project-directory // dotnet user-secrets set ConnectionStrings:openai "Key=YOUR-API-KEY" var openai = builder.AddConnectionString("openai"); +#elif (IsMock) #else // IsAzureOpenAI // See https://learn.microsoft.com/dotnet/aspire/azure/local-provisioning#configuration @@ -58,6 +59,7 @@ .WaitFor(embeddings); #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 9b4f08998f4..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 @@ -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 66b017af999..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 @@ -19,6 +19,12 @@ builder.AddOllamaApiClient("embeddings") .AddEmbeddingGenerator(); #elif (IsAzureAIFoundry) +#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"); 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 ae7ccc9d0ae..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 @@ -42,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 29552b95212..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,6 +23,20 @@ This incompatibility can be addressed by upgrading to Docker Desktop 4.41.1. See #### ---#endif # Configure the AI Model Provider +#### ---#if (IsMock) +## 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? +#### ---#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..55202558bb4 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 (IsMock) + 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/MockChatClientExtensions.cs b/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/templates/AIChatWeb-CSharp/AIChatWeb-CSharp.Web/Services/MockChatClientExtensions.cs new file mode 100644 index 00000000000..b71e15bbc49 --- /dev/null +++ b/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/templates/AIChatWeb-CSharp/AIChatWeb-CSharp.Web/Services/MockChatClientExtensions.cs @@ -0,0 +1,294 @@ +using System.Linq; +using System.Text.Json; +using System.Xml; +using System.Xml.Linq; +using Microsoft.Extensions.AI; + +namespace AIChatWeb_CSharp.Web.Services; + +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)); + } + } + } +} 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..c3d6cd5020d --- /dev/null +++ b/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/templates/AIChatWeb-CSharp/AIChatWeb-CSharp.Web/Services/MockServices.cs @@ -0,0 +1,157 @@ +using Microsoft.Extensions.AI; +namespace AIChatWeb_CSharp.Web.Services; + +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 MockEmbeddingGenerator(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; + } +} 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 fc3071a5e80..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,6 +22,20 @@ This incompatibility can be addressed by upgrading to Docker Desktop 4.41.1. See # Configure the AI Model Provider +#### ---#if (IsMock) +## 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? +#### ---#endif #### ---#if (IsOpenAI) ## Using OpenAI diff --git a/src/ProjectTemplates/README.md b/src/ProjectTemplates/README.md index d4dad3ab88a..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/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.Extensions.AI.Templates.IntegrationTests/AIChatWebExecutionTests.cs b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/AIChatWebExecutionTests.cs index 4d4ceb4073c..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", "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 c94354aa0cd..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("--provider=mock")] [InlineData("--provider=openai")] [InlineData("--provider=ollama", "--vector-store=qdrant")] [InlineData("--provider=openai", "--vector-store=azureaisearch")] - [InlineData("--aspire", "--provider=openai")] + [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_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_mock.verified/aichatweb/README.md b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.A_mock.verified/aichatweb/README.md new file mode 100644 index 00000000000..5d3b5afb2ca --- /dev/null +++ b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.A_mock.verified/aichatweb/README.md @@ -0,0 +1,56 @@ +# 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). + + +### Known Issues + +#### Errors running Ollama or Docker + +A recent incompatibility was found between Ollama and Docker Desktop. This issue results in runtime errors when connecting to Ollama, and the workaround for that can lead to Docker not working for Aspire projects. + +This incompatibility can be addressed by upgrading to Docker Desktop 4.41.1. See [ollama/ollama#9509](https://github.com/ollama/ollama/issues/9509#issuecomment-2842461831) for more information and a link to install the version of Docker Desktop with the fix. + +# 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? + +# Running the application + +## Using Visual Studio + +1. Open the `.sln` file in Visual Studio. +2. Press `Ctrl+F5` or click the "Start" button in the toolbar to run the project. + +## Using Visual Studio Code + +1. Open the project folder in Visual Studio Code. +2. Install the [C# Dev Kit extension](https://marketplace.visualstudio.com/items?itemName=ms-dotnettools.csdevkit) for Visual Studio Code. +3. Once installed, Open the `Program.cs` file in the aichatweb.AppHost project. +4. Run the project by clicking the "Run" button in the Debug view. + +## Trust the localhost certificate + +Several Aspire templates include ASP.NET Core projects that are configured to use HTTPS by default. If this is the first time you're running the project, an exception might occur when loading the Aspire dashboard. This error can be resolved by trusting the self-signed development certificate with the .NET CLI. + +See [Troubleshoot untrusted localhost certificate in Aspire](https://learn.microsoft.com/dotnet/aspire/troubleshooting/untrusted-localhost-certificate) for more information. + +# Updating JavaScript dependencies + +This template leverages JavaScript libraries to provide essential functionality. These libraries are located in the wwwroot/lib folder of the aichatweb.Web project. For instructions on updating each dependency, please refer to the README.md file in each respective folder. + +# Learn More +To learn more about development with .NET and AI, check out the following links: + +* [AI for .NET Developers](https://learn.microsoft.com/dotnet/ai/) diff --git a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.A_mock.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 new file mode 100644 index 00000000000..7b8358c847a --- /dev/null +++ b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.A_mock.verified/aichatweb/aichatweb.AppHost/AppHost.cs @@ -0,0 +1,11 @@ +var builder = DistributedApplication.CreateBuilder(args); + +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 + .WithEnvironment("MARKITDOWN_MCP_URL", markitdown.GetEndpoint("http")); + +builder.Build().Run(); diff --git a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.A_mock.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 new file mode 100644 index 00000000000..f08691f26f1 --- /dev/null +++ b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.A_mock.verified/aichatweb/aichatweb.AppHost/Properties/launchSettings.json @@ -0,0 +1,29 @@ +{ + "$schema": "https://json.schemastore.org/launchsettings.json", + "profiles": { + "https": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": true, + "applicationUrl": "https://localhost:9995;http://localhost:9996", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development", + "DOTNET_ENVIRONMENT": "Development", + "ASPIRE_DASHBOARD_OTLP_ENDPOINT_URL": "https://localhost:9995", + "ASPIRE_RESOURCE_SERVICE_ENDPOINT_URL": "https://localhost:9995" + } + }, + "http": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": true, + "applicationUrl": "http://localhost:9996", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development", + "DOTNET_ENVIRONMENT": "Development", + "ASPIRE_DASHBOARD_OTLP_ENDPOINT_URL": "http://localhost:9996", + "ASPIRE_RESOURCE_SERVICE_ENDPOINT_URL": "http://localhost:9996" + } + } + } +} diff --git a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.A_mock.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 new file mode 100644 index 00000000000..c9340b988a1 --- /dev/null +++ b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.A_mock.verified/aichatweb/aichatweb.AppHost/aichatweb.AppHost.csproj @@ -0,0 +1,21 @@ + + + + + + Exe + net10.0 + enable + enable + {00000000-0000-0000-0000-000000000000} + + + + + + + + + + + diff --git a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.A_mock.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 new file mode 100644 index 00000000000..b0bacf42851 --- /dev/null +++ b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.A_mock.verified/aichatweb/aichatweb.AppHost/appsettings.Development.json @@ -0,0 +1,8 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + } +} diff --git a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.A_mock.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 new file mode 100644 index 00000000000..bfad98588cd --- /dev/null +++ b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.A_mock.verified/aichatweb/aichatweb.AppHost/appsettings.json @@ -0,0 +1,9 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning", + "Aspire.Hosting.Dcp": "Warning" + } + } +} diff --git a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.A_mock.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 new file mode 100644 index 00000000000..8d0b0cd5d67 --- /dev/null +++ b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.A_mock.verified/aichatweb/aichatweb.ServiceDefaults/Extensions.cs @@ -0,0 +1,134 @@ +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Diagnostics.HealthChecks; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Diagnostics.HealthChecks; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.ServiceDiscovery; +using OpenTelemetry; +using OpenTelemetry.Metrics; +using OpenTelemetry.Trace; + +namespace Microsoft.Extensions.Hosting; + +// Adds common Aspire services: service discovery, resilience, health checks, and OpenTelemetry. +// This project should be referenced by each service project in your solution. +// To learn more about using this project, see https://aka.ms/dotnet/aspire/service-defaults +public static class Extensions +{ + private const string HealthEndpointPath = "/health"; + private const string AlivenessEndpointPath = "/alive"; + + public static TBuilder AddServiceDefaults(this TBuilder builder) where TBuilder : IHostApplicationBuilder + { + builder.ConfigureOpenTelemetry(); + + builder.AddDefaultHealthChecks(); + + builder.Services.AddServiceDiscovery(); + + builder.Services.ConfigureHttpClientDefaults(http => + { +#pragma warning disable EXTEXP0001 // RemoveAllResilienceHandlers is experimental + http.RemoveAllResilienceHandlers(); +#pragma warning restore EXTEXP0001 + + // Turn on resilience by default + http.AddStandardResilienceHandler(); + + // Turn on service discovery by default + http.AddServiceDiscovery(); + }); + + // Uncomment the following to restrict the allowed schemes for service discovery. + // builder.Services.Configure(options => + // { + // options.AllowedSchemes = ["https"]; + // }); + + return builder; + } + + public static TBuilder ConfigureOpenTelemetry(this TBuilder builder) where TBuilder : IHostApplicationBuilder + { + builder.Logging.AddOpenTelemetry(logging => + { + logging.IncludeFormattedMessage = true; + logging.IncludeScopes = true; + }); + + builder.Services.AddOpenTelemetry() + .WithMetrics(metrics => + { + metrics.AddAspNetCoreInstrumentation() + .AddHttpClientInstrumentation() + .AddRuntimeInstrumentation() + .AddMeter("Experimental.Microsoft.Extensions.AI"); + }) + .WithTracing(tracing => + { + tracing.AddSource(builder.Environment.ApplicationName) + .AddAspNetCoreInstrumentation(tracing => + // Exclude health check requests from tracing + tracing.Filter = context => + !context.Request.Path.StartsWithSegments(HealthEndpointPath) + && !context.Request.Path.StartsWithSegments(AlivenessEndpointPath) + ) + // Uncomment the following line to enable gRPC instrumentation (requires the OpenTelemetry.Instrumentation.GrpcNetClient package) + //.AddGrpcClientInstrumentation() + .AddHttpClientInstrumentation() + .AddSource("Experimental.Microsoft.Extensions.AI") + .AddSource("Experimental.Microsoft.Extensions.DataIngestion"); + }); + + builder.AddOpenTelemetryExporters(); + + return builder; + } + + private static TBuilder AddOpenTelemetryExporters(this TBuilder builder) where TBuilder : IHostApplicationBuilder + { + var useOtlpExporter = !string.IsNullOrWhiteSpace(builder.Configuration["OTEL_EXPORTER_OTLP_ENDPOINT"]); + + if (useOtlpExporter) + { + builder.Services.AddOpenTelemetry().UseOtlpExporter(); + } + + // Uncomment the following lines to enable the Azure Monitor exporter (requires the Azure.Monitor.OpenTelemetry.AspNetCore package) + //if (!string.IsNullOrEmpty(builder.Configuration["APPLICATIONINSIGHTS_CONNECTION_STRING"])) + //{ + // builder.Services.AddOpenTelemetry() + // .UseAzureMonitor(); + //} + + return builder; + } + + public static TBuilder AddDefaultHealthChecks(this TBuilder builder) where TBuilder : IHostApplicationBuilder + { + builder.Services.AddHealthChecks() + // Add a default liveness check to ensure app is responsive + .AddCheck("self", () => HealthCheckResult.Healthy(), ["live"]); + + return builder; + } + + public static WebApplication MapDefaultEndpoints(this WebApplication app) + { + // Adding health checks endpoints to applications in non-development environments has security implications. + // See https://aka.ms/dotnet/aspire/healthchecks for details before enabling these endpoints in non-development environments. + if (app.Environment.IsDevelopment()) + { + // All health checks must pass for app to be considered ready to accept traffic after starting + app.MapHealthChecks(HealthEndpointPath); + + // Only health checks tagged with the "live" tag must pass for app to be considered alive + app.MapHealthChecks(AlivenessEndpointPath, new HealthCheckOptions + { + Predicate = r => r.Tags.Contains("live") + }); + } + + return app; + } +} diff --git a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.A_mock.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 new file mode 100644 index 00000000000..b3853baa2d2 --- /dev/null +++ b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.A_mock.verified/aichatweb/aichatweb.ServiceDefaults/aichatweb.ServiceDefaults.csproj @@ -0,0 +1,22 @@ + + + + net10.0 + enable + enable + true + + + + + + + + + + + + + + + diff --git a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.A_mock.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 new file mode 100644 index 00000000000..262359d5f5a --- /dev/null +++ b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.A_mock.verified/aichatweb/aichatweb.Web/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.A_mock.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 new file mode 100644 index 00000000000..116455ce45b --- /dev/null +++ b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.A_mock.verified/aichatweb/aichatweb.Web/Components/Layout/LoadingSpinner.razor @@ -0,0 +1 @@ +
diff --git a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.A_mock.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 new file mode 100644 index 00000000000..d85b851a679 --- /dev/null +++ b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.A_mock.verified/aichatweb/aichatweb.Web/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.A_mock.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 new file mode 100644 index 00000000000..96fbbe6cc42 --- /dev/null +++ b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.A_mock.verified/aichatweb/aichatweb.Web/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.A_mock.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 new file mode 100644 index 00000000000..cda2020dcb0 --- /dev/null +++ b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.A_mock.verified/aichatweb/aichatweb.Web/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.A_mock.verified/aichatweb/aichatweb.Web/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 new file mode 100644 index 00000000000..9d33e3ff5ed --- /dev/null +++ b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.A_mock.verified/aichatweb/aichatweb.Web/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.A_mock.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 new file mode 100644 index 00000000000..98ed1ba7d1e --- /dev/null +++ b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.A_mock.verified/aichatweb/aichatweb.Web/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.A_mock.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 new file mode 100644 index 00000000000..667189beabd --- /dev/null +++ b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.A_mock.verified/aichatweb/aichatweb.Web/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.A_mock.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 new file mode 100644 index 00000000000..0ca029b7e64 --- /dev/null +++ b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.A_mock.verified/aichatweb/aichatweb.Web/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.A_mock.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 new file mode 100644 index 00000000000..12b1d524e23 --- /dev/null +++ b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.A_mock.verified/aichatweb/aichatweb.Web/Components/Pages/Chat/ChatHeader.razor @@ -0,0 +1,17 @@ +
+
+ +
+ +

aichatweb.Web

+
+ +@code { + [Parameter] + public EventCallback OnNewChat { get; set; } +} diff --git a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.A_mock.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 new file mode 100644 index 00000000000..6adcc414540 --- /dev/null +++ b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.A_mock.verified/aichatweb/aichatweb.Web/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.A_mock.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 new file mode 100644 index 00000000000..e87ac6ccf47 --- /dev/null +++ b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.A_mock.verified/aichatweb/aichatweb.Web/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.A_mock.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 new file mode 100644 index 00000000000..3b26c9af316 --- /dev/null +++ b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.A_mock.verified/aichatweb/aichatweb.Web/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.A_mock.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 new file mode 100644 index 00000000000..39e18ac7b74 --- /dev/null +++ b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.A_mock.verified/aichatweb/aichatweb.Web/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.A_mock.verified/aichatweb/aichatweb.Web/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 new file mode 100644 index 00000000000..33367fa3974 --- /dev/null +++ b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.A_mock.verified/aichatweb/aichatweb.Web/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.A_mock.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 new file mode 100644 index 00000000000..10453454be8 --- /dev/null +++ b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.A_mock.verified/aichatweb/aichatweb.Web/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.A_mock.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 new file mode 100644 index 00000000000..d245f455f11 --- /dev/null +++ b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.A_mock.verified/aichatweb/aichatweb.Web/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.A_mock.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 new file mode 100644 index 00000000000..6fbf083c7fa --- /dev/null +++ b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.A_mock.verified/aichatweb/aichatweb.Web/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.A_mock.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 new file mode 100644 index 00000000000..3de8de273b8 --- /dev/null +++ b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.A_mock.verified/aichatweb/aichatweb.Web/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.A_mock.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 new file mode 100644 index 00000000000..69ca922a8ce --- /dev/null +++ b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.A_mock.verified/aichatweb/aichatweb.Web/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.A_mock.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 new file mode 100644 index 00000000000..b291042c6d4 --- /dev/null +++ b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.A_mock.verified/aichatweb/aichatweb.Web/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.A_mock.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 new file mode 100644 index 00000000000..576cc2d2f4d --- /dev/null +++ b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.A_mock.verified/aichatweb/aichatweb.Web/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.A_mock.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 new file mode 100644 index 00000000000..f756e19dfbc --- /dev/null +++ b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.A_mock.verified/aichatweb/aichatweb.Web/Components/Routes.razor @@ -0,0 +1,6 @@ + + + + + + diff --git a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.A_mock.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 new file mode 100644 index 00000000000..fa7cadef6ea --- /dev/null +++ b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.A_mock.verified/aichatweb/aichatweb.Web/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.Web +@using aichatweb.Web.Components +@using aichatweb.Web.Components.Layout +@using aichatweb.Web.Services diff --git a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.A_mock.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 new file mode 100644 index 00000000000..f31aa5b1e8a --- /dev/null +++ b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.A_mock.verified/aichatweb/aichatweb.Web/Program.cs @@ -0,0 +1,43 @@ +using Microsoft.Extensions.AI; +using aichatweb.Web.Components; +using aichatweb.Web.Services; +using aichatweb.Web.Services.Ingestion; + +var builder = WebApplication.CreateBuilder(args); +builder.AddServiceDefaults(); +builder.Services.AddRazorComponents().AddInteractiveServerComponents(); + +builder.Services.AddChatClient(MockServices.CreateChatClient()) + .UseFunctionInvocation() + .UseOpenTelemetry(configure: c => + c.EnableSensitiveData = builder.Environment.IsDevelopment()); +builder.Services.AddEmbeddingGenerator(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"))); + +var app = builder.Build(); + +app.MapDefaultEndpoints(); + +// 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.A_mock.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 new file mode 100644 index 00000000000..6e300d8a802 --- /dev/null +++ b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.A_mock.verified/aichatweb/aichatweb.Web/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.A_mock.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 new file mode 100644 index 00000000000..2f1ca15090a --- /dev/null +++ b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.A_mock.verified/aichatweb/aichatweb.Web/Services/IngestedChunk.cs @@ -0,0 +1,31 @@ +using System.Text.Json.Serialization; +using Microsoft.Extensions.VectorData; + +namespace aichatweb.Web.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.A_mock.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 new file mode 100644 index 00000000000..9dd366a03a5 --- /dev/null +++ b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.A_mock.verified/aichatweb/aichatweb.Web/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.Web.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.A_mock.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 new file mode 100644 index 00000000000..60fcdbdc128 --- /dev/null +++ b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.A_mock.verified/aichatweb/aichatweb.Web/Services/Ingestion/DocumentReader.cs @@ -0,0 +1,42 @@ +using Microsoft.Extensions.DataIngestion; + +namespace aichatweb.Web.Services.Ingestion; + +internal sealed class DocumentReader(DirectoryInfo rootDirectory) : IngestionDocumentReader +{ + private readonly MarkdownReader _markdownReader = new(); + private readonly MarkItDownMcpReader _pdfReader = new(mcpServerUri: GetMarkItDownMcpServerUrl()); + + 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 + }; + + private static Uri GetMarkItDownMcpServerUrl() + { + var markItDownMcpUrl = $"{Environment.GetEnvironmentVariable("MARKITDOWN_MCP_URL")}/mcp"; + return new Uri(markItDownMcpUrl); + } +} 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..1444bf2fb91 --- /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,294 @@ +using System.Linq; +using System.Text.Json; +using System.Xml; +using System.Xml.Linq; +using Microsoft.Extensions.AI; + +namespace aichatweb.Web.Services; + +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)); + } + } + } +} 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..d1cb8c2b201 --- /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,157 @@ +using Microsoft.Extensions.AI; +namespace aichatweb.Web.Services; + +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 MockEmbeddingGenerator(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; + } +} diff --git a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.A_mock.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 new file mode 100644 index 00000000000..d043c8efb84 --- /dev/null +++ b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.A_mock.verified/aichatweb/aichatweb.Web/Services/SemanticSearch.cs @@ -0,0 +1,27 @@ +using aichatweb.Web.Services.Ingestion; +using Microsoft.Extensions.VectorData; + +namespace aichatweb.Web.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.A_mock.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 new file mode 100644 index 00000000000..1d3b3e6cf38 --- /dev/null +++ b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.A_mock.verified/aichatweb/aichatweb.Web/aichatweb.Web.csproj @@ -0,0 +1,25 @@ + + + + net10.0 + enable + enable + {00000000-0000-0000-0000-000000000000} + + + + + + + + + + + + + + + + + + diff --git a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.A_mock.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 new file mode 100644 index 00000000000..e22bd83cf3a --- /dev/null +++ b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.A_mock.verified/aichatweb/aichatweb.Web/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.A_mock.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 new file mode 100644 index 00000000000..d286041f99d --- /dev/null +++ b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.A_mock.verified/aichatweb/aichatweb.Web/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.A_mock.verified/aichatweb/aichatweb.sln b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.A_mock.verified/aichatweb/aichatweb.sln new file mode 100644 index 00000000000..67d2a3cad3c --- /dev/null +++ b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.A_mock.verified/aichatweb/aichatweb.sln @@ -0,0 +1,34 @@ + +Microsoft Visual Studio Solution File, Format Version 12.00 +# Visual Studio Version 17 +VisualStudioVersion = 17.0.31903.59 +MinimumVisualStudioVersion = 10.0.40219.1 +Project("{00000000-0000-0000-0000-000000000000}") = "aichatweb.AppHost", "aichatweb.AppHost\aichatweb.AppHost.csproj", "{00000000-0000-0000-0000-000000000000}" +EndProject +Project("{00000000-0000-0000-0000-000000000000}") = "aichatweb.ServiceDefaults", "aichatweb.ServiceDefaults\aichatweb.ServiceDefaults.csproj", "{00000000-0000-0000-0000-000000000000}" +EndProject +Project("{00000000-0000-0000-0000-000000000000}") = "aichatweb.Web", "aichatweb.Web\aichatweb.Web.csproj", "{00000000-0000-0000-0000-000000000000}" +EndProject +Global + GlobalSection(SolutionConfigurationPlatforms) = preSolution + Debug|Any CPU = Debug|Any CPU + Release|Any CPU = Release|Any CPU + EndGlobalSection + GlobalSection(SolutionProperties) = preSolution + HideSolutionNode = FALSE + EndGlobalSection + GlobalSection(ProjectConfigurationPlatforms) = postSolution + {00000000-0000-0000-0000-000000000000}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {00000000-0000-0000-0000-000000000000}.Debug|Any CPU.Build.0 = Debug|Any CPU + {00000000-0000-0000-0000-000000000000}.Release|Any CPU.ActiveCfg = Release|Any CPU + {00000000-0000-0000-0000-000000000000}.Release|Any CPU.Build.0 = Release|Any CPU + {00000000-0000-0000-0000-000000000000}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {00000000-0000-0000-0000-000000000000}.Debug|Any CPU.Build.0 = Debug|Any CPU + {00000000-0000-0000-0000-000000000000}.Release|Any CPU.ActiveCfg = Release|Any CPU + {00000000-0000-0000-0000-000000000000}.Release|Any CPU.Build.0 = Release|Any CPU + {00000000-0000-0000-0000-000000000000}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {00000000-0000-0000-0000-000000000000}.Debug|Any CPU.Build.0 = Debug|Any CPU + {00000000-0000-0000-0000-000000000000}.Release|Any CPU.ActiveCfg = Release|Any CPU + {00000000-0000-0000-0000-000000000000}.Release|Any CPU.Build.0 = Release|Any CPU + EndGlobalSection +EndGlobal diff --git a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.mock.verified/aichatweb/Components/App.razor b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.mock.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.mock.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.mock.verified/aichatweb/Components/Layout/LoadingSpinner.razor b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.mock.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.mock.verified/aichatweb/Components/Layout/LoadingSpinner.razor @@ -0,0 +1 @@ +
diff --git a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.mock.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 new file mode 100644 index 00000000000..d85b851a679 --- /dev/null +++ b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.mock.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.mock.verified/aichatweb/Components/Layout/MainLayout.razor b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.mock.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.mock.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.mock.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 new file mode 100644 index 00000000000..cda2020dcb0 --- /dev/null +++ b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.mock.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.mock.verified/aichatweb/Components/Pages/Chat/Chat.razor b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.mock.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.mock.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.mock.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 new file mode 100644 index 00000000000..98ed1ba7d1e --- /dev/null +++ b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.mock.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.mock.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 new file mode 100644 index 00000000000..667189beabd --- /dev/null +++ b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.mock.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.mock.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 new file mode 100644 index 00000000000..0ca029b7e64 --- /dev/null +++ b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.mock.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.mock.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 new file mode 100644 index 00000000000..0e9d9c8894a --- /dev/null +++ b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.mock.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.mock.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 new file mode 100644 index 00000000000..6adcc414540 --- /dev/null +++ b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.mock.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.mock.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 new file mode 100644 index 00000000000..e87ac6ccf47 --- /dev/null +++ b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.mock.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.mock.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 new file mode 100644 index 00000000000..3b26c9af316 --- /dev/null +++ b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.mock.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.mock.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 new file mode 100644 index 00000000000..39e18ac7b74 --- /dev/null +++ b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.mock.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.mock.verified/aichatweb/Components/Pages/Chat/ChatMessageItem.razor b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.mock.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.mock.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.mock.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 new file mode 100644 index 00000000000..10453454be8 --- /dev/null +++ b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.mock.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.mock.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 new file mode 100644 index 00000000000..d245f455f11 --- /dev/null +++ b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.mock.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.mock.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 new file mode 100644 index 00000000000..6fbf083c7fa --- /dev/null +++ b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.mock.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.mock.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 new file mode 100644 index 00000000000..3de8de273b8 --- /dev/null +++ b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.mock.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.mock.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 new file mode 100644 index 00000000000..69ca922a8ce --- /dev/null +++ b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.mock.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.mock.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 new file mode 100644 index 00000000000..b291042c6d4 --- /dev/null +++ b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.mock.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.mock.verified/aichatweb/Components/Pages/Error.razor b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.mock.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.mock.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.mock.verified/aichatweb/Components/Routes.razor b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.mock.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.mock.verified/aichatweb/Components/Routes.razor @@ -0,0 +1,6 @@ + + + + + + diff --git a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.mock.verified/aichatweb/Components/_Imports.razor b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.mock.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.mock.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.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.mock.verified/aichatweb/Properties/launchSettings.json b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.mock.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.mock.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.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.mock.verified/aichatweb/Services/IngestedChunk.cs b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.mock.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.mock.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.mock.verified/aichatweb/Services/Ingestion/DataIngestor.cs b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.mock.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.mock.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.mock.verified/aichatweb/Services/Ingestion/DocumentReader.cs b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.mock.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.mock.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.mock.verified/aichatweb/Services/Ingestion/PdfPigReader.cs b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.mock.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.mock.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.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..e0ea691e8d7 --- /dev/null +++ b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.mock.verified/aichatweb/Services/MockChatClientExtensions.cs @@ -0,0 +1,294 @@ +using System.Linq; +using System.Text.Json; +using System.Xml; +using System.Xml.Linq; +using Microsoft.Extensions.AI; + +namespace aichatweb.Services; + +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)); + } + } + } +} 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..1c406db28db --- /dev/null +++ b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.mock.verified/aichatweb/Services/MockServices.cs @@ -0,0 +1,157 @@ +using Microsoft.Extensions.AI; +namespace aichatweb.Services; + +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 MockEmbeddingGenerator(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; + } +} diff --git a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.mock.verified/aichatweb/Services/SemanticSearch.cs b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.mock.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.mock.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.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.mock.verified/aichatweb/appsettings.Development.json b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.mock.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.mock.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.mock.verified/aichatweb/appsettings.json b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.mock.verified/aichatweb/appsettings.json new file mode 100644 index 00000000000..d286041f99d --- /dev/null +++ b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.mock.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.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.oai.verified/aichatweb/Program.cs b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.oai.verified/aichatweb/Program.cs new file mode 100644 index 00000000000..429840c1d69 --- /dev/null +++ b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.oai.verified/aichatweb/Program.cs @@ -0,0 +1,53 @@ +using System.ClientModel; +using Microsoft.Extensions.AI; +using OpenAI; +using aichatweb.Components; +using aichatweb.Services; +using aichatweb.Services.Ingestion; + +var builder = WebApplication.CreateBuilder(args); +builder.Services.AddRazorComponents().AddInteractiveServerComponents(); + +// 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 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 embeddingGenerator = openAIClient.GetEmbeddingClient("text-embedding-3-small").AsIEmbeddingGenerator(); + +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.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..68af3ef20fb --- /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 Guid 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.oai.verified/aichatweb/aichatweb.csproj b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.oai.verified/aichatweb/aichatweb.csproj new file mode 100644 index 00000000000..a4536e86ee6 --- /dev/null +++ b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.oai.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.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; } } From 10d41d65c137b8685e4fc361cb6b7d834279505b Mon Sep 17 00:00:00 2001 From: Jeff Handley Date: Thu, 23 Jul 2026 12:24:11 -0700 Subject: [PATCH 6/7] Add mock provider to aiagent-webapi template with canned responses Introduces the 'mock' provider option (--provider mock) to the aiagent-webapi project template. The mock provider uses Microsoft.Extensions.AI.Testing's MockChatClient seeded with 3 canned responses for the story writer/editor workflow (generate a story, edit for tone, format as HTML), allowing the template to be explored without any external AI provider credentials while still demonstrating the multi-agent pipeline with writer and editor stages. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: dc263661-1b95-4f80-a7f4-3a4d3cd275ff --- ...icrosoft.Agents.AI.ProjectTemplates.csproj | 1 + .../.template.config/template.json | 21 ++- .../AIAgentWebApi-CSharp.csproj-in | 3 + .../MockChatClientExtensions.cs | 69 +++++++++ .../AIAgentWebApi-CSharp/MockServices.cs | 144 ++++++++++++++++++ .../templates/AIAgentWebApi-CSharp/Program.cs | 3 + .../templates/AIAgentWebApi-CSharp/README.md | 27 +++- .../AIAgentWebAPIExecutionTests.cs | 2 +- .../AIAgentWebAPISnapshotTests.cs | 1 + .../aiagent-webapi/Program.cs | 1 + .../aiagent-webapi/README.md | 4 +- .../aiagent-webapi/Program.cs | 1 + .../aiagent-webapi/README.md | 4 +- .../MockChatClientExtensions.cs | 69 +++++++++ .../aiagent-webapi/MockServices.cs | 144 ++++++++++++++++++ .../aiagent-webapi/Program.cs | 57 +++++++ .../Properties/launchSettings.json | 25 +++ .../aiagent-webapi/README.md | 104 +++++++++++++ .../aiagent-webapi/aiagent-webapi.csproj | 20 +++ .../aiagent-webapi/appsettings.json | 9 ++ .../aiagent-webapi/Program.cs | 1 + .../aiagent-webapi/README.md | 4 +- .../aiagent-webapi/Program.cs | 1 + .../aiagent-webapi/README.md | 4 +- 24 files changed, 706 insertions(+), 13 deletions(-) create mode 100644 src/ProjectTemplates/Microsoft.Agents.AI.ProjectTemplates/templates/AIAgentWebApi-CSharp/MockChatClientExtensions.cs create mode 100644 src/ProjectTemplates/Microsoft.Agents.AI.ProjectTemplates/templates/AIAgentWebApi-CSharp/MockServices.cs create mode 100644 test/ProjectTemplates/Microsoft.Agents.AI.ProjectTemplates.IntegrationTests/Snapshots/aiagent-webapi/aiagent-webapi.mock.verified/aiagent-webapi/MockChatClientExtensions.cs create mode 100644 test/ProjectTemplates/Microsoft.Agents.AI.ProjectTemplates.IntegrationTests/Snapshots/aiagent-webapi/aiagent-webapi.mock.verified/aiagent-webapi/MockServices.cs create mode 100644 test/ProjectTemplates/Microsoft.Agents.AI.ProjectTemplates.IntegrationTests/Snapshots/aiagent-webapi/aiagent-webapi.mock.verified/aiagent-webapi/Program.cs create mode 100644 test/ProjectTemplates/Microsoft.Agents.AI.ProjectTemplates.IntegrationTests/Snapshots/aiagent-webapi/aiagent-webapi.mock.verified/aiagent-webapi/Properties/launchSettings.json create mode 100644 test/ProjectTemplates/Microsoft.Agents.AI.ProjectTemplates.IntegrationTests/Snapshots/aiagent-webapi/aiagent-webapi.mock.verified/aiagent-webapi/README.md create mode 100644 test/ProjectTemplates/Microsoft.Agents.AI.ProjectTemplates.IntegrationTests/Snapshots/aiagent-webapi/aiagent-webapi.mock.verified/aiagent-webapi/aiagent-webapi.csproj create mode 100644 test/ProjectTemplates/Microsoft.Agents.AI.ProjectTemplates.IntegrationTests/Snapshots/aiagent-webapi/aiagent-webapi.mock.verified/aiagent-webapi/appsettings.json 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..34ab37da82d --- /dev/null +++ b/src/ProjectTemplates/Microsoft.Agents.AI.ProjectTemplates/templates/AIAgentWebApi-CSharp/MockChatClientExtensions.cs @@ -0,0 +1,69 @@ +using Microsoft.Extensions.AI; + +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)); + } + } + } +} 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..5b9389227ed --- /dev/null +++ b/src/ProjectTemplates/Microsoft.Agents.AI.ProjectTemplates/templates/AIAgentWebApi-CSharp/MockServices.cs @@ -0,0 +1,144 @@ +using Microsoft.Extensions.AI; + +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; + } +} 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 7bc6241dcd9..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 @@ -33,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: @@ -87,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 8dd93d1ecca..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,7 +4,9 @@ This is an AI Agent Web API application created from the `aiagent-webapi` templa ## Prerequisites - + +- No external AI provider prerequisites + - An OpenAI API key - An Azure OpenAI service deployment @@ -16,7 +18,20 @@ This is an AI Agent Web API application created from the `aiagent-webapi` templa ### 1. Configure Your AI Service - + +#### Mock Configuration + +This application uses a deterministic mock chat provider seeded for the writer/editor workflow. + +No API keys or model runtimes are required. + +Try prompts such as: + +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 This application uses the OpenAI Platform (model: gpt-4o-mini). You'll need to configure your OpenAI API key: @@ -163,6 +178,7 @@ dotnet new aiagent-webapi --provider ollama --chat-model llama3.1 ### Available Parameters - **`--provider`**: Choose the AI service provider + - `mock` - Deterministic mock provider - `azureopenai` - Azure OpenAI - `openai` - OpenAI Platform - `ollama` - Ollama (local development) @@ -197,7 +213,12 @@ dotnet new aiagent-webapi --provider ollama --chat-model llama3.1 ## Troubleshooting - + +**Problem**: Responses seem repetitive. + +**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" **Solution**: Make sure you've configured your OpenAI API key using one of the methods described above. diff --git a/test/ProjectTemplates/Microsoft.Agents.AI.ProjectTemplates.IntegrationTests/AIAgentWebAPIExecutionTests.cs b/test/ProjectTemplates/Microsoft.Agents.AI.ProjectTemplates.IntegrationTests/AIAgentWebAPIExecutionTests.cs index dca4e7769f8..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", "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 1cb35bf7167..238a2060ba0 100644 --- a/test/ProjectTemplates/Microsoft.Agents.AI.ProjectTemplates.IntegrationTests/AIAgentWebAPISnapshotTests.cs +++ b/test/ProjectTemplates/Microsoft.Agents.AI.ProjectTemplates.IntegrationTests/AIAgentWebAPISnapshotTests.cs @@ -22,6 +22,7 @@ public AIAgentWebAPISnapshotTests(ITestOutputHelper log) } [Theory] + [InlineData("--provider=mock")] [InlineData("--provider=openai")] [InlineData("--provider=ollama")] [InlineData("--provider=azureopenai", "--managed-identity=true")] 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..a0d702fbb7f --- /dev/null +++ b/test/ProjectTemplates/Microsoft.Agents.AI.ProjectTemplates.IntegrationTests/Snapshots/aiagent-webapi/aiagent-webapi.mock.verified/aiagent-webapi/MockChatClientExtensions.cs @@ -0,0 +1,69 @@ +using Microsoft.Extensions.AI; + +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)); + } + } + } +} 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..f82509e5945 --- /dev/null +++ b/test/ProjectTemplates/Microsoft.Agents.AI.ProjectTemplates.IntegrationTests/Snapshots/aiagent-webapi/aiagent-webapi.mock.verified/aiagent-webapi/MockServices.cs @@ -0,0 +1,144 @@ +using Microsoft.Extensions.AI; + +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; + } +} diff --git a/test/ProjectTemplates/Microsoft.Agents.AI.ProjectTemplates.IntegrationTests/Snapshots/aiagent-webapi/aiagent-webapi.mock.verified/aiagent-webapi/Program.cs b/test/ProjectTemplates/Microsoft.Agents.AI.ProjectTemplates.IntegrationTests/Snapshots/aiagent-webapi/aiagent-webapi.mock.verified/aiagent-webapi/Program.cs new file mode 100644 index 00000000000..6ccb091734a --- /dev/null +++ b/test/ProjectTemplates/Microsoft.Agents.AI.ProjectTemplates.IntegrationTests/Snapshots/aiagent-webapi/aiagent-webapi.mock.verified/aiagent-webapi/Program.cs @@ -0,0 +1,57 @@ +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; + +var builder = WebApplication.CreateBuilder(args); + +var chatClient = MockServices.CreateChatClient(); + +builder.Services.AddChatClient(chatClient); + +builder.AddAIAgent("writer", "You write short stories (300 words or less) about the specified topic."); + +builder.AddAIAgent("editor", (sp, key) => new ChatClientAgent( + chatClient, + name: key, + instructions: "You edit short stories to improve grammar and style, ensuring the stories are less than 300 words. Once finished editing, you select a title and format the story for publishing.", + tools: [AIFunctionFactory.Create(FormatStory)] +)); + +builder.AddWorkflow("publisher", (sp, key) => AgentWorkflowBuilder.BuildSequential( + workflowName: key, + agents: + [ + sp.GetRequiredKeyedService("writer"), + sp.GetRequiredKeyedService("editor") + ] +)).AddAsAIAgent("publisher-agent"); + +// 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(); + +// Map endpoints for OpenAI responses and conversations (also required for DevUI) +app.MapOpenAIResponses(); +app.MapOpenAIConversations(); + +if (builder.Environment.IsDevelopment()) +{ + // Map DevUI endpoint to /devui + app.MapDevUI(); +} + +app.Run(); + +[Description("Formats the story for publication, revealing its title.")] +string FormatStory(string title, string story) => $""" + **Title**: {title} + + {story} + """; diff --git a/test/ProjectTemplates/Microsoft.Agents.AI.ProjectTemplates.IntegrationTests/Snapshots/aiagent-webapi/aiagent-webapi.mock.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 new file mode 100644 index 00000000000..d71b1876d35 --- /dev/null +++ b/test/ProjectTemplates/Microsoft.Agents.AI.ProjectTemplates.IntegrationTests/Snapshots/aiagent-webapi/aiagent-webapi.mock.verified/aiagent-webapi/Properties/launchSettings.json @@ -0,0 +1,25 @@ +{ + "$schema": "https://json.schemastore.org/launchsettings.json", + "profiles": { + "http": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": true, + "launchUrl": "devui/", + "applicationUrl": "http://localhost:9996", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + }, + "https": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": true, + "launchUrl": "devui/", + "applicationUrl": "https://localhost:9995;http://localhost:9996", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + } + } +} diff --git a/test/ProjectTemplates/Microsoft.Agents.AI.ProjectTemplates.IntegrationTests/Snapshots/aiagent-webapi/aiagent-webapi.mock.verified/aiagent-webapi/README.md b/test/ProjectTemplates/Microsoft.Agents.AI.ProjectTemplates.IntegrationTests/Snapshots/aiagent-webapi/aiagent-webapi.mock.verified/aiagent-webapi/README.md new file mode 100644 index 00000000000..8aaac3ba211 --- /dev/null +++ b/test/ProjectTemplates/Microsoft.Agents.AI.ProjectTemplates.IntegrationTests/Snapshots/aiagent-webapi/aiagent-webapi.mock.verified/aiagent-webapi/README.md @@ -0,0 +1,104 @@ +# AI Agent Web API + +This is an AI Agent Web API application created from the `aiagent-webapi` template. This template is currently in a preview stage. If you have feedback, please take a [brief survey](https://aka.ms/dotnet/aiagent-webapi/preview1/survey). + +## Prerequisites + +- No external AI provider prerequisites + +## Getting Started + +### 1. Configure Your AI Service + +#### Mock Configuration + +This application uses a deterministic mock chat provider seeded for the writer/editor workflow. + +No API keys or model runtimes are required. + +Try prompts such as: + +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 + +```bash +dotnet run -lp https +``` + +The application will start and listen on: +- HTTP: `http://localhost:9996` +- HTTPS: `https://localhost:9995` + +### 3. Test the Application + +The application exposes OpenAI-compatible API endpoints. You can interact with the AI agents using any OpenAI-compatible client or tools. + +In development environments, a `/devui/` route is mapped to the Agent Framework development UI (DevUI), and when the app is launched through an IDE a browser will open to this URL. DevUI provides a web-based UI for interacting with agents and workflows. DevUI operates as an OpenAI-compatible client using the Responses and Conversations endpoints. + +## How It Works + +This application demonstrates Agent Framework with: + +1. **Writer Agent**: Writes short stories (300 words or less) about specified topics +2. **Editor Agent**: Edits stories to improve grammar and style, ensuring they stay under 300 words +3. **Publisher Workflow Agent**: A sequential workflow agent that combines the writer and editor agents + +The agents are exposed through OpenAI-compatible API endpoints, making them easy to integrate with existing tools and applications. + +## Template Parameters + +When creating a new project, you can customize it using template parameters: + +```bash +# Specify AI service provider +dotnet new aiagent-webapi --provider azureopenai + +# Specify a custom chat model +dotnet new aiagent-webapi --chat-model gpt-4o + +# Use API key authentication for Azure OpenAI +dotnet new aiagent-webapi --provider azureopenai --managed-identity false + +# Use Ollama with a different model +dotnet new aiagent-webapi --provider ollama --chat-model llama3.1 +``` + +### Available Parameters + +- **`--provider`**: Choose the AI service provider + - `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: `gpt-4o-mini` + - Default for Ollama: `llama3.2` + +- **`--managed-identity`**: Use managed identity for Azure services (default: `true`) + - Only applicable when `--provider azureopenai` + +- **`--framework`**: Target framework (default: `net10.0`) + - Options: `net10.0`, `net9.0`, `net8.0` + +## Project Structure + +- `Program.cs` - Application entry point and configuration +- `appsettings.json` - Application configuration +- `Properties/launchSettings.json` - Launch profiles for development + +## Learn More + +- [AI apps for .NET developers](https://learn.microsoft.com/dotnet/ai) +- [Microsoft Agent Framework Documentation](https://aka.ms/dotnet/agent-framework/docs) + +## Troubleshooting + +**Problem**: Responses seem repetitive. + +**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.mock.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 new file mode 100644 index 00000000000..e8661283e17 --- /dev/null +++ b/test/ProjectTemplates/Microsoft.Agents.AI.ProjectTemplates.IntegrationTests/Snapshots/aiagent-webapi/aiagent-webapi.mock.verified/aiagent-webapi/aiagent-webapi.csproj @@ -0,0 +1,20 @@ + + + + net10.0 + enable + enable + {00000000-0000-0000-0000-000000000000} + + + + + + + + + + + + + diff --git a/test/ProjectTemplates/Microsoft.Agents.AI.ProjectTemplates.IntegrationTests/Snapshots/aiagent-webapi/aiagent-webapi.mock.verified/aiagent-webapi/appsettings.json b/test/ProjectTemplates/Microsoft.Agents.AI.ProjectTemplates.IntegrationTests/Snapshots/aiagent-webapi/aiagent-webapi.mock.verified/aiagent-webapi/appsettings.json new file mode 100644 index 00000000000..223027717b4 --- /dev/null +++ b/test/ProjectTemplates/Microsoft.Agents.AI.ProjectTemplates.IntegrationTests/Snapshots/aiagent-webapi/aiagent-webapi.mock.verified/aiagent-webapi/appsettings.json @@ -0,0 +1,9 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + }, + "AllowedHosts": "*" +} 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`) From 52ae066cefc5673faef044525b8896d4ad121fc7 Mon Sep 17 00:00:00 2001 From: Jeff Handley Date: Fri, 24 Jul 2026 14:35:21 -0700 Subject: [PATCH 7/7] Migrate embedding test generators to testing library Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: dc263661-1b95-4f80-a7f4-3a4d3cd275ff --- .../MockChatClient.cs | 15 +- .../MockEmbeddingGenerator.cs | 138 +++++++----------- .../Microsoft.Extensions.AI.Testing/README.md | 17 ++- .../.template.config/template.json | 3 +- .../Services/LexicalMockEmbeddingGenerator.cs | 104 +++++++++++++ .../Services/MockServices.cs | 2 +- .../DelegatingEmbeddingGeneratorTests.cs | 12 +- .../EmbeddingGeneratorExtensionsTests.cs | 14 +- .../Embeddings/GeneratedEmbeddingsTests.cs | 2 +- ...ft.Extensions.AI.Abstractions.Tests.csproj | 1 + .../TestEmbeddingGenerator.cs | 41 ------ .../MockChatClientTests.cs | 87 +++++++---- .../Microsoft.Extensions.AI.Tests.csproj | 2 +- .../MockEmbeddingGeneratorAlias.cs | 4 + .../SemanticSimilarityChunkerTests.cs | 18 ++- .../IngestionPipelineTests.cs | 41 ++++-- ...soft.Extensions.DataIngestion.Tests.csproj | 2 +- .../Utils/TestEmbeddingGenerator.cs | 32 ---- .../Writers/InMemoryVectorStoreWriterTests.cs | 3 +- .../Writers/SqliteVectorStoreWriterTests.cs | 3 +- .../Writers/VectorStoreWriterTests.cs | 28 ++-- .../Services/LexicalMockEmbeddingGenerator.cs | 104 +++++++++++++ .../aichatweb.Web/Services/MockServices.cs | 2 +- .../Services/LexicalMockEmbeddingGenerator.cs | 104 +++++++++++++ .../aichatweb/Services/MockServices.cs | 2 +- 25 files changed, 542 insertions(+), 239 deletions(-) create mode 100644 src/ProjectTemplates/Microsoft.Extensions.AI.Templates/templates/AIChatWeb-CSharp/AIChatWeb-CSharp.Web/Services/LexicalMockEmbeddingGenerator.cs delete mode 100644 test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/TestEmbeddingGenerator.cs create mode 100644 test/Libraries/Microsoft.Extensions.AI.Tests/MockEmbeddingGeneratorAlias.cs delete mode 100644 test/Libraries/Microsoft.Extensions.DataIngestion.Tests/Utils/TestEmbeddingGenerator.cs create mode 100644 test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.A_mock.verified/aichatweb/aichatweb.Web/Services/LexicalMockEmbeddingGenerator.cs create mode 100644 test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.mock.verified/aichatweb/Services/LexicalMockEmbeddingGenerator.cs diff --git a/src/Libraries/Microsoft.Extensions.AI.Testing/MockChatClient.cs b/src/Libraries/Microsoft.Extensions.AI.Testing/MockChatClient.cs index 5d78f06f5ef..813b0ab1b75 100644 --- a/src/Libraries/Microsoft.Extensions.AI.Testing/MockChatClient.cs +++ b/src/Libraries/Microsoft.Extensions.AI.Testing/MockChatClient.cs @@ -327,8 +327,21 @@ public virtual IAsyncEnumerable GetStreamingResponseAsync( } /// Disposes this instance and clears all seeds and recorded requests. - public virtual void Dispose() + public void Dispose() { + Dispose(disposing: true); + GC.SuppressFinalize(this); + } + + /// Releases resources used by this mock client. + /// when called from . + protected virtual void Dispose(bool disposing) + { + if (!disposing) + { + return; + } + lock (_sync) { _disposed = true; diff --git a/src/Libraries/Microsoft.Extensions.AI.Testing/MockEmbeddingGenerator.cs b/src/Libraries/Microsoft.Extensions.AI.Testing/MockEmbeddingGenerator.cs index ebed6d55b90..ea05467f0c0 100644 --- a/src/Libraries/Microsoft.Extensions.AI.Testing/MockEmbeddingGenerator.cs +++ b/src/Libraries/Microsoft.Extensions.AI.Testing/MockEmbeddingGenerator.cs @@ -10,119 +10,91 @@ namespace Microsoft.Extensions.AI; /// -/// A deterministic for tests and local mock scenarios. +/// A configurable for tests and local mock scenarios. /// +/// The type of input accepted by the embedding generator. /// -/// The generated vectors are repeatable pseudo-embeddings. They use lexical overlap to exercise vector data flows, -/// but they do not represent semantic similarity. +/// Configure to produce the embeddings required by a test. The callback receives +/// the original input enumerable and cancellation token. records every generation request. /// -public class MockEmbeddingGenerator : IEmbeddingGenerator> +public class MockEmbeddingGenerator : IEmbeddingGenerator> { - private static float[] CreateVector(string? value, int dimensions) - { - var vector = new float[dimensions]; - if (value is null || value.Length == 0) - { - return vector; - } - - // Hash each case-insensitive token and each three-character shingle into the vector. Shared words and word - // fragments therefore contribute to the same dimensions, providing deterministic lexical similarity. - 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 compares 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; - } - } + private int _callCount; - return vector; - } - - private static void AddTokenFeatures(string value, int tokenStart, int tokenLength, float[] vector) + /// Initializes a new instance of the class. + public MockEmbeddingGenerator() { - AddFeature(value, tokenStart, tokenLength, vector); - - for (int i = tokenStart; i <= tokenStart + tokenLength - 3; i++) - { - AddFeature(value, i, 3, vector); - } + GetServiceCallback = DefaultGetServiceCallback; } - 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; - } + /// Gets or sets the callback that generates embeddings. + /// + /// The callback must be set before calling . + /// + public Func, EmbeddingGenerationOptions?, CancellationToken, Task>>>? GenerateAsyncCallback { get; set; } - private readonly int _dimensions; + /// 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; } - /// Initializes a new instance of the class. - /// The number of values in each generated vector. - /// is less than one. - public MockEmbeddingGenerator(int dimensions) - { - _dimensions = Throw.IfLessThan(dimensions, 1, nameof(dimensions)); - } + /// Gets the number of calls to . + public int CallCount => Volatile.Read(ref _callCount); /// - public virtual Task>> GenerateAsync( - IEnumerable values, + public Task>> GenerateAsync( + IEnumerable values, EmbeddingGenerationOptions? options = null, CancellationToken cancellationToken = default) { _ = Throw.IfNull(values); + RecordCall(); - GeneratedEmbeddings> embeddings = []; - foreach (string value in values) + 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) { - cancellationToken.ThrowIfCancellationRequested(); - embeddings.Add(new Embedding(CreateVector(value, _dimensions))); + throw new InvalidOperationException("No embedding generation callback has been configured."); } - return Task.FromResult(embeddings); + return Throw.IfNull(callback(values, options, cancellationToken)); } /// public virtual object? GetService(Type serviceType, object? serviceKey = null) { _ = Throw.IfNull(serviceType); - return serviceKey is null && serviceType.IsInstanceOfType(this) ? this : null; + return Throw.IfNull(GetServiceCallback)(serviceType, serviceKey); } /// - public virtual void Dispose() + 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 index f7368b0f067..07333b5976c 100644 --- a/src/Libraries/Microsoft.Extensions.AI.Testing/README.md +++ b/src/Libraries/Microsoft.Extensions.AI.Testing/README.md @@ -23,7 +23,7 @@ Or in a project file: - `MockChatClient` (`IChatClient`) for deterministic, request-aware chat responses. - `MockChatClientRequest` for request matching and request-history inspection. -- `MockEmbeddingGenerator` (`IEmbeddingGenerator>`) for repeatable pseudo-embeddings with lexical similarity. +- `MockEmbeddingGenerator` (`IEmbeddingGenerator>`) for configurable, deterministic embeddings. ## MockChatClient behavior @@ -172,13 +172,22 @@ client.AddException( ## Mock embeddings -Construct `MockEmbeddingGenerator` with the required vector dimension. It produces a repeatable vector for every input, making it useful for deterministic vector-data tests and local demos. The vectors model lexical overlap, not semantic meaning. +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(dimensions: 384); -GeneratedEmbeddings> result = await embeddings.GenerateAsync(["trail", "navigation"]); +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.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 a7f4ccce926..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 @@ -103,7 +103,8 @@ "condition": "(!IsMock)", "exclude": [ "AIChatWeb-CSharp.Web/Services/MockServices.cs", - "AIChatWeb-CSharp.Web/Services/MockChatClientExtensions.cs" + "AIChatWeb-CSharp.Web/Services/MockChatClientExtensions.cs", + "AIChatWeb-CSharp.Web/Services/LexicalMockEmbeddingGenerator.cs" ] } ] 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..6c43f4e5d43 --- /dev/null +++ b/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/templates/AIChatWeb-CSharp/AIChatWeb-CSharp.Web/Services/LexicalMockEmbeddingGenerator.cs @@ -0,0 +1,104 @@ +using System; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Extensions.AI; + +namespace AIChatWeb_CSharp.Web.Services; + +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; + } +} 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 index c3d6cd5020d..64bd0dc0c8d 100644 --- 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 @@ -116,7 +116,7 @@ internal static MockChatClient CreateChatClient() => includeCitation: true); internal static IEmbeddingGenerator> CreateEmbeddingGenerator() => - new MockEmbeddingGenerator(IngestedChunk.VectorDimensions); + new LexicalMockEmbeddingGenerator(IngestedChunk.VectorDimensions); private static bool LastQuestionContains(MockChatClientRequest request, params string[] terms) { 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/MockChatClientTests.cs b/test/Libraries/Microsoft.Extensions.AI.Testing.Tests/MockChatClientTests.cs index 8bde504e7ae..a157ea04bcd 100644 --- a/test/Libraries/Microsoft.Extensions.AI.Testing.Tests/MockChatClientTests.cs +++ b/test/Libraries/Microsoft.Extensions.AI.Testing.Tests/MockChatClientTests.cs @@ -361,54 +361,81 @@ public void Registration_RequiresRequestPredicate() } [Fact] - public async Task MockEmbeddingGenerator_GeneratesRepeatableVectors() + public async Task MockEmbeddingGenerator_InvokesConfiguredCallbackAndRecordsCall() { - using var generator = new MockEmbeddingGenerator(4); + 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 })]); - GeneratedEmbeddings> embeddings = await generator.GenerateAsync(["trail", "trail", "camp"]); + 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.Equal(3, embeddings.Count); - Assert.Equal(4, embeddings[0].Vector.Length); - Assert.Equal(embeddings[0].Vector.ToArray(), embeddings[1].Vector.ToArray()); - Assert.NotEqual(embeddings[0].Vector.ToArray(), embeddings[2].Vector.ToArray()); + Assert.Same(expected, await generator.GenerateAsync(values, options, cancellationTokenSource.Token)); + Assert.Equal(1, generator.CallCount); } [Fact] - public async Task MockEmbeddingGenerator_PrefersLexicallySimilarText() + public async Task MockEmbeddingGenerator_SupportsGenericInputs() { - using var generator = new MockEmbeddingGenerator(64); - - GeneratedEmbeddings> embeddings = await generator.GenerateAsync( - [ - "TrailMaster GPS Watch supports offline maps", - "Emergency survival kits include water treatment", - "TrailMaster GPS Watch provides location tracking", - ]); + 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.True( - CosineSimilarity(embeddings[0], embeddings[2]) > CosineSimilarity(embeddings[0], embeddings[1])); + Assert.Same(expected, await generator.GenerateAsync([value])); } [Fact] - public void MockEmbeddingGenerator_RequiresPositiveDimensions() + public async Task MockEmbeddingGenerator_RecordsCallsForDerivedGenerators() { - Assert.Throws(() => new MockEmbeddingGenerator(0)); - } + var expected = new GeneratedEmbeddings>([new(new float[] { 1, 2, 3, 4 })]); + using var generator = new DerivedMockEmbeddingGenerator(expected); - private static Task CreateResponseAsync(string text) => - Task.FromResult(new ChatResponse(new ChatMessage(ChatRole.Assistant, text))); + Assert.Same(expected, await generator.GenerateAsync(["trail"])); + Assert.Equal(1, generator.CallCount); + } - private static float CosineSimilarity(Embedding left, Embedding right) + [Fact] + public void MockEmbeddingGenerator_ConfiguresServiceResolution() { - float similarity = 0; - for (int i = 0; i < left.Vector.Length; i++) - { - similarity += left.Vector.Span[i] * right.Vector.Span[i]; - } + 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))); - return similarity; + generator.GetServiceCallback = (_, _) => expected; + Assert.Same(expected, generator.GetService(typeof(object))); } + 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) 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..16c3bd40f62 100644 --- a/test/Libraries/Microsoft.Extensions.DataIngestion.Tests/Chunkers/SemanticSimilarityChunkerTests.cs +++ b/test/Libraries/Microsoft.Extensions.DataIngestion.Tests/Chunkers/SemanticSimilarityChunkerTests.cs @@ -16,12 +16,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 +42,7 @@ public async Task SingleParagraph() new IngestionDocumentParagraph(text) } }); - using TestEmbeddingGenerator customGenerator = new() + using MockEmbeddingGenerator customGenerator = new() { GenerateAsyncCallback = static async (values, options, ct) => { @@ -77,7 +77,7 @@ public async Task TwoTopicsParagraphs() } }); - using var customGenerator = new TestEmbeddingGenerator + using var customGenerator = new MockEmbeddingGenerator { GenerateAsyncCallback = async (values, options, ct) => { @@ -154,7 +154,7 @@ public async Task TwoSeparateTopicsWithAllKindsOfElements() } }); - using var customGenerator = new TestEmbeddingGenerator + using var customGenerator = new MockEmbeddingGenerator { GenerateAsyncCallback = async (values, options, ct) => { @@ -236,6 +236,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); diff --git a/test/Libraries/Microsoft.Extensions.DataIngestion.Tests/IngestionPipelineTests.cs b/test/Libraries/Microsoft.Extensions.DataIngestion.Tests/IngestionPipelineTests.cs index 7159cdc8718..e1e87363a13 100644 --- a/test/Libraries/Microsoft.Extensions.DataIngestion.Tests/IngestionPipelineTests.cs +++ b/test/Libraries/Microsoft.Extensions.DataIngestion.Tests/IngestionPipelineTests.cs @@ -22,6 +22,8 @@ namespace Microsoft.Extensions.DataIngestion.Tests; public sealed class IngestionPipelineTests : IDisposable { + private const int EmbeddingDimensionCount = 4; + private readonly FileInfo _withTable; private readonly FileInfo _withImage; private readonly IReadOnlyList _sampleFiles; @@ -82,9 +84,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 +94,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 +117,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 +128,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 +151,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 +164,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 +199,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 +227,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") 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..7969a74acef 100644 --- a/test/Libraries/Microsoft.Extensions.DataIngestion.Tests/Writers/InMemoryVectorStoreWriterTests.cs +++ b/test/Libraries/Microsoft.Extensions.DataIngestion.Tests/Writers/InMemoryVectorStoreWriterTests.cs @@ -2,12 +2,13 @@ // The .NET Foundation licenses this file to you under the MIT license. using CommunityToolkit.VectorData.InMemory; +using Microsoft.Extensions.AI; using Microsoft.Extensions.VectorData; 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 }); } diff --git a/test/Libraries/Microsoft.Extensions.DataIngestion.Tests/Writers/SqliteVectorStoreWriterTests.cs b/test/Libraries/Microsoft.Extensions.DataIngestion.Tests/Writers/SqliteVectorStoreWriterTests.cs index 7f29da9358d..5751c99c3ec 100644 --- a/test/Libraries/Microsoft.Extensions.DataIngestion.Tests/Writers/SqliteVectorStoreWriterTests.cs +++ b/test/Libraries/Microsoft.Extensions.DataIngestion.Tests/Writers/SqliteVectorStoreWriterTests.cs @@ -4,6 +4,7 @@ using System; using System.IO; using CommunityToolkit.VectorData.SqliteVec; +using Microsoft.Extensions.AI; using Microsoft.Extensions.VectorData; namespace Microsoft.Extensions.DataIngestion.Writers.Tests; @@ -14,6 +15,6 @@ 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 }); } diff --git a/test/Libraries/Microsoft.Extensions.DataIngestion.Tests/Writers/VectorStoreWriterTests.cs b/test/Libraries/Microsoft.Extensions.DataIngestion.Tests/Writers/VectorStoreWriterTests.cs index 0395c470ce0..ac31f9b437a 100644 --- a/test/Libraries/Microsoft.Extensions.DataIngestion.Tests/Writers/VectorStoreWriterTests.cs +++ b/test/Libraries/Microsoft.Extensions.DataIngestion.Tests/Writers/VectorStoreWriterTests.cs @@ -5,6 +5,7 @@ using System.Collections.Generic; using System.Linq; using System.Threading.Tasks; +using Microsoft.Extensions.AI; using Microsoft.Extensions.VectorData; using Xunit; @@ -12,16 +13,18 @@ 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 +41,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 +52,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 +65,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 +125,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 +170,12 @@ 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 })]), + }; } 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..cd7af1fecc7 --- /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,104 @@ +using System; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Extensions.AI; + +namespace aichatweb.Web.Services; + +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; + } +} 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 index d1cb8c2b201..73991ba2513 100644 --- 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 @@ -116,7 +116,7 @@ internal static MockChatClient CreateChatClient() => includeCitation: true); internal static IEmbeddingGenerator> CreateEmbeddingGenerator() => - new MockEmbeddingGenerator(IngestedChunk.VectorDimensions); + new LexicalMockEmbeddingGenerator(IngestedChunk.VectorDimensions); private static bool LastQuestionContains(MockChatClientRequest request, params string[] terms) { 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..742bc6d93d5 --- /dev/null +++ b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/aichatweb/aichatweb.mock.verified/aichatweb/Services/LexicalMockEmbeddingGenerator.cs @@ -0,0 +1,104 @@ +using System; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Extensions.AI; + +namespace aichatweb.Services; + +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; + } +} 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 index 1c406db28db..35eab095ce1 100644 --- 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 @@ -116,7 +116,7 @@ internal static MockChatClient CreateChatClient() => includeCitation: true); internal static IEmbeddingGenerator> CreateEmbeddingGenerator() => - new MockEmbeddingGenerator(IngestedChunk.VectorDimensions); + new LexicalMockEmbeddingGenerator(IngestedChunk.VectorDimensions); private static bool LastQuestionContains(MockChatClientRequest request, params string[] terms) {