Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
101 changes: 87 additions & 14 deletions samples/StreamingBot/Program.cs
Original file line number Diff line number Diff line change
Expand Up @@ -13,32 +13,56 @@
WebApplicationBuilder builder = WebApplication.CreateSlimBuilder(args);
builder.Services.AddTeamsBotApplication();

string endpoint = builder.Configuration["AzureOpenAI:Endpoint"] ?? throw new InvalidOperationException("AzureOpenAI:Endpoint is required.");
string apiKey = builder.Configuration["AzureOpenAI:ApiKey"] ?? throw new InvalidOperationException("AzureOpenAI:ApiKey is required.");
string deployment = builder.Configuration["AzureOpenAI:Deployment"] ?? throw new InvalidOperationException("AzureOpenAI:Deployment is required.");

builder.Services.AddSingleton<AzureOpenAIClient>(_ => new AzureOpenAIClient(new Uri(endpoint), new ApiKeyCredential(apiKey)));
builder.Services.AddSingleton<IChatClient>(sp =>
sp.GetRequiredService<AzureOpenAIClient>()
.GetChatClient(deployment)
.AsIChatClient());
// Azure OpenAI is optional. When all three settings are present the default message path streams
// live model output; otherwise it falls back to a canned streamed response so the sample still runs.
string? endpoint = builder.Configuration["AzureOpenAI:Endpoint"];
Comment thread
MehakBindra marked this conversation as resolved.
string? apiKey = builder.Configuration["AzureOpenAI:ApiKey"];
string? deployment = builder.Configuration["AzureOpenAI:Deployment"];
bool aiConfigured = !string.IsNullOrWhiteSpace(endpoint)
&& !string.IsNullOrWhiteSpace(apiKey)
&& !string.IsNullOrWhiteSpace(deployment);

if (aiConfigured)
{
builder.Services.AddSingleton<AzureOpenAIClient>(_ => new AzureOpenAIClient(new Uri(endpoint!), new ApiKeyCredential(apiKey!)));
builder.Services.AddSingleton<IChatClient>(sp =>
sp.GetRequiredService<AzureOpenAIClient>()
.GetChatClient(deployment!)
.AsIChatClient());
}

WebApplication webApp = builder.Build();
TeamsBotApplication teamsApp = webApp.UseTeamsBotApplication();
IChatClient chatClient = webApp.Services.GetRequiredService<IChatClient>();
IChatClient? chatClient = webApp.Services.GetService<IChatClient>();

teamsApp.OnMessage(async (context, cancellationToken) =>
{
TeamsStreamingWriter writer = TeamsStreamingWriter.CreateFromContext(context);

// Send "multi-stream" to demo reusing the same writer for a second streamed message
// after FinalizeResponseAsync (stream reuse). This path does not call OpenAI.
if ((context.Activity.Text ?? string.Empty).Replace("-", " ").Contains("multi stream", StringComparison.OrdinalIgnoreCase))
string normalized = (context.Activity.Text ?? string.Empty).Replace("-", " ");

// "multi stream": reuse the same writer for a second streamed message after finalize.
if (normalized.Contains("multi stream", StringComparison.OrdinalIgnoreCase))
{
await RunMultiStreamDemoAsync(writer, cancellationToken);
return;
}

// "extended markdown": stream content that only renders correctly under extended markdown.
if (normalized.Contains("extended markdown", StringComparison.OrdinalIgnoreCase))
{
await RunExtendedMarkdownDemoAsync(writer, cancellationToken);
return;
}

// Default path: stream live model output when Azure OpenAI is configured, otherwise a
// canned streamed response so the sample works without any AI backend.
if (chatClient is null)
{
await RunCannedResponseAsync(writer, cancellationToken);
return;
}

await writer.SendInformativeUpdateAsync("Thinking…", cancellationToken);
await Task.Delay(500, cancellationToken);
await writer.SendInformativeUpdateAsync("Thinking again !!!", cancellationToken);
Expand Down Expand Up @@ -75,7 +99,7 @@ [new ChatMessage(ChatRole.User, userText)],
};

TeamsAttachment card = TeamsAttachment.CreateBuilder()
.WithAdaptiveCard(CreateResponseCard(deployment))
.WithAdaptiveCard(CreateResponseCard(deployment!))
.Build();

MessageActivityInput final = new MessageActivityInput()
Expand All @@ -94,6 +118,27 @@ [new ChatMessage(ChatRole.User, userText)],
return new InvokeResponse(200);
});

static async Task RunCannedResponseAsync(TeamsStreamingWriter writer, CancellationToken cancellationToken)
{
string[] messages =
[
"Azure OpenAI isn't configured, so here's a canned streamed reply. ",
"Set AzureOpenAI:Endpoint, :ApiKey, and :Deployment to stream live model output instead. ",
"You can also try `extended markdown`, `extended markdown before`, or `multi stream`.",
];

await writer.SendInformativeUpdateAsync("Thinking…", cancellationToken);
await Task.Delay(500, cancellationToken);

foreach (string message in messages)
{
await Task.Delay(500, cancellationToken);
await writer.AppendResponseAsync(message, cancellationToken);
}

await writer.FinalizeResponseAsync(cancellationToken: cancellationToken);
}

static async Task RunMultiStreamDemoAsync(TeamsStreamingWriter writer, CancellationToken cancellationToken)
{
string[] firstStreamMessages =
Expand Down Expand Up @@ -143,6 +188,34 @@ static async Task RunMultiStreamDemoAsync(TeamsStreamingWriter writer, Cancellat
await writer.FinalizeResponseAsync(cancellationToken: cancellationToken);
}

static async Task RunExtendedMarkdownDemoAsync(TeamsStreamingWriter writer, CancellationToken cancellationToken)
{
// Task lists ("- [x]") and strikethrough ("~~...~~") only render under extended markdown.
string[] messages =
[
"**Extended markdown stream** — rendering features plain markdown can't:\n\n",
"- [x] Sent with `textFormat: 'extendedmarkdown'`\n",
"- [x] Task list items render as real checkboxes\n",
"- [ ] ~~Under plain markdown these would be literal `[ ]` text~~\n",
"- [x] Strikethrough renders too\n",
];

#pragma warning disable ExperimentalTeamsExtendedMarkdown
// The informative update carries its own format (plain markdown here); the streamed chunks
// set extended markdown per-chunk via MessageActivityInput, which also formats the final message.
await writer.SendInformativeUpdateAsync("Starting the *extended* markdown stream…", TextFormats.Markdown, cancellationToken);
await Task.Delay(1000, cancellationToken);

foreach (string message in messages)
{
await Task.Delay(500, cancellationToken);
await writer.AppendResponseAsync(new MessageActivityInput().WithText(message, TextFormats.ExtendedMarkdown), cancellationToken);
}
#pragma warning restore ExperimentalTeamsExtendedMarkdown

await writer.FinalizeResponseAsync(cancellationToken: cancellationToken);
}

static TeamsAttachment CreateSimpleCard()
{
AdaptiveCard card = new([
Expand Down
6 changes: 4 additions & 2 deletions samples/StreamingBot/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,8 @@ Shows streaming responses in Teams using `TeamsStreamingWriter`, including incre
## Prerequisites

- Bot registered and installed in Teams.
- Azure OpenAI configured:
- Azure OpenAI (optional) — enables live model output on the default message path. Without it, the
default path streams a canned response instead. To enable, set:
- `AzureOpenAI__Endpoint`
- `AzureOpenAI__ApiKey`
- `AzureOpenAI__Deployment`
Expand All @@ -21,7 +22,8 @@ Shows streaming responses in Teams using `TeamsStreamingWriter`, including incre

| Input | Behavior |
|---|---|
| any text | Streams progress + model output, then sends final card/citation response |
| any text | Streams progress + model output (or a canned response when Azure OpenAI isn't configured), then sends the final response |
| `extended markdown` | Streams content that demonstrates extended-markdown features (task lists + strikethrough), setting the format per-chunk via `AppendResponseAsync(new MessageActivityInput().WithText(text, TextFormats.ExtendedMarkdown))` — the fix under test |
| `multi stream` | Runs two streamed responses back-to-back using the same writer |

## Running the Sample
Expand Down
15 changes: 15 additions & 0 deletions src/Microsoft.Teams.Apps/TeamsStreamingWriter.Activity.cs
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,12 @@ internal StreamingActivityInput() : base(TeamsActivityTypes.Typing)
[JsonPropertyName("text")]
public string? Text { get; set; }

/// <summary>
/// Gets or sets the text format. See <see cref="TextFormats"/> for common values.
/// </summary>
[JsonPropertyName("textFormat")]
public TextFormat? TextFormat { get; set; }

/// <summary>
/// Gets or sets the stream info entity for this streaming activity.
/// </summary>
Expand Down Expand Up @@ -78,6 +84,15 @@ public StreamingActivityInputBuilder WithText(string text)
return this;
}

/// <summary>
/// Sets the format of the streaming chunk's text. See <see cref="TextFormats"/>.
/// </summary>
public StreamingActivityInputBuilder WithTextFormat(TextFormat textFormat)
{
_activity.TextFormat = textFormat;
return this;
}

/// <summary>
/// Sets the stream metadata for this chunk (writes channel data and adds a <see cref="StreamInfoEntity"/>).
/// </summary>
Expand Down
73 changes: 66 additions & 7 deletions src/Microsoft.Teams.Apps/TeamsStreamingWriter.cs
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,19 @@ namespace Microsoft.Teams.Apps;
/// await writer.FinalizeResponseAsync(final);
/// </code>
///
/// To stream content in a non-default <see cref="TextFormat"/> (ex. extended markdown), pass the
/// format on the chunk itself: use <see cref="AppendResponseAsync(MessageActivityInput, CancellationToken)"/>
/// with a <see cref="MessageActivityInput.TextFormat"/> set, or
/// <see cref="SendInformativeUpdateAsync(string, TextFormat, CancellationToken)"/> for an informative update.
/// The most recent streamed chunk's format is also used as the final message's format, unless the caller
/// sets <see cref="MessageActivityInput.TextFormat"/> explicitly on the activity passed to
/// <see cref="FinalizeResponseAsync"/>.
/// <code>
/// await writer.AppendResponseAsync(
/// new MessageActivityInput().WithText("- [x] Rendered as extended markdown", TextFormats.ExtendedMarkdown));
/// await writer.FinalizeResponseAsync();
/// </code>
///
/// The writer is reusable: appending or sending an informative update after
/// <see cref="FinalizeResponseAsync"/> reopens the stream on the same instance and starts a new
/// streamed message. Finalizing is idempotent until the next append/informative update.
Expand All @@ -64,6 +77,10 @@ public sealed class TeamsStreamingWriter
private bool _timedOut;
private readonly System.Text.StringBuilder _accumulated = new();
private DateTime _lastChunkSent = DateTime.MinValue;
// The most recent streamed chunk's text format (set by AppendResponseAsync with a
// MessageActivityInput carrying a TextFormat). Applied to subsequent streamed chunks and
// used as the final message's default format unless the caller sets one explicitly.
private TextFormat? _textFormat;

/// <summary>
/// Whether the stream has been cancelled, for example when the user pressed the Stop button.
Expand Down Expand Up @@ -96,9 +113,23 @@ public static TeamsStreamingWriter CreateFromContext<TActivity>(Context<TActivit

/// <summary>
/// Sends an informative placeholder (streamType = "informative").
/// Optional — if omitted the first <see cref="AppendResponseAsync"/> call begins the stream.
/// Optional — if omitted the first <see cref="AppendResponseAsync(string, CancellationToken)"/> call begins the stream.
/// </summary>
public async Task SendInformativeUpdateAsync(string text, CancellationToken cancellationToken = default)
public Task SendInformativeUpdateAsync(string text, CancellationToken cancellationToken = default)
=> SendInformativeUpdateCoreAsync(text, null, cancellationToken);

/// <summary>
/// Sends an informative placeholder (streamType = "informative") rendered with the given
/// <paramref name="textFormat"/> (ex. extended markdown).
/// </summary>
/// <remarks>
/// The format applies to this informative chunk only; it does not change the format of
/// subsequent streamed chunks or the final message.
/// </remarks>
public Task SendInformativeUpdateAsync(string text, TextFormat textFormat, CancellationToken cancellationToken = default)
=> SendInformativeUpdateCoreAsync(text, textFormat, cancellationToken);

private async Task SendInformativeUpdateCoreAsync(string text, TextFormat? textFormat, CancellationToken cancellationToken)
{
if (_cancelled)
return;
Expand All @@ -115,7 +146,7 @@ public async Task SendInformativeUpdateAsync(string text, CancellationToken canc

_sequence++;
_logger.LogDebug("Sending informative streaming update (sequence {Sequence}).", _sequence);
SendActivityResponse? response = await TrySendChunkAsync(BuildActivity(text, StreamTypes.Informative), cancellationToken).ConfigureAwait(false);
SendActivityResponse? response = await TrySendChunkAsync(BuildActivity(text, StreamTypes.Informative, textFormat), cancellationToken).ConfigureAwait(false);
Comment thread
MehakBindra marked this conversation as resolved.
_streamId ??= response?.Id;
_logger.LogDebug("Stream started with streamId '{StreamId}'.", _streamId);
}
Expand Down Expand Up @@ -164,6 +195,26 @@ public async Task AppendResponseAsync(string chunk, CancellationToken cancellati
_lastChunkSent = DateTime.UtcNow;
}

/// <summary>
/// Appends <paramref name="chunk"/>'s <see cref="MessageActivityInput.Text"/> to the accumulated
/// text and sends the full accumulated text as an intermediate streaming update, rendered with
/// <paramref name="chunk"/>'s <see cref="MessageActivityInput.TextFormat"/> when set.
/// </summary>
/// <remarks>
/// Only <see cref="MessageActivityInput.Text"/> and <see cref="MessageActivityInput.TextFormat"/>
/// are honored for intermediate chunks; attachments, entities, and other properties belong on the
/// final message passed to <see cref="FinalizeResponseAsync"/>. When <paramref name="chunk"/> carries
/// a <see cref="MessageActivityInput.TextFormat"/>, it becomes the format of this and subsequent
/// streamed chunks (last-wins) and the final message's default format.
/// </remarks>
public Task AppendResponseAsync(MessageActivityInput chunk, CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(chunk);
if (chunk.TextFormat is not null)
_textFormat = chunk.TextFormat;
return AppendResponseAsync(chunk.Text ?? string.Empty, cancellationToken);
}

/// <summary>
/// Sends the final streaming activity and marks the stream complete.
/// </summary>
Expand Down Expand Up @@ -193,6 +244,9 @@ public async Task FinalizeResponseAsync(MessageActivityInput? final = null, Canc

final ??= new MessageActivityInput();
final.Text ??= _accumulated.ToString();
// Same format that was applied to the intermediate typing chunks, unless the caller
// set a different one explicitly on the final activity.
final.TextFormat ??= _textFormat;
final.ReplyToId = _reference.Id;

if (string.IsNullOrEmpty(final.Text) && (final.Attachments == null || final.Attachments.Count == 0))
Expand Down Expand Up @@ -371,15 +425,20 @@ private void ResetForNextStream()
_timedOut = false;
_accumulated.Clear();
_lastChunkSent = DateTime.MinValue;
_textFormat = null;
}

private StreamingActivityInput BuildActivity(string text, StreamType streamType)
private StreamingActivityInput BuildActivity(string text, StreamType streamType, TextFormat? textFormatOverride = null)
{
StreamingActivityInput activity = StreamingActivityInput.CreateBuilder()
StreamingActivityInputBuilder builder = StreamingActivityInput.CreateBuilder()
.WithText(text)
.WithStreamInfo(streamType, _streamId, _sequence)
.Build();
.WithStreamInfo(streamType, _streamId, _sequence);

TextFormat? textFormat = textFormatOverride ?? _textFormat;
if (textFormat is not null)
builder = builder.WithTextFormat(textFormat);

StreamingActivityInput activity = builder.Build();
activity.ReplyToId = _reference.Id;
return activity;
}
Expand Down
Loading
Loading