mirror of
https://github.com/microsoft/agent-framework.git
synced 2026-06-16 21:04:09 +08:00
Compare commits
87
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ea370f8ff6 | ||
|
|
24c822590f | ||
|
|
953fde69ac | ||
|
|
7a05849609 | ||
|
|
0aa0579b1b | ||
|
|
844d345106 | ||
|
|
ed5278c41d | ||
|
|
928c9d54ad | ||
|
|
0aba02c402 | ||
|
|
3ef67eff10 | ||
|
|
deea844bc7 | ||
|
|
577ad4b838 | ||
|
|
8b4f7d5e29 | ||
|
|
4b8a545589 | ||
|
|
5ab47596ff | ||
|
|
a32702cf38 | ||
|
|
8b743af217 | ||
|
|
0e152a0e33 | ||
|
|
3b77192ad0 | ||
|
|
defe0f1a89 | ||
|
|
85d70f01f6 | ||
|
|
6930c0f0b6 | ||
|
|
d83cf93f07 | ||
|
|
8783ac58f1 | ||
|
|
e15eab7da6 | ||
|
|
19a9e13788 | ||
|
|
b0a7a1fcb8 | ||
|
|
a841bdd1cc | ||
|
|
d46adffe6c | ||
|
|
b0b5777363 | ||
|
|
37b4cfd024 | ||
|
|
ff9343d7cc | ||
|
|
8ff34f9a43 | ||
|
|
e3f8bfc645 | ||
|
|
b4f2709b6d | ||
|
|
e5c11d38d6 | ||
|
|
a71f768331 | ||
|
|
0298e0a401 | ||
|
|
ca1532cf22 | ||
|
|
360839782c | ||
|
|
ee53fe4666 | ||
|
|
3cd805f0bf | ||
|
|
c7ddb8aa14 | ||
|
|
d5527982b6 | ||
|
|
ec1c5e9c11 | ||
|
|
06cdcb93f0 | ||
|
|
6adcac2e97 | ||
|
|
8fca71e5ad | ||
|
|
2bde58f915 | ||
|
|
03a403d2fa | ||
|
|
e319707058 | ||
|
|
54f482df73 | ||
|
|
754dfb2c9d | ||
|
|
b15466f058 | ||
|
|
3a7047f6e4 | ||
|
|
2f06fe557a | ||
|
|
1dbf3fd5cf | ||
|
|
0132cf65e4 | ||
|
|
a53a3c7af8 | ||
|
|
3c322c91e7 | ||
|
|
958a488f96 | ||
|
|
11d6dcfe80 | ||
|
|
3139347526 | ||
|
|
3c379718e9 | ||
|
|
a7298757f5 | ||
|
|
0dcebc6eae | ||
|
|
e0ff153ee9 | ||
|
|
e008144187 | ||
|
|
0fc7933a92 | ||
|
|
d7434d59ce | ||
|
|
eb1117fff4 | ||
|
|
16230d3b20 | ||
|
|
8d53b20026 | ||
|
|
c376868ec9 | ||
|
|
8bb9927f3c | ||
|
|
194486c4cc | ||
|
|
0413f4220a | ||
|
|
67e83042cf | ||
|
|
5da1c2fd4c | ||
|
|
989b6ebe71 | ||
|
|
3481914981 | ||
|
|
4c6a5d4aa1 | ||
|
|
191779ce80 | ||
|
|
638fbb5f03 | ||
|
|
523305ac62 | ||
|
|
3f4eeb00be | ||
|
|
b378ca75d1 |
@@ -0,0 +1,8 @@
|
||||
blank_issues_enabled: false
|
||||
contact_links:
|
||||
- name: Documentation
|
||||
url: https://aka.ms/agent-framework
|
||||
about: Check out the official documentation for guides and API reference.
|
||||
- name: Discussions
|
||||
url: https://github.com/microsoft/agent-framework/discussions
|
||||
about: Ask questions and share ideas in GitHub Discussions.
|
||||
@@ -0,0 +1,203 @@
|
||||
name: Issue Report
|
||||
description: Report a bug, request a feature, or ask a question about Microsoft Agent Framework
|
||||
title: "[Issue]: "
|
||||
labels: ["triage"]
|
||||
body:
|
||||
- type: dropdown
|
||||
id: language
|
||||
attributes:
|
||||
label: Language
|
||||
description: Which language/SDK are you using?
|
||||
options:
|
||||
- .NET
|
||||
- Python
|
||||
- None / Not Applicable
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: dropdown
|
||||
id: issue-type
|
||||
attributes:
|
||||
label: Type of Issue
|
||||
description: What type of issue is this?
|
||||
options:
|
||||
- Bug
|
||||
- Feature Request
|
||||
- Question
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: |
|
||||
## Version Information
|
||||
Please provide the version of the package(s) you are using. Select the relevant packages below.
|
||||
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: "### .NET Packages"
|
||||
|
||||
- type: input
|
||||
id: dotnet-agents-ai
|
||||
attributes:
|
||||
label: Microsoft.Agents.AI
|
||||
description: Version of Microsoft.Agents.AI (e.g., 1.0.0)
|
||||
placeholder: "e.g., 1.0.0"
|
||||
validations:
|
||||
required: false
|
||||
|
||||
- type: input
|
||||
id: dotnet-agents-ai-abstractions
|
||||
attributes:
|
||||
label: Microsoft.Agents.AI.Abstractions
|
||||
description: Version of Microsoft.Agents.AI.Abstractions
|
||||
placeholder: "e.g., 1.0.0"
|
||||
validations:
|
||||
required: false
|
||||
|
||||
- type: input
|
||||
id: dotnet-agents-ai-openai
|
||||
attributes:
|
||||
label: Microsoft.Agents.AI.OpenAI
|
||||
description: Version of Microsoft.Agents.AI.OpenAI
|
||||
placeholder: "e.g., 1.0.0"
|
||||
validations:
|
||||
required: false
|
||||
|
||||
- type: input
|
||||
id: dotnet-agents-ai-azureai
|
||||
attributes:
|
||||
label: Microsoft.Agents.AI.AzureAI
|
||||
description: Version of Microsoft.Agents.AI.AzureAI
|
||||
placeholder: "e.g., 1.0.0"
|
||||
validations:
|
||||
required: false
|
||||
|
||||
- type: input
|
||||
id: dotnet-agents-ai-anthropic
|
||||
attributes:
|
||||
label: Microsoft.Agents.AI.Anthropic
|
||||
description: Version of Microsoft.Agents.AI.Anthropic
|
||||
placeholder: "e.g., 1.0.0"
|
||||
validations:
|
||||
required: false
|
||||
|
||||
- type: input
|
||||
id: dotnet-agents-ai-hosting
|
||||
attributes:
|
||||
label: Microsoft.Agents.AI.Hosting
|
||||
description: Version of Microsoft.Agents.AI.Hosting
|
||||
placeholder: "e.g., 1.0.0"
|
||||
validations:
|
||||
required: false
|
||||
|
||||
- type: input
|
||||
id: dotnet-agents-ai-workflows
|
||||
attributes:
|
||||
label: Microsoft.Agents.AI.Workflows
|
||||
description: Version of Microsoft.Agents.AI.Workflows
|
||||
placeholder: "e.g., 1.0.0"
|
||||
validations:
|
||||
required: false
|
||||
|
||||
- type: input
|
||||
id: dotnet-other-packages
|
||||
attributes:
|
||||
label: Other .NET Packages
|
||||
description: List any other Microsoft.Agents.* packages and versions you are using
|
||||
placeholder: "e.g., Microsoft.Agents.AI.CopilotStudio: 1.0.0, Microsoft.Agents.AI.Purview: 1.0.0"
|
||||
validations:
|
||||
required: false
|
||||
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: "### Python Packages"
|
||||
|
||||
- type: input
|
||||
id: python-core
|
||||
attributes:
|
||||
label: agent-framework-core
|
||||
description: Version of agent-framework-core
|
||||
placeholder: "e.g., 1.0.0b1"
|
||||
validations:
|
||||
required: false
|
||||
|
||||
- type: input
|
||||
id: python-azure-ai
|
||||
attributes:
|
||||
label: agent-framework-azure-ai
|
||||
description: Version of agent-framework-azure-ai
|
||||
placeholder: "e.g., 1.0.0b1"
|
||||
validations:
|
||||
required: false
|
||||
|
||||
- type: input
|
||||
id: python-anthropic
|
||||
attributes:
|
||||
label: agent-framework-anthropic
|
||||
description: Version of agent-framework-anthropic
|
||||
placeholder: "e.g., 1.0.0b1"
|
||||
validations:
|
||||
required: false
|
||||
|
||||
- type: input
|
||||
id: python-azurefunctions
|
||||
attributes:
|
||||
label: agent-framework-azurefunctions
|
||||
description: Version of agent-framework-azurefunctions
|
||||
placeholder: "e.g., 1.0.0b1"
|
||||
validations:
|
||||
required: false
|
||||
|
||||
- type: input
|
||||
id: python-other-packages
|
||||
attributes:
|
||||
label: Other Python Packages
|
||||
description: List any other agent-framework-* packages and versions you are using
|
||||
placeholder: "e.g., agent-framework-mem0: 1.0.0b1, agent-framework-redis: 1.0.0b1"
|
||||
validations:
|
||||
required: false
|
||||
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: "---"
|
||||
|
||||
- type: textarea
|
||||
id: description
|
||||
attributes:
|
||||
label: Description
|
||||
description: Please provide a clear and detailed description of the issue, feature request, or question.
|
||||
placeholder: |
|
||||
For bugs: Describe what happened, what you expected to happen, and steps to reproduce.
|
||||
For features: Describe the feature you'd like and why it would be useful.
|
||||
For questions: Describe what you're trying to accomplish.
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: code-sample
|
||||
attributes:
|
||||
label: Code Sample
|
||||
description: If applicable, provide a minimal code sample that demonstrates the issue or your use case.
|
||||
placeholder: |
|
||||
```python
|
||||
# Your code here
|
||||
```
|
||||
|
||||
or
|
||||
|
||||
```csharp
|
||||
// Your code here
|
||||
```
|
||||
render: markdown
|
||||
validations:
|
||||
required: false
|
||||
|
||||
- type: textarea
|
||||
id: additional-context
|
||||
attributes:
|
||||
label: Additional Context
|
||||
description: Add any other context, screenshots, error messages, or stack traces that might be helpful.
|
||||
placeholder: "Any additional information..."
|
||||
validations:
|
||||
required: false
|
||||
@@ -28,6 +28,18 @@ runs:
|
||||
echo "Waiting for Azurite (Azure Storage emulator) to be ready"
|
||||
timeout 30 bash -c 'until curl --silent http://localhost:10000/devstoreaccount1; do sleep 1; done'
|
||||
echo "Azurite (Azure Storage emulator) is ready"
|
||||
- name: Start Redis
|
||||
shell: bash
|
||||
run: |
|
||||
if [ "$(docker ps -aq -f name=redis)" ]; then
|
||||
echo "Stopping and removing existing Redis"
|
||||
docker rm -f redis
|
||||
fi
|
||||
echo "Starting Redis"
|
||||
docker run -d --name redis -p 6379:6379 redis:latest
|
||||
echo "Waiting for Redis to be ready"
|
||||
timeout 30 bash -c 'until docker exec redis redis-cli ping | grep -q PONG; do sleep 1; done'
|
||||
echo "Redis is ready"
|
||||
- name: Install Azure Functions Core Tools
|
||||
shell: bash
|
||||
run: |
|
||||
|
||||
@@ -839,7 +839,7 @@ var agentOptions = new ChatClientAgentRunOptions(new ChatOptions
|
||||
{
|
||||
MaxOutputTokens = 8000,
|
||||
// Breaking glass to access provider-specific options
|
||||
RawRepresentationFactory = (_) => new OpenAI.Responses.ResponseCreationOptions()
|
||||
RawRepresentationFactory = (_) => new OpenAI.Responses.CreateResponseOptions()
|
||||
{
|
||||
ReasoningOptions = new()
|
||||
{
|
||||
|
||||
@@ -32,7 +32,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v5
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
|
||||
@@ -35,19 +35,25 @@ jobs:
|
||||
contents: read
|
||||
pull-requests: read
|
||||
outputs:
|
||||
dotnetChanges: ${{ steps.filter.outputs.dotnet}}
|
||||
dotnetChanges: ${{ steps.filter.outputs.dotnet }}
|
||||
cosmosDbChanges: ${{ steps.filter.outputs.cosmosdb }}
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
- uses: actions/checkout@v6
|
||||
- uses: dorny/paths-filter@v3
|
||||
id: filter
|
||||
with:
|
||||
filters: |
|
||||
dotnet:
|
||||
- 'dotnet/**'
|
||||
cosmosdb:
|
||||
- 'dotnet/src/Microsoft.Agents.AI.CosmosNoSql/**'
|
||||
# run only if 'dotnet' files were changed
|
||||
- name: dotnet tests
|
||||
if: steps.filter.outputs.dotnet == 'true'
|
||||
run: echo "Dotnet file"
|
||||
- name: dotnet CosmosDB tests
|
||||
if: steps.filter.outputs.cosmosdb == 'true'
|
||||
run: echo "Dotnet CosmosDB changes"
|
||||
# run only if not 'dotnet' files were changed
|
||||
- name: not dotnet tests
|
||||
if: steps.filter.outputs.dotnet != 'true'
|
||||
@@ -68,7 +74,7 @@ jobs:
|
||||
runs-on: ${{ matrix.os }}
|
||||
environment: ${{ matrix.environment }}
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
- uses: actions/checkout@v6
|
||||
with:
|
||||
persist-credentials: false
|
||||
sparse-checkout: |
|
||||
@@ -77,6 +83,16 @@ jobs:
|
||||
dotnet
|
||||
python
|
||||
workflow-samples
|
||||
|
||||
# Start Cosmos DB Emulator for all integration tests and only for unit tests when CosmosDB changes happened)
|
||||
- name: Start Azure Cosmos DB Emulator
|
||||
if: ${{ runner.os == 'Windows' && (needs.paths-filter.outputs.cosmosDbChanges == 'true' || (github.event_name != 'pull_request' && matrix.integration-tests)) }}
|
||||
shell: pwsh
|
||||
run: |
|
||||
Write-Host "Launching Azure Cosmos DB Emulator"
|
||||
Import-Module "$env:ProgramFiles\Azure Cosmos DB Emulator\PSModules\Microsoft.Azure.CosmosDB.Emulator"
|
||||
Start-CosmosDbEmulator -NoUI -Key "C2y6yDjf5/R+ob0N8A7Cgv30VRDJIWEHLM+4QDU5DE2nQ9nDuVTqobD4b8mGGyPMbIZnqyMsEcaGQy67XIw/Jw=="
|
||||
echo "COSMOS_EMULATOR_AVAILABLE=true" >> $env:GITHUB_ENV
|
||||
|
||||
- name: Setup dotnet
|
||||
uses: actions/setup-dotnet@v5.0.1
|
||||
@@ -123,17 +139,7 @@ jobs:
|
||||
popd
|
||||
popd
|
||||
rm -rf "$TEMP_DIR"
|
||||
|
||||
# Start Cosmos DB Emulator for Cosmos-based unit tests (only on Windows)
|
||||
- name: Start Azure Cosmos DB Emulator
|
||||
if: runner.os == 'Windows'
|
||||
shell: pwsh
|
||||
run: |
|
||||
Write-Host "Launching Azure Cosmos DB Emulator"
|
||||
Import-Module "$env:ProgramFiles\Azure Cosmos DB Emulator\PSModules\Microsoft.Azure.CosmosDB.Emulator"
|
||||
Start-CosmosDbEmulator -NoUI -Key "C2y6yDjf5/R+ob0N8A7Cgv30VRDJIWEHLM+4QDU5DE2nQ9nDuVTqobD4b8mGGyPMbIZnqyMsEcaGQy67XIw/Jw=="
|
||||
echo "COSMOS_EMULATOR_AVAILABLE=true" >> $env:GITHUB_ENV
|
||||
|
||||
|
||||
- name: Run Unit Tests
|
||||
shell: bash
|
||||
run: |
|
||||
@@ -225,7 +231,7 @@ jobs:
|
||||
|
||||
- name: Upload coverage report artifact
|
||||
if: matrix.targetFramework == env.COVERAGE_FRAMEWORK
|
||||
uses: actions/upload-artifact@v5
|
||||
uses: actions/upload-artifact@v6
|
||||
with:
|
||||
name: CoverageReport-${{ matrix.os }}-${{ matrix.targetFramework }}-${{ matrix.configuration }} # Artifact name
|
||||
path: ./TestResults/Reports # Directory containing files to upload
|
||||
|
||||
@@ -30,7 +30,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Check out code
|
||||
uses: actions/checkout@v5
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
fetch-depth: 0
|
||||
persist-credentials: false
|
||||
|
||||
@@ -45,19 +45,58 @@ jobs:
|
||||
labels.push("triage")
|
||||
}
|
||||
|
||||
// Check if the body or the title contains the word 'python' (case-insensitive)
|
||||
if ((body != null && body.match(/python/i)) || (title != null && title.match(/python/i))) {
|
||||
// Add the 'python' label to the array
|
||||
labels.push("python")
|
||||
// Helper function to extract field value from issue form body
|
||||
// Issue forms format fields as: ### Field Name\n\nValue
|
||||
function getFormFieldValue(body, fieldName) {
|
||||
if (!body) return null
|
||||
const regex = new RegExp(`###\\s*${fieldName}\\s*\\n\\n([^\\n#]+)`, 'i')
|
||||
const match = body.match(regex)
|
||||
return match ? match[1].trim() : null
|
||||
}
|
||||
|
||||
// Check if the body or the title contains the words 'dotnet', '.net', 'c#' or 'csharp' (case-insensitive)
|
||||
if ((body != null && body.match(/.net/i)) || (title != null && title.match(/.net/i)) ||
|
||||
(body != null && body.match(/dotnet/i)) || (title != null && title.match(/dotnet/i)) ||
|
||||
(body != null && body.match(/C#/i)) || (title != null && title.match(/C#/i)) ||
|
||||
(body != null && body.match(/csharp/i)) || (title != null && title.match(/csharp/i))) {
|
||||
// Add the '.NET' label to the array
|
||||
labels.push(".NET")
|
||||
// Check for language from issue form dropdown first
|
||||
const languageField = getFormFieldValue(body, 'Language')
|
||||
let languageLabelAdded = false
|
||||
|
||||
if (languageField) {
|
||||
if (languageField === 'Python') {
|
||||
labels.push("python")
|
||||
languageLabelAdded = true
|
||||
} else if (languageField === '.NET') {
|
||||
labels.push(".NET")
|
||||
languageLabelAdded = true
|
||||
}
|
||||
// 'None / Not Applicable' - don't add any language label
|
||||
}
|
||||
|
||||
// Fallback: Check if the body or the title contains the word 'python' (case-insensitive)
|
||||
// Only if language wasn't already determined from the form field
|
||||
if (!languageLabelAdded) {
|
||||
if ((body != null && body.match(/python/i)) || (title != null && title.match(/python/i))) {
|
||||
// Add the 'python' label to the array
|
||||
labels.push("python")
|
||||
}
|
||||
|
||||
// Check if the body or the title contains the words 'dotnet', '.net', 'c#' or 'csharp' (case-insensitive)
|
||||
if ((body != null && body.match(/\.net/i)) || (title != null && title.match(/\.net/i)) ||
|
||||
(body != null && body.match(/dotnet/i)) || (title != null && title.match(/dotnet/i)) ||
|
||||
(body != null && body.match(/C#/i)) || (title != null && title.match(/C#/i)) ||
|
||||
(body != null && body.match(/csharp/i)) || (title != null && title.match(/csharp/i))) {
|
||||
// Add the '.NET' label to the array
|
||||
labels.push(".NET")
|
||||
}
|
||||
}
|
||||
|
||||
// Check for issue type from issue form dropdown
|
||||
const issueTypeField = getFormFieldValue(body, 'Type of Issue')
|
||||
if (issueTypeField) {
|
||||
if (issueTypeField === 'Bug') {
|
||||
labels.push("bug")
|
||||
} else if (issueTypeField === 'Feature Request') {
|
||||
labels.push("enhancement")
|
||||
} else if (issueTypeField === 'Question') {
|
||||
labels.push("question")
|
||||
}
|
||||
}
|
||||
|
||||
// Add the labels to the issue (only if there are labels to add)
|
||||
|
||||
@@ -19,7 +19,7 @@ jobs:
|
||||
runs-on: ubuntu-22.04
|
||||
# check out the latest version of the code
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
- uses: actions/checkout@v6
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
|
||||
@@ -27,7 +27,7 @@ jobs:
|
||||
env:
|
||||
UV_PYTHON: ${{ matrix.python-version }}
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
- uses: actions/checkout@v6
|
||||
with:
|
||||
fetch-depth: 0
|
||||
- name: Set up python and install the project
|
||||
@@ -39,7 +39,7 @@ jobs:
|
||||
env:
|
||||
# Configure a constant location for the uv cache
|
||||
UV_CACHE_DIR: /tmp/.uv-cache
|
||||
- uses: actions/cache@v4
|
||||
- uses: actions/cache@v5
|
||||
with:
|
||||
path: ~/.cache/pre-commit
|
||||
key: pre-commit|${{ matrix.python-version }}|${{ hashFiles('python/.pre-commit-config.yaml') }}
|
||||
|
||||
@@ -24,7 +24,7 @@ jobs:
|
||||
run:
|
||||
working-directory: python
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
- uses: actions/checkout@v6
|
||||
- name: Set up uv
|
||||
uses: astral-sh/setup-uv@v7
|
||||
with:
|
||||
|
||||
@@ -24,7 +24,7 @@ jobs:
|
||||
outputs:
|
||||
pythonChanges: ${{ steps.filter.outputs.python}}
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
- uses: actions/checkout@v6
|
||||
- uses: dorny/paths-filter@v3
|
||||
id: filter
|
||||
with:
|
||||
@@ -59,7 +59,7 @@ jobs:
|
||||
run:
|
||||
working-directory: python
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
- uses: actions/checkout@v6
|
||||
|
||||
- name: Set up python and install the project
|
||||
id: python-setup
|
||||
|
||||
@@ -28,7 +28,7 @@ jobs:
|
||||
outputs:
|
||||
pythonChanges: ${{ steps.filter.outputs.python}}
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
- uses: actions/checkout@v6
|
||||
- uses: dorny/paths-filter@v3
|
||||
id: filter
|
||||
with:
|
||||
@@ -75,7 +75,7 @@ jobs:
|
||||
run:
|
||||
working-directory: python
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
- uses: actions/checkout@v6
|
||||
- name: Set up python and install the project
|
||||
id: python-setup
|
||||
uses: ./.github/actions/python-setup
|
||||
@@ -135,7 +135,7 @@ jobs:
|
||||
run:
|
||||
working-directory: python
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
- uses: actions/checkout@v6
|
||||
- name: Set up python and install the project
|
||||
id: python-setup
|
||||
uses: ./.github/actions/python-setup
|
||||
@@ -154,7 +154,7 @@ jobs:
|
||||
subscription-id: ${{ secrets.AZURE_SUBSCRIPTION_ID }}
|
||||
- name: Test with pytest
|
||||
timeout-minutes: 10
|
||||
run: uv run poe azure-ai-tests -n logical --dist loadfile --dist worksteal --timeout 300 --retries 3 --retry-delay 10
|
||||
run: uv run --directory packages/azure-ai poe integration-tests -n logical --dist loadfile --dist worksteal --timeout 300 --retries 3 --retry-delay 10
|
||||
working-directory: ./python
|
||||
- name: Test Azure AI samples
|
||||
timeout-minutes: 10
|
||||
|
||||
@@ -23,7 +23,7 @@ jobs:
|
||||
run:
|
||||
working-directory: python
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
- uses: actions/checkout@v6
|
||||
- name: Set up python and install the project
|
||||
id: python-setup
|
||||
uses: ./.github/actions/python-setup
|
||||
|
||||
@@ -19,9 +19,9 @@ jobs:
|
||||
run:
|
||||
working-directory: python
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
- uses: actions/checkout@v6
|
||||
- name: Download coverage report
|
||||
uses: actions/download-artifact@v6
|
||||
uses: actions/download-artifact@v7
|
||||
with:
|
||||
github-token: ${{ secrets.GH_ACTIONS_PR_WRITE }}
|
||||
run-id: ${{ github.event.workflow_run.id }}
|
||||
|
||||
@@ -20,7 +20,7 @@ jobs:
|
||||
env:
|
||||
UV_PYTHON: "3.10"
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
- uses: actions/checkout@v6
|
||||
# Save the PR number to a file since the workflow_run event
|
||||
# in the coverage report workflow does not have access to it
|
||||
- name: Save PR number
|
||||
@@ -38,7 +38,7 @@ jobs:
|
||||
- name: Run all tests with coverage report
|
||||
run: uv run poe all-tests-cov --cov-report=xml:python-coverage.xml -q --junitxml=pytest.xml
|
||||
- name: Upload coverage report
|
||||
uses: actions/upload-artifact@v5
|
||||
uses: actions/upload-artifact@v6
|
||||
with:
|
||||
path: |
|
||||
python/python-coverage.xml
|
||||
|
||||
@@ -27,7 +27,7 @@ jobs:
|
||||
run:
|
||||
working-directory: python
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
- uses: actions/checkout@v6
|
||||
- name: Set up python and install the project
|
||||
id: python-setup
|
||||
uses: ./.github/actions/python-setup
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
# Declarative Agents
|
||||
|
||||
This folder contains sample agent definitions than be ran using the declarative agent support, for python see the [declarative agent python sample folder](../python/samples/getting_started/declarative/).
|
||||
This folder contains sample agent definitions that can be run using the declarative agent support, for python see the [declarative agent python sample folder](../python/samples/getting_started/declarative/).
|
||||
|
||||
@@ -10,19 +10,19 @@ model:
|
||||
temperature: 0.9
|
||||
topP: 0.95
|
||||
connection:
|
||||
kind: ApiKey
|
||||
key: =Env.OPENAI_API_KEY
|
||||
kind: key
|
||||
apiKey: =Env.OPENAI_APIKEY
|
||||
outputSchema:
|
||||
properties:
|
||||
language:
|
||||
type: string
|
||||
kind: string
|
||||
required: true
|
||||
description: The language of the answer.
|
||||
answer:
|
||||
type: string
|
||||
kind: string
|
||||
required: true
|
||||
description: The answer text.
|
||||
type:
|
||||
type: string
|
||||
kind: string
|
||||
required: true
|
||||
description: The type of the response.
|
||||
|
||||
@@ -64,7 +64,7 @@ Approaches observed from the compared SDKs:
|
||||
| AutoGen | **Approach 1** Separates messages into Agent-Agent (maps to Primary) and Internal (maps to Secondary) and these are returned as separate properties on the agent response object. See [types of messages](https://microsoft.github.io/autogen/stable/user-guide/agentchat-user-guide/tutorial/messages.html#types-of-messages) and [Response](https://microsoft.github.io/autogen/stable/reference/python/autogen_agentchat.base.html#autogen_agentchat.base.Response) | **Approach 2** Returns a stream of internal events and the last item is a Response object. See [ChatAgent.on_messages_stream](https://microsoft.github.io/autogen/stable/reference/python/autogen_agentchat.base.html#autogen_agentchat.base.ChatAgent.on_messages_stream) |
|
||||
| OpenAI Agent SDK | **Approach 1** Separates new_items (Primary+Secondary) from final output (Primary) as separate properties on the [RunResult](https://github.com/openai/openai-agents-python/blob/main/src/agents/result.py#L39) | **Approach 1** Similar to non-streaming, has a way of streaming updates via a method on the response object which includes all data, and then a separate final output property on the response object which is populated only when the run is complete. See [RunResultStreaming](https://github.com/openai/openai-agents-python/blob/main/src/agents/result.py#L136) |
|
||||
| Google ADK | **Approach 2** [Emits events](https://google.github.io/adk-docs/runtime/#step-by-step-breakdown) with [FinalResponse](https://github.com/google/adk-java/blob/main/core/src/main/java/com/google/adk/events/Event.java#L232) true (Primary) / false (Secondary) and callers have to filter out those with false to get just the final response message | **Approach 2** Similar to non-streaming except [events](https://google.github.io/adk-docs/runtime/#streaming-vs-non-streaming-output-partialtrue) are emitted with [Partial](https://github.com/google/adk-java/blob/main/core/src/main/java/com/google/adk/events/Event.java#L133) true to indicate that they are streaming messages. A final non partial event is also emitted. |
|
||||
| AWS (Strands) | **Approach 3** Returns an [AgentResult](https://strandsagents.com/latest/api-reference/agent/#strands.agent.agent_result.AgentResult) (Primary) with messages and a reason for the run's completion. | **Approach 2** [Streams events](https://strandsagents.com/latest/api-reference/agent/#strands.agent.agent.Agent.stream_async) (Primary+Secondary) including, response text, current_tool_use, even data from "callbacks" (strands plugins) |
|
||||
| AWS (Strands) | **Approach 3** Returns an [AgentResult](https://strandsagents.com/latest/documentation/docs/api-reference/python/agent/agent_result/) (Primary) with messages and a reason for the run's completion. | **Approach 2** [Streams events](https://strandsagents.com/latest/documentation/docs/api-reference/python/agent/agent/#strands.agent.agent.Agent.stream_async) (Primary+Secondary) including, response text, current_tool_use, even data from "callbacks" (strands plugins) |
|
||||
| LangGraph | **Approach 2** A mixed list of all [messages](https://langchain-ai.github.io/langgraph/agents/run_agents/#output-format) | **Approach 2** A mixed list of all [messages](https://langchain-ai.github.io/langgraph/agents/run_agents/#output-format) |
|
||||
| Agno | **Combination of various approaches** Returns a [RunResponse](https://docs.agno.com/reference/agents/run-response) object with text content, messages (essentially chat history including inputs and instructions), reasoning and thinking text properties. Secondary events could potentially be extracted from messages. | **Approach 2** Returns [RunResponseEvent](https://docs.agno.com/reference/agents/run-response#runresponseevent-types-and-attributes) objects including tool call, memory update, etc, information, where the [RunResponseCompletedEvent](https://docs.agno.com/reference/agents/run-response#runresponsecompletedevent) has similar properties to RunResponse|
|
||||
| A2A | **Approach 3** Returns a [Task or Message](https://a2aproject.github.io/A2A/latest/specification/#71-messagesend) where the message is the final result (Primary) and task is a reference to a long running process. | **Approach 2** Returns a [stream](https://a2aproject.github.io/A2A/latest/specification/#72-messagestream) that contains task updates (Secondary) and a final message (Primary) |
|
||||
@@ -495,8 +495,8 @@ We need to decide what AIContent types, each agent response type will be mapped
|
||||
| SDK | Structured Outputs support |
|
||||
|-|-|
|
||||
| AutoGen | **Approach 1** Supports [configuring an agent](https://microsoft.github.io/autogen/stable/user-guide/agentchat-user-guide/tutorial/agents.html#structured-output) at agent creation. |
|
||||
| Google ADK | **Approach 1** Both [input and output shemas can be specified for LLM Agents](https://google.github.io/adk-docs/agents/llm-agents/#structuring-data-input_schema-output_schema-output_key) at construction time. This option is specific to this agent type and other agent types do not necessarily support |
|
||||
| AWS (Strands) | **Approach 2** Supports a special invocation method called [structured_output](https://strandsagents.com/latest/api-reference/agent/#strands.agent.agent.Agent.structured_output) |
|
||||
| Google ADK | **Approach 1** Both [input and output schemas can be specified for LLM Agents](https://google.github.io/adk-docs/agents/llm-agents/#structuring-data-input_schema-output_schema-output_key) at construction time. This option is specific to this agent type and other agent types do not necessarily support |
|
||||
| AWS (Strands) | **Approach 2** Supports a special invocation method called [structured_output](https://strandsagents.com/latest/documentation/docs/api-reference/python/agent/agent/#strands.agent.agent.Agent.structured_output) |
|
||||
| LangGraph | **Approach 1** Supports [configuring an agent](https://langchain-ai.github.io/langgraph/agents/agents/?h=structured#6-configure-structured-output) at agent construction time, and a [structured response](https://langchain-ai.github.io/langgraph/agents/run_agents/#output-format) can be retrieved as a special property on the agent response |
|
||||
| Agno | **Approach 1** Supports [configuring an agent](https://docs.agno.com/examples/getting-started/structured-output) at agent construction time |
|
||||
| A2A | **Informal Approach 2** Doesn't formally support schema negotiation, but [hints can be provided via metadata](https://a2a-protocol.org/latest/specification/#97-structured-data-exchange-requesting-and-providing-json) at invocation time |
|
||||
@@ -508,7 +508,7 @@ We need to decide what AIContent types, each agent response type will be mapped
|
||||
|-|-|
|
||||
| AutoGen | Supports a [stop reason](https://microsoft.github.io/autogen/stable/reference/python/autogen_agentchat.base.html#autogen_agentchat.base.TaskResult.stop_reason) which is a freeform text string |
|
||||
| Google ADK | [No equivalent present](https://github.com/google/adk-python/blob/main/src/google/adk/events/event.py) |
|
||||
| AWS (Strands) | Exposes a [stop_reason](https://strandsagents.com/latest/api-reference/types/#strands.types.event_loop.StopReason) property on the [AgentResult](https://strandsagents.com/latest/api-reference/agent/#strands.agent.agent_result.AgentResult) class with options that are tied closely to LLM operations. |
|
||||
| AWS (Strands) | Exposes a [stop_reason](https://strandsagents.com/latest/documentation/docs/api-reference/python/types/event_loop/#strands.types.event_loop.StopReason) property on the [AgentResult](https://strandsagents.com/latest/documentation/docs/api-reference/python/agent/agent_result/) class with options that are tied closely to LLM operations. |
|
||||
| LangGraph | No equivalent present, output contains only [messages](https://langchain-ai.github.io/langgraph/agents/run_agents/#output-format) |
|
||||
| Agno | [No equivalent present](https://docs.agno.com/reference/agents/run-response) |
|
||||
| A2A | No equivalent present, response only contains a [message](https://a2a-protocol.org/latest/specification/#64-message-object) or [task](https://a2a-protocol.org/latest/specification/#61-task-object). |
|
||||
|
||||
@@ -0,0 +1,147 @@
|
||||
# Time-To-Live (TTL) for durable agent sessions
|
||||
|
||||
## Overview
|
||||
|
||||
The durable agents automatically maintain conversation history and state for each session. Without automatic cleanup, this state can accumulate indefinitely, consuming storage resources and increasing costs. The Time-To-Live (TTL) feature provides automatic cleanup of idle agent sessions, ensuring that sessions are automatically deleted after a period of inactivity.
|
||||
|
||||
## What is TTL?
|
||||
|
||||
Time-To-Live (TTL) is a configurable duration that determines how long an agent session state will be retained after its last interaction. When an agent session is idle (no messages sent to it) for longer than the TTL period, the session state is automatically deleted. Each new interaction with an agent resets the TTL timer, extending the session's lifetime.
|
||||
|
||||
## Benefits
|
||||
|
||||
- **Automatic cleanup**: No manual intervention required to clean up idle agent sessions
|
||||
- **Cost optimization**: Reduces storage costs by automatically removing unused session state
|
||||
- **Resource management**: Prevents unbounded growth of agent session state in storage
|
||||
- **Configurable**: Set TTL globally or per-agent type to match your application's needs
|
||||
|
||||
## Configuration
|
||||
|
||||
TTL can be configured at two levels:
|
||||
|
||||
1. **Global default TTL**: Applies to all agent sessions unless overridden
|
||||
2. **Per-agent type TTL**: Overrides the global default for specific agent types
|
||||
|
||||
Additionally, you can configure a **minimum deletion delay** that controls how frequently deletion operations are scheduled. The default value is 5 minutes, and the maximum allowed value is also 5 minutes.
|
||||
|
||||
> [!NOTE]
|
||||
> Reducing the minimum deletion delay below 5 minutes can be useful for testing or for ensuring rapid cleanup of short-lived agent sessions. However, this can also increase the load on the system and should be used with caution.
|
||||
|
||||
### Default values
|
||||
|
||||
- **Default TTL**: 14 days
|
||||
- **Minimum TTL deletion delay**: 5 minutes (maximum allowed value, subject to change in future releases)
|
||||
|
||||
### Configuration examples
|
||||
|
||||
#### .NET
|
||||
|
||||
```csharp
|
||||
// Configure global default TTL and minimum signal delay
|
||||
services.ConfigureDurableAgents(
|
||||
options =>
|
||||
{
|
||||
// Set global default TTL to 7 days
|
||||
options.DefaultTimeToLive = TimeSpan.FromDays(7);
|
||||
|
||||
// Add agents (will use global default TTL)
|
||||
options.AddAIAgent(myAgent);
|
||||
});
|
||||
|
||||
// Configure per-agent TTL
|
||||
services.ConfigureDurableAgents(
|
||||
options =>
|
||||
{
|
||||
options.DefaultTimeToLive = TimeSpan.FromDays(14); // Global default
|
||||
|
||||
// Agent with custom TTL of 1 day
|
||||
options.AddAIAgent(shortLivedAgent, timeToLive: TimeSpan.FromDays(1));
|
||||
|
||||
// Agent with custom TTL of 90 days
|
||||
options.AddAIAgent(longLivedAgent, timeToLive: TimeSpan.FromDays(90));
|
||||
|
||||
// Agent using global default (14 days)
|
||||
options.AddAIAgent(defaultAgent);
|
||||
});
|
||||
|
||||
// Disable TTL for specific agents by setting TTL to null
|
||||
services.ConfigureDurableAgents(
|
||||
options =>
|
||||
{
|
||||
options.DefaultTimeToLive = TimeSpan.FromDays(14);
|
||||
|
||||
// Agent with no TTL (never expires)
|
||||
options.AddAIAgent(permanentAgent, timeToLive: null);
|
||||
});
|
||||
```
|
||||
|
||||
## How TTL works
|
||||
|
||||
The following sections describe how TTL works in detail.
|
||||
|
||||
### Expiration tracking
|
||||
|
||||
Each agent session maintains an expiration timestamp in its internally managed state that is updated whenever the session processes a message:
|
||||
|
||||
1. When a message is sent to an agent session, the expiration time is set to `current time + TTL`
|
||||
2. The runtime schedules a delete operation for the expiration time (subject to minimum delay constraints)
|
||||
3. When the delete operation runs, if the current time is past the expiration time, the session state is deleted. Otherwise, the delete operation is rescheduled for the next expiration time.
|
||||
|
||||
### State deletion
|
||||
|
||||
When an agent session expires, its entire state is deleted, including:
|
||||
|
||||
- Conversation history
|
||||
- Any custom state data
|
||||
- Expiration timestamps
|
||||
|
||||
After deletion, if a message is sent to the same agent session, a new session is created with a fresh conversation history.
|
||||
|
||||
## Behavior examples
|
||||
|
||||
The following examples illustrate how TTL works in different scenarios.
|
||||
|
||||
### Example 1: Agent session expires after TTL
|
||||
|
||||
1. Agent configured with 30-day TTL
|
||||
2. User sends message at Day 0 → agent session created, expiration set to Day 30
|
||||
3. No further messages sent
|
||||
4. At Day 30 → Agent session is deleted
|
||||
5. User sends message at Day 31 → New agent session created with fresh conversation history
|
||||
|
||||
### Example 2: TTL reset on interaction
|
||||
|
||||
1. Agent configured with 30-day TTL
|
||||
2. User sends message at Day 0 → agent session created, expiration set to Day 30
|
||||
3. User sends message at Day 15 → Expiration reset to Day 45
|
||||
4. User sends message at Day 40 → Expiration reset to Day 70
|
||||
5. Agent session remains active as long as there are regular interactions
|
||||
|
||||
## Logging
|
||||
|
||||
The TTL feature includes comprehensive logging to track state changes:
|
||||
|
||||
- **Expiration time updated**: Logged when TTL expiration time is set or updated
|
||||
- **Deletion scheduled**: Logged when a deletion check signal is scheduled
|
||||
- **Deletion check**: Logged when a deletion check operation runs
|
||||
- **Session expired**: Logged when an agent session is deleted due to expiration
|
||||
- **TTL rescheduled**: Logged when a deletion signal is rescheduled
|
||||
|
||||
These logs help monitor TTL behavior and troubleshoot any issues.
|
||||
|
||||
## Best practices
|
||||
|
||||
1. **Choose appropriate TTL values**: Balance between storage costs and user experience. Too short TTLs may delete active sessions, while too long TTLs may accumulate unnecessary state.
|
||||
|
||||
2. **Use per-agent TTLs**: Different agents may have different usage patterns. Configure TTLs per-agent based on expected session lifetimes.
|
||||
|
||||
3. **Monitor expiration logs**: Review logs to understand TTL behavior and adjust configuration as needed.
|
||||
|
||||
4. **Test with short TTLs**: During development, use short TTLs (e.g., minutes) to verify TTL behavior without waiting for long periods.
|
||||
|
||||
## Limitations
|
||||
|
||||
- TTL is based on wall-clock time, not activity time. The expiration timer starts from the last message timestamp.
|
||||
- Deletion checks are durably scheduled operations and may have slight delays depending on system load.
|
||||
- Once an agent session is deleted, its conversation history cannot be recovered.
|
||||
- TTL deletion requires at least one worker to be available to process the deletion operation message.
|
||||
@@ -209,6 +209,7 @@ dotnet_diagnostic.CA2000.severity = none # Call System.IDisposable.Dispose on ob
|
||||
dotnet_diagnostic.CA2225.severity = none # Operator overloads have named alternates
|
||||
dotnet_diagnostic.CA2227.severity = none # Change to be read-only by removing the property setter
|
||||
dotnet_diagnostic.CA2249.severity = suggestion # Consider using 'Contains' method instead of 'IndexOf' method
|
||||
dotnet_diagnostic.CA2252.severity = none # Requires preview
|
||||
dotnet_diagnostic.CA2253.severity = none # Named placeholders in the logging message template should not be comprised of only numeric characters
|
||||
dotnet_diagnostic.CA2253.severity = none # Named placeholders in the logging message template should not be comprised of only numeric characters
|
||||
dotnet_diagnostic.CA2263.severity = suggestion # Use generic overload
|
||||
|
||||
@@ -11,19 +11,19 @@
|
||||
</PropertyGroup>
|
||||
<ItemGroup>
|
||||
<!-- Aspire.* -->
|
||||
<PackageVersion Include="Anthropic" Version="11.0.0" />
|
||||
<PackageVersion Include="Anthropic" Version="12.0.0" />
|
||||
<PackageVersion Include="Anthropic.Foundry" Version="0.1.0" />
|
||||
<PackageVersion Include="Aspire.Azure.AI.OpenAI" Version="13.0.0-preview.1.25560.3" />
|
||||
<PackageVersion Include="Aspire.Hosting.AppHost" Version="$(AspireAppHostSdkVersion)" />
|
||||
<PackageVersion Include="Aspire.Hosting.Azure.CognitiveServices" Version="$(AspireAppHostSdkVersion)" />
|
||||
<PackageVersion Include="Aspire.Microsoft.Azure.Cosmos" Version="$(AspireAppHostSdkVersion)" />
|
||||
<PackageVersion Include="CommunityToolkit.Aspire.OllamaSharp" Version="13.0.0-beta.440" />
|
||||
<PackageVersion Include="CommunityToolkit.Aspire.OllamaSharp" Version="13.0.0" />
|
||||
<!-- Azure.* -->
|
||||
<PackageVersion Include="Azure.AI.Projects" Version="1.2.0-beta.3" />
|
||||
<PackageVersion Include="Azure.AI.Projects.OpenAI" Version="1.0.0-beta.4" />
|
||||
<PackageVersion Include="Azure.AI.Projects" Version="1.2.0-beta.5" />
|
||||
<PackageVersion Include="Azure.AI.Projects.OpenAI" Version="1.0.0-beta.5" />
|
||||
<PackageVersion Include="Azure.AI.Agents.Persistent" Version="1.2.0-beta.8" />
|
||||
<PackageVersion Include="Azure.AI.OpenAI" Version="2.7.0-beta.2" />
|
||||
<PackageVersion Include="Azure.Identity" Version="1.17.0" />
|
||||
<PackageVersion Include="Azure.AI.OpenAI" Version="2.8.0-beta.1" />
|
||||
<PackageVersion Include="Azure.Identity" Version="1.17.1" />
|
||||
<PackageVersion Include="Azure.Monitor.OpenTelemetry.Exporter" Version="1.4.0" />
|
||||
<!-- Google Gemini -->
|
||||
<PackageVersion Include="Google.GenAI" Version="0.6.0" />
|
||||
@@ -33,18 +33,18 @@
|
||||
<!-- Newtonsoft.Json -->
|
||||
<PackageVersion Include="Newtonsoft.Json" Version="13.0.4" />
|
||||
<!-- System.* -->
|
||||
<PackageVersion Include="Microsoft.Bcl.AsyncInterfaces" Version="10.0.0" />
|
||||
<PackageVersion Include="Microsoft.Bcl.AsyncInterfaces" Version="10.0.1" />
|
||||
<PackageVersion Include="Microsoft.Bcl.HashCode" Version="6.0.0" />
|
||||
<PackageVersion Include="System.ClientModel" Version="1.8.1" />
|
||||
<PackageVersion Include="System.CodeDom" Version="10.0.0" />
|
||||
<PackageVersion Include="System.Collections.Immutable" Version="10.0.0" />
|
||||
<PackageVersion Include="System.CommandLine" Version="2.0.0-rc.2.25502.107" />
|
||||
<PackageVersion Include="System.Diagnostics.DiagnosticSource" Version="10.0.0" />
|
||||
<PackageVersion Include="System.Diagnostics.DiagnosticSource" Version="10.0.1" />
|
||||
<PackageVersion Include="System.Linq.AsyncEnumerable" Version="10.0.0" />
|
||||
<PackageVersion Include="System.Net.Http.Json" Version="10.0.0" />
|
||||
<PackageVersion Include="System.Net.ServerSentEvents" Version="10.0.0" />
|
||||
<PackageVersion Include="System.Text.Json" Version="10.0.0" />
|
||||
<PackageVersion Include="System.Threading.Channels" Version="10.0.0" />
|
||||
<PackageVersion Include="System.Text.Json" Version="10.0.1" />
|
||||
<PackageVersion Include="System.Threading.Channels" Version="10.0.1" />
|
||||
<PackageVersion Include="System.Threading.Tasks.Extensions" Version="4.6.3" />
|
||||
<PackageVersion Include="System.Net.Security" Version="4.3.2" />
|
||||
<!-- OpenTelemetry -->
|
||||
@@ -61,10 +61,9 @@
|
||||
<PackageVersion Include="Microsoft.AspNetCore.OpenApi" Version="10.0.0" />
|
||||
<PackageVersion Include="Swashbuckle.AspNetCore.SwaggerUI" Version="10.0.0" />
|
||||
<!-- Microsoft.Extensions.* -->
|
||||
<PackageVersion Include="Microsoft.Extensions.AI" Version="10.0.1" />
|
||||
<PackageVersion Include="Microsoft.Extensions.AI.Abstractions" Version="10.0.1" />
|
||||
<PackageVersion Include="Microsoft.Extensions.AI.AzureAIInference" Version="10.0.0-preview.1.25559.3" />
|
||||
<PackageVersion Include="Microsoft.Extensions.AI.OpenAI" Version="10.0.1-preview.1.25571.5" />
|
||||
<PackageVersion Include="Microsoft.Extensions.AI" Version="10.1.1" />
|
||||
<PackageVersion Include="Microsoft.Extensions.AI.Abstractions" Version="10.1.1" />
|
||||
<PackageVersion Include="Microsoft.Extensions.AI.OpenAI" Version="10.1.1-preview.1.25612.2" />
|
||||
<PackageVersion Include="Microsoft.Extensions.Caching.Memory" Version="10.0.0" />
|
||||
<PackageVersion Include="Microsoft.Extensions.Configuration" Version="10.0.0" />
|
||||
<PackageVersion Include="Microsoft.Extensions.Configuration.Binder" Version="10.0.0" />
|
||||
@@ -72,11 +71,11 @@
|
||||
<PackageVersion Include="Microsoft.Extensions.Configuration.Json" Version="10.0.0" />
|
||||
<PackageVersion Include="Microsoft.Extensions.Configuration.UserSecrets" Version="10.0.0" />
|
||||
<PackageVersion Include="Microsoft.Extensions.DependencyInjection" Version="10.0.0" />
|
||||
<PackageVersion Include="Microsoft.Extensions.DependencyInjection.Abstractions" Version="10.0.0" />
|
||||
<PackageVersion Include="Microsoft.Extensions.DependencyInjection.Abstractions" Version="10.0.1" />
|
||||
<PackageVersion Include="Microsoft.Extensions.Hosting" Version="10.0.0" />
|
||||
<PackageVersion Include="Microsoft.Extensions.Http.Resilience" Version="10.0.0" />
|
||||
<PackageVersion Include="Microsoft.Extensions.Logging" Version="10.0.0" />
|
||||
<PackageVersion Include="Microsoft.Extensions.Logging.Abstractions" Version="10.0.0" />
|
||||
<PackageVersion Include="Microsoft.Extensions.Logging.Abstractions" Version="10.0.1" />
|
||||
<PackageVersion Include="Microsoft.Extensions.Logging.Console" Version="10.0.0" />
|
||||
<PackageVersion Include="Microsoft.Extensions.ServiceDiscovery" Version="10.0.0" />
|
||||
<PackageVersion Include="Microsoft.Extensions.VectorData.Abstractions" Version="9.7.0" />
|
||||
@@ -101,11 +100,10 @@
|
||||
<!-- MCP -->
|
||||
<PackageVersion Include="ModelContextProtocol" Version="0.4.0-preview.3" />
|
||||
<!-- Inference SDKs -->
|
||||
<PackageVersion Include="Anthropic.SDK" Version="5.8.0" />
|
||||
<PackageVersion Include="AWSSDK.Extensions.Bedrock.MEAI" Version="4.0.4.7" />
|
||||
<PackageVersion Include="AWSSDK.Extensions.Bedrock.MEAI" Version="4.0.5" />
|
||||
<PackageVersion Include="Microsoft.ML.OnnxRuntimeGenAI" Version="0.10.0" />
|
||||
<PackageVersion Include="OllamaSharp" Version="5.4.8" />
|
||||
<PackageVersion Include="OpenAI" Version="2.7.0" />
|
||||
<PackageVersion Include="OpenAI" Version="2.8.0" />
|
||||
<!-- Identity -->
|
||||
<PackageVersion Include="Microsoft.Identity.Client.Extensions.Msal" Version="4.78.0" />
|
||||
<!-- Workflows -->
|
||||
@@ -114,19 +112,21 @@
|
||||
<PackageVersion Include="Microsoft.Bot.ObjectModel.PowerFx" Version="1.2025.1106.1" />
|
||||
<PackageVersion Include="Microsoft.PowerFx.Interpreter" Version="1.5.0-build.20251008-1002" />
|
||||
<!-- Durable Task -->
|
||||
<PackageVersion Include="Microsoft.DurableTask.Client" Version="1.16.2" />
|
||||
<PackageVersion Include="Microsoft.DurableTask.Client.AzureManaged" Version="1.16.2-preview.1" />
|
||||
<PackageVersion Include="Microsoft.DurableTask.Worker" Version="1.16.2" />
|
||||
<PackageVersion Include="Microsoft.DurableTask.Worker.AzureManaged" Version="1.16.2-preview.1" />
|
||||
<PackageVersion Include="Microsoft.DurableTask.Client" Version="1.18.0" />
|
||||
<PackageVersion Include="Microsoft.DurableTask.Client.AzureManaged" Version="1.18.0" />
|
||||
<PackageVersion Include="Microsoft.DurableTask.Worker" Version="1.18.0" />
|
||||
<PackageVersion Include="Microsoft.DurableTask.Worker.AzureManaged" Version="1.18.0" />
|
||||
<!-- Azure Functions -->
|
||||
<PackageVersion Include="Microsoft.Azure.Functions.Worker" Version="2.50.0" />
|
||||
<PackageVersion Include="Microsoft.Azure.Functions.Worker.ApplicationInsights" Version="2.50.0" />
|
||||
<PackageVersion Include="Microsoft.Azure.Functions.Worker.Extensions.DurableTask" Version="1.9.0" />
|
||||
<PackageVersion Include="Microsoft.Azure.Functions.Worker.Extensions.DurableTask.AzureManaged" Version="1.0.0" />
|
||||
<PackageVersion Include="Microsoft.Azure.Functions.Worker.Extensions.DurableTask" Version="1.11.0" />
|
||||
<PackageVersion Include="Microsoft.Azure.Functions.Worker.Extensions.DurableTask.AzureManaged" Version="1.0.1" />
|
||||
<PackageVersion Include="Microsoft.Azure.Functions.Worker.Extensions.Http" Version="3.3.0" />
|
||||
<PackageVersion Include="Microsoft.Azure.Functions.Worker.Extensions.Http.AspNetCore" Version="2.1.0" />
|
||||
<PackageVersion Include="Microsoft.Azure.Functions.Worker.Extensions.Mcp" Version="1.0.0" />
|
||||
<PackageVersion Include="Microsoft.Azure.Functions.Worker.Sdk" Version="2.0.7" />
|
||||
<!-- Redis -->
|
||||
<PackageVersion Include="StackExchange.Redis" Version="2.10.1" />
|
||||
<!-- Test -->
|
||||
<PackageVersion Include="FluentAssertions" Version="8.8.0" />
|
||||
<PackageVersion Include="Microsoft.AspNetCore.TestHost" Condition="'$(TargetFramework)' == 'net8.0'" Version="8.0.22" />
|
||||
|
||||
@@ -20,14 +20,9 @@
|
||||
</Folder>
|
||||
<Folder Name="/Samples/AGUIClientServer/">
|
||||
<Project Path="samples/AGUIClientServer/AGUIClient/AGUIClient.csproj" />
|
||||
<Project Path="samples/AGUIClientServer/AGUIDojoServer/AGUIDojoServer.csproj" />
|
||||
<Project Path="samples/AGUIClientServer/AGUIServer/AGUIServer.csproj" />
|
||||
</Folder>
|
||||
<Folder Name="/Samples/AGUIDojo/">
|
||||
<File Path="samples/AGUIDojo/Design.md" />
|
||||
<File Path="samples/AGUIDojo/Plan.md" />
|
||||
<Project Path="samples/AGUIDojo/AGUIDojoClient/AGUIDojoClient.csproj" />
|
||||
<Project Path="samples/AGUIDojo/AGUIDojoServer/AGUIDojoServer.csproj" />
|
||||
</Folder>
|
||||
<Folder Name="/Samples/AzureFunctions/">
|
||||
<File Path="samples/AzureFunctions/.editorconfig" />
|
||||
<File Path="samples/AzureFunctions/README.md" />
|
||||
@@ -38,6 +33,7 @@
|
||||
<Project Path="samples/AzureFunctions/05_AgentOrchestration_HITL/05_AgentOrchestration_HITL.csproj" />
|
||||
<Project Path="samples/AzureFunctions/06_LongRunningTools/06_LongRunningTools.csproj" />
|
||||
<Project Path="samples/AzureFunctions/07_AgentAsMcpTool/07_AgentAsMcpTool.csproj" />
|
||||
<Project Path="samples/AzureFunctions/08_ReliableStreaming/08_ReliableStreaming.csproj" />
|
||||
</Folder>
|
||||
<Folder Name="/Samples/GettingStarted/">
|
||||
<File Path="samples/GettingStarted/README.md" />
|
||||
@@ -134,6 +130,7 @@
|
||||
<Project Path="samples/GettingStarted/AgentWithOpenAI/Agent_OpenAI_Step02_Reasoning/Agent_OpenAI_Step02_Reasoning.csproj" />
|
||||
<Project Path="samples/GettingStarted/AgentWithOpenAI/Agent_OpenAI_Step03_CreateFromChatClient/Agent_OpenAI_Step03_CreateFromChatClient.csproj" />
|
||||
<Project Path="samples/GettingStarted/AgentWithOpenAI/Agent_OpenAI_Step04_CreateFromOpenAIResponseClient/Agent_OpenAI_Step04_CreateFromOpenAIResponseClient.csproj" />
|
||||
<Project Path="samples/GettingStarted/AgentWithOpenAI/Agent_OpenAI_Step05_Conversation/Agent_OpenAI_Step05_Conversation.csproj" />
|
||||
</Folder>
|
||||
<Folder Name="/Samples/Purview/" />
|
||||
<Folder Name="/Samples/Purview/AgentWithPurview/">
|
||||
|
||||
@@ -2,9 +2,9 @@
|
||||
<PropertyGroup>
|
||||
<!-- Central version prefix - applies to all nuget packages. -->
|
||||
<VersionPrefix>1.0.0</VersionPrefix>
|
||||
<PackageVersion Condition="'$(VersionSuffix)' != ''">$(VersionPrefix)-$(VersionSuffix).251204.1</PackageVersion>
|
||||
<PackageVersion Condition="'$(VersionSuffix)' == ''">$(VersionPrefix)-preview.251204.1</PackageVersion>
|
||||
<GitTag>1.0.0-preview.251204.1</GitTag>
|
||||
<PackageVersion Condition="'$(VersionSuffix)' != ''">$(VersionPrefix)-$(VersionSuffix).251219.1</PackageVersion>
|
||||
<PackageVersion Condition="'$(VersionSuffix)' == ''">$(VersionPrefix)-preview.251219.1</PackageVersion>
|
||||
<GitTag>1.0.0-preview.251219.1</GitTag>
|
||||
|
||||
<Configurations>Debug;Release;Publish</Configurations>
|
||||
<IsPackable>true</IsPackable>
|
||||
|
||||
+1
-2
@@ -2,8 +2,7 @@
|
||||
|
||||
<PropertyGroup>
|
||||
<OutputType>Exe</OutputType>
|
||||
<TargetFrameworks></TargetFrameworks>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<TargetFrameworks>net10.0</TargetFrameworks>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<UserSecretsId>b9c3f1e1-2fb4-5g29-0e52-53e2b7g9gf21</UserSecretsId>
|
||||
+3
-3
@@ -19,12 +19,12 @@ internal sealed class AgenticUIAgent : DelegatingAIAgent
|
||||
this._jsonSerializerOptions = jsonSerializerOptions;
|
||||
}
|
||||
|
||||
public override Task<AgentRunResponse> RunAsync(IEnumerable<ChatMessage> messages, AgentThread? thread = null, AgentRunOptions? options = null, CancellationToken cancellationToken = default)
|
||||
protected override Task<AgentRunResponse> RunCoreAsync(IEnumerable<ChatMessage> messages, AgentThread? thread = null, AgentRunOptions? options = null, CancellationToken cancellationToken = default)
|
||||
{
|
||||
return this.RunStreamingAsync(messages, thread, options, cancellationToken).ToAgentRunResponseAsync(cancellationToken);
|
||||
return this.RunCoreStreamingAsync(messages, thread, options, cancellationToken).ToAgentRunResponseAsync(cancellationToken);
|
||||
}
|
||||
|
||||
public override async IAsyncEnumerable<AgentRunResponseUpdate> RunStreamingAsync(
|
||||
protected override async IAsyncEnumerable<AgentRunResponseUpdate> RunCoreStreamingAsync(
|
||||
IEnumerable<ChatMessage> messages,
|
||||
AgentThread? thread = null,
|
||||
AgentRunOptions? options = null,
|
||||
+2
-2
@@ -135,7 +135,7 @@ internal static class ChatClientAgentFactory
|
||||
{
|
||||
Instructions = """
|
||||
You are a document editor assistant. When asked to write or edit content:
|
||||
|
||||
|
||||
IMPORTANT:
|
||||
- Use the `write_document` tool with the full document text in Markdown format
|
||||
- Format the document extensively so it's easy to read
|
||||
@@ -145,7 +145,7 @@ internal static class ChatClientAgentFactory
|
||||
- When making edits to the document, try to make them minimal - do not change every word
|
||||
- Keep stories SHORT!
|
||||
- After you are done writing the document you MUST call a confirm_changes tool after you call write_document
|
||||
|
||||
|
||||
After the user confirms the changes, provide a brief summary of what you wrote.
|
||||
""",
|
||||
Tools = [
|
||||
+3
-3
@@ -20,12 +20,12 @@ internal sealed class PredictiveStateUpdatesAgent : DelegatingAIAgent
|
||||
this._jsonSerializerOptions = jsonSerializerOptions;
|
||||
}
|
||||
|
||||
public override Task<AgentRunResponse> RunAsync(IEnumerable<ChatMessage> messages, AgentThread? thread = null, AgentRunOptions? options = null, CancellationToken cancellationToken = default)
|
||||
protected override Task<AgentRunResponse> RunCoreAsync(IEnumerable<ChatMessage> messages, AgentThread? thread = null, AgentRunOptions? options = null, CancellationToken cancellationToken = default)
|
||||
{
|
||||
return this.RunStreamingAsync(messages, thread, options, cancellationToken).ToAgentRunResponseAsync(cancellationToken);
|
||||
return this.RunCoreStreamingAsync(messages, thread, options, cancellationToken).ToAgentRunResponseAsync(cancellationToken);
|
||||
}
|
||||
|
||||
public override async IAsyncEnumerable<AgentRunResponseUpdate> RunStreamingAsync(
|
||||
protected override async IAsyncEnumerable<AgentRunResponseUpdate> RunCoreStreamingAsync(
|
||||
IEnumerable<ChatMessage> messages,
|
||||
AgentThread? thread = null,
|
||||
AgentRunOptions? options = null,
|
||||
+3
-3
@@ -19,12 +19,12 @@ internal sealed class SharedStateAgent : DelegatingAIAgent
|
||||
this._jsonSerializerOptions = jsonSerializerOptions;
|
||||
}
|
||||
|
||||
public override Task<AgentRunResponse> RunAsync(IEnumerable<ChatMessage> messages, AgentThread? thread = null, AgentRunOptions? options = null, CancellationToken cancellationToken = default)
|
||||
protected override Task<AgentRunResponse> RunCoreAsync(IEnumerable<ChatMessage> messages, AgentThread? thread = null, AgentRunOptions? options = null, CancellationToken cancellationToken = default)
|
||||
{
|
||||
return this.RunStreamingAsync(messages, thread, options, cancellationToken).ToAgentRunResponseAsync(cancellationToken);
|
||||
return this.RunCoreStreamingAsync(messages, thread, options, cancellationToken).ToAgentRunResponseAsync(cancellationToken);
|
||||
}
|
||||
|
||||
public override async IAsyncEnumerable<AgentRunResponseUpdate> RunStreamingAsync(
|
||||
protected override async IAsyncEnumerable<AgentRunResponseUpdate> RunCoreStreamingAsync(
|
||||
IEnumerable<ChatMessage> messages,
|
||||
AgentThread? thread = null,
|
||||
AgentRunOptions? options = null,
|
||||
@@ -1 +0,0 @@
|
||||
.playwright-mcp/
|
||||
@@ -1,11 +0,0 @@
|
||||
<Solution>
|
||||
<Project Path="../../src/Microsoft.Agents.AI.Abstractions/Microsoft.Agents.AI.Abstractions.csproj" />
|
||||
<Project Path="../../src/Microsoft.Agents.AI.AGUI/Microsoft.Agents.AI.AGUI.csproj" />
|
||||
<Project Path="../../src/Microsoft.Agents.AI.Hosting.AGUI.AspNetCore/Microsoft.Agents.AI.Hosting.AGUI.AspNetCore.csproj" />
|
||||
<Project Path="../../src/Microsoft.Agents.AI.Hosting/Microsoft.Agents.AI.Hosting.csproj" />
|
||||
<Project Path="../../src/Microsoft.Agents.AI.OpenAI/Microsoft.Agents.AI.OpenAI.csproj" />
|
||||
<Project Path="../../src/Microsoft.Agents.AI.Workflows/Microsoft.Agents.AI.Workflows.csproj" />
|
||||
<Project Path="../../src/Microsoft.Agents.AI/Microsoft.Agents.AI.csproj" />
|
||||
<Project Path="AGUIDojoClient/AGUIDojoClient.csproj" />
|
||||
<Project Path="AGUIDojoServer/AGUIDojoServer.csproj" />
|
||||
</Solution>
|
||||
@@ -1,16 +0,0 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk.Web">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFrameworks></TargetFrameworks>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<BlazorDisableThrowNavigationException>true</BlazorDisableThrowNavigationException>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\..\src\Microsoft.Agents.AI\Microsoft.Agents.AI.csproj" />
|
||||
<ProjectReference Include="..\..\..\src\Microsoft.Agents.AI.AGUI\Microsoft.Agents.AI.AGUI.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -1,141 +0,0 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using Microsoft.Agents.AI;
|
||||
using Microsoft.AspNetCore.Components.Rendering;
|
||||
|
||||
namespace Microsoft.AspNetCore.Components.AI;
|
||||
|
||||
[CascadingTypeParameter(nameof(TState))]
|
||||
public partial class AgentStateBoundary<TState> : IComponent, IDisposable
|
||||
{
|
||||
private RenderHandle _renderHandle;
|
||||
private RenderFragment? _renderWithState;
|
||||
private protected AgentBoundaryContext<TState>? _context;
|
||||
private AgentThread? _currentThread;
|
||||
private AIAgent? _currentAgent;
|
||||
private bool _disposed;
|
||||
|
||||
[EditorRequired][Parameter] public AIAgent? Agent { get; set; }
|
||||
|
||||
[Parameter] public AgentThread? Thread { get; set; }
|
||||
|
||||
[Parameter] public TState? State { get; set; }
|
||||
|
||||
[Parameter] public RenderFragment? ChildContent { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Callback invoked when the boundary context is created or recreated.
|
||||
/// Use this to register tools or perform other initialization.
|
||||
/// </summary>
|
||||
[Parameter] public EventCallback<IAgentBoundaryContext> OnContextCreated { get; set; }
|
||||
|
||||
public void Attach(RenderHandle renderHandle)
|
||||
{
|
||||
this._renderHandle = renderHandle;
|
||||
this._renderWithState = this.RenderWithState;
|
||||
}
|
||||
|
||||
// Agent boundary renders once when it receives the initial set of parameters
|
||||
// and only ever renders again if the Agent, Thread, or State changes.
|
||||
// Re-rendering the agent boundary will dispose the existing context and create
|
||||
// a new one.
|
||||
public Task SetParametersAsync(ParameterView parameters)
|
||||
{
|
||||
parameters.SetParameterProperties(this);
|
||||
|
||||
if (this.Agent == null)
|
||||
{
|
||||
throw new InvalidOperationException("AgentBoundary requires an Agent parameter.");
|
||||
}
|
||||
|
||||
// Use Thread parameter if provided, otherwise keep existing thread or create new one
|
||||
var thread = this.Thread ?? this._currentThread ?? this.Agent.GetNewThread();
|
||||
|
||||
TState? currentState = this._context != null ? this._context.CurrentState : default;
|
||||
|
||||
bool agentChanged = this.Agent != this._currentAgent;
|
||||
bool threadChanged = thread != this._currentThread;
|
||||
bool stateChanged = !EqualityComparer<TState?>.Default.Equals(this.State, currentState);
|
||||
|
||||
bool refresh = agentChanged || threadChanged || stateChanged;
|
||||
|
||||
if (refresh)
|
||||
{
|
||||
this._context?.Dispose();
|
||||
this._context = null;
|
||||
this._currentThread = null;
|
||||
}
|
||||
|
||||
this._currentAgent = this.Agent;
|
||||
this._currentThread = thread;
|
||||
|
||||
// Agent is validated non-null above, thread is either from Thread parameter or GetNewThread()
|
||||
bool isNewContext = this._context == null;
|
||||
this._context ??= new AgentBoundaryContext<TState>(this.Agent!, thread!);
|
||||
this._context.CurrentState = this.State;
|
||||
|
||||
// Invoke the context created callback if we created a new context
|
||||
if (isNewContext && this.OnContextCreated.HasDelegate)
|
||||
{
|
||||
_ = this.OnContextCreated.InvokeAsync(this._context);
|
||||
}
|
||||
|
||||
if (refresh)
|
||||
{
|
||||
this.Render();
|
||||
}
|
||||
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
private void Render()
|
||||
{
|
||||
this._renderHandle.Render(builder =>
|
||||
{
|
||||
builder.OpenComponent<CascadingValue<AgentBoundaryContext<TState>>>(0);
|
||||
builder.AddComponentParameter(1, "Value", this._context);
|
||||
builder.AddComponentParameter(2, "IsFixed", true);
|
||||
builder.AddComponentParameter(3, "ChildContent", this._renderWithState);
|
||||
builder.CloseComponent();
|
||||
});
|
||||
}
|
||||
|
||||
protected virtual void RenderWithState(RenderTreeBuilder builder)
|
||||
{
|
||||
builder.OpenComponent<CascadingValue<TState?>>(0);
|
||||
builder.AddComponentParameter(1, "Value", this.State);
|
||||
builder.AddComponentParameter(2, "IsFixed", false);
|
||||
builder.AddComponentParameter(3, "ChildContent", this.ChildContent);
|
||||
builder.CloseComponent();
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
this.Dispose(true);
|
||||
GC.SuppressFinalize(this);
|
||||
}
|
||||
|
||||
protected virtual void Dispose(bool disposing)
|
||||
{
|
||||
if (!this._disposed)
|
||||
{
|
||||
if (disposing)
|
||||
{
|
||||
this._context?.Dispose();
|
||||
}
|
||||
this._disposed = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public class AgentBoundary : AgentStateBoundary<object?>
|
||||
{
|
||||
protected override void RenderWithState(RenderTreeBuilder builder)
|
||||
{
|
||||
builder.OpenComponent<CascadingValue<IAgentBoundaryContext>>(0);
|
||||
builder.AddComponentParameter(1, "Value", this._context);
|
||||
builder.AddComponentParameter(2, "IsFixed", true);
|
||||
builder.AddComponentParameter(3, "ChildContent", this.ChildContent);
|
||||
builder.CloseComponent();
|
||||
}
|
||||
}
|
||||
@@ -1,411 +0,0 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using Microsoft.Agents.AI;
|
||||
using Microsoft.Extensions.AI;
|
||||
|
||||
namespace Microsoft.AspNetCore.Components.AI;
|
||||
|
||||
public sealed partial class AgentBoundaryContext<TState> : IAgentBoundaryContext, IDisposable
|
||||
{
|
||||
private readonly AIAgent _agent;
|
||||
private readonly AgentThread _thread;
|
||||
private readonly CancellationTokenSource _cancellationTokenSource;
|
||||
private readonly List<ChatMessage> _messages = [];
|
||||
private readonly List<ChatMessage> _pendingMessages = [];
|
||||
private readonly List<Action> _messageChangeSubscribers = [];
|
||||
private readonly List<Action> _responseUpdateSubscribers = [];
|
||||
private readonly List<Action> _runStatusSubscribers = [];
|
||||
private readonly List<AITool> _tools = [];
|
||||
private readonly Dictionary<string, TaskCompletionSource<object>> _pendingResponses = [];
|
||||
|
||||
public CancellationToken CancellationToken => this._cancellationTokenSource.Token;
|
||||
|
||||
public IReadOnlyList<ChatMessage> CompletedMessages => this._messages.AsReadOnly();
|
||||
|
||||
public TState? CurrentState { get; set; }
|
||||
|
||||
public IReadOnlyList<ChatMessage> PendingMessages => this._pendingMessages.AsReadOnly();
|
||||
|
||||
public ChatMessage? CurrentMessage { get; private set; }
|
||||
|
||||
public ChatResponseUpdate? CurrentUpdate { get; private set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets whether the agent is currently processing a turn.
|
||||
/// </summary>
|
||||
public bool IsProcessing { get; private set; }
|
||||
|
||||
public AgentBoundaryContext(AIAgent agent, AgentThread thread)
|
||||
{
|
||||
this._agent = agent;
|
||||
this._thread = thread;
|
||||
this._cancellationTokenSource = new CancellationTokenSource();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Registers a client-side tool that will be passed to the agent during invocation.
|
||||
/// These tools are sent to the server via ChatClientAgentRunOptions.ChatOptions.Tools.
|
||||
/// </summary>
|
||||
public void RegisterTool(AITool tool)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(tool);
|
||||
this._tools.Add(tool);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Registers multiple client-side tools that will be passed to the agent during invocation.
|
||||
/// </summary>
|
||||
public void RegisterTools(params AITool[] tools)
|
||||
{
|
||||
foreach (var tool in tools)
|
||||
{
|
||||
this.RegisterTool(tool);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates a pending response for the given key and returns a task that completes when ProvideResponse is called.
|
||||
/// Used by frontend tools to wait for user input from UI components.
|
||||
/// </summary>
|
||||
/// <param name="key">A unique key to identify this pending response (e.g., function call ID).</param>
|
||||
/// <returns>A task that completes with the response object when ProvideResponse is called.</returns>
|
||||
public Task<object> WaitForResponse(string key)
|
||||
{
|
||||
var tcs = new TaskCompletionSource<object>(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
this._pendingResponses[key] = tcs;
|
||||
return tcs.Task;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Provides a response for a pending request, completing the task returned by WaitForResponse.
|
||||
/// Called by UI components when user interaction is complete.
|
||||
/// </summary>
|
||||
/// <param name="key">The key used when calling WaitForResponse.</param>
|
||||
/// <param name="response">The response object to return.</param>
|
||||
/// <returns>True if the response was provided successfully, false if no pending request was found.</returns>
|
||||
public bool ProvideResponse(string key, object response)
|
||||
{
|
||||
if (this._pendingResponses.TryGetValue(key, out var tcs))
|
||||
{
|
||||
this._pendingResponses.Remove(key);
|
||||
tcs.TrySetResult(response);
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
this._cancellationTokenSource.Cancel();
|
||||
this._cancellationTokenSource.Dispose();
|
||||
GC.SuppressFinalize(this);
|
||||
}
|
||||
|
||||
public async Task SendAsync(params ChatMessage[] userMessages)
|
||||
{
|
||||
// This starts a new turn. Once completed, we will add the new messages to the conversation.
|
||||
this._pendingMessages.Clear();
|
||||
this._pendingMessages.AddRange(userMessages);
|
||||
|
||||
// Mark as processing
|
||||
this.IsProcessing = true;
|
||||
|
||||
// Notify subscribers that run status changed (processing started)
|
||||
this.TriggerRunStatusChanged();
|
||||
|
||||
// User messages added, notify subscribers.
|
||||
this.TriggerMessageChanges();
|
||||
|
||||
// Build run options with registered tools if any
|
||||
AgentRunOptions? options = null;
|
||||
if (this._tools.Count > 0)
|
||||
{
|
||||
options = new ChatClientAgentRunOptions(new ChatOptions
|
||||
{
|
||||
Tools = [.. this._tools]
|
||||
});
|
||||
}
|
||||
|
||||
// Start a turn. Collect all updates as we stream them.
|
||||
await foreach (var update in this._agent.RunStreamingAsync(
|
||||
userMessages,
|
||||
this._thread,
|
||||
options,
|
||||
this._cancellationTokenSource.Token))
|
||||
{
|
||||
var chatUpdate = update.AsChatResponseUpdate();
|
||||
|
||||
this.CurrentUpdate = chatUpdate;
|
||||
|
||||
// Notify subscribers of the new update, this always happens before a new message is created/updated.
|
||||
this.TriggerChatResponseUpdate();
|
||||
|
||||
// This creates or adds a new message to the pending messages as needed.
|
||||
var isNewMessage = MessageHelpers.ProcessUpdate(chatUpdate, this._pendingMessages);
|
||||
if (isNewMessage)
|
||||
{
|
||||
this.CurrentMessage = this._pendingMessages[this._pendingMessages.Count - 1];
|
||||
|
||||
// Finalize the previous message's content if we have 2 or more messages now.
|
||||
if (this._pendingMessages.Count > userMessages.Length + 1)
|
||||
{
|
||||
MessageHelpers.CoalesceContent(this._pendingMessages[this._pendingMessages.Count - 2].Contents);
|
||||
}
|
||||
|
||||
// Notify subscribers of new message
|
||||
this.TriggerMessageChanges();
|
||||
}
|
||||
}
|
||||
|
||||
// Process any remaining updates to finalize the last message.
|
||||
if (this._pendingMessages.Count > userMessages.Length)
|
||||
{
|
||||
MessageHelpers.CoalesceContent(this._pendingMessages[this._pendingMessages.Count - 1].Contents);
|
||||
}
|
||||
|
||||
// Add the new messages to the conversation
|
||||
this._messages.AddRange(this._pendingMessages);
|
||||
|
||||
// Finish the turn
|
||||
this._pendingMessages.Clear();
|
||||
this.CurrentMessage = null;
|
||||
this.CurrentUpdate = null;
|
||||
this.IsProcessing = false;
|
||||
|
||||
// Notify subscribers that run status changed (processing ended)
|
||||
this.TriggerRunStatusChanged();
|
||||
|
||||
// Notify subscribers
|
||||
this.TriggerChatResponseUpdate();
|
||||
this.TriggerMessageChanges();
|
||||
}
|
||||
|
||||
private void TriggerChatResponseUpdate()
|
||||
{
|
||||
// Iterate backwards to avoid issues if subscribers are removed during iteration
|
||||
for (var i = this._responseUpdateSubscribers.Count - 1; i >= 0; i--)
|
||||
{
|
||||
this._responseUpdateSubscribers[i]();
|
||||
}
|
||||
}
|
||||
|
||||
private void TriggerMessageChanges()
|
||||
{
|
||||
// Iterate backwards to avoid issues if subscribers are removed during iteration
|
||||
for (var i = this._messageChangeSubscribers.Count - 1; i >= 0; i--)
|
||||
{
|
||||
this._messageChangeSubscribers[i]();
|
||||
}
|
||||
}
|
||||
|
||||
public MessageSubscription SubscribeToMessageChanges(Action onNewMessage)
|
||||
{
|
||||
return new MessageSubscription(this._messageChangeSubscribers, onNewMessage);
|
||||
}
|
||||
|
||||
public ResponseUpdateSubscription SubscribeToResponseUpdates(Action onChatResponse)
|
||||
{
|
||||
return new ResponseUpdateSubscription(this._responseUpdateSubscribers, onChatResponse);
|
||||
}
|
||||
|
||||
public RunStatusSubscription SubscribeToRunStatusChanges(Action onRunStatusChanged)
|
||||
{
|
||||
return new RunStatusSubscription(this._runStatusSubscribers, onRunStatusChanged);
|
||||
}
|
||||
|
||||
private void TriggerRunStatusChanged()
|
||||
{
|
||||
// Iterate backwards to avoid issues if subscribers are removed during iteration
|
||||
for (var i = this._runStatusSubscribers.Count - 1; i >= 0; i--)
|
||||
{
|
||||
this._runStatusSubscribers[i]();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public interface IAgentBoundaryContext
|
||||
{
|
||||
// Push new messages to the agent
|
||||
Task SendAsync(params ChatMessage[] userMessages);
|
||||
|
||||
// All message interactions from previous turns
|
||||
IReadOnlyList<ChatMessage> CompletedMessages { get; }
|
||||
|
||||
// All message interactions from the current turn. These represent completed messages only.
|
||||
IReadOnlyList<ChatMessage> PendingMessages { get; }
|
||||
|
||||
// The current message being processed by the agent.
|
||||
ChatMessage? CurrentMessage { get; }
|
||||
|
||||
ChatResponseUpdate? CurrentUpdate { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets whether the agent is currently processing a turn.
|
||||
/// </summary>
|
||||
bool IsProcessing { get; }
|
||||
|
||||
// Triggered any time there is a change on a message.
|
||||
MessageSubscription SubscribeToMessageChanges(Action onNewMessage);
|
||||
|
||||
ResponseUpdateSubscription SubscribeToResponseUpdates(Action onChatResponse);
|
||||
|
||||
/// <summary>
|
||||
/// Subscribes to run status changes (when processing starts or ends).
|
||||
/// </summary>
|
||||
/// <param name="onRunStatusChanged">The callback to invoke when run status changes.</param>
|
||||
/// <returns>A subscription that can be disposed to unsubscribe.</returns>
|
||||
RunStatusSubscription SubscribeToRunStatusChanges(Action onRunStatusChanged);
|
||||
|
||||
CancellationToken CancellationToken { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Registers a client-side tool that will be passed to the agent during invocation.
|
||||
/// </summary>
|
||||
void RegisterTool(AITool tool);
|
||||
|
||||
/// <summary>
|
||||
/// Registers multiple client-side tools that will be passed to the agent during invocation.
|
||||
/// </summary>
|
||||
void RegisterTools(params AITool[] tools);
|
||||
|
||||
/// <summary>
|
||||
/// Creates a pending response for the given key and returns a task that completes when ProvideResponse is called.
|
||||
/// Used by frontend tools to wait for user input from UI components.
|
||||
/// </summary>
|
||||
/// <param name="key">A unique key to identify this pending response (e.g., function call ID).</param>
|
||||
/// <returns>A task that completes with the response object when ProvideResponse is called.</returns>
|
||||
Task<object> WaitForResponse(string key);
|
||||
|
||||
/// <summary>
|
||||
/// Provides a response for a pending request, completing the task returned by WaitForResponse.
|
||||
/// Called by UI components when user interaction is complete.
|
||||
/// </summary>
|
||||
/// <param name="key">The key used when calling WaitForResponse.</param>
|
||||
/// <param name="response">The response object to return.</param>
|
||||
/// <returns>True if the response was provided successfully, false if no pending request was found.</returns>
|
||||
bool ProvideResponse(string key, object response);
|
||||
}
|
||||
|
||||
public readonly struct MessageSubscription : IDisposable, IEquatable<MessageSubscription>
|
||||
{
|
||||
internal readonly List<Action> _subscribers;
|
||||
internal readonly Action _subscription;
|
||||
|
||||
internal MessageSubscription(List<Action> subscribers, Action subscription)
|
||||
{
|
||||
this._subscribers = subscribers;
|
||||
this._subscription = subscription;
|
||||
this._subscribers.Add(this._subscription);
|
||||
}
|
||||
|
||||
public void Dispose() => this._subscribers.Remove(this._subscription);
|
||||
|
||||
public override bool Equals(object? obj)
|
||||
{
|
||||
return obj is MessageSubscription subscription && this.Equals(subscription);
|
||||
}
|
||||
|
||||
public bool Equals(MessageSubscription other)
|
||||
{
|
||||
return EqualityComparer<List<Action>>.Default.Equals(this._subscribers, other._subscribers) &&
|
||||
EqualityComparer<Action>.Default.Equals(this._subscription, other._subscription);
|
||||
}
|
||||
|
||||
public override int GetHashCode()
|
||||
{
|
||||
return HashCode.Combine(this._subscribers, this._subscription);
|
||||
}
|
||||
|
||||
public static bool operator ==(MessageSubscription left, MessageSubscription right)
|
||||
{
|
||||
return left.Equals(right);
|
||||
}
|
||||
|
||||
public static bool operator !=(MessageSubscription left, MessageSubscription right)
|
||||
{
|
||||
return !(left == right);
|
||||
}
|
||||
}
|
||||
|
||||
public readonly struct ResponseUpdateSubscription : IDisposable, IEquatable<ResponseUpdateSubscription>
|
||||
{
|
||||
private readonly List<Action> _subscribers;
|
||||
private readonly Action _subscription;
|
||||
|
||||
public ResponseUpdateSubscription(List<Action> subscribers, Action subscription)
|
||||
{
|
||||
this._subscribers = subscribers;
|
||||
this._subscription = subscription;
|
||||
this._subscribers.Add(this._subscription);
|
||||
}
|
||||
|
||||
public void Dispose() => this._subscribers.Remove(this._subscription);
|
||||
|
||||
public override bool Equals(object? obj)
|
||||
{
|
||||
return obj is ResponseUpdateSubscription subscription && this.Equals(subscription);
|
||||
}
|
||||
|
||||
public bool Equals(ResponseUpdateSubscription other)
|
||||
{
|
||||
return EqualityComparer<List<Action>>.Default.Equals(this._subscribers, other._subscribers) &&
|
||||
EqualityComparer<Action>.Default.Equals(this._subscription, other._subscription);
|
||||
}
|
||||
|
||||
public override int GetHashCode()
|
||||
{
|
||||
return HashCode.Combine(this._subscribers, this._subscription);
|
||||
}
|
||||
|
||||
public static bool operator ==(ResponseUpdateSubscription left, ResponseUpdateSubscription right)
|
||||
{
|
||||
return left.Equals(right);
|
||||
}
|
||||
|
||||
public static bool operator !=(ResponseUpdateSubscription left, ResponseUpdateSubscription right)
|
||||
{
|
||||
return !(left == right);
|
||||
}
|
||||
}
|
||||
|
||||
public readonly struct RunStatusSubscription : IDisposable, IEquatable<RunStatusSubscription>
|
||||
{
|
||||
private readonly List<Action> _subscribers;
|
||||
private readonly Action _subscription;
|
||||
|
||||
public RunStatusSubscription(List<Action> subscribers, Action subscription)
|
||||
{
|
||||
this._subscribers = subscribers;
|
||||
this._subscription = subscription;
|
||||
this._subscribers.Add(this._subscription);
|
||||
}
|
||||
|
||||
public void Dispose() => this._subscribers.Remove(this._subscription);
|
||||
|
||||
public override bool Equals(object? obj)
|
||||
{
|
||||
return obj is RunStatusSubscription subscription && this.Equals(subscription);
|
||||
}
|
||||
|
||||
public bool Equals(RunStatusSubscription other)
|
||||
{
|
||||
return EqualityComparer<List<Action>>.Default.Equals(this._subscribers, other._subscribers) &&
|
||||
EqualityComparer<Action>.Default.Equals(this._subscription, other._subscription);
|
||||
}
|
||||
|
||||
public override int GetHashCode()
|
||||
{
|
||||
return HashCode.Combine(this._subscribers, this._subscription);
|
||||
}
|
||||
|
||||
public static bool operator ==(RunStatusSubscription left, RunStatusSubscription right)
|
||||
{
|
||||
return left.Equals(right);
|
||||
}
|
||||
|
||||
public static bool operator !=(RunStatusSubscription left, RunStatusSubscription right)
|
||||
{
|
||||
return !(left == right);
|
||||
}
|
||||
}
|
||||
@@ -1,94 +0,0 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using Microsoft.Extensions.AI;
|
||||
|
||||
namespace Microsoft.AspNetCore.Components.AI;
|
||||
|
||||
public sealed partial class AgentInput : IComponent, IDisposable
|
||||
{
|
||||
private RenderHandle _renderHandle;
|
||||
private AgentBoundaryContext<object?>? _context;
|
||||
private string? _inputText;
|
||||
private RunStatusSubscription? _subscription;
|
||||
|
||||
[CascadingParameter] public AgentBoundaryContext<object?>? AgentContext { get; set; }
|
||||
|
||||
[Parameter] public string Placeholder { get; set; } = "Type a message...";
|
||||
|
||||
public void Attach(RenderHandle renderHandle)
|
||||
{
|
||||
this._renderHandle = renderHandle;
|
||||
}
|
||||
|
||||
public Task SetParametersAsync(ParameterView parameters)
|
||||
{
|
||||
parameters.SetParameterProperties(this);
|
||||
|
||||
// Unsubscribe from previous context if it changed
|
||||
if (this._context != this.AgentContext)
|
||||
{
|
||||
this._subscription?.Dispose();
|
||||
this._context = this.AgentContext;
|
||||
|
||||
// Subscribe to run status changes
|
||||
if (this._context != null)
|
||||
{
|
||||
this._subscription = this._context.SubscribeToRunStatusChanges(this.OnRunStatusChanged);
|
||||
}
|
||||
}
|
||||
|
||||
this.Render();
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
private void OnRunStatusChanged()
|
||||
{
|
||||
this.Render();
|
||||
}
|
||||
|
||||
private void Render()
|
||||
{
|
||||
var isProcessing = this._context?.IsProcessing ?? false;
|
||||
var isDisabled = string.IsNullOrWhiteSpace(this._inputText) || isProcessing;
|
||||
|
||||
this._renderHandle.Render(builder =>
|
||||
{
|
||||
builder.OpenElement(0, "div");
|
||||
builder.AddAttribute(1, "class", "agent-input");
|
||||
|
||||
builder.OpenElement(2, "textarea");
|
||||
builder.AddAttribute(3, "value", this._inputText);
|
||||
builder.AddAttribute(4, "oninput", EventCallback.Factory.Create<ChangeEventArgs>(this, e => { this._inputText = e.Value?.ToString(); this.Render(); }));
|
||||
builder.AddAttribute(5, "placeholder", this.Placeholder);
|
||||
builder.AddAttribute(6, "rows", "1");
|
||||
builder.AddAttribute(7, "disabled", isProcessing);
|
||||
builder.CloseElement();
|
||||
|
||||
builder.OpenElement(8, "button");
|
||||
builder.AddAttribute(9, "class", "send-button");
|
||||
builder.AddAttribute(10, "onclick", EventCallback.Factory.Create(this, this.SendAsync));
|
||||
builder.AddAttribute(11, "disabled", isDisabled);
|
||||
builder.AddMarkupContent(12, """<svg width="20" height="20" viewBox="0 0 24 24" fill="currentColor"><path d="M2.01 21L23 12 2.01 3 2 10l15 2-15 2z"/></svg>""");
|
||||
builder.CloseElement(); // close button
|
||||
|
||||
builder.CloseElement(); // close div
|
||||
});
|
||||
}
|
||||
|
||||
private async Task SendAsync()
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(this._inputText) && this._context is { IsProcessing: false })
|
||||
{
|
||||
var text = this._inputText;
|
||||
this._inputText = ""; // Clear input immediately
|
||||
this.Render(); // Re-render to clear input
|
||||
|
||||
await this._context.SendAsync(new ChatMessage(ChatRole.User, text));
|
||||
}
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
this._subscription?.Dispose();
|
||||
}
|
||||
}
|
||||
@@ -1,72 +0,0 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
namespace Microsoft.AspNetCore.Components.AI;
|
||||
|
||||
/// <summary>
|
||||
/// A component that displays a loading indicator (three animated dots) when the agent is processing.
|
||||
/// </summary>
|
||||
public sealed partial class AgentLoadingIndicator : IComponent, IDisposable
|
||||
{
|
||||
private RenderHandle _renderHandle;
|
||||
private AgentBoundaryContext<object?>? _context;
|
||||
private RunStatusSubscription? _subscription;
|
||||
|
||||
[CascadingParameter] public AgentBoundaryContext<object?>? AgentContext { get; set; }
|
||||
|
||||
public void Attach(RenderHandle renderHandle)
|
||||
{
|
||||
this._renderHandle = renderHandle;
|
||||
}
|
||||
|
||||
public Task SetParametersAsync(ParameterView parameters)
|
||||
{
|
||||
parameters.SetParameterProperties(this);
|
||||
|
||||
// Unsubscribe from previous context if it changed
|
||||
if (this._context != this.AgentContext)
|
||||
{
|
||||
this._subscription?.Dispose();
|
||||
this._context = this.AgentContext;
|
||||
|
||||
// Subscribe to run status changes
|
||||
if (this._context != null)
|
||||
{
|
||||
this._subscription = this._context.SubscribeToRunStatusChanges(this.OnRunStatusChanged);
|
||||
}
|
||||
}
|
||||
|
||||
this.Render();
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
private void OnRunStatusChanged()
|
||||
{
|
||||
this.Render();
|
||||
}
|
||||
|
||||
private void Render()
|
||||
{
|
||||
var isProcessing = this._context?.IsProcessing ?? false;
|
||||
|
||||
this._renderHandle.Render(builder =>
|
||||
{
|
||||
if (!isProcessing)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
builder.OpenElement(0, "div");
|
||||
builder.AddAttribute(1, "class", "agent-loading-indicator");
|
||||
builder.OpenElement(2, "div");
|
||||
builder.AddAttribute(3, "class", "agent-loading-dots");
|
||||
builder.AddMarkupContent(4, "<span></span><span></span><span></span>");
|
||||
builder.CloseElement();
|
||||
builder.CloseElement();
|
||||
});
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
this._subscription?.Dispose();
|
||||
}
|
||||
}
|
||||
@@ -1,93 +0,0 @@
|
||||
@* Copyright (c) Microsoft. All rights reserved. *@
|
||||
@namespace Microsoft.AspNetCore.Components.AI
|
||||
@using Microsoft.Extensions.AI
|
||||
@typeparam TState
|
||||
@implements IDisposable
|
||||
|
||||
<CascadingValue Value="@CurrentState" IsFixed="false">
|
||||
@ChildContent
|
||||
</CascadingValue>
|
||||
|
||||
@code {
|
||||
private ResponseUpdateSubscription? _subscription;
|
||||
|
||||
/// <summary>
|
||||
/// The agent boundary context to subscribe to for state updates.
|
||||
/// </summary>
|
||||
[CascadingParameter]
|
||||
public IAgentBoundaryContext? BoundaryContext { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// The current state value. Can be set initially and will be updated by state events.
|
||||
/// </summary>
|
||||
[Parameter]
|
||||
public TState? CurrentState { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Callback to deserialize a STATE_SNAPSHOT (application/json) into TState.
|
||||
/// </summary>
|
||||
[Parameter]
|
||||
public Func<ReadOnlyMemory<byte>, TState?>? OnSnapshot { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Callback to apply a STATE_DELTA (application/json-patch+json) to the current state.
|
||||
/// Returns the updated state.
|
||||
/// </summary>
|
||||
[Parameter]
|
||||
public Func<TState?, ReadOnlyMemory<byte>, TState?>? OnDelta { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Optional callback invoked whenever state changes.
|
||||
/// </summary>
|
||||
[Parameter]
|
||||
public EventCallback<TState?> CurrentStateChanged { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Child content that will receive the cascaded state.
|
||||
/// </summary>
|
||||
[Parameter]
|
||||
public RenderFragment? ChildContent { get; set; }
|
||||
|
||||
protected override void OnInitialized()
|
||||
{
|
||||
if (BoundaryContext is not null)
|
||||
{
|
||||
_subscription = BoundaryContext.SubscribeToResponseUpdates(OnResponseUpdate);
|
||||
}
|
||||
}
|
||||
|
||||
private void OnResponseUpdate()
|
||||
{
|
||||
var update = BoundaryContext?.CurrentUpdate;
|
||||
if (update?.Contents is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
foreach (var content in update.Contents)
|
||||
{
|
||||
if (content is DataContent dataContent)
|
||||
{
|
||||
if (string.Equals(dataContent.MediaType, "application/json", StringComparison.OrdinalIgnoreCase) && OnSnapshot is not null)
|
||||
{
|
||||
// STATE_SNAPSHOT - let app deserialize
|
||||
CurrentState = OnSnapshot(dataContent.Data);
|
||||
_ = CurrentStateChanged.InvokeAsync(CurrentState);
|
||||
InvokeAsync(StateHasChanged);
|
||||
}
|
||||
else if (string.Equals(dataContent.MediaType, "application/json-patch+json", StringComparison.OrdinalIgnoreCase) && OnDelta is not null)
|
||||
{
|
||||
// STATE_DELTA - let app apply the patch
|
||||
CurrentState = OnDelta(CurrentState, dataContent.Data);
|
||||
_ = CurrentStateChanged.InvokeAsync(CurrentState);
|
||||
InvokeAsync(StateHasChanged);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
_subscription?.Dispose();
|
||||
}
|
||||
}
|
||||
@@ -1,148 +0,0 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using Microsoft.Extensions.AI;
|
||||
|
||||
namespace Microsoft.AspNetCore.Components.AI;
|
||||
|
||||
/// <summary>
|
||||
/// Represents a suggestion that can be displayed and sent to the agent.
|
||||
/// </summary>
|
||||
public readonly struct Suggestion : IEquatable<Suggestion>
|
||||
{
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="Suggestion"/> struct.
|
||||
/// </summary>
|
||||
/// <param name="text">The display text for the suggestion.</param>
|
||||
/// <param name="message">The message to send when the suggestion is selected.</param>
|
||||
public Suggestion(string text, ChatMessage message)
|
||||
{
|
||||
this.Text = text;
|
||||
this.Message = message;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="Suggestion"/> struct with a simple text message.
|
||||
/// </summary>
|
||||
/// <param name="text">The display text for the suggestion, also used as the message content.</param>
|
||||
public Suggestion(string text)
|
||||
{
|
||||
this.Text = text;
|
||||
this.Message = new ChatMessage(ChatRole.User, text);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the display text for the suggestion.
|
||||
/// </summary>
|
||||
public string Text { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the message to send when the suggestion is selected.
|
||||
/// </summary>
|
||||
public ChatMessage Message { get; }
|
||||
|
||||
/// <inheritdoc/>
|
||||
public override bool Equals(object? obj) => obj is Suggestion other && this.Equals(other);
|
||||
|
||||
/// <inheritdoc/>
|
||||
public bool Equals(Suggestion other) => this.Text == other.Text;
|
||||
|
||||
/// <inheritdoc/>
|
||||
public override int GetHashCode() => this.Text?.GetHashCode() ?? 0;
|
||||
|
||||
/// <summary>
|
||||
/// Determines whether two <see cref="Suggestion"/> instances are equal.
|
||||
/// </summary>
|
||||
public static bool operator ==(Suggestion left, Suggestion right) => left.Equals(right);
|
||||
|
||||
/// <summary>
|
||||
/// Determines whether two <see cref="Suggestion"/> instances are not equal.
|
||||
/// </summary>
|
||||
public static bool operator !=(Suggestion left, Suggestion right) => !left.Equals(right);
|
||||
}
|
||||
|
||||
public sealed partial class AgentSuggestions : IComponent, IDisposable
|
||||
{
|
||||
private RenderHandle _renderHandle;
|
||||
private AgentBoundaryContext<object?>? _context;
|
||||
private IReadOnlyList<Suggestion>? _suggestions;
|
||||
private RunStatusSubscription? _subscription;
|
||||
|
||||
[CascadingParameter] public AgentBoundaryContext<object?>? AgentContext { get; set; }
|
||||
|
||||
[Parameter] public IReadOnlyList<Suggestion>? Suggestions { get; set; }
|
||||
|
||||
public void Attach(RenderHandle renderHandle)
|
||||
{
|
||||
this._renderHandle = renderHandle;
|
||||
}
|
||||
|
||||
public Task SetParametersAsync(ParameterView parameters)
|
||||
{
|
||||
parameters.SetParameterProperties(this);
|
||||
|
||||
// Unsubscribe from previous context if it changed
|
||||
if (this._context != this.AgentContext)
|
||||
{
|
||||
this._subscription?.Dispose();
|
||||
this._context = this.AgentContext;
|
||||
|
||||
// Subscribe to run status changes
|
||||
if (this._context != null)
|
||||
{
|
||||
this._subscription = this._context.SubscribeToRunStatusChanges(this.OnRunStatusChanged);
|
||||
}
|
||||
}
|
||||
|
||||
this._suggestions = this.Suggestions;
|
||||
this.Render();
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
private void OnRunStatusChanged()
|
||||
{
|
||||
this.Render();
|
||||
}
|
||||
|
||||
private void Render()
|
||||
{
|
||||
var isProcessing = this._context?.IsProcessing ?? false;
|
||||
|
||||
this._renderHandle.Render(builder =>
|
||||
{
|
||||
if (this._suggestions is null || this._suggestions.Count == 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
builder.OpenElement(0, "div");
|
||||
builder.AddAttribute(1, "class", "agent-suggestions");
|
||||
|
||||
for (var i = 0; i < this._suggestions.Count; i++)
|
||||
{
|
||||
var suggestion = this._suggestions[i];
|
||||
builder.OpenElement(2, "button");
|
||||
builder.SetKey(suggestion.Text);
|
||||
builder.AddAttribute(3, "class", "suggestion-button");
|
||||
builder.AddAttribute(4, "onclick", EventCallback.Factory.Create(this, () => this.SelectSuggestionAsync(suggestion)));
|
||||
builder.AddAttribute(5, "disabled", isProcessing);
|
||||
builder.AddContent(6, suggestion.Text);
|
||||
builder.CloseElement();
|
||||
}
|
||||
|
||||
builder.CloseElement();
|
||||
});
|
||||
}
|
||||
|
||||
private async Task SelectSuggestionAsync(Suggestion suggestion)
|
||||
{
|
||||
if (this._context is { IsProcessing: false })
|
||||
{
|
||||
await this._context.SendAsync(suggestion.Message);
|
||||
}
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
this._subscription?.Dispose();
|
||||
}
|
||||
}
|
||||
@@ -1,33 +0,0 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using Microsoft.AspNetCore.Components.Rendering;
|
||||
|
||||
namespace Microsoft.AspNetCore.Components.AI;
|
||||
|
||||
#pragma warning disable CA1812 // Internal class is apparently never instantiated
|
||||
internal sealed class ContentBlock : IComponent
|
||||
#pragma warning restore CA1812 // Internal class is apparently never instantiated
|
||||
{
|
||||
private RenderHandle _renderHandle;
|
||||
|
||||
[Parameter] public RenderFragment ChildContent { get; set; } = default!;
|
||||
|
||||
public void Attach(RenderHandle renderHandle)
|
||||
{
|
||||
this._renderHandle = renderHandle;
|
||||
}
|
||||
|
||||
public Task SetParametersAsync(ParameterView parameters)
|
||||
{
|
||||
parameters.SetParameterProperties(this);
|
||||
// Always render when parameters are set - the parent component (MessageList)
|
||||
// only triggers renders when there are actual updates to show.
|
||||
this._renderHandle.Render(this.Render);
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
private void Render(RenderTreeBuilder builder)
|
||||
{
|
||||
builder.AddContent(0, this.ChildContent);
|
||||
}
|
||||
}
|
||||
-10
@@ -1,10 +0,0 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using Microsoft.Extensions.AI;
|
||||
|
||||
namespace Microsoft.AspNetCore.Components.AI;
|
||||
|
||||
public class ContentContext(AIContent content)
|
||||
{
|
||||
public AIContent Content { get; init; } = content;
|
||||
}
|
||||
-14
@@ -1,14 +0,0 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
namespace Microsoft.AspNetCore.Components.AI;
|
||||
|
||||
public abstract class ContentTemplateBase : IComponent
|
||||
{
|
||||
public abstract void Attach(RenderHandle renderHandle);
|
||||
|
||||
public abstract Task SetParametersAsync(ParameterView parameters);
|
||||
|
||||
public virtual bool When(ContentContext context) => true;
|
||||
|
||||
[Parameter] public RenderFragment<ContentContext> ChildContent { get; set; } = (content) => builder => { };
|
||||
}
|
||||
-36
@@ -1,36 +0,0 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using Microsoft.Extensions.AI;
|
||||
|
||||
namespace Microsoft.AspNetCore.Components.AI;
|
||||
|
||||
/// <summary>
|
||||
/// Default template for rendering DataContent. Renders nothing by default
|
||||
/// as DataContent typically contains binary/JSON data not meant for display.
|
||||
/// </summary>
|
||||
public class DataContentTemplate : ContentTemplateBase
|
||||
{
|
||||
[CascadingParameter] internal MessageListContext Context { get; set; } = default!;
|
||||
|
||||
public override void Attach(RenderHandle renderHandle)
|
||||
{
|
||||
this.ChildContent = this.RenderData;
|
||||
}
|
||||
|
||||
public override Task SetParametersAsync(ParameterView parameters)
|
||||
{
|
||||
parameters.SetParameterProperties(this);
|
||||
this.Context.RegisterContentTemplate(this);
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public override bool When(ContentContext context)
|
||||
{
|
||||
return context.Content is DataContent;
|
||||
}
|
||||
|
||||
private RenderFragment RenderData(ContentContext content) => builder =>
|
||||
{
|
||||
// By default, render nothing.
|
||||
};
|
||||
}
|
||||
-43
@@ -1,43 +0,0 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using Microsoft.AspNetCore.Components;
|
||||
using Microsoft.Extensions.AI;
|
||||
|
||||
namespace Microsoft.AspNetCore.Components.AI;
|
||||
|
||||
/// <summary>
|
||||
/// Default template for rendering ErrorContent. Renders nothing by default
|
||||
/// to prevent the error from crashing the UI. More specific templates can
|
||||
/// override this to display error messages.
|
||||
/// </summary>
|
||||
public class ErrorTemplate : ContentTemplateBase
|
||||
{
|
||||
[CascadingParameter] internal MessageListContext Context { get; set; } = default!;
|
||||
|
||||
public override void Attach(RenderHandle renderHandle)
|
||||
{
|
||||
this.ChildContent = this.RenderError;
|
||||
}
|
||||
|
||||
public override Task SetParametersAsync(ParameterView parameters)
|
||||
{
|
||||
parameters.SetParameterProperties(this);
|
||||
this.Context.RegisterContentTemplate(this);
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Determines if this template should handle the given content.
|
||||
/// Matches ErrorContent.
|
||||
/// </summary>
|
||||
public override bool When(ContentContext context)
|
||||
{
|
||||
return context.Content is ErrorContent;
|
||||
}
|
||||
|
||||
private RenderFragment RenderError(ContentContext content) => builder =>
|
||||
{
|
||||
// By default, render nothing.
|
||||
// Specific templates can override to display error messages.
|
||||
};
|
||||
}
|
||||
-60
@@ -1,60 +0,0 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using Microsoft.Extensions.AI;
|
||||
|
||||
namespace Microsoft.AspNetCore.Components.AI;
|
||||
|
||||
/// <summary>
|
||||
/// Template for rendering function/tool call content in messages.
|
||||
/// Provides access to both the call and its result (when available) via InvocationContext.
|
||||
/// </summary>
|
||||
public class FunctionCallTemplate : ContentTemplateBase
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets or sets the tool name to filter on. If null, matches all function calls.
|
||||
/// </summary>
|
||||
[Parameter] public string? ToolName { get; set; }
|
||||
|
||||
[CascadingParameter] internal MessageListContext Context { get; set; } = default!;
|
||||
|
||||
public override void Attach(RenderHandle renderHandle)
|
||||
{
|
||||
// This component never renders anything by itself.
|
||||
this.ChildContent = this.RenderFunctionCall;
|
||||
}
|
||||
|
||||
public override Task SetParametersAsync(ParameterView parameters)
|
||||
{
|
||||
parameters.SetParameterProperties(this);
|
||||
this.Context.RegisterContentTemplate(this);
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Determines if this template should handle the given content.
|
||||
/// </summary>
|
||||
/// <param name="context">The content context.</param>
|
||||
/// <returns>True if this template should render the content.</returns>
|
||||
public override bool When(ContentContext context)
|
||||
{
|
||||
if (context.Content is not FunctionCallContent call)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
// Filter by tool name if specified
|
||||
if (this.ToolName != null && !string.Equals(call.Name, this.ToolName, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private RenderFragment RenderFunctionCall(ContentContext content) => builder =>
|
||||
{
|
||||
// By default, function calls are not rendered visually.
|
||||
// Custom templates (like WeatherCallTemplate) can override this
|
||||
// behavior for specific functions by registering before this template.
|
||||
};
|
||||
}
|
||||
-39
@@ -1,39 +0,0 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using Microsoft.Extensions.AI;
|
||||
|
||||
namespace Microsoft.AspNetCore.Components.AI;
|
||||
|
||||
/// <summary>
|
||||
/// Default template for FunctionResultContent that renders nothing.
|
||||
/// Function results are internal tool responses and typically don't need
|
||||
/// visual representation in the chat UI.
|
||||
/// </summary>
|
||||
public class FunctionResultTemplate : ContentTemplateBase
|
||||
{
|
||||
[CascadingParameter] internal MessageListContext Context { get; set; } = default!;
|
||||
|
||||
public override void Attach(RenderHandle renderHandle)
|
||||
{
|
||||
// This component never renders anything by itself.
|
||||
this.ChildContent = this.RenderFunctionResult;
|
||||
}
|
||||
|
||||
public override Task SetParametersAsync(ParameterView parameters)
|
||||
{
|
||||
parameters.SetParameterProperties(this);
|
||||
this.Context.RegisterContentTemplate(this);
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Only match FunctionResultContent.
|
||||
/// </summary>
|
||||
public override bool When(ContentContext context) => context.Content is FunctionResultContent;
|
||||
|
||||
private RenderFragment RenderFunctionResult(ContentContext content) => builder =>
|
||||
{
|
||||
// By default, function results are not rendered visually.
|
||||
// The result data is typically processed by the agent to generate text responses.
|
||||
};
|
||||
}
|
||||
-135
@@ -1,135 +0,0 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System.Text.Json;
|
||||
using Microsoft.Extensions.AI;
|
||||
|
||||
namespace Microsoft.AspNetCore.Components.AI;
|
||||
|
||||
/// <summary>
|
||||
/// Context for a function invocation, tracking the call and its result.
|
||||
/// </summary>
|
||||
public class InvocationContext
|
||||
{
|
||||
private Action? _resultArrived;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="InvocationContext"/> class.
|
||||
/// </summary>
|
||||
/// <param name="call">The function call content.</param>
|
||||
public InvocationContext(FunctionCallContent call)
|
||||
{
|
||||
this.Call = call;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the function call content.
|
||||
/// </summary>
|
||||
public FunctionCallContent Call { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the function result content, if available.
|
||||
/// </summary>
|
||||
public FunctionResultContent? ResultContent { get; private set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets a value indicating whether the result has arrived.
|
||||
/// </summary>
|
||||
public bool HasResult => this.ResultContent != null;
|
||||
|
||||
/// <summary>
|
||||
/// Gets the function name from the call.
|
||||
/// </summary>
|
||||
public string FunctionName => this.Call.Name;
|
||||
|
||||
/// <summary>
|
||||
/// Gets the call ID.
|
||||
/// </summary>
|
||||
public string CallId => this.Call.CallId;
|
||||
|
||||
/// <summary>
|
||||
/// Gets the arguments from the call.
|
||||
/// </summary>
|
||||
public IDictionary<string, object?>? Arguments => this.Call.Arguments;
|
||||
|
||||
/// <summary>
|
||||
/// Event raised when the result arrives.
|
||||
/// </summary>
|
||||
#pragma warning disable CA1003 // Use generic event handler instances
|
||||
public event Action? ResultArrived
|
||||
#pragma warning restore CA1003 // Use generic event handler instances
|
||||
{
|
||||
add => this._resultArrived += value;
|
||||
remove => this._resultArrived -= value;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Sets the result and raises the ResultArrived event.
|
||||
/// </summary>
|
||||
/// <param name="result">The function result content.</param>
|
||||
internal void SetResult(FunctionResultContent result)
|
||||
{
|
||||
this.ResultContent = result;
|
||||
this._resultArrived?.Invoke();
|
||||
this._resultArrived = null; // Clear invocation list after firing
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets an argument value by name, deserializing from JSON if necessary.
|
||||
/// </summary>
|
||||
/// <typeparam name="T">The type to deserialize to.</typeparam>
|
||||
/// <param name="name">The argument name.</param>
|
||||
/// <returns>The argument value, or default if not found.</returns>
|
||||
public T? GetArgument<T>(string name)
|
||||
{
|
||||
if (this.Arguments is null || !this.Arguments.TryGetValue(name, out var value))
|
||||
{
|
||||
return default;
|
||||
}
|
||||
|
||||
if (value is null)
|
||||
{
|
||||
return default;
|
||||
}
|
||||
|
||||
if (value is T typed)
|
||||
{
|
||||
return typed;
|
||||
}
|
||||
|
||||
if (value is JsonElement jsonElement)
|
||||
{
|
||||
return jsonElement.Deserialize<T>();
|
||||
}
|
||||
|
||||
// Try to convert via JSON serialization
|
||||
var json = JsonSerializer.Serialize(value);
|
||||
return JsonSerializer.Deserialize<T>(json);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the result as a specific type, deserializing from JSON if necessary.
|
||||
/// </summary>
|
||||
/// <typeparam name="T">The type to deserialize to.</typeparam>
|
||||
/// <returns>The result value, or default if not available.</returns>
|
||||
public T? GetResult<T>()
|
||||
{
|
||||
if (this.ResultContent?.Result is null)
|
||||
{
|
||||
return default;
|
||||
}
|
||||
|
||||
if (this.ResultContent.Result is T typed)
|
||||
{
|
||||
return typed;
|
||||
}
|
||||
|
||||
if (this.ResultContent.Result is JsonElement jsonElement)
|
||||
{
|
||||
return jsonElement.Deserialize<T>();
|
||||
}
|
||||
|
||||
// Try to convert via JSON serialization
|
||||
var json = JsonSerializer.Serialize(this.ResultContent.Result);
|
||||
return JsonSerializer.Deserialize<T>(json);
|
||||
}
|
||||
}
|
||||
-37
@@ -1,37 +0,0 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using Microsoft.Extensions.AI;
|
||||
|
||||
namespace Microsoft.AspNetCore.Components.AI;
|
||||
|
||||
public class TextTemplate : ContentTemplateBase
|
||||
{
|
||||
[CascadingParameter] internal MessageListContext Context { get; set; } = default!;
|
||||
|
||||
public override void Attach(RenderHandle renderHandle)
|
||||
{
|
||||
// This component never renders anything by itself.
|
||||
this.ChildContent = this.RenderText;
|
||||
}
|
||||
|
||||
public override Task SetParametersAsync(ParameterView parameters)
|
||||
{
|
||||
parameters.SetParameterProperties(this);
|
||||
this.Context.RegisterContentTemplate(this);
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Only match TextContent, allowing other content templates to handle
|
||||
/// FunctionCallContent, FunctionResultContent, etc.
|
||||
/// </summary>
|
||||
public override bool When(ContentContext context) => context.Content is TextContent;
|
||||
|
||||
private RenderFragment RenderText(ContentContext content) => builder =>
|
||||
{
|
||||
if (content.Content is TextContent textContent)
|
||||
{
|
||||
builder.AddContent(0, textContent.Text);
|
||||
}
|
||||
};
|
||||
}
|
||||
-116
@@ -1,116 +0,0 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using Microsoft.Extensions.AI;
|
||||
|
||||
namespace Microsoft.AspNetCore.Components.AI;
|
||||
|
||||
#pragma warning disable CA1812 // Internal class is apparently never instantiated
|
||||
internal sealed class DefaultMessageTemplate : MessageTemplateBase
|
||||
#pragma warning restore CA1812 // Internal class is apparently never instantiated
|
||||
{
|
||||
public override bool When(MessageContext context) => true;
|
||||
|
||||
// Buffer to convert response updates to chat messages.
|
||||
private readonly List<ChatMessage> _buffer = [];
|
||||
|
||||
public DefaultMessageTemplate()
|
||||
{
|
||||
this.ChildContent = this.SelectTemplate;
|
||||
}
|
||||
|
||||
private RenderFragment SelectTemplate(MessageContext messageContext)
|
||||
{
|
||||
if (messageContext.ChatMessage is not null)
|
||||
{
|
||||
var getRenderContents = messageContext.RenderContents();
|
||||
// Return a render fragment that checks visibility at render time
|
||||
// This is important because message contents may change during streaming
|
||||
return CreateRenderMessage(
|
||||
messageContext.ChatMessage,
|
||||
getRenderContents);
|
||||
}
|
||||
else
|
||||
{
|
||||
var getRenderContents = messageContext.RenderContents();
|
||||
var updates = messageContext.ResponseUpdates ?? [];
|
||||
if (updates.Count == 0)
|
||||
{
|
||||
throw new InvalidOperationException("MessageContext must have either a ChatMessage or at least one ResponseUpdate.");
|
||||
}
|
||||
this._buffer.Clear();
|
||||
this._buffer.AddMessages(updates);
|
||||
if (this._buffer.Count != 1)
|
||||
{
|
||||
throw new InvalidOperationException("DefaultMessageTemplate only supports a single ResponseUpdate.");
|
||||
}
|
||||
|
||||
return CreateRenderMessage(
|
||||
this._buffer[0],
|
||||
getRenderContents);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Checks if a message has any visible content that should be displayed.
|
||||
/// Messages that only contain FunctionResultContent are internal tool messages
|
||||
/// and should not be rendered as chat bubbles.
|
||||
/// FunctionCallContent is considered visible because content templates can render them
|
||||
/// (e.g., WeatherCallTemplate renders weather tool calls as cards).
|
||||
/// </summary>
|
||||
private static bool HasVisibleContent(ChatMessage message)
|
||||
{
|
||||
if (message.Contents.Count == 0)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
foreach (var content in message.Contents)
|
||||
{
|
||||
// TextContent is visible
|
||||
if (content is TextContent textContent && !string.IsNullOrWhiteSpace(textContent.Text))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
// FunctionCallContent is visible - content templates can render them
|
||||
// (e.g., WeatherCallTemplate renders weather tool calls as cards)
|
||||
if (content is FunctionCallContent)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
// Other content types that are not function results are visible
|
||||
if (content is not FunctionResultContent)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private static RenderFragment CreateRenderMessage(
|
||||
ChatMessage message,
|
||||
RenderFragment getRenderContents)
|
||||
{
|
||||
var roleClass = $"{message.Role}-message";
|
||||
return builder =>
|
||||
{
|
||||
// Check visibility at render time, not at template creation time
|
||||
// This is important because message contents may change during streaming
|
||||
if (!HasVisibleContent(message))
|
||||
{
|
||||
return; // Don't render messages without visible content
|
||||
}
|
||||
|
||||
builder.OpenElement(0, "div");
|
||||
if (!string.IsNullOrEmpty(message.MessageId))
|
||||
{
|
||||
builder.AddAttribute(1, "id", message.MessageId);
|
||||
}
|
||||
builder.AddAttribute(2, "class", $"chat-message {roleClass}");
|
||||
builder.AddContent(3, getRenderContents);
|
||||
builder.CloseElement();
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -1,96 +0,0 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System.Diagnostics;
|
||||
using Microsoft.Extensions.AI;
|
||||
|
||||
namespace Microsoft.AspNetCore.Components.AI;
|
||||
|
||||
public class MessageContext
|
||||
{
|
||||
private readonly MessageListContext _messageListContext;
|
||||
private RenderFragment? _contentsRenderer;
|
||||
private MessageTemplateBase? _template;
|
||||
private readonly Dictionary<AIContent, RenderFragment> _contentRenderers = [];
|
||||
|
||||
internal MessageContext(ChatMessage message, MessageListContext messageListContext)
|
||||
{
|
||||
this.ChatMessage = message;
|
||||
this._messageListContext = messageListContext;
|
||||
}
|
||||
|
||||
public ChatMessage? ChatMessage { get; init; }
|
||||
|
||||
public IList<ChatResponseUpdate>? ResponseUpdates { get; init; }
|
||||
|
||||
public RenderFragment RenderContents() => this.GetOrCreateContentsRenderer();
|
||||
|
||||
internal void SetTemplate(MessageTemplateBase template)
|
||||
{
|
||||
this._template = template;
|
||||
}
|
||||
|
||||
private RenderFragment GetOrCreateContentsRenderer()
|
||||
{
|
||||
if (this._contentsRenderer != null)
|
||||
{
|
||||
return this._contentsRenderer;
|
||||
}
|
||||
|
||||
if (this._template == null)
|
||||
{
|
||||
throw new InvalidOperationException("Message template has not been set for this message context.");
|
||||
}
|
||||
|
||||
this._contentsRenderer = builder =>
|
||||
{
|
||||
if (this.ChatMessage != null)
|
||||
{
|
||||
for (int i = 0; i < this.ChatMessage.Contents.Count; i++)
|
||||
{
|
||||
var content = this.ChatMessage.Contents[i];
|
||||
builder.AddContent(0, this.ResolveContentRenderer(content));
|
||||
}
|
||||
}
|
||||
else if (this.ResponseUpdates != null)
|
||||
{
|
||||
for (int i = 0; i < this.ResponseUpdates.Count; i++)
|
||||
{
|
||||
var update = this.ResponseUpdates[i];
|
||||
for (int j = 0; j < update.Contents.Count; j++)
|
||||
{
|
||||
var content = update.Contents[j];
|
||||
if (this._contentRenderers.TryGetValue(content, out var contentRenderer))
|
||||
{
|
||||
builder.AddContent(0, contentRenderer);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
throw new InvalidOperationException("MessageContext must have either a ChatMessage or ResponseUpdates to render contents.");
|
||||
}
|
||||
};
|
||||
|
||||
return this._contentsRenderer;
|
||||
}
|
||||
|
||||
private RenderFragment ResolveContentRenderer(AIContent content)
|
||||
{
|
||||
if (this._contentRenderers.TryGetValue(content, out var contentRenderer))
|
||||
{
|
||||
return contentRenderer;
|
||||
}
|
||||
|
||||
Debug.Assert(this._template != null);
|
||||
contentRenderer = this._template.GetContentTemplate(content);
|
||||
if (contentRenderer != null)
|
||||
{
|
||||
this._contentRenderers[content] = contentRenderer;
|
||||
return contentRenderer;
|
||||
}
|
||||
contentRenderer = this._messageListContext.GetContentTemplate(content);
|
||||
this._contentRenderers[content] = contentRenderer;
|
||||
return contentRenderer;
|
||||
}
|
||||
}
|
||||
@@ -1,322 +0,0 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System.Diagnostics;
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
using System.Text;
|
||||
using Microsoft.Extensions.AI;
|
||||
|
||||
namespace Microsoft.AspNetCore.Components.AI;
|
||||
|
||||
// Roughly lifted from src/Libraries/Microsoft.Extensions.AI.Abstractions/ChatCompletion/ChatResponseExtensions.cs
|
||||
internal static class MessageHelpers
|
||||
{
|
||||
internal static void CoalesceContent(IList<AIContent> contents)
|
||||
{
|
||||
Coalesce<TextContent>(
|
||||
contents,
|
||||
mergeSingle: false,
|
||||
canMerge: null,
|
||||
static (contents, start, end) => new(MergeText(contents, start, end)) { AdditionalProperties = contents[start].AdditionalProperties?.Clone() });
|
||||
|
||||
Coalesce(
|
||||
contents,
|
||||
mergeSingle: false,
|
||||
canMerge: static (r1, r2) => string.IsNullOrEmpty(r1.ProtectedData), // we allow merging if the first item has no ProtectedData, even if the second does
|
||||
static (contents, start, end) =>
|
||||
{
|
||||
TextReasoningContent content = new(MergeText(contents, start, end))
|
||||
{
|
||||
AdditionalProperties = contents[start].AdditionalProperties?.Clone()
|
||||
};
|
||||
|
||||
#if DEBUG
|
||||
for (int i = start; i < end - 1; i++)
|
||||
{
|
||||
Debug.Assert(contents[i] is TextReasoningContent { ProtectedData: null }, "Expected all but the last to have a null ProtectedData");
|
||||
}
|
||||
#endif
|
||||
|
||||
if (((TextReasoningContent)contents[end - 1]).ProtectedData is { } protectedData)
|
||||
{
|
||||
content.ProtectedData = protectedData;
|
||||
}
|
||||
|
||||
return content;
|
||||
});
|
||||
|
||||
Coalesce(
|
||||
contents,
|
||||
mergeSingle: false,
|
||||
canMerge: static (r1, r2) => r1.MediaType == r2.MediaType && r1.HasTopLevelMediaType("text") && r1.Name == r2.Name,
|
||||
static (contents, start, end) =>
|
||||
{
|
||||
Debug.Assert(end - start > 1, "Expected multiple contents to merge");
|
||||
|
||||
MemoryStream ms = new();
|
||||
for (int i = start; i < end; i++)
|
||||
{
|
||||
var current = (DataContent)contents[i];
|
||||
#if NET
|
||||
ms.Write(current.Data.Span);
|
||||
#else
|
||||
if (!MemoryMarshal.TryGetArray(current.Data, out var segment))
|
||||
{
|
||||
segment = new(current.Data.ToArray());
|
||||
}
|
||||
|
||||
ms.Write(segment.Array!, segment.Offset, segment.Count);
|
||||
#endif
|
||||
}
|
||||
|
||||
var first = (DataContent)contents[start];
|
||||
return new DataContent(new ReadOnlyMemory<byte>(ms.GetBuffer(), 0, (int)ms.Length), first.MediaType) { Name = first.Name };
|
||||
});
|
||||
|
||||
Coalesce(
|
||||
contents,
|
||||
mergeSingle: true,
|
||||
canMerge: static (r1, r2) => r1.CallId == r2.CallId,
|
||||
static (contents, start, end) =>
|
||||
{
|
||||
var firstContent = (CodeInterpreterToolCallContent)contents[start];
|
||||
|
||||
if (start == end - 1)
|
||||
{
|
||||
if (firstContent.Inputs is not null)
|
||||
{
|
||||
CoalesceContent(firstContent.Inputs);
|
||||
}
|
||||
|
||||
return firstContent;
|
||||
}
|
||||
|
||||
List<AIContent>? inputs = null;
|
||||
|
||||
for (int i = start; i < end; i++)
|
||||
{
|
||||
(inputs ??= []).AddRange(((CodeInterpreterToolCallContent)contents[i]).Inputs ?? []);
|
||||
}
|
||||
|
||||
if (inputs is not null)
|
||||
{
|
||||
CoalesceContent(inputs);
|
||||
}
|
||||
|
||||
return new()
|
||||
{
|
||||
CallId = firstContent.CallId,
|
||||
Inputs = inputs,
|
||||
AdditionalProperties = firstContent.AdditionalProperties?.Clone(),
|
||||
};
|
||||
});
|
||||
|
||||
Coalesce(
|
||||
contents,
|
||||
mergeSingle: true,
|
||||
canMerge: static (r1, r2) => r1.CallId is not null && r2.CallId is not null && r1.CallId == r2.CallId,
|
||||
static (contents, start, end) =>
|
||||
{
|
||||
var firstContent = (CodeInterpreterToolResultContent)contents[start];
|
||||
|
||||
if (start == end - 1)
|
||||
{
|
||||
if (firstContent.Outputs is not null)
|
||||
{
|
||||
CoalesceContent(firstContent.Outputs);
|
||||
}
|
||||
|
||||
return firstContent;
|
||||
}
|
||||
|
||||
List<AIContent>? output = null;
|
||||
|
||||
for (int i = start; i < end; i++)
|
||||
{
|
||||
(output ??= []).AddRange(((CodeInterpreterToolResultContent)contents[i]).Outputs ?? []);
|
||||
}
|
||||
|
||||
if (output is not null)
|
||||
{
|
||||
CoalesceContent(output);
|
||||
}
|
||||
|
||||
return new()
|
||||
{
|
||||
CallId = firstContent.CallId,
|
||||
Outputs = output,
|
||||
AdditionalProperties = firstContent.AdditionalProperties?.Clone(),
|
||||
};
|
||||
});
|
||||
|
||||
static string MergeText(IList<AIContent> contents, int start, int end)
|
||||
{
|
||||
Debug.Assert(end - start > 1, "Expected multiple contents to merge");
|
||||
|
||||
StringBuilder sb = new();
|
||||
for (int i = start; i < end; i++)
|
||||
{
|
||||
_ = sb.Append(contents[i]);
|
||||
}
|
||||
|
||||
return sb.ToString();
|
||||
}
|
||||
|
||||
static void Coalesce<TContent>(
|
||||
IList<AIContent> contents,
|
||||
bool mergeSingle,
|
||||
Func<TContent, TContent, bool>? canMerge,
|
||||
Func<IList<AIContent>, int, int, TContent> merge)
|
||||
where TContent : AIContent
|
||||
{
|
||||
// Iterate through all of the items in the list looking for contiguous items that can be coalesced.
|
||||
int start = 0;
|
||||
while (start < contents.Count)
|
||||
{
|
||||
if (!TryAsCoalescable(contents[start], out var firstContent))
|
||||
{
|
||||
start++;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Iterate until we find a non-coalescable item.
|
||||
int i = start + 1;
|
||||
TContent prev = firstContent;
|
||||
while (i < contents.Count && TryAsCoalescable(contents[i], out TContent? next) && (canMerge is null || canMerge(prev, next)))
|
||||
{
|
||||
i++;
|
||||
prev = next;
|
||||
}
|
||||
|
||||
// If there's only one item in the run, and we don't want to merge single items, skip it.
|
||||
if (start == i - 1 && !mergeSingle)
|
||||
{
|
||||
start++;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Store the replacement node and null out all of the nodes that we coalesced.
|
||||
// We can then remove all coalesced nodes in one O(N) operation via RemoveAll.
|
||||
// Leave start positioned at the start of the next run.
|
||||
contents[start] = merge(contents, start, i);
|
||||
|
||||
start++;
|
||||
while (start < i)
|
||||
{
|
||||
contents[start++] = null!;
|
||||
}
|
||||
|
||||
static bool TryAsCoalescable(AIContent content, [NotNullWhen(true)] out TContent? coalescable)
|
||||
{
|
||||
if (content is TContent tmp && tmp.Annotations is not { Count: > 0 })
|
||||
{
|
||||
coalescable = tmp;
|
||||
return true;
|
||||
}
|
||||
|
||||
coalescable = null;
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// Remove all of the null slots left over from the coalescing process.
|
||||
RemoveNullContents(contents);
|
||||
}
|
||||
}
|
||||
|
||||
private static void RemoveNullContents<T>(IList<T> contents)
|
||||
where T : class
|
||||
{
|
||||
if (contents is List<AIContent> contentsList)
|
||||
{
|
||||
_ = contentsList.RemoveAll(u => u is null);
|
||||
}
|
||||
else
|
||||
{
|
||||
int nextSlot = 0;
|
||||
int contentsCount = contents.Count;
|
||||
for (int i = 0; i < contentsCount; i++)
|
||||
{
|
||||
if (contents[i] is { } content)
|
||||
{
|
||||
contents[nextSlot++] = content;
|
||||
}
|
||||
}
|
||||
|
||||
for (int i = contentsCount - 1; i >= nextSlot; i--)
|
||||
{
|
||||
contents.RemoveAt(i);
|
||||
}
|
||||
|
||||
Debug.Assert(nextSlot == contents.Count, "Expected final count to equal list length.");
|
||||
}
|
||||
}
|
||||
|
||||
internal static bool ProcessUpdate(ChatResponseUpdate update, List<ChatMessage> messages)
|
||||
{
|
||||
// If there is no message created yet, or if the last update we saw had a different
|
||||
// identifying parts, create a new message.
|
||||
bool isNewMessage = true;
|
||||
if (messages.Count != 0)
|
||||
{
|
||||
var lastMessage = messages[messages.Count - 1];
|
||||
isNewMessage =
|
||||
NotEmptyOrEqual(update.AuthorName, lastMessage.AuthorName) ||
|
||||
NotEmptyOrEqual(update.MessageId, lastMessage.MessageId) ||
|
||||
NotNullOrEqual(update.Role, lastMessage.Role);
|
||||
}
|
||||
|
||||
// Get the message to target, either a new one or the last ones.
|
||||
ChatMessage message;
|
||||
if (isNewMessage)
|
||||
{
|
||||
message = new(ChatRole.Assistant, []);
|
||||
messages.Add(message);
|
||||
}
|
||||
else
|
||||
{
|
||||
message = messages[messages.Count - 1];
|
||||
}
|
||||
|
||||
// Some members on ChatResponseUpdate map to members of ChatMessage.
|
||||
// Incorporate those into the latest message; in cases where the message
|
||||
// stores a single value, prefer the latest update's value over anything
|
||||
// stored in the message.
|
||||
|
||||
if (update.AuthorName is not null)
|
||||
{
|
||||
message.AuthorName = update.AuthorName;
|
||||
}
|
||||
|
||||
if (message.CreatedAt is null || (update.CreatedAt is not null && update.CreatedAt > message.CreatedAt))
|
||||
{
|
||||
message.CreatedAt = update.CreatedAt;
|
||||
}
|
||||
|
||||
if (update.Role is ChatRole role)
|
||||
{
|
||||
message.Role = role;
|
||||
}
|
||||
|
||||
if (update.MessageId is { Length: > 0 })
|
||||
{
|
||||
// Note that this must come after the message checks earlier, as they depend
|
||||
// on this value for change detection.
|
||||
message.MessageId = update.MessageId;
|
||||
}
|
||||
|
||||
foreach (var content in update.Contents)
|
||||
{
|
||||
message.Contents.Add(content);
|
||||
}
|
||||
|
||||
return isNewMessage;
|
||||
}
|
||||
|
||||
/// <summary>Gets whether both strings are not null/empty and not the same as each other.</summary>
|
||||
private static bool NotEmptyOrEqual(string? s1, string? s2) =>
|
||||
s1 is { Length: > 0 } str1 && s2 is { Length: > 0 } str2 && str1 != str2;
|
||||
|
||||
/// <summary>Gets whether two roles are not null and not the same as each other.</summary>
|
||||
private static bool NotNullOrEqual(ChatRole? r1, ChatRole? r2) =>
|
||||
r1.HasValue && r2.HasValue && r1.Value != r2.Value;
|
||||
}
|
||||
@@ -1,139 +0,0 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System.Linq;
|
||||
using Microsoft.AspNetCore.Components.Rendering;
|
||||
using Microsoft.Extensions.AI;
|
||||
|
||||
namespace Microsoft.AspNetCore.Components.AI;
|
||||
|
||||
#pragma warning disable CA1812 // Internal class is apparently never instantiated
|
||||
internal sealed partial class MessageList : IComponent, IDisposable
|
||||
#pragma warning restore CA1812 // Internal class is apparently never instantiated
|
||||
{
|
||||
private RenderHandle _renderHandle;
|
||||
private MessageSubscription _messageSubscription;
|
||||
private ResponseUpdateSubscription _responseSubscription;
|
||||
|
||||
[CascadingParameter] public MessageListContext MessageListContext { get; set; } = default!;
|
||||
|
||||
public void Attach(RenderHandle renderHandle)
|
||||
{
|
||||
this._renderHandle = renderHandle;
|
||||
}
|
||||
|
||||
public Task SetParametersAsync(ParameterView parameters)
|
||||
{
|
||||
var previousContext = this.MessageListContext;
|
||||
parameters.SetParameterProperties(this);
|
||||
|
||||
if (previousContext != null && this.MessageListContext != previousContext)
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
$"{nameof(MessageList)} does not support changing the {nameof(this.MessageListContext)} once it has been set.");
|
||||
}
|
||||
|
||||
// Subscribe to message updates and response updates for streaming
|
||||
this._messageSubscription = this.MessageListContext.AgentBoundaryContext.SubscribeToMessageChanges(this.ProcessUpdate);
|
||||
this._responseSubscription = this.MessageListContext.AgentBoundaryContext.SubscribeToResponseUpdates(this.ProcessUpdate);
|
||||
|
||||
// Initial render. This component will only render once since the only parameter is a cascading parameter and it's fixed.
|
||||
this._renderHandle.Render(this.Render);
|
||||
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
private void ProcessUpdate()
|
||||
{
|
||||
// Scan all messages for function calls and results to track invocations.
|
||||
// This allows us to associate results with their calls when results arrive.
|
||||
foreach (var message in this.MessageListContext.AgentBoundaryContext.CompletedMessages)
|
||||
{
|
||||
this.ProcessMessageContents(message);
|
||||
}
|
||||
|
||||
foreach (var message in this.MessageListContext.AgentBoundaryContext.PendingMessages)
|
||||
{
|
||||
this.ProcessMessageContents(message);
|
||||
}
|
||||
|
||||
this._renderHandle.Render(this.Render);
|
||||
}
|
||||
|
||||
private void ProcessMessageContents(ChatMessage message)
|
||||
{
|
||||
foreach (var content in message.Contents)
|
||||
{
|
||||
if (content is FunctionCallContent call)
|
||||
{
|
||||
this.MessageListContext.GetOrCreateInvocation(call);
|
||||
}
|
||||
else if (content is FunctionResultContent result)
|
||||
{
|
||||
this.MessageListContext.AssociateResult(result);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public void Render(RenderTreeBuilder builder)
|
||||
{
|
||||
// Track all render keys to detect duplicates
|
||||
var allRenderKeys = new HashSet<string?>();
|
||||
|
||||
// Open container div for message list with flex layout
|
||||
builder.OpenElement(0, "div");
|
||||
builder.AddAttribute(1, "class", "messages-container");
|
||||
|
||||
foreach (var message in this.MessageListContext.AgentBoundaryContext.CompletedMessages)
|
||||
{
|
||||
var renderKey = GetUniqueRenderKey(message);
|
||||
|
||||
// Calling GetTemplate will stop template collection on the first message if it
|
||||
// was still ongoing.
|
||||
builder.OpenComponent<ContentBlock>(2);
|
||||
builder.SetKey(renderKey);
|
||||
builder.AddComponentParameter(3, "ChildContent", this.MessageListContext.GetTemplate(message));
|
||||
builder.CloseComponent();
|
||||
}
|
||||
|
||||
foreach (var message in this.MessageListContext.AgentBoundaryContext.PendingMessages)
|
||||
{
|
||||
var renderKey = GetUniqueRenderKey(message);
|
||||
|
||||
builder.OpenComponent<ContentBlock>(4);
|
||||
builder.SetKey(renderKey);
|
||||
builder.AddComponentParameter(5, "ChildContent", this.MessageListContext.GetTemplate(message));
|
||||
builder.CloseComponent();
|
||||
}
|
||||
|
||||
// Close container div
|
||||
builder.CloseElement();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets a unique render key for a message.
|
||||
/// For tool result messages (role=tool), we use MessageId + CallId to ensure uniqueness
|
||||
/// because the AGUI protocol may reuse MessageId for multiple tool results in the same batch.
|
||||
/// </summary>
|
||||
private static string? GetUniqueRenderKey(ChatMessage message)
|
||||
{
|
||||
// For tool result messages, combine MessageId with CallId to ensure uniqueness
|
||||
// This works around a bug in the AGUI protocol where multiple tool results
|
||||
// can share the same MessageId when processed in the same update batch.
|
||||
if (message.Role == ChatRole.Tool)
|
||||
{
|
||||
var resultContent = message.Contents.OfType<FunctionResultContent>().FirstOrDefault();
|
||||
if (resultContent != null && !string.IsNullOrEmpty(resultContent.CallId))
|
||||
{
|
||||
return $"{message.MessageId}_{resultContent.CallId}";
|
||||
}
|
||||
}
|
||||
|
||||
return message.MessageId;
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
((IDisposable)this._messageSubscription).Dispose();
|
||||
((IDisposable)this._responseSubscription).Dispose();
|
||||
}
|
||||
}
|
||||
@@ -1,137 +0,0 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using Microsoft.Extensions.AI;
|
||||
|
||||
namespace Microsoft.AspNetCore.Components.AI;
|
||||
|
||||
public sealed partial class MessageListContext
|
||||
{
|
||||
private bool _collectingTemplates;
|
||||
|
||||
private readonly List<MessageTemplateBase> _templates = [];
|
||||
private readonly List<ContentTemplateBase> _contentTemplates = [];
|
||||
|
||||
// We compute a render fragment to render each message only once and cache it here.
|
||||
private readonly Dictionary<ChatMessage, RenderFragment> _templateCache = [];
|
||||
|
||||
// Track function invocations by CallId to associate calls with results.
|
||||
private readonly Dictionary<string, InvocationContext> _invocationMap = [];
|
||||
|
||||
public MessageListContext(IAgentBoundaryContext context)
|
||||
{
|
||||
this.AgentBoundaryContext = context;
|
||||
}
|
||||
|
||||
public IAgentBoundaryContext AgentBoundaryContext { get; }
|
||||
|
||||
public void BeginCollectingTemplates()
|
||||
{
|
||||
// This is triggered by the Messages component before rendering its children.
|
||||
// In this situation we are going to render again the MessageTemplates and
|
||||
// ContentTemplates and since we can't tell if they have changed we have to
|
||||
// recompute all the templates again.
|
||||
this._collectingTemplates = true;
|
||||
this._templates.Clear();
|
||||
this._contentTemplates.Clear();
|
||||
this._templateCache.Clear();
|
||||
}
|
||||
|
||||
public void RegisterTemplate(MessageTemplateBase template)
|
||||
{
|
||||
if (this._collectingTemplates)
|
||||
{
|
||||
this._templates.Add(template);
|
||||
}
|
||||
}
|
||||
|
||||
public void RegisterContentTemplate(ContentTemplateBase template)
|
||||
{
|
||||
if (this._collectingTemplates)
|
||||
{
|
||||
this._contentTemplates.Add(template);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets or creates an invocation context for the given function call.
|
||||
/// </summary>
|
||||
/// <param name="call">The function call content.</param>
|
||||
/// <returns>The invocation context for this call.</returns>
|
||||
public InvocationContext GetOrCreateInvocation(FunctionCallContent call)
|
||||
{
|
||||
if (!this._invocationMap.TryGetValue(call.CallId, out var context))
|
||||
{
|
||||
context = new InvocationContext(call);
|
||||
this._invocationMap[call.CallId] = context;
|
||||
}
|
||||
|
||||
return context;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Associates a function result with its corresponding call.
|
||||
/// </summary>
|
||||
/// <param name="result">The function result content.</param>
|
||||
public void AssociateResult(FunctionResultContent result)
|
||||
{
|
||||
if (this._invocationMap.TryGetValue(result.CallId, out var context))
|
||||
{
|
||||
context.SetResult(result);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets an invocation context by call ID.
|
||||
/// </summary>
|
||||
/// <param name="callId">The call ID.</param>
|
||||
/// <returns>The invocation context, or null if not found.</returns>
|
||||
public InvocationContext? GetInvocation(string callId)
|
||||
{
|
||||
return this._invocationMap.TryGetValue(callId, out var context) ? context : null;
|
||||
}
|
||||
|
||||
internal RenderFragment GetTemplate(ChatMessage message)
|
||||
{
|
||||
// We are about to render the first message. If we were collecting templates, stop now.
|
||||
this._collectingTemplates = false;
|
||||
if (this._templateCache.TryGetValue(message, out var cachedTemplate))
|
||||
{
|
||||
return cachedTemplate;
|
||||
}
|
||||
|
||||
var messageContext = new MessageContext(message, this);
|
||||
foreach (var template in this._templates)
|
||||
{
|
||||
if (template.When(messageContext))
|
||||
{
|
||||
var chosen = template;
|
||||
messageContext.SetTemplate(chosen);
|
||||
// We ask the template to create a RenderFragment for the message.
|
||||
// The template will render a wrapper and use the messageContext to
|
||||
// render the contents.
|
||||
// The template might call back through the messageContext to get renderers for
|
||||
// contents if the message template doesn't override the full rendering or
|
||||
// if it doesn't define the rendering for a content type.
|
||||
var renderer = chosen.ChildContent(messageContext);
|
||||
this._templateCache[message] = renderer;
|
||||
return renderer;
|
||||
}
|
||||
}
|
||||
|
||||
throw new InvalidOperationException($"No message template found for message of type {message.Role}.");
|
||||
}
|
||||
|
||||
internal RenderFragment GetContentTemplate(AIContent content)
|
||||
{
|
||||
foreach (var template in this._contentTemplates)
|
||||
{
|
||||
var contentContext = new ContentContext(content);
|
||||
if (template.When(contentContext))
|
||||
{
|
||||
return template.ChildContent(contentContext);
|
||||
}
|
||||
}
|
||||
|
||||
throw new InvalidOperationException($"No content template found for content of type {content.GetType().Name}.");
|
||||
}
|
||||
}
|
||||
@@ -1,30 +0,0 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using Microsoft.Extensions.AI;
|
||||
|
||||
namespace Microsoft.AspNetCore.Components.AI;
|
||||
|
||||
public abstract class MessageTemplateBase : IComponent
|
||||
{
|
||||
[CascadingParameter] internal MessageListContext Context { get; set; } = default!;
|
||||
|
||||
public abstract bool When(MessageContext context);
|
||||
|
||||
[Parameter] public RenderFragment<MessageContext> ChildContent { get; set; } = (message) => builder => { };
|
||||
|
||||
public void Attach(RenderHandle renderHandle)
|
||||
{
|
||||
}
|
||||
|
||||
public Task SetParametersAsync(ParameterView parameters)
|
||||
{
|
||||
parameters.SetParameterProperties(this);
|
||||
this.Context.RegisterTemplate(this);
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
internal RenderFragment? GetContentTemplate(AIContent content)
|
||||
{
|
||||
return this.Context.GetContentTemplate(content);
|
||||
}
|
||||
}
|
||||
@@ -1,94 +0,0 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System.Diagnostics;
|
||||
using Microsoft.AspNetCore.Components.Rendering;
|
||||
|
||||
namespace Microsoft.AspNetCore.Components.AI;
|
||||
|
||||
public partial class Messages : IComponent
|
||||
{
|
||||
private RenderHandle _renderHandle;
|
||||
private RenderFragment? _renderContents;
|
||||
|
||||
private IAgentBoundaryContext? _context;
|
||||
private MessageListContext? _messageListContext;
|
||||
|
||||
[CascadingParameter] public IAgentBoundaryContext? AgentContext { get; set; }
|
||||
|
||||
[Parameter]
|
||||
public RenderFragment MessageTemplates { get; set; } = builder =>
|
||||
{
|
||||
builder.OpenComponent<DefaultMessageTemplate>(0);
|
||||
builder.CloseComponent();
|
||||
};
|
||||
|
||||
[Parameter]
|
||||
public RenderFragment ContentTemplates { get; set; } = builder =>
|
||||
{
|
||||
builder.OpenComponent<TextTemplate>(0);
|
||||
builder.CloseComponent();
|
||||
builder.OpenComponent<FunctionCallTemplate>(1);
|
||||
builder.CloseComponent();
|
||||
builder.OpenComponent<FunctionResultTemplate>(2);
|
||||
builder.CloseComponent();
|
||||
builder.OpenComponent<ErrorTemplate>(3);
|
||||
builder.CloseComponent();
|
||||
builder.OpenComponent<DataContentTemplate>(4);
|
||||
builder.CloseComponent();
|
||||
};
|
||||
|
||||
public void Attach(RenderHandle renderHandle)
|
||||
{
|
||||
this._renderHandle = renderHandle;
|
||||
this._renderContents = this.RenderContents;
|
||||
}
|
||||
|
||||
public Task SetParametersAsync(ParameterView parameters)
|
||||
{
|
||||
parameters.SetParameterProperties(this);
|
||||
|
||||
if (this.AgentContext == null)
|
||||
{
|
||||
throw new InvalidOperationException("Messages component must be used within an AgentBoundary.");
|
||||
}
|
||||
|
||||
if (this._context != null && this.AgentContext != this._context)
|
||||
{
|
||||
throw new InvalidOperationException("Messages component cannot change AgentBoundaryContext.");
|
||||
}
|
||||
|
||||
this._context = this.AgentContext;
|
||||
|
||||
if (this._messageListContext == null)
|
||||
{
|
||||
this._messageListContext = new MessageListContext(this.AgentContext);
|
||||
this.Render();
|
||||
}
|
||||
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
private void Render()
|
||||
{
|
||||
this._renderHandle.Render(this.RenderCore);
|
||||
}
|
||||
|
||||
private void RenderCore(RenderTreeBuilder builder)
|
||||
{
|
||||
builder.OpenComponent<CascadingValue<MessageListContext>>(0);
|
||||
builder.AddComponentParameter(1, "Value", this._messageListContext);
|
||||
builder.AddComponentParameter(2, "IsFixed", true);
|
||||
builder.AddComponentParameter(3, "ChildContent", this._renderContents);
|
||||
builder.CloseComponent();
|
||||
}
|
||||
|
||||
private void RenderContents(RenderTreeBuilder builder)
|
||||
{
|
||||
Debug.Assert(this._messageListContext != null);
|
||||
this._messageListContext.BeginCollectingTemplates();
|
||||
builder.AddContent(1, this.MessageTemplates);
|
||||
builder.AddContent(2, this.ContentTemplates);
|
||||
builder.OpenComponent<MessageList>(3);
|
||||
builder.CloseComponent();
|
||||
}
|
||||
}
|
||||
@@ -1,309 +0,0 @@
|
||||
/* Copyright (c) Microsoft. All rights reserved. */
|
||||
/* ==========================================================================
|
||||
AG-UI Components - Shared Styles
|
||||
These styles provide consistent appearance for AI components across demos.
|
||||
========================================================================== */
|
||||
|
||||
/* ==========================================================================
|
||||
Agent Input Component
|
||||
========================================================================== */
|
||||
|
||||
.agent-input {
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
gap: 12px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.agent-input textarea {
|
||||
flex: 1;
|
||||
padding: 12px 16px;
|
||||
border: 1px solid #d1d5db;
|
||||
border-radius: 8px;
|
||||
font-size: 14px;
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen', 'Ubuntu', 'Cantarell', sans-serif;
|
||||
resize: none;
|
||||
min-height: 44px;
|
||||
max-height: 200px;
|
||||
line-height: 1.5;
|
||||
outline: none;
|
||||
transition: border-color 0.2s, box-shadow 0.2s;
|
||||
}
|
||||
|
||||
.agent-input textarea:focus {
|
||||
border-color: #2563eb;
|
||||
box-shadow: 0 0 0 3px rgba(37, 99, 235, 0.1);
|
||||
}
|
||||
|
||||
.agent-input textarea::placeholder {
|
||||
color: #9ca3af;
|
||||
}
|
||||
|
||||
.agent-input textarea:disabled {
|
||||
background-color: #f9fafb;
|
||||
color: #9ca3af;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.agent-input .send-button {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
background-color: #2563eb;
|
||||
color: white;
|
||||
cursor: pointer;
|
||||
transition: background-color 0.2s, opacity 0.2s;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.agent-input .send-button:hover:not(:disabled) {
|
||||
background-color: #1d4ed8;
|
||||
}
|
||||
|
||||
.agent-input .send-button:disabled {
|
||||
background-color: #d1d5db;
|
||||
color: #9ca3af;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.agent-input .send-button svg {
|
||||
display: block;
|
||||
}
|
||||
|
||||
/* ==========================================================================
|
||||
Agent Suggestions Component
|
||||
========================================================================== */
|
||||
|
||||
.agent-suggestions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.5rem;
|
||||
justify-content: flex-start;
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
|
||||
.suggestion-button {
|
||||
padding: 0.5rem 1rem;
|
||||
background: white;
|
||||
border: 1px solid #d1d1d1;
|
||||
border-radius: 1rem;
|
||||
cursor: pointer;
|
||||
font-size: 0.875rem;
|
||||
color: #424242;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.suggestion-button:hover:not(:disabled) {
|
||||
background: #f0f0f0;
|
||||
border-color: #0078d4;
|
||||
color: #0078d4;
|
||||
}
|
||||
|
||||
.suggestion-button:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
/* ==========================================================================
|
||||
Loading Indicator - Triple Dot Animation
|
||||
========================================================================== */
|
||||
|
||||
.agent-loading-indicator {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 8px 16px;
|
||||
color: #6b7280;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.agent-loading-dots {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.agent-loading-dots span {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
background-color: #2563eb;
|
||||
border-radius: 50%;
|
||||
animation: agent-dot-bounce 1.4s ease-in-out infinite both;
|
||||
}
|
||||
|
||||
.agent-loading-dots span:nth-child(1) {
|
||||
animation-delay: -0.32s;
|
||||
}
|
||||
|
||||
.agent-loading-dots span:nth-child(2) {
|
||||
animation-delay: -0.16s;
|
||||
}
|
||||
|
||||
.agent-loading-dots span:nth-child(3) {
|
||||
animation-delay: 0s;
|
||||
}
|
||||
|
||||
@keyframes agent-dot-bounce {
|
||||
0%, 80%, 100% {
|
||||
transform: scale(0.6);
|
||||
opacity: 0.5;
|
||||
}
|
||||
40% {
|
||||
transform: scale(1);
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
/* Inline loading indicator for use within messages */
|
||||
.agent-loading-inline {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 4px 8px;
|
||||
background-color: #f3f4f6;
|
||||
border-radius: 12px;
|
||||
}
|
||||
|
||||
.agent-loading-inline span {
|
||||
width: 5px;
|
||||
height: 5px;
|
||||
background-color: #6b7280;
|
||||
border-radius: 50%;
|
||||
animation: agent-dot-bounce 1.4s ease-in-out infinite both;
|
||||
}
|
||||
|
||||
.agent-loading-inline span:nth-child(1) {
|
||||
animation-delay: -0.32s;
|
||||
}
|
||||
|
||||
.agent-loading-inline span:nth-child(2) {
|
||||
animation-delay: -0.16s;
|
||||
}
|
||||
|
||||
.agent-loading-inline span:nth-child(3) {
|
||||
animation-delay: 0s;
|
||||
}
|
||||
|
||||
/* ==========================================================================
|
||||
Processing State Overlay
|
||||
========================================================================== */
|
||||
|
||||
.agent-input-container {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.agent-input-container.processing .agent-input textarea {
|
||||
background-color: #f9fafb;
|
||||
}
|
||||
|
||||
/* ==========================================================================
|
||||
Message Styles
|
||||
========================================================================== */
|
||||
|
||||
.message-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.message {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.message-user {
|
||||
align-items: flex-end;
|
||||
}
|
||||
|
||||
.message-assistant {
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.message-content {
|
||||
max-width: 80%;
|
||||
padding: 12px 16px;
|
||||
border-radius: 12px;
|
||||
font-size: 14px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.message-user .message-content {
|
||||
background-color: #2563eb;
|
||||
color: white;
|
||||
border-bottom-right-radius: 4px;
|
||||
}
|
||||
|
||||
.message-assistant .message-content {
|
||||
background-color: #f3f4f6;
|
||||
color: #1f2937;
|
||||
border-bottom-left-radius: 4px;
|
||||
}
|
||||
|
||||
/* ==========================================================================
|
||||
Common Chat Layout Styles
|
||||
========================================================================== */
|
||||
|
||||
.chat-layout {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.chat-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 1.5rem 2rem;
|
||||
border-bottom: 1px solid #e5e5e5;
|
||||
}
|
||||
|
||||
.chat-title {
|
||||
font-size: 1.5rem;
|
||||
font-weight: 600;
|
||||
color: #1a1a1a;
|
||||
}
|
||||
|
||||
.new-chat-button {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.5rem 1rem;
|
||||
background: white;
|
||||
border: 1px solid #d1d1d1;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
font-size: 0.875rem;
|
||||
color: #424242;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.new-chat-button:hover {
|
||||
background: #f5f5f5;
|
||||
border-color: #b3b3b3;
|
||||
}
|
||||
|
||||
.button-icon {
|
||||
font-size: 1.2rem;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.chat-content {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 2rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.chat-input-container {
|
||||
padding: 1.5rem 2rem 2rem;
|
||||
border-top: 1px solid #e5e5e5;
|
||||
background: white;
|
||||
}
|
||||
@@ -1,23 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<base href="/" />
|
||||
<link rel="stylesheet" href="app.css" />
|
||||
<link rel="stylesheet" href="AGUIDojoClient.styles.css" />
|
||||
<link rel="icon" type="image/png" href="favicon.png" />
|
||||
<HeadOutlet @rendermode="@renderMode" />
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<Routes @rendermode="@renderMode" />
|
||||
<script src="_framework/blazor.web.js"></script>
|
||||
</body>
|
||||
|
||||
</html>
|
||||
|
||||
@code {
|
||||
private readonly IComponentRenderMode renderMode = new InteractiveServerRenderMode(prerender: false);
|
||||
}
|
||||
-72
@@ -1,72 +0,0 @@
|
||||
@* Copyright (c) Microsoft. All rights reserved. *@
|
||||
@using Microsoft.AspNetCore.Components.AI
|
||||
@using Microsoft.Agents.AI
|
||||
@using Microsoft.Extensions.AI
|
||||
@using Microsoft.Extensions.DependencyInjection
|
||||
@using AGUIDojoClient.Services
|
||||
@inject IServiceProvider ServiceProvider
|
||||
@inject IBackgroundColorService BackgroundColorService
|
||||
@implements IDisposable
|
||||
|
||||
<PageTitle>Agentic Chat</PageTitle>
|
||||
|
||||
<div class="chat-layout" style="@GetBackgroundStyle()">
|
||||
<div class="chat-header">
|
||||
<div class="chat-title">AGUI WebChat</div>
|
||||
<button class="new-chat-button" @onclick="ResetConversationAsync">
|
||||
<span class="button-icon">+</span> New chat
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<AgentBoundary Agent="@agent">
|
||||
<div class="chat-content">
|
||||
<Messages />
|
||||
<AgentLoadingIndicator />
|
||||
</div>
|
||||
|
||||
<div class="chat-input-container">
|
||||
<AgentSuggestions Suggestions="@suggestions" />
|
||||
<AgentInput Placeholder="Type your message..." />
|
||||
</div>
|
||||
</AgentBoundary>
|
||||
</div>
|
||||
|
||||
@code {
|
||||
private AIAgent? agent;
|
||||
private string? _backgroundColor;
|
||||
private Suggestion[] suggestions = [
|
||||
new Suggestion("Change background", new ChatMessage(ChatRole.User, "Change background to light blue")),
|
||||
new Suggestion("Generate sonnet")
|
||||
];
|
||||
|
||||
[Parameter]
|
||||
public string ScenarioId { get; set; } = "agentic_chat";
|
||||
|
||||
protected override void OnInitialized()
|
||||
{
|
||||
agent = ServiceProvider.GetRequiredKeyedService<AIAgent>("agentic-chat");
|
||||
BackgroundColorService.ColorChanged += OnColorChanged;
|
||||
}
|
||||
|
||||
private string GetBackgroundStyle()
|
||||
{
|
||||
return _backgroundColor != null ? $"background-color: {_backgroundColor}" : "";
|
||||
}
|
||||
|
||||
private async void OnColorChanged(object? sender, BackgroundColorChangedEventArgs e)
|
||||
{
|
||||
_backgroundColor = e.Color;
|
||||
await InvokeAsync(StateHasChanged);
|
||||
}
|
||||
|
||||
private void ResetConversationAsync()
|
||||
{
|
||||
// Reset would need to be implemented in AgentBoundary
|
||||
StateHasChanged();
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
BackgroundColorService.ColorChanged -= OnColorChanged;
|
||||
}
|
||||
}
|
||||
-74
@@ -1,74 +0,0 @@
|
||||
/* Copyright (c) Microsoft. All rights reserved. */
|
||||
|
||||
.chat-layout {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.chat-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 1.5rem 2rem;
|
||||
border-bottom: 1px solid #e5e5e5;
|
||||
}
|
||||
|
||||
.chat-title {
|
||||
font-size: 1.5rem;
|
||||
font-weight: 600;
|
||||
color: #1a1a1a;
|
||||
}
|
||||
|
||||
.new-chat-button {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.5rem 1rem;
|
||||
background: white;
|
||||
border: 1px solid #d1d1d1;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
font-size: 0.875rem;
|
||||
color: #424242;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.new-chat-button:hover {
|
||||
background: #f5f5f5;
|
||||
border-color: #b3b3b3;
|
||||
}
|
||||
|
||||
.button-icon {
|
||||
font-size: 1.2rem;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.chat-content {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 2rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.chat-input-container {
|
||||
padding: 1rem 2rem 1.5rem;
|
||||
border-top: 1px solid #e5e5e5;
|
||||
background: white;
|
||||
}
|
||||
|
||||
|
||||
|
||||
.agentic-chat-demo {
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.chat-container {
|
||||
padding: 0 2rem 1.5rem;
|
||||
border-top: 1px solid #e0e0e0;
|
||||
}
|
||||
-111
@@ -1,111 +0,0 @@
|
||||
@* Copyright (c) Microsoft. All rights reserved. *@
|
||||
@using Microsoft.AspNetCore.Components.AI
|
||||
@using Microsoft.Agents.AI
|
||||
@using Microsoft.Extensions.AI
|
||||
@using Microsoft.Extensions.DependencyInjection
|
||||
@using AGUIDojoClient.Components.Shared
|
||||
@using System.Text.Json
|
||||
@inject IServiceProvider ServiceProvider
|
||||
|
||||
<PageTitle>Agentic Generative UI</PageTitle>
|
||||
|
||||
<div class="chat-layout">
|
||||
<div class="chat-header">
|
||||
<div class="chat-title">Agentic Generative UI</div>
|
||||
<button class="new-chat-button" @onclick="ResetConversation">
|
||||
<span class="button-icon">+</span> New chat
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<AgentBoundary Agent="@agent">
|
||||
<AgentState TState="Plan"
|
||||
CurrentState="@currentPlan"
|
||||
OnSnapshot="@DeserializePlan"
|
||||
OnDelta="@ApplyPlanDelta"
|
||||
CurrentStateChanged="@OnPlanChanged">
|
||||
<div class="chat-content">
|
||||
<Messages>
|
||||
<ContentTemplates>
|
||||
<TaskProgressTemplate />
|
||||
<TextTemplate />
|
||||
<FunctionCallTemplate />
|
||||
<FunctionResultTemplate />
|
||||
<ErrorTemplate />
|
||||
<DataContentTemplate />
|
||||
</ContentTemplates>
|
||||
</Messages>
|
||||
<AgentLoadingIndicator />
|
||||
</div>
|
||||
|
||||
<div class="chat-input-container">
|
||||
<AgentSuggestions Suggestions="@suggestions" />
|
||||
<AgentInput Placeholder="Ask me to plan something..." />
|
||||
</div>
|
||||
</AgentState>
|
||||
</AgentBoundary>
|
||||
</div>
|
||||
|
||||
@code {
|
||||
private AIAgent? agent;
|
||||
private Plan? currentPlan;
|
||||
|
||||
private Suggestion[] suggestions = [
|
||||
new Suggestion("Simple plan", new ChatMessage(ChatRole.User, "Please build a plan to go to mars in 5 steps.")),
|
||||
new Suggestion("Complex plan", new ChatMessage(ChatRole.User, "Please build a plan to make pizza in 10 steps."))
|
||||
];
|
||||
|
||||
[Parameter]
|
||||
public string ScenarioId { get; set; } = "agentic_generative_ui";
|
||||
|
||||
protected override void OnInitialized()
|
||||
{
|
||||
agent = ServiceProvider.GetRequiredKeyedService<AIAgent>("agentic-generative-ui");
|
||||
}
|
||||
|
||||
private Plan? DeserializePlan(ReadOnlyMemory<byte> data)
|
||||
{
|
||||
try
|
||||
{
|
||||
return JsonSerializer.Deserialize<Plan>(data.Span);
|
||||
}
|
||||
catch
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private Plan? ApplyPlanDelta(Plan? current, ReadOnlyMemory<byte> deltaData)
|
||||
{
|
||||
if (current is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var operations = JsonSerializer.Deserialize<List<JsonPatchOperation>>(deltaData.Span);
|
||||
if (operations is not null)
|
||||
{
|
||||
PlanPatcher.Apply(current, operations);
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Ignore deserialization errors
|
||||
}
|
||||
|
||||
return current;
|
||||
}
|
||||
|
||||
private void OnPlanChanged(Plan? plan)
|
||||
{
|
||||
currentPlan = plan;
|
||||
StateHasChanged();
|
||||
}
|
||||
|
||||
private void ResetConversation()
|
||||
{
|
||||
currentPlan = null;
|
||||
StateHasChanged();
|
||||
}
|
||||
}
|
||||
-86
@@ -1,86 +0,0 @@
|
||||
/* Copyright (c) Microsoft. All rights reserved. */
|
||||
|
||||
.chat-layout {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.chat-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 1.5rem 2rem;
|
||||
border-bottom: 1px solid #e5e5e5;
|
||||
}
|
||||
|
||||
.chat-title {
|
||||
font-size: 1.5rem;
|
||||
font-weight: 600;
|
||||
color: #1a1a1a;
|
||||
}
|
||||
|
||||
.new-chat-button {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.5rem 1rem;
|
||||
background: white;
|
||||
border: 1px solid #d1d1d1;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
font-size: 0.875rem;
|
||||
color: #424242;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.new-chat-button:hover {
|
||||
background: #f5f5f5;
|
||||
border-color: #b3b3b3;
|
||||
}
|
||||
|
||||
.button-icon {
|
||||
font-size: 1.2rem;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.chat-content {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 2rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.chat-input-container {
|
||||
padding: 1.5rem 2rem 2rem;
|
||||
border-top: 1px solid #e5e5e5;
|
||||
background: white;
|
||||
}
|
||||
|
||||
::deep .agent-suggestions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.5rem;
|
||||
justify-content: flex-start;
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
|
||||
::deep .suggestion-button {
|
||||
padding: 0.5rem 1rem;
|
||||
background: white;
|
||||
border: 1px solid #d1d1d1;
|
||||
border-radius: 1rem;
|
||||
cursor: pointer;
|
||||
font-size: 0.875rem;
|
||||
color: #424242;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
::deep .suggestion-button:hover {
|
||||
background: #f0f0f0;
|
||||
border-color: #0078d4;
|
||||
color: #0078d4;
|
||||
}
|
||||
-29
@@ -1,29 +0,0 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace AGUIDojoClient.Components.Demos.AgenticGenerativeUI;
|
||||
|
||||
/// <summary>
|
||||
/// Represents a JSON Patch operation (RFC 6902).
|
||||
/// </summary>
|
||||
public sealed class JsonPatchOperation
|
||||
{
|
||||
/// <summary>
|
||||
/// The operation to perform (e.g., "replace", "add", "remove").
|
||||
/// </summary>
|
||||
[JsonPropertyName("op")]
|
||||
public string Op { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// The JSON Pointer path to the target location.
|
||||
/// </summary>
|
||||
[JsonPropertyName("path")]
|
||||
public string Path { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// The value for the operation (used with "replace", "add", "test").
|
||||
/// </summary>
|
||||
[JsonPropertyName("value")]
|
||||
public object? Value { get; set; }
|
||||
}
|
||||
@@ -1,35 +0,0 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace AGUIDojoClient.Components.Demos.AgenticGenerativeUI;
|
||||
|
||||
/// <summary>
|
||||
/// Represents a plan with multiple steps.
|
||||
/// </summary>
|
||||
public sealed class Plan
|
||||
{
|
||||
/// <summary>
|
||||
/// The list of steps in the plan.
|
||||
/// </summary>
|
||||
[JsonPropertyName("steps")]
|
||||
public List<Step> Steps { get; set; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// Gets the count of completed steps.
|
||||
/// </summary>
|
||||
[JsonIgnore]
|
||||
public int CompletedCount => this.Steps.Count(s => s.IsCompleted);
|
||||
|
||||
/// <summary>
|
||||
/// Gets the total number of steps.
|
||||
/// </summary>
|
||||
[JsonIgnore]
|
||||
public int TotalCount => this.Steps.Count;
|
||||
|
||||
/// <summary>
|
||||
/// Gets whether all steps are completed.
|
||||
/// </summary>
|
||||
[JsonIgnore]
|
||||
public bool IsComplete => this.Steps.Count > 0 && this.Steps.All(s => s.IsCompleted);
|
||||
}
|
||||
-73
@@ -1,73 +0,0 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System.Text.Json;
|
||||
using System.Text.RegularExpressions;
|
||||
|
||||
namespace AGUIDojoClient.Components.Demos.AgenticGenerativeUI;
|
||||
|
||||
/// <summary>
|
||||
/// Applies JSON Patch operations to a Plan object.
|
||||
/// </summary>
|
||||
public static partial class PlanPatcher
|
||||
{
|
||||
/// <summary>
|
||||
/// Applies a list of JSON Patch operations to the given plan.
|
||||
/// </summary>
|
||||
/// <param name="plan">The plan to modify.</param>
|
||||
/// <param name="operations">The patch operations to apply.</param>
|
||||
public static void Apply(Plan plan, IEnumerable<JsonPatchOperation> operations)
|
||||
{
|
||||
foreach (var operation in operations)
|
||||
{
|
||||
ApplyOperation(plan, operation);
|
||||
}
|
||||
}
|
||||
|
||||
private static void ApplyOperation(Plan plan, JsonPatchOperation operation)
|
||||
{
|
||||
// Parse paths like "/steps/0/status" or "/steps/0/description"
|
||||
var match = StepPathRegex().Match(operation.Path);
|
||||
if (!match.Success)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (!int.TryParse(match.Groups["index"].Value, out var index))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (index < 0 || index >= plan.Steps.Count)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var property = match.Groups["property"].Value;
|
||||
var step = plan.Steps[index];
|
||||
|
||||
if (string.Equals(operation.Op, "replace", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
if (string.Equals(property, "status", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
step.Status = GetStringValue(operation.Value) ?? step.Status;
|
||||
}
|
||||
else if (string.Equals(property, "description", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
step.Description = GetStringValue(operation.Value) ?? step.Description;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static string? GetStringValue(object? value)
|
||||
{
|
||||
return value switch
|
||||
{
|
||||
string s => s,
|
||||
JsonElement { ValueKind: JsonValueKind.String } je => je.GetString(),
|
||||
_ => value?.ToString()
|
||||
};
|
||||
}
|
||||
|
||||
[GeneratedRegex(@"^/steps/(?<index>\d+)/(?<property>\w+)$")]
|
||||
private static partial Regex StepPathRegex();
|
||||
}
|
||||
@@ -1,29 +0,0 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace AGUIDojoClient.Components.Demos.AgenticGenerativeUI;
|
||||
|
||||
/// <summary>
|
||||
/// Represents a single step in a plan.
|
||||
/// </summary>
|
||||
public sealed class Step
|
||||
{
|
||||
/// <summary>
|
||||
/// The description of the step.
|
||||
/// </summary>
|
||||
[JsonPropertyName("description")]
|
||||
public string Description { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// The status of the step (pending or completed).
|
||||
/// </summary>
|
||||
[JsonPropertyName("status")]
|
||||
public string Status { get; set; } = "pending";
|
||||
|
||||
/// <summary>
|
||||
/// Gets whether this step is completed.
|
||||
/// </summary>
|
||||
[JsonIgnore]
|
||||
public bool IsCompleted => string.Equals(this.Status, "completed", StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
-42
@@ -1,42 +0,0 @@
|
||||
@* Copyright (c) Microsoft. All rights reserved. *@
|
||||
|
||||
@if (Plan is not null && Plan.Steps.Count > 0)
|
||||
{
|
||||
<div class="task-progress-card @(Plan.IsComplete ? "completed" : "in-progress")">
|
||||
<div class="task-header">
|
||||
<h3>Task Progress</h3>
|
||||
<span class="task-counter">@Plan.CompletedCount/@Plan.TotalCount Complete</span>
|
||||
</div>
|
||||
<div class="task-steps">
|
||||
@for (int i = 0; i < Plan.Steps.Count; i++)
|
||||
{
|
||||
var step = Plan.Steps[i];
|
||||
var isCurrentStep = !step.IsCompleted &&
|
||||
(i == 0 || Plan.Steps[i - 1].IsCompleted);
|
||||
|
||||
<div class="task-step @(step.IsCompleted ? "step-completed" : isCurrentStep ? "step-current" : "step-pending")">
|
||||
<span class="step-icon">
|
||||
@if (step.IsCompleted)
|
||||
{
|
||||
<span class="check-icon">✓</span>
|
||||
}
|
||||
else if (isCurrentStep)
|
||||
{
|
||||
<span class="current-icon">●</span>
|
||||
}
|
||||
else
|
||||
{
|
||||
<span class="pending-icon">○</span>
|
||||
}
|
||||
</span>
|
||||
<span class="step-description">@step.Description</span>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
@code {
|
||||
[CascadingParameter]
|
||||
public Plan? Plan { get; set; }
|
||||
}
|
||||
-139
@@ -1,139 +0,0 @@
|
||||
/* Copyright (c) Microsoft. All rights reserved. */
|
||||
|
||||
.task-progress-card {
|
||||
background: #ffffff;
|
||||
border: 1px solid #e5e5e5;
|
||||
border-radius: 12px;
|
||||
padding: 16px;
|
||||
margin: 8px 0;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08);
|
||||
}
|
||||
|
||||
.task-progress-card.completed {
|
||||
background: linear-gradient(135deg, #f0fdf4 0%, #dcfce7 100%);
|
||||
border-color: #86efac;
|
||||
}
|
||||
|
||||
.task-progress-card.in-progress {
|
||||
background: #ffffff;
|
||||
}
|
||||
|
||||
.task-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 16px;
|
||||
padding-bottom: 12px;
|
||||
border-bottom: 1px solid #e5e5e5;
|
||||
}
|
||||
|
||||
.task-header h3 {
|
||||
margin: 0;
|
||||
font-size: 1.1rem;
|
||||
font-weight: 600;
|
||||
color: #1a1a1a;
|
||||
}
|
||||
|
||||
.task-counter {
|
||||
font-size: 0.875rem;
|
||||
color: #666666;
|
||||
background: #f5f5f5;
|
||||
padding: 4px 10px;
|
||||
border-radius: 20px;
|
||||
}
|
||||
|
||||
.task-steps {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.task-step {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 12px;
|
||||
padding: 10px 12px;
|
||||
border-radius: 8px;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.task-step.step-completed {
|
||||
background: rgba(34, 197, 94, 0.1);
|
||||
border-left: 3px solid #22c55e;
|
||||
}
|
||||
|
||||
.task-step.step-current {
|
||||
background: rgba(0, 120, 212, 0.1);
|
||||
border-left: 3px solid #0078d4;
|
||||
animation: pulse-border 2s infinite;
|
||||
}
|
||||
|
||||
.task-step.step-pending {
|
||||
background: #fafafa;
|
||||
border-left: 3px solid #d1d1d1;
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
@keyframes pulse-border {
|
||||
0%, 100% {
|
||||
border-left-color: #0078d4;
|
||||
box-shadow: 0 0 0 0 rgba(0, 120, 212, 0.4);
|
||||
}
|
||||
50% {
|
||||
border-left-color: #50a0e8;
|
||||
box-shadow: 0 0 8px 0 rgba(0, 120, 212, 0.2);
|
||||
}
|
||||
}
|
||||
|
||||
.step-icon {
|
||||
flex-shrink: 0;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
.check-icon {
|
||||
color: #22c55e;
|
||||
font-weight: bold;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.current-icon {
|
||||
color: #0078d4;
|
||||
font-size: 12px;
|
||||
animation: pulse 1.5s infinite;
|
||||
}
|
||||
|
||||
.pending-icon {
|
||||
color: #999999;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
@keyframes pulse {
|
||||
0%, 100% {
|
||||
opacity: 1;
|
||||
transform: scale(1);
|
||||
}
|
||||
50% {
|
||||
opacity: 0.6;
|
||||
transform: scale(1.2);
|
||||
}
|
||||
}
|
||||
|
||||
.step-description {
|
||||
flex: 1;
|
||||
font-size: 0.9rem;
|
||||
line-height: 1.5;
|
||||
color: #424242;
|
||||
}
|
||||
|
||||
.step-completed .step-description {
|
||||
color: #166534;
|
||||
}
|
||||
|
||||
.step-pending .step-description {
|
||||
color: #666666;
|
||||
}
|
||||
-62
@@ -1,62 +0,0 @@
|
||||
@* Copyright (c) Microsoft. All rights reserved. *@
|
||||
@using Microsoft.AspNetCore.Components.AI
|
||||
@using Microsoft.Agents.AI
|
||||
@using Microsoft.Extensions.AI
|
||||
@using Microsoft.Extensions.DependencyInjection
|
||||
@using AGUIDojoClient.Components.Shared
|
||||
@inject IServiceProvider ServiceProvider
|
||||
|
||||
<PageTitle>Backend Tool Rendering</PageTitle>
|
||||
|
||||
<div class="chat-layout">
|
||||
<div class="chat-header">
|
||||
<div class="chat-title">Backend Tool Rendering</div>
|
||||
<button class="new-chat-button" @onclick="ResetConversation">
|
||||
<span class="button-icon">+</span> New chat
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<AgentBoundary Agent="@agent">
|
||||
<div class="chat-content">
|
||||
<Messages>
|
||||
<ContentTemplates>
|
||||
<TextTemplate />
|
||||
<WeatherCallTemplate />
|
||||
<FunctionCallTemplate />
|
||||
<FunctionResultTemplate />
|
||||
<ErrorTemplate />
|
||||
<DataContentTemplate />
|
||||
</ContentTemplates>
|
||||
</Messages>
|
||||
<AgentLoadingIndicator />
|
||||
</div>
|
||||
|
||||
<div class="chat-input-container">
|
||||
<AgentSuggestions Suggestions="@suggestions" />
|
||||
<AgentInput Placeholder="Ask about the weather..." />
|
||||
</div>
|
||||
</AgentBoundary>
|
||||
</div>
|
||||
|
||||
@code {
|
||||
private AIAgent? agent;
|
||||
private Suggestion[] suggestions = [
|
||||
new Suggestion("Weather in San Francisco", new ChatMessage(ChatRole.User, "What's the weather like in San Francisco?")),
|
||||
new Suggestion("Weather in New York", new ChatMessage(ChatRole.User, "What's the weather like in New York?")),
|
||||
new Suggestion("Weather in Tokyo", new ChatMessage(ChatRole.User, "What's the weather like in Tokyo?"))
|
||||
];
|
||||
|
||||
[Parameter]
|
||||
public string ScenarioId { get; set; } = "backend_tool_rendering";
|
||||
|
||||
protected override void OnInitialized()
|
||||
{
|
||||
agent = ServiceProvider.GetRequiredKeyedService<AIAgent>("backend-tool-rendering");
|
||||
}
|
||||
|
||||
private void ResetConversation()
|
||||
{
|
||||
// Reset would need to be implemented - for now just trigger re-render
|
||||
StateHasChanged();
|
||||
}
|
||||
}
|
||||
-86
@@ -1,86 +0,0 @@
|
||||
/* Copyright (c) Microsoft. All rights reserved. */
|
||||
|
||||
.chat-layout {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.chat-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 1.5rem 2rem;
|
||||
border-bottom: 1px solid #e5e5e5;
|
||||
}
|
||||
|
||||
.chat-title {
|
||||
font-size: 1.5rem;
|
||||
font-weight: 600;
|
||||
color: #1a1a1a;
|
||||
}
|
||||
|
||||
.new-chat-button {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.5rem 1rem;
|
||||
background: white;
|
||||
border: 1px solid #d1d1d1;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
font-size: 0.875rem;
|
||||
color: #424242;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.new-chat-button:hover {
|
||||
background: #f5f5f5;
|
||||
border-color: #b3b3b3;
|
||||
}
|
||||
|
||||
.button-icon {
|
||||
font-size: 1.2rem;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.chat-content {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 2rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.chat-input-container {
|
||||
padding: 1.5rem 2rem 2rem;
|
||||
border-top: 1px solid #e5e5e5;
|
||||
background: white;
|
||||
}
|
||||
|
||||
::deep .agent-suggestions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.5rem;
|
||||
justify-content: flex-start;
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
|
||||
::deep .suggestion-button {
|
||||
padding: 0.5rem 1rem;
|
||||
background: white;
|
||||
border: 1px solid #d1d1d1;
|
||||
border-radius: 1rem;
|
||||
cursor: pointer;
|
||||
font-size: 0.875rem;
|
||||
color: #424242;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
::deep .suggestion-button:hover {
|
||||
background: #f0f0f0;
|
||||
border-color: #0078d4;
|
||||
color: #0078d4;
|
||||
}
|
||||
-59
@@ -1,59 +0,0 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using Microsoft.Extensions.AI;
|
||||
|
||||
namespace Microsoft.AspNetCore.Components.AI;
|
||||
|
||||
/// <summary>
|
||||
/// Template for rendering weather function call content with its result.
|
||||
/// Uses InvocationContext to access both the call arguments and result.
|
||||
/// </summary>
|
||||
public class WeatherCallTemplate : ContentTemplateBase
|
||||
{
|
||||
[CascadingParameter] internal MessageListContext Context { get; set; } = default!;
|
||||
|
||||
public override void Attach(RenderHandle renderHandle)
|
||||
{
|
||||
// This component never renders anything by itself.
|
||||
this.ChildContent = this.RenderWeatherCall;
|
||||
}
|
||||
|
||||
public override Task SetParametersAsync(ParameterView parameters)
|
||||
{
|
||||
parameters.SetParameterProperties(this);
|
||||
this.Context.RegisterContentTemplate(this);
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Determines if this template should handle the given content.
|
||||
/// Matches FunctionCallContent for the get_weather function.
|
||||
/// </summary>
|
||||
public override bool When(ContentContext context)
|
||||
{
|
||||
// Only match FunctionCallContent for the get_weather function
|
||||
return context.Content is FunctionCallContent call &&
|
||||
string.Equals(call.Name, "get_weather", StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
private RenderFragment RenderWeatherCall(ContentContext content) => builder =>
|
||||
{
|
||||
if (content.Content is FunctionCallContent call)
|
||||
{
|
||||
// Get the invocation context which tracks both call and result
|
||||
var invocation = this.Context.GetOrCreateInvocation(call);
|
||||
|
||||
// Provide the invocation context to child components
|
||||
builder.OpenComponent<CascadingValue<InvocationContext>>(0);
|
||||
builder.AddComponentParameter(1, "Value", invocation);
|
||||
builder.AddComponentParameter(2, "IsFixed", true);
|
||||
builder.AddComponentParameter(3, "ChildContent", (RenderFragment)(innerBuilder =>
|
||||
{
|
||||
// Render the WeatherCard component which uses InvocationContext
|
||||
innerBuilder.OpenComponent<AGUIDojoClient.Components.Demos.BackendToolRendering.WeatherCard>(0);
|
||||
innerBuilder.CloseComponent();
|
||||
}));
|
||||
builder.CloseComponent();
|
||||
}
|
||||
};
|
||||
}
|
||||
-117
@@ -1,117 +0,0 @@
|
||||
@* Copyright (c) Microsoft. All rights reserved. *@
|
||||
@using AGUIDojoClient.Components.Shared
|
||||
@using Microsoft.AspNetCore.Components.AI
|
||||
|
||||
@if (Weather is null)
|
||||
{
|
||||
<div class="weather-card weather-loading">
|
||||
<div class="weather-header">
|
||||
<div class="weather-location">
|
||||
<h3>@Location</h3>
|
||||
<p>Loading weather...</p>
|
||||
</div>
|
||||
<span class="weather-icon skeleton-icon"></span>
|
||||
</div>
|
||||
|
||||
<div class="weather-main">
|
||||
<div class="temperature">
|
||||
<span class="temp-value skeleton-text skeleton-temp"></span>
|
||||
<span class="temp-fahrenheit skeleton-text skeleton-temp-f"></span>
|
||||
</div>
|
||||
<div class="conditions skeleton-text skeleton-conditions"></div>
|
||||
</div>
|
||||
|
||||
<div class="weather-details">
|
||||
<div class="detail-item">
|
||||
<p class="detail-label">Humidity</p>
|
||||
<p class="detail-value skeleton-text skeleton-detail"></p>
|
||||
</div>
|
||||
<div class="detail-item">
|
||||
<p class="detail-label">Wind</p>
|
||||
<p class="detail-value skeleton-text skeleton-detail"></p>
|
||||
</div>
|
||||
<div class="detail-item">
|
||||
<p class="detail-label">Feels Like</p>
|
||||
<p class="detail-value skeleton-text skeleton-detail"></p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
else
|
||||
{
|
||||
<div class="weather-card @GetConditionClass()">
|
||||
<div class="weather-header">
|
||||
<div class="weather-location">
|
||||
<h3>@Location</h3>
|
||||
<p>Current Weather</p>
|
||||
</div>
|
||||
<span class="weather-icon">@Weather.ConditionIcon</span>
|
||||
</div>
|
||||
|
||||
<div class="weather-main">
|
||||
<div class="temperature">
|
||||
<span class="temp-value">@Weather.Temperature° C</span>
|
||||
<span class="temp-fahrenheit">/ @Weather.TemperatureFahrenheit.ToString("F1")° F</span>
|
||||
</div>
|
||||
<div class="conditions">@Weather.Conditions</div>
|
||||
</div>
|
||||
|
||||
<div class="weather-details">
|
||||
<div class="detail-item">
|
||||
<p class="detail-label">Humidity</p>
|
||||
<p class="detail-value">@(Weather.Humidity)%</p>
|
||||
</div>
|
||||
<div class="detail-item">
|
||||
<p class="detail-label">Wind</p>
|
||||
<p class="detail-value">@Weather.WindSpeed mph</p>
|
||||
</div>
|
||||
<div class="detail-item">
|
||||
<p class="detail-label">Feels Like</p>
|
||||
<p class="detail-value">@(Weather.FeelsLike)°</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
@code {
|
||||
[CascadingParameter]
|
||||
public InvocationContext Invocation { get; set; } = default!;
|
||||
|
||||
private string Location => Invocation?.GetArgument<string>("location") ?? "Unknown Location";
|
||||
|
||||
private WeatherInfo? Weather => Invocation?.HasResult == true
|
||||
? Invocation.GetResult<WeatherInfo>()
|
||||
: null;
|
||||
|
||||
protected override void OnInitialized()
|
||||
{
|
||||
if (Invocation is not null && !Invocation.HasResult)
|
||||
{
|
||||
Invocation.ResultArrived += OnResultArrived;
|
||||
}
|
||||
}
|
||||
|
||||
private void OnResultArrived()
|
||||
{
|
||||
InvokeAsync(StateHasChanged);
|
||||
}
|
||||
|
||||
private string GetConditionClass()
|
||||
{
|
||||
if (Weather is null)
|
||||
{
|
||||
return "condition-default";
|
||||
}
|
||||
|
||||
return Weather.Conditions.ToLowerInvariant() switch
|
||||
{
|
||||
"sunny" or "clear" => "condition-sunny",
|
||||
"cloudy" or "overcast" => "condition-cloudy",
|
||||
"rainy" or "rain" => "condition-rainy",
|
||||
"stormy" or "thunderstorm" => "condition-stormy",
|
||||
"snowy" or "snow" => "condition-snowy",
|
||||
"foggy" or "fog" => "condition-foggy",
|
||||
_ => "condition-default"
|
||||
};
|
||||
}
|
||||
}
|
||||
-162
@@ -1,162 +0,0 @@
|
||||
.weather-card {
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
border-radius: 16px;
|
||||
padding: 20px;
|
||||
color: white;
|
||||
max-width: 320px;
|
||||
box-shadow: 0 10px 40px rgba(102, 126, 234, 0.4);
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||
}
|
||||
|
||||
.weather-card.condition-sunny {
|
||||
background: linear-gradient(135deg, #f6d365 0%, #fda085 100%);
|
||||
}
|
||||
|
||||
.weather-card.condition-cloudy {
|
||||
background: linear-gradient(135deg, #bdc3c7 0%, #2c3e50 100%);
|
||||
}
|
||||
|
||||
.weather-card.condition-rainy {
|
||||
background: linear-gradient(135deg, #4facfe 0%, #00f2fe 100%);
|
||||
}
|
||||
|
||||
.weather-card.condition-stormy {
|
||||
background: linear-gradient(135deg, #373b44 0%, #4286f4 100%);
|
||||
}
|
||||
|
||||
.weather-card.condition-snowy {
|
||||
background: linear-gradient(135deg, #e6dada 0%, #274046 100%);
|
||||
}
|
||||
|
||||
.weather-card.condition-foggy {
|
||||
background: linear-gradient(135deg, #606c88 0%, #3f4c6b 100%);
|
||||
}
|
||||
|
||||
.weather-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.weather-location h3 {
|
||||
margin: 0;
|
||||
font-size: 1.5rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.weather-location p {
|
||||
margin: 4px 0 0 0;
|
||||
font-size: 0.85rem;
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
.weather-icon {
|
||||
font-size: 3rem;
|
||||
}
|
||||
|
||||
.weather-main {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.temperature {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.temp-value {
|
||||
font-size: 3rem;
|
||||
font-weight: 300;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.temp-fahrenheit {
|
||||
font-size: 1rem;
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.conditions {
|
||||
font-size: 1.1rem;
|
||||
text-transform: capitalize;
|
||||
margin-top: 8px;
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
.weather-details {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
padding-top: 16px;
|
||||
border-top: 1px solid rgba(255, 255, 255, 0.2);
|
||||
}
|
||||
|
||||
.detail-item {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.detail-label {
|
||||
margin: 0;
|
||||
font-size: 0.75rem;
|
||||
opacity: 0.7;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
.detail-value {
|
||||
margin: 4px 0 0 0;
|
||||
font-size: 1.1rem;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
/* Loading/Skeleton state */
|
||||
.weather-loading {
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
}
|
||||
|
||||
.skeleton-text {
|
||||
background: linear-gradient(90deg, rgba(255, 255, 255, 0.2) 25%, rgba(255, 255, 255, 0.4) 50%, rgba(255, 255, 255, 0.2) 75%);
|
||||
background-size: 200% 100%;
|
||||
animation: skeleton-shimmer 1.5s infinite;
|
||||
border-radius: 4px;
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
.skeleton-icon {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
background: linear-gradient(90deg, rgba(255, 255, 255, 0.2) 25%, rgba(255, 255, 255, 0.4) 50%, rgba(255, 255, 255, 0.2) 75%);
|
||||
background-size: 200% 100%;
|
||||
animation: skeleton-shimmer 1.5s infinite;
|
||||
border-radius: 8px;
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
.skeleton-temp {
|
||||
width: 80px;
|
||||
height: 3rem;
|
||||
}
|
||||
|
||||
.skeleton-temp-f {
|
||||
width: 60px;
|
||||
height: 1rem;
|
||||
}
|
||||
|
||||
.skeleton-conditions {
|
||||
width: 100px;
|
||||
height: 1.1rem;
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.skeleton-detail {
|
||||
width: 40px;
|
||||
height: 1.1rem;
|
||||
}
|
||||
|
||||
@keyframes skeleton-shimmer {
|
||||
0% {
|
||||
background-position: 200% 0;
|
||||
}
|
||||
100% {
|
||||
background-position: -200% 0;
|
||||
}
|
||||
}
|
||||
-69
@@ -1,69 +0,0 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using Microsoft.AspNetCore.Components;
|
||||
using Microsoft.AspNetCore.Components.AI;
|
||||
using Microsoft.Extensions.AI;
|
||||
|
||||
namespace AGUIDojoClient.Components.Demos.HumanInTheLoop;
|
||||
|
||||
/// <summary>
|
||||
/// Template for rendering create_plan function call content.
|
||||
/// The PlanCard will subscribe to events to track confirm_plan and update_plan_step calls.
|
||||
/// </summary>
|
||||
public class CreatePlanCallTemplate : ContentTemplateBase
|
||||
{
|
||||
[CascadingParameter] internal MessageListContext Context { get; set; } = default!;
|
||||
|
||||
public override void Attach(RenderHandle renderHandle)
|
||||
{
|
||||
// This component never renders anything by itself.
|
||||
this.ChildContent = this.RenderCreatePlanCall;
|
||||
}
|
||||
|
||||
public override Task SetParametersAsync(ParameterView parameters)
|
||||
{
|
||||
parameters.SetParameterProperties(this);
|
||||
this.Context.RegisterContentTemplate(this);
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Determines if this template should handle the given content.
|
||||
/// Matches FunctionCallContent for the create_plan function.
|
||||
/// </summary>
|
||||
public override bool When(ContentContext context)
|
||||
{
|
||||
// Only match FunctionCallContent for the create_plan function
|
||||
return context.Content is FunctionCallContent call &&
|
||||
string.Equals(call.Name, "create_plan", StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
private RenderFragment RenderCreatePlanCall(ContentContext content) => builder =>
|
||||
{
|
||||
if (content.Content is FunctionCallContent call)
|
||||
{
|
||||
// Get the invocation context which tracks both call and result
|
||||
var invocation = this.Context.GetOrCreateInvocation(call);
|
||||
|
||||
// Provide both the invocation context and message list context to PlanCard
|
||||
builder.OpenComponent<CascadingValue<InvocationContext>>(0);
|
||||
builder.AddComponentParameter(1, "Value", invocation);
|
||||
builder.AddComponentParameter(2, "IsFixed", true);
|
||||
builder.AddComponentParameter(3, "ChildContent", (RenderFragment)(innerBuilder =>
|
||||
{
|
||||
// Also cascade the MessageListContext so PlanCard can track other tool calls
|
||||
innerBuilder.OpenComponent<CascadingValue<MessageListContext>>(0);
|
||||
innerBuilder.AddComponentParameter(1, "Value", this.Context);
|
||||
innerBuilder.AddComponentParameter(2, "IsFixed", true);
|
||||
innerBuilder.AddComponentParameter(3, "ChildContent", (RenderFragment)(cardBuilder =>
|
||||
{
|
||||
// Render the PlanCard component
|
||||
cardBuilder.OpenComponent<PlanCard>(0);
|
||||
cardBuilder.CloseComponent();
|
||||
}));
|
||||
innerBuilder.CloseComponent();
|
||||
}));
|
||||
builder.CloseComponent();
|
||||
}
|
||||
};
|
||||
}
|
||||
-56
@@ -1,56 +0,0 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using Microsoft.Agents.AI;
|
||||
using Microsoft.Extensions.AI;
|
||||
|
||||
namespace AGUIDojoClient.Components.Demos.HumanInTheLoop;
|
||||
|
||||
/// <summary>
|
||||
/// A delegating agent that prepends instructions for the human-in-the-loop workflow.
|
||||
/// </summary>
|
||||
internal sealed class HumanInTheLoopAgent : DelegatingAIAgent
|
||||
{
|
||||
private static readonly ChatMessage InstructionsMessage = new(
|
||||
ChatRole.System,
|
||||
"""
|
||||
You help users create and execute plans. Follow this workflow:
|
||||
|
||||
1. When asked to create a plan, use the `create_plan` tool with a list of step descriptions.
|
||||
2. IMMEDIATELY after creating a plan, call `confirm_plan` with the plan object to ask for user approval.
|
||||
3. Wait for the user to confirm which steps they want to proceed with.
|
||||
4. Once confirmed, use `update_plan_step` to mark steps as 'completed' as you execute them.
|
||||
|
||||
IMPORTANT:
|
||||
- Always call `confirm_plan` right after `create_plan` - don't skip this step!
|
||||
- The plan parameter for `confirm_plan` should be the exact plan object returned from `create_plan`.
|
||||
- Do NOT start executing steps until the user confirms.
|
||||
- After receiving confirmation, update each selected step to 'completed' status.
|
||||
""");
|
||||
|
||||
public HumanInTheLoopAgent(AIAgent innerAgent)
|
||||
: base(innerAgent)
|
||||
{
|
||||
}
|
||||
|
||||
public override Task<AgentRunResponse> RunAsync(
|
||||
IEnumerable<ChatMessage> messages,
|
||||
AgentThread? thread = null,
|
||||
AgentRunOptions? options = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
// Prepend instructions message
|
||||
var messagesWithInstructions = messages.Prepend(InstructionsMessage);
|
||||
return base.RunAsync(messagesWithInstructions, thread, options, cancellationToken);
|
||||
}
|
||||
|
||||
public override IAsyncEnumerable<AgentRunResponseUpdate> RunStreamingAsync(
|
||||
IEnumerable<ChatMessage> messages,
|
||||
AgentThread? thread = null,
|
||||
AgentRunOptions? options = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
// Prepend instructions message
|
||||
var messagesWithInstructions = messages.Prepend(InstructionsMessage);
|
||||
return base.RunStreamingAsync(messagesWithInstructions, thread, options, cancellationToken);
|
||||
}
|
||||
}
|
||||
-162
@@ -1,162 +0,0 @@
|
||||
@* Copyright (c) Microsoft. All rights reserved. *@
|
||||
@using Microsoft.AspNetCore.Components.AI
|
||||
@using Microsoft.Agents.AI
|
||||
@using Microsoft.Extensions.AI
|
||||
@using Microsoft.Extensions.DependencyInjection
|
||||
@using AGUIDojoClient.Components.Shared
|
||||
@using System.Text.Json
|
||||
@using System.ComponentModel
|
||||
@inject IServiceProvider ServiceProvider
|
||||
|
||||
<PageTitle>Human in the Loop</PageTitle>
|
||||
|
||||
<div class="chat-layout">
|
||||
<div class="chat-header">
|
||||
<div class="chat-title">Human in the Loop</div>
|
||||
<button class="new-chat-button" @onclick="ResetConversation">
|
||||
<span class="button-icon">+</span> New chat
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<AgentBoundary Agent="@agent" OnContextCreated="OnContextCreated">
|
||||
<div class="chat-content">
|
||||
<Messages>
|
||||
<ContentTemplates>
|
||||
<CreatePlanCallTemplate />
|
||||
<TextTemplate />
|
||||
<FunctionCallTemplate />
|
||||
<FunctionResultTemplate />
|
||||
<ErrorTemplate />
|
||||
<DataContentTemplate />
|
||||
</ContentTemplates>
|
||||
</Messages>
|
||||
<AgentLoadingIndicator />
|
||||
</div>
|
||||
|
||||
<div class="chat-input-container">
|
||||
<AgentSuggestions Suggestions="@suggestions" />
|
||||
<AgentInput Placeholder="Ask for a plan..." />
|
||||
</div>
|
||||
</AgentBoundary>
|
||||
</div>
|
||||
|
||||
@code {
|
||||
private AIAgent? agent;
|
||||
private IAgentBoundaryContext? boundaryContext;
|
||||
private Plan? currentPlan;
|
||||
|
||||
private Suggestion[] suggestions = [
|
||||
new Suggestion("Simple plan", new ChatMessage(ChatRole.User, "Create a simple 5-step plan for organizing a birthday party")),
|
||||
new Suggestion("Complex plan", new ChatMessage(ChatRole.User, "Create a detailed 10-step plan for launching a new product"))
|
||||
];
|
||||
|
||||
[Parameter]
|
||||
public string ScenarioId { get; set; } = "human_in_the_loop";
|
||||
|
||||
protected override void OnInitialized()
|
||||
{
|
||||
agent = ServiceProvider.GetRequiredKeyedService<AIAgent>("human-in-the-loop");
|
||||
}
|
||||
|
||||
private void OnContextCreated(IAgentBoundaryContext context)
|
||||
{
|
||||
boundaryContext = context;
|
||||
|
||||
// Register all three frontend tools for the human-in-the-loop scenario
|
||||
|
||||
// 1. create_plan - creates a plan and stores it
|
||||
var createPlanTool = AIFunctionFactory.Create(
|
||||
(List<string> steps) => CreatePlan(steps),
|
||||
"create_plan",
|
||||
"Create a plan with multiple steps. Call this first before confirm_plan.");
|
||||
|
||||
// 2. confirm_plan - waits for user confirmation via UI, receives the plan to display
|
||||
var confirmPlanTool = AIFunctionFactory.Create(
|
||||
(Plan plan) => ConfirmPlanAsync(context, plan),
|
||||
"confirm_plan",
|
||||
"Present the plan to the user for confirmation. The user can select which steps to proceed with. Pass the plan returned from create_plan.");
|
||||
|
||||
// 3. update_plan_step - updates a step's status
|
||||
var updatePlanStepTool = AIFunctionFactory.Create(
|
||||
(int index, string? description, string? status) => UpdatePlanStep(index, description, status),
|
||||
"update_plan_step",
|
||||
"Update a step in the plan with new description or status. Use status 'completed' to mark a step as done.");
|
||||
|
||||
context.RegisterTools(createPlanTool, confirmPlanTool, updatePlanStepTool);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Frontend tool that creates a plan with the given steps.
|
||||
/// </summary>
|
||||
[Description("Create a plan with multiple steps.")]
|
||||
private Plan CreatePlan([Description("List of step descriptions to create the plan.")] List<string> steps)
|
||||
{
|
||||
currentPlan = new Plan
|
||||
{
|
||||
Steps = [.. steps.Select(s => new Step { Description = s, Status = "pending" })]
|
||||
};
|
||||
return currentPlan;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Frontend tool that waits for user confirmation via the UI.
|
||||
/// The PlanCard component will call ProvideResponse when the user confirms/rejects.
|
||||
/// </summary>
|
||||
[Description("Present the plan to the user for confirmation.")]
|
||||
private static async Task<PlanConfirmationResult> ConfirmPlanAsync(
|
||||
IAgentBoundaryContext context,
|
||||
[Description("The plan to present to the user for confirmation.")] Plan plan)
|
||||
{
|
||||
// The plan parameter is received and will be accessible via InvocationContext in the PlanCard
|
||||
// Wait for the PlanCard component to provide the response
|
||||
var response = await context.WaitForResponse("confirm_plan");
|
||||
return (PlanConfirmationResult)response;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Frontend tool that updates a step in the plan.
|
||||
/// </summary>
|
||||
[Description("Update a step in the plan with new description or status.")]
|
||||
private List<JsonPatchOperation> UpdatePlanStep(
|
||||
[Description("The index of the step to update.")] int index,
|
||||
[Description("The new description for the step (optional).")] string? description = null,
|
||||
[Description("The new status for the step: 'pending' or 'completed'.")] string? status = null)
|
||||
{
|
||||
var changes = new List<JsonPatchOperation>();
|
||||
|
||||
if (currentPlan is null || index < 0 || index >= currentPlan.Steps.Count)
|
||||
{
|
||||
return changes;
|
||||
}
|
||||
|
||||
if (description is not null)
|
||||
{
|
||||
currentPlan.Steps[index].Description = description;
|
||||
changes.Add(new JsonPatchOperation
|
||||
{
|
||||
Op = "replace",
|
||||
Path = $"/steps/{index}/description",
|
||||
Value = description
|
||||
});
|
||||
}
|
||||
|
||||
if (status is not null)
|
||||
{
|
||||
currentPlan.Steps[index].Status = status.ToLowerInvariant();
|
||||
changes.Add(new JsonPatchOperation
|
||||
{
|
||||
Op = "replace",
|
||||
Path = $"/steps/{index}/status",
|
||||
Value = status.ToLowerInvariant()
|
||||
});
|
||||
}
|
||||
|
||||
return changes;
|
||||
}
|
||||
|
||||
private void ResetConversation()
|
||||
{
|
||||
currentPlan = null;
|
||||
StateHasChanged();
|
||||
}
|
||||
}
|
||||
-86
@@ -1,86 +0,0 @@
|
||||
/* Copyright (c) Microsoft. All rights reserved. */
|
||||
|
||||
.chat-layout {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.chat-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 1.5rem 2rem;
|
||||
border-bottom: 1px solid #e5e5e5;
|
||||
}
|
||||
|
||||
.chat-title {
|
||||
font-size: 1.5rem;
|
||||
font-weight: 600;
|
||||
color: #1a1a1a;
|
||||
}
|
||||
|
||||
.new-chat-button {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.5rem 1rem;
|
||||
background: white;
|
||||
border: 1px solid #d1d1d1;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
font-size: 0.875rem;
|
||||
color: #424242;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.new-chat-button:hover {
|
||||
background: #f5f5f5;
|
||||
border-color: #b3b3b3;
|
||||
}
|
||||
|
||||
.button-icon {
|
||||
font-size: 1.2rem;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.chat-content {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 2rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.chat-input-container {
|
||||
padding: 1.5rem 2rem 2rem;
|
||||
border-top: 1px solid #e5e5e5;
|
||||
background: white;
|
||||
}
|
||||
|
||||
::deep .agent-suggestions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.5rem;
|
||||
justify-content: flex-start;
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
|
||||
::deep .suggestion-button {
|
||||
padding: 0.5rem 1rem;
|
||||
background: white;
|
||||
border: 1px solid #d1d1d1;
|
||||
border-radius: 1rem;
|
||||
cursor: pointer;
|
||||
font-size: 0.875rem;
|
||||
color: #424242;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
::deep .suggestion-button:hover {
|
||||
background: #f0f0f0;
|
||||
border-color: #0078d4;
|
||||
color: #0078d4;
|
||||
}
|
||||
-29
@@ -1,29 +0,0 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace AGUIDojoClient.Components.Demos.HumanInTheLoop;
|
||||
|
||||
/// <summary>
|
||||
/// Represents a JSON Patch operation.
|
||||
/// </summary>
|
||||
public sealed class JsonPatchOperation
|
||||
{
|
||||
/// <summary>
|
||||
/// The operation type (e.g., "replace", "add", "remove").
|
||||
/// </summary>
|
||||
[JsonPropertyName("op")]
|
||||
public string Op { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// The JSON Pointer path to the target location.
|
||||
/// </summary>
|
||||
[JsonPropertyName("path")]
|
||||
public string Path { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// The value for the operation.
|
||||
/// </summary>
|
||||
[JsonPropertyName("value")]
|
||||
public object? Value { get; set; }
|
||||
}
|
||||
@@ -1,35 +0,0 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace AGUIDojoClient.Components.Demos.HumanInTheLoop;
|
||||
|
||||
/// <summary>
|
||||
/// Represents a plan with multiple steps.
|
||||
/// </summary>
|
||||
public sealed class Plan
|
||||
{
|
||||
/// <summary>
|
||||
/// The list of steps in the plan.
|
||||
/// </summary>
|
||||
[JsonPropertyName("steps")]
|
||||
public List<Step> Steps { get; set; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// Gets the count of completed steps.
|
||||
/// </summary>
|
||||
[JsonIgnore]
|
||||
public int CompletedCount => this.Steps.Count(s => s.IsCompleted);
|
||||
|
||||
/// <summary>
|
||||
/// Gets the total number of steps.
|
||||
/// </summary>
|
||||
[JsonIgnore]
|
||||
public int TotalCount => this.Steps.Count;
|
||||
|
||||
/// <summary>
|
||||
/// Gets whether all steps are completed.
|
||||
/// </summary>
|
||||
[JsonIgnore]
|
||||
public bool IsComplete => this.Steps.Count > 0 && this.Steps.All(s => s.IsCompleted);
|
||||
}
|
||||
-324
@@ -1,324 +0,0 @@
|
||||
@* Copyright (c) Microsoft. All rights reserved. *@
|
||||
@using Microsoft.AspNetCore.Components.AI
|
||||
@using Microsoft.Extensions.AI
|
||||
@using System.Text.Json
|
||||
@implements IDisposable
|
||||
|
||||
@if (IsWaitingForPlan)
|
||||
{
|
||||
<div class="plan-card plan-loading">
|
||||
<div class="plan-header">
|
||||
<h3>Loading Plan...</h3>
|
||||
<div class="plan-progress">
|
||||
<span class="skeleton-text skeleton-progress"></span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="plan-steps">
|
||||
@for (int i = 0; i < 3; i++)
|
||||
{
|
||||
<div class="plan-step skeleton-step">
|
||||
<span class="step-checkbox skeleton-checkbox"></span>
|
||||
<span class="step-description skeleton-text skeleton-description"></span>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
<div class="plan-actions">
|
||||
<button class="plan-button plan-button-skeleton" disabled>Loading...</button>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
else if (CurrentPlan is not null && AwaitingConfirmation)
|
||||
{
|
||||
<div class="plan-card plan-active">
|
||||
<div class="plan-header">
|
||||
<h3>Plan Confirmation</h3>
|
||||
<div class="plan-progress">
|
||||
<span class="progress-count">@CurrentPlan.CompletedCount / @CurrentPlan.TotalCount completed</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="plan-steps">
|
||||
@for (int i = 0; i < CurrentPlan.Steps.Count; i++)
|
||||
{
|
||||
var step = CurrentPlan.Steps[i];
|
||||
var index = i;
|
||||
<div class="plan-step @(step.IsCompleted ? "step-completed" : "step-pending") @(selectedSteps.Contains(index) ? "step-selected" : "")">
|
||||
<label class="step-checkbox-label">
|
||||
<input type="checkbox"
|
||||
class="step-checkbox"
|
||||
checked="@selectedSteps.Contains(index)"
|
||||
disabled="@step.IsCompleted"
|
||||
@onchange="() => ToggleStepSelection(index)" />
|
||||
<span class="checkmark @(step.IsCompleted || selectedSteps.Contains(index) ? "checkmark-completed" : "")">
|
||||
@if (step.IsCompleted || selectedSteps.Contains(index))
|
||||
{
|
||||
<span class="check-icon">✓</span>
|
||||
}
|
||||
</span>
|
||||
</label>
|
||||
<span class="step-description @(step.IsCompleted ? "description-completed" : "")">@step.Description</span>
|
||||
<span class="step-status @(step.IsCompleted ? "status-completed" : "status-pending")">
|
||||
@(step.IsCompleted ? "Done" : "Pending")
|
||||
</span>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
<div class="plan-actions">
|
||||
<button class="plan-button plan-button-confirm" @onclick="ConfirmPlan" disabled="@(selectedSteps.Count == 0)">
|
||||
Confirm Selected (@selectedSteps.Count)
|
||||
</button>
|
||||
<button class="plan-button plan-button-reject" @onclick="RejectPlan">
|
||||
Reject
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
else if (CurrentPlan is not null && !AwaitingConfirmation && !WasRejected)
|
||||
{
|
||||
<div class="plan-card @(CurrentPlan.IsComplete ? "plan-completed" : "plan-executing")">
|
||||
<div class="plan-header">
|
||||
<h3>@(CurrentPlan.IsComplete ? "Plan Completed" : "Executing Plan")</h3>
|
||||
@if (CurrentPlan.IsComplete)
|
||||
{
|
||||
<span class="completion-badge">✓ All Done</span>
|
||||
}
|
||||
else
|
||||
{
|
||||
<div class="plan-progress">
|
||||
<span class="progress-count">@CurrentPlan.CompletedCount / @CurrentPlan.TotalCount completed</span>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
<div class="plan-steps">
|
||||
@foreach (var step in CurrentPlan.Steps)
|
||||
{
|
||||
<div class="plan-step @(step.IsCompleted ? "step-completed" : "step-pending")">
|
||||
<span class="checkmark @(step.IsCompleted ? "checkmark-completed" : "")">
|
||||
@if (step.IsCompleted)
|
||||
{
|
||||
<span class="check-icon">✓</span>
|
||||
}
|
||||
</span>
|
||||
<span class="step-description @(step.IsCompleted ? "description-completed" : "")">@step.Description</span>
|
||||
<span class="step-status @(step.IsCompleted ? "status-completed" : "status-pending")">
|
||||
@(step.IsCompleted ? "Done" : "Pending")
|
||||
</span>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
else if (WasRejected)
|
||||
{
|
||||
<div class="plan-card plan-rejected">
|
||||
<div class="plan-header">
|
||||
<h3>Plan Rejected</h3>
|
||||
<span class="rejection-badge">✗ Cancelled</span>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
@code {
|
||||
[CascadingParameter]
|
||||
public InvocationContext Invocation { get; set; } = default!;
|
||||
|
||||
[CascadingParameter]
|
||||
public MessageListContext? MessageListContext { get; set; }
|
||||
|
||||
[CascadingParameter]
|
||||
public IAgentBoundaryContext? BoundaryContext { get; set; }
|
||||
|
||||
private Plan? CurrentPlan;
|
||||
private HashSet<int> selectedSteps = new();
|
||||
private bool AwaitingConfirmation;
|
||||
private bool WasRejected;
|
||||
private bool IsWaitingForPlan => CurrentPlan is null && !WasRejected;
|
||||
private ResponseUpdateSubscription? _responseSubscription;
|
||||
private string? _confirmPlanCallId;
|
||||
|
||||
protected override void OnInitialized()
|
||||
{
|
||||
// The plan comes from the create_plan function result
|
||||
if (Invocation?.HasResult == true)
|
||||
{
|
||||
TryParsePlanFromResult();
|
||||
}
|
||||
else if (Invocation is not null)
|
||||
{
|
||||
// Subscribe to wait for the result
|
||||
Invocation.ResultArrived += OnCreatePlanResultArrived;
|
||||
}
|
||||
|
||||
// Subscribe to response updates to detect confirm_plan and update_plan_step calls
|
||||
if (BoundaryContext is not null)
|
||||
{
|
||||
_responseSubscription = BoundaryContext.SubscribeToResponseUpdates(OnResponseUpdate);
|
||||
}
|
||||
}
|
||||
|
||||
private void OnCreatePlanResultArrived()
|
||||
{
|
||||
TryParsePlanFromResult();
|
||||
InvokeAsync(StateHasChanged);
|
||||
}
|
||||
|
||||
private void TryParsePlanFromResult()
|
||||
{
|
||||
var plan = Invocation?.GetResult<Plan>();
|
||||
if (plan is not null)
|
||||
{
|
||||
CurrentPlan = plan;
|
||||
InitializeSelectedSteps();
|
||||
}
|
||||
}
|
||||
|
||||
private void InitializeSelectedSteps()
|
||||
{
|
||||
// Select all pending steps by default
|
||||
if (CurrentPlan is not null)
|
||||
{
|
||||
selectedSteps.Clear();
|
||||
for (int i = 0; i < CurrentPlan.Steps.Count; i++)
|
||||
{
|
||||
if (!CurrentPlan.Steps[i].IsCompleted)
|
||||
{
|
||||
selectedSteps.Add(i);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void OnResponseUpdate()
|
||||
{
|
||||
// Check the current update for tool calls
|
||||
var update = BoundaryContext?.CurrentUpdate;
|
||||
if (update is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (update.Contents is not null)
|
||||
{
|
||||
foreach (var content in update.Contents)
|
||||
{
|
||||
if (content is FunctionCallContent call)
|
||||
{
|
||||
if (string.Equals(call.Name, "confirm_plan", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
HandleConfirmPlanCall(call);
|
||||
}
|
||||
else if (string.Equals(call.Name, "update_plan_step", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
HandleUpdatePlanStepCall(call);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
InvokeAsync(StateHasChanged);
|
||||
}
|
||||
|
||||
private void HandleConfirmPlanCall(FunctionCallContent call)
|
||||
{
|
||||
// When confirm_plan is called, show the confirmation UI
|
||||
if (!AwaitingConfirmation && !WasRejected && _confirmPlanCallId is null)
|
||||
{
|
||||
_confirmPlanCallId = call.CallId;
|
||||
AwaitingConfirmation = true;
|
||||
}
|
||||
}
|
||||
|
||||
private void HandleUpdatePlanStepCall(FunctionCallContent call)
|
||||
{
|
||||
if (CurrentPlan is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
// Get the index and status from arguments
|
||||
if (call.Arguments is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
int? index = null;
|
||||
string? status = null;
|
||||
string? description = null;
|
||||
|
||||
if (call.Arguments.TryGetValue("index", out var indexObj))
|
||||
{
|
||||
if (indexObj is int i) index = i;
|
||||
else if (indexObj is long l) index = (int)l;
|
||||
else if (indexObj is JsonElement je && je.ValueKind == JsonValueKind.Number) index = je.GetInt32();
|
||||
}
|
||||
|
||||
if (call.Arguments.TryGetValue("status", out var statusObj))
|
||||
{
|
||||
if (statusObj is string s) status = s;
|
||||
else if (statusObj is JsonElement je && je.ValueKind == JsonValueKind.String) status = je.GetString();
|
||||
}
|
||||
|
||||
if (call.Arguments.TryGetValue("description", out var descObj))
|
||||
{
|
||||
if (descObj is string s) description = s;
|
||||
else if (descObj is JsonElement je && je.ValueKind == JsonValueKind.String) description = je.GetString();
|
||||
}
|
||||
|
||||
// Apply the update
|
||||
if (index.HasValue && index.Value >= 0 && index.Value < CurrentPlan.Steps.Count)
|
||||
{
|
||||
if (status is not null)
|
||||
{
|
||||
CurrentPlan.Steps[index.Value].Status = status.ToLowerInvariant();
|
||||
}
|
||||
if (description is not null)
|
||||
{
|
||||
CurrentPlan.Steps[index.Value].Description = description;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void ToggleStepSelection(int index)
|
||||
{
|
||||
if (selectedSteps.Contains(index))
|
||||
{
|
||||
selectedSteps.Remove(index);
|
||||
}
|
||||
else
|
||||
{
|
||||
selectedSteps.Add(index);
|
||||
}
|
||||
StateHasChanged();
|
||||
}
|
||||
|
||||
private void ConfirmPlan()
|
||||
{
|
||||
AwaitingConfirmation = false;
|
||||
var result = new PlanConfirmationResult
|
||||
{
|
||||
Confirmed = true,
|
||||
SelectedStepIndices = selectedSteps.ToList()
|
||||
};
|
||||
BoundaryContext?.ProvideResponse("confirm_plan", result);
|
||||
}
|
||||
|
||||
private void RejectPlan()
|
||||
{
|
||||
WasRejected = true;
|
||||
AwaitingConfirmation = false;
|
||||
var result = new PlanConfirmationResult
|
||||
{
|
||||
Confirmed = false,
|
||||
SelectedStepIndices = []
|
||||
};
|
||||
BoundaryContext?.ProvideResponse("confirm_plan", result);
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
if (Invocation is not null)
|
||||
{
|
||||
Invocation.ResultArrived -= OnCreatePlanResultArrived;
|
||||
}
|
||||
_responseSubscription?.Dispose();
|
||||
}
|
||||
}
|
||||
-244
@@ -1,244 +0,0 @@
|
||||
.plan-card {
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
border-radius: 16px;
|
||||
padding: 20px;
|
||||
color: white;
|
||||
max-width: 420px;
|
||||
box-shadow: 0 10px 40px rgba(102, 126, 234, 0.4);
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||
}
|
||||
|
||||
.plan-card.plan-active {
|
||||
background: linear-gradient(135deg, #4facfe 0%, #00f2fe 100%);
|
||||
}
|
||||
|
||||
.plan-card.plan-completed {
|
||||
background: linear-gradient(135deg, #11998e 0%, #38ef7d 100%);
|
||||
}
|
||||
|
||||
.plan-card.plan-rejected {
|
||||
background: linear-gradient(135deg, #f5576c 0%, #f093fb 100%);
|
||||
}
|
||||
|
||||
.plan-card.plan-loading {
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
}
|
||||
|
||||
.plan-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 20px;
|
||||
padding-bottom: 12px;
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.2);
|
||||
}
|
||||
|
||||
.plan-header h3 {
|
||||
margin: 0;
|
||||
font-size: 1.3rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.plan-progress .progress-count {
|
||||
font-size: 0.85rem;
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
.completion-badge,
|
||||
.rejection-badge {
|
||||
font-size: 0.9rem;
|
||||
padding: 4px 10px;
|
||||
border-radius: 12px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.completion-badge {
|
||||
background: rgba(255, 255, 255, 0.25);
|
||||
}
|
||||
|
||||
.rejection-badge {
|
||||
background: rgba(255, 255, 255, 0.25);
|
||||
}
|
||||
|
||||
.plan-steps {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.plan-step {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 10px 12px;
|
||||
background: rgba(255, 255, 255, 0.15);
|
||||
border-radius: 10px;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.plan-step.step-selected {
|
||||
background: rgba(255, 255, 255, 0.25);
|
||||
box-shadow: 0 0 0 2px rgba(255, 255, 255, 0.5);
|
||||
}
|
||||
|
||||
.plan-step.step-completed {
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
.step-checkbox-label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.step-checkbox {
|
||||
position: absolute;
|
||||
opacity: 0;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.checkmark {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
border: 2px solid rgba(255, 255, 255, 0.6);
|
||||
border-radius: 6px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: transparent;
|
||||
transition: all 0.2s ease;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.step-checkbox:checked + .checkmark {
|
||||
background: rgba(255, 255, 255, 0.3);
|
||||
border-color: white;
|
||||
}
|
||||
|
||||
.checkmark-completed {
|
||||
background: rgba(255, 255, 255, 0.3);
|
||||
border-color: white;
|
||||
}
|
||||
|
||||
.check-icon {
|
||||
font-size: 14px;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.step-description {
|
||||
flex: 1;
|
||||
font-size: 0.95rem;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.description-completed {
|
||||
opacity: 0.7;
|
||||
text-decoration: line-through;
|
||||
}
|
||||
|
||||
.step-status {
|
||||
font-size: 0.75rem;
|
||||
padding: 3px 8px;
|
||||
border-radius: 6px;
|
||||
font-weight: 500;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
.status-pending {
|
||||
background: rgba(255, 255, 255, 0.2);
|
||||
}
|
||||
|
||||
.status-completed {
|
||||
background: rgba(255, 255, 255, 0.3);
|
||||
}
|
||||
|
||||
.plan-actions {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
margin-top: 16px;
|
||||
}
|
||||
|
||||
.plan-button {
|
||||
flex: 1;
|
||||
padding: 12px 20px;
|
||||
border: none;
|
||||
border-radius: 10px;
|
||||
font-size: 0.95rem;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.plan-button:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.plan-button-confirm {
|
||||
background: rgba(255, 255, 255, 0.25);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.plan-button-confirm:hover:not(:disabled) {
|
||||
background: rgba(255, 255, 255, 0.35);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.plan-button-reject {
|
||||
background: rgba(0, 0, 0, 0.15);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.plan-button-reject:hover {
|
||||
background: rgba(0, 0, 0, 0.25);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.plan-button-skeleton {
|
||||
background: rgba(255, 255, 255, 0.15);
|
||||
color: rgba(255, 255, 255, 0.5);
|
||||
}
|
||||
|
||||
/* Loading/Skeleton state */
|
||||
.skeleton-text {
|
||||
background: linear-gradient(90deg, rgba(255, 255, 255, 0.2) 25%, rgba(255, 255, 255, 0.4) 50%, rgba(255, 255, 255, 0.2) 75%);
|
||||
background-size: 200% 100%;
|
||||
animation: skeleton-shimmer 1.5s infinite;
|
||||
border-radius: 4px;
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
.skeleton-progress {
|
||||
width: 100px;
|
||||
height: 1rem;
|
||||
}
|
||||
|
||||
.skeleton-step {
|
||||
padding: 14px 12px;
|
||||
}
|
||||
|
||||
.skeleton-checkbox {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
background: linear-gradient(90deg, rgba(255, 255, 255, 0.2) 25%, rgba(255, 255, 255, 0.4) 50%, rgba(255, 255, 255, 0.2) 75%);
|
||||
background-size: 200% 100%;
|
||||
animation: skeleton-shimmer 1.5s infinite;
|
||||
border-radius: 6px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.skeleton-description {
|
||||
width: 200px;
|
||||
height: 1rem;
|
||||
}
|
||||
|
||||
@keyframes skeleton-shimmer {
|
||||
0% {
|
||||
background-position: 200% 0;
|
||||
}
|
||||
100% {
|
||||
background-position: -200% 0;
|
||||
}
|
||||
}
|
||||
-12
@@ -1,12 +0,0 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
namespace AGUIDojoClient.Components.Demos.HumanInTheLoop;
|
||||
|
||||
/// <summary>
|
||||
/// Result of user's plan confirmation decision.
|
||||
/// </summary>
|
||||
public class PlanConfirmationResult
|
||||
{
|
||||
public bool Confirmed { get; set; }
|
||||
public List<int> SelectedStepIndices { get; set; } = [];
|
||||
}
|
||||
@@ -1,87 +0,0 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System.Text.RegularExpressions;
|
||||
|
||||
namespace AGUIDojoClient.Components.Demos.HumanInTheLoop;
|
||||
|
||||
/// <summary>
|
||||
/// Applies JSON Patch operations to a Plan.
|
||||
/// Uses hardcoded path parsing for the expected paths:
|
||||
/// - /steps/{index}/status
|
||||
/// - /steps/{index}/description
|
||||
/// </summary>
|
||||
public static partial class PlanPatcher
|
||||
{
|
||||
// Regex to match paths like /steps/0/status or /steps/1/description
|
||||
[GeneratedRegex(@"^/steps/(\d+)/(status|description)$")]
|
||||
private static partial Regex StepPropertyPathRegex();
|
||||
|
||||
/// <summary>
|
||||
/// Applies a JSON Patch operation to the plan.
|
||||
/// Only supports "replace" operations on /steps/{index}/status and /steps/{index}/description paths.
|
||||
/// </summary>
|
||||
/// <param name="plan">The plan to modify.</param>
|
||||
/// <param name="operation">The patch operation to apply.</param>
|
||||
/// <returns>True if the operation was applied successfully, false otherwise.</returns>
|
||||
public static bool ApplyPatch(Plan plan, JsonPatchOperation operation)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(plan);
|
||||
ArgumentNullException.ThrowIfNull(operation);
|
||||
|
||||
// Only support "replace" operations
|
||||
if (!string.Equals(operation.Op, "replace", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var match = StepPropertyPathRegex().Match(operation.Path);
|
||||
if (!match.Success)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!int.TryParse(match.Groups[1].Value, out int stepIndex))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (stepIndex < 0 || stepIndex >= plan.Steps.Count)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var propertyName = match.Groups[2].Value;
|
||||
var step = plan.Steps[stepIndex];
|
||||
|
||||
switch (propertyName.ToUpperInvariant())
|
||||
{
|
||||
case "STATUS":
|
||||
step.Status = operation.Value?.ToString() ?? "pending";
|
||||
return true;
|
||||
case "DESCRIPTION":
|
||||
step.Description = operation.Value?.ToString() ?? string.Empty;
|
||||
return true;
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Applies multiple JSON Patch operations to the plan.
|
||||
/// </summary>
|
||||
/// <param name="plan">The plan to modify.</param>
|
||||
/// <param name="operations">The patch operations to apply.</param>
|
||||
/// <returns>The number of operations successfully applied.</returns>
|
||||
public static int ApplyPatches(Plan plan, IEnumerable<JsonPatchOperation> operations)
|
||||
{
|
||||
int appliedCount = 0;
|
||||
foreach (var operation in operations)
|
||||
{
|
||||
if (ApplyPatch(plan, operation))
|
||||
{
|
||||
appliedCount++;
|
||||
}
|
||||
}
|
||||
return appliedCount;
|
||||
}
|
||||
}
|
||||
@@ -1,29 +0,0 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace AGUIDojoClient.Components.Demos.HumanInTheLoop;
|
||||
|
||||
/// <summary>
|
||||
/// Represents a single step in a plan.
|
||||
/// </summary>
|
||||
public sealed class Step
|
||||
{
|
||||
/// <summary>
|
||||
/// The description of the step.
|
||||
/// </summary>
|
||||
[JsonPropertyName("description")]
|
||||
public string Description { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// The status of the step (pending or completed).
|
||||
/// </summary>
|
||||
[JsonPropertyName("status")]
|
||||
public string Status { get; set; } = "pending";
|
||||
|
||||
/// <summary>
|
||||
/// Gets whether this step is completed.
|
||||
/// </summary>
|
||||
[JsonIgnore]
|
||||
public bool IsCompleted => string.Equals(this.Status, "completed", StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
-14
@@ -1,14 +0,0 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
namespace AGUIDojoClient.Components.Demos.PredictiveStateUpdates;
|
||||
|
||||
/// <summary>
|
||||
/// Represents the result of the confirm_changes frontend tool.
|
||||
/// </summary>
|
||||
public sealed class ConfirmChangesResult
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets or sets a value indicating whether the user confirmed the changes.
|
||||
/// </summary>
|
||||
public bool Confirmed { get; set; }
|
||||
}
|
||||
-18
@@ -1,18 +0,0 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace AGUIDojoClient.Components.Demos.PredictiveStateUpdates;
|
||||
|
||||
/// <summary>
|
||||
/// Represents the document state for the Predictive State Updates demo.
|
||||
/// This model mirrors the server-side DocumentState and is updated via streaming state updates.
|
||||
/// </summary>
|
||||
public sealed class DocumentState
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets or sets the document content in Markdown format.
|
||||
/// </summary>
|
||||
[JsonPropertyName("document")]
|
||||
public string Document { get; set; } = string.Empty;
|
||||
}
|
||||
-249
@@ -1,249 +0,0 @@
|
||||
@* Copyright (c) Microsoft. All rights reserved. *@
|
||||
@using Microsoft.AspNetCore.Components.AI
|
||||
@using Microsoft.Agents.AI
|
||||
@using Microsoft.Extensions.AI
|
||||
@using Microsoft.Extensions.DependencyInjection
|
||||
@using AGUIDojoClient.Components.Shared
|
||||
@using System.Text.Json
|
||||
@using System.ComponentModel
|
||||
@implements IDisposable
|
||||
@inject IServiceProvider ServiceProvider
|
||||
|
||||
<PageTitle>AI Document Editor</PageTitle>
|
||||
|
||||
<div class="predictive-state-layout">
|
||||
@* Left panel: Document editor *@
|
||||
<div class="document-panel">
|
||||
<div class="document-header">
|
||||
<h2>Document Editor</h2>
|
||||
@if (isStreaming)
|
||||
{
|
||||
<span class="streaming-indicator">
|
||||
<span class="streaming-dot"></span>
|
||||
Writing...
|
||||
</span>
|
||||
}
|
||||
</div>
|
||||
<div class="document-content">
|
||||
@if (string.IsNullOrWhiteSpace(currentDocument))
|
||||
{
|
||||
<div class="document-placeholder">
|
||||
Write whatever you want here in Markdown format...
|
||||
</div>
|
||||
}
|
||||
else
|
||||
{
|
||||
<pre class="document-text">@currentDocument</pre>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@* Right panel: Chat sidebar *@
|
||||
<div class="chat-panel">
|
||||
<div class="chat-header">
|
||||
<div class="chat-title">AI Document Editor</div>
|
||||
<button class="new-chat-button" @onclick="ResetConversation">
|
||||
<span class="button-icon">+</span> New chat
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<AgentBoundary Agent="@agent" OnContextCreated="OnContextCreated">
|
||||
<div class="chat-content">
|
||||
<Messages>
|
||||
<ContentTemplates>
|
||||
<TextTemplate />
|
||||
<FunctionCallTemplate />
|
||||
<FunctionResultTemplate />
|
||||
<ErrorTemplate />
|
||||
<DataContentTemplate />
|
||||
</ContentTemplates>
|
||||
</Messages>
|
||||
<AgentLoadingIndicator />
|
||||
</div>
|
||||
|
||||
<AgentState TState="DocumentState"
|
||||
CurrentState="@currentDocumentState"
|
||||
OnSnapshot="@DeserializeDocumentState"
|
||||
CurrentStateChanged="@OnDocumentStateChanged" />
|
||||
|
||||
<div class="chat-input-container">
|
||||
<AgentSuggestions Suggestions="@suggestions" />
|
||||
<AgentInput Placeholder="Ask the AI to write or edit..." />
|
||||
</div>
|
||||
</AgentBoundary>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@* Confirmation modal *@
|
||||
@if (awaitingConfirmation)
|
||||
{
|
||||
<div class="confirmation-overlay">
|
||||
<div class="confirmation-modal">
|
||||
<h3>Confirm Changes</h3>
|
||||
<p>Do you want to accept the changes?</p>
|
||||
<div class="confirmation-actions">
|
||||
<button class="confirm-button reject" @onclick="RejectChanges">
|
||||
Reject
|
||||
</button>
|
||||
<button class="confirm-button accept" @onclick="ConfirmChanges">
|
||||
Confirm
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
@code {
|
||||
private AIAgent? agent;
|
||||
private IAgentBoundaryContext? boundaryContext;
|
||||
private ResponseUpdateSubscription? responseSubscription;
|
||||
|
||||
private DocumentState? currentDocumentState;
|
||||
private string currentDocument = string.Empty;
|
||||
private string previousDocument = string.Empty;
|
||||
private bool isStreaming;
|
||||
private bool awaitingConfirmation;
|
||||
|
||||
private Suggestion[] suggestions = [
|
||||
new Suggestion("Write a pirate story", new ChatMessage(ChatRole.User, "Please write a story about a pirate named Candy Beard")),
|
||||
new Suggestion("Write a mermaid story", new ChatMessage(ChatRole.User, "Please write a story about a mermaid named Pearl")),
|
||||
new Suggestion("Add character", new ChatMessage(ChatRole.User, "Add a new character to the story"))
|
||||
];
|
||||
|
||||
[Parameter]
|
||||
public string ScenarioId { get; set; } = "predictive_state_updates";
|
||||
|
||||
protected override void OnInitialized()
|
||||
{
|
||||
agent = ServiceProvider.GetRequiredKeyedService<AIAgent>("predictive-state-updates");
|
||||
}
|
||||
|
||||
private void OnContextCreated(IAgentBoundaryContext context)
|
||||
{
|
||||
boundaryContext = context;
|
||||
|
||||
// Register the confirm_changes frontend tool
|
||||
var confirmChangesTool = AIFunctionFactory.Create(
|
||||
() => ConfirmChangesAsync(context),
|
||||
"confirm_changes",
|
||||
"Ask the user to confirm or reject the document changes.");
|
||||
|
||||
context.RegisterTools(confirmChangesTool);
|
||||
|
||||
// Subscribe to response updates to detect when streaming starts/stops
|
||||
responseSubscription = context.SubscribeToResponseUpdates(OnResponseUpdate);
|
||||
}
|
||||
|
||||
private void OnResponseUpdate()
|
||||
{
|
||||
var update = boundaryContext?.CurrentUpdate;
|
||||
if (update is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
// Check for function calls to detect confirm_changes
|
||||
if (update.Contents is not null)
|
||||
{
|
||||
foreach (var content in update.Contents)
|
||||
{
|
||||
if (content is FunctionCallContent call &&
|
||||
string.Equals(call.Name, "confirm_changes", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
// Show confirmation dialog when confirm_changes is called
|
||||
awaitingConfirmation = true;
|
||||
isStreaming = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
InvokeAsync(StateHasChanged);
|
||||
}
|
||||
|
||||
private DocumentState? DeserializeDocumentState(ReadOnlyMemory<byte> data)
|
||||
{
|
||||
try
|
||||
{
|
||||
return JsonSerializer.Deserialize<DocumentState>(data.Span);
|
||||
}
|
||||
catch
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private void OnDocumentStateChanged(DocumentState? state)
|
||||
{
|
||||
if (state is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
currentDocumentState = state;
|
||||
|
||||
// Store the previous document before updating (for reject functionality)
|
||||
if (string.IsNullOrEmpty(previousDocument) && !string.IsNullOrEmpty(currentDocument))
|
||||
{
|
||||
previousDocument = currentDocument;
|
||||
}
|
||||
|
||||
// Check if we're starting to stream (document changed)
|
||||
if (currentDocument != state.Document)
|
||||
{
|
||||
isStreaming = true;
|
||||
}
|
||||
|
||||
currentDocument = state.Document;
|
||||
StateHasChanged();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Frontend tool that waits for user confirmation via the UI.
|
||||
/// </summary>
|
||||
[Description("Ask the user to confirm or reject the document changes.")]
|
||||
private static async Task<ConfirmChangesResult> ConfirmChangesAsync(IAgentBoundaryContext context)
|
||||
{
|
||||
// Wait for the user to click Confirm or Reject
|
||||
var response = await context.WaitForResponse("confirm_changes");
|
||||
return (ConfirmChangesResult)response;
|
||||
}
|
||||
|
||||
private void ConfirmChanges()
|
||||
{
|
||||
awaitingConfirmation = false;
|
||||
isStreaming = false;
|
||||
|
||||
// Update the previous document to the current one (changes accepted)
|
||||
previousDocument = currentDocument;
|
||||
|
||||
boundaryContext?.ProvideResponse("confirm_changes", new ConfirmChangesResult { Confirmed = true });
|
||||
StateHasChanged();
|
||||
}
|
||||
|
||||
private void RejectChanges()
|
||||
{
|
||||
awaitingConfirmation = false;
|
||||
isStreaming = false;
|
||||
|
||||
// Revert to the previous document
|
||||
currentDocument = previousDocument;
|
||||
|
||||
boundaryContext?.ProvideResponse("confirm_changes", new ConfirmChangesResult { Confirmed = false });
|
||||
StateHasChanged();
|
||||
}
|
||||
|
||||
private void ResetConversation()
|
||||
{
|
||||
currentDocument = string.Empty;
|
||||
previousDocument = string.Empty;
|
||||
currentDocumentState = null;
|
||||
isStreaming = false;
|
||||
awaitingConfirmation = false;
|
||||
StateHasChanged();
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
responseSubscription?.Dispose();
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user