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
32 changes: 22 additions & 10 deletions src/OneWare.Chat/Services/AiFunctionProvider.cs
Original file line number Diff line number Diff line change
Expand Up @@ -94,16 +94,21 @@ public ICollection<AIFunction> GetTools()

public void CancelActiveFunctions()
{
foreach (var cancellationSource in _activeFunctions.Values)
foreach (var id in _activeFunctions.Keys)
CancelFunction(id);
}

public void CancelFunction(string id)
{
if (!_activeFunctions.TryGetValue(id, out var cancellationSource)) return;

try
{
try
{
cancellationSource.Cancel();
}
catch (ObjectDisposedException)
{
// The function completed while cancellation was being requested.
}
cancellationSource.Cancel();
}
catch (ObjectDisposedException)
{
// The function completed while cancellation was being requested.
}
}

Expand Down Expand Up @@ -143,7 +148,7 @@ await Dispatcher.UIThread.InvokeAsync(() =>
{
Id = id,
Result = exception == null,
ToolOutput = exception?.ToString()
ToolOutput = exception is OperationCanceledException ? "Cancelled." : exception?.ToString()
}));
}

Expand Down Expand Up @@ -190,6 +195,13 @@ private sealed class RegisteredOneWareAiFunction(

return await InvokeDefinitionAsync(context, arguments, functionCancellationSource.Token);
}
catch (OperationCanceledException ex) when (!cancellationToken.IsCancellationRequested)
{
// Only this tool call was cancelled (e.g. via its stop button) — report it to the
// model as a result instead of failing the whole chat turn.
exception = ex;
return "The tool call was stopped by the user before it finished.";
}
catch (Exception ex)
{
exception = ex;
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
using System.Runtime.Serialization;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using OneWare.Essentials.Controls;

namespace OneWare.Chat.ViewModels.ChatMessages;
Expand Down Expand Up @@ -32,6 +33,13 @@ public bool IsToolRunning
get;
set => SetProperty(ref field, value);
}

/// <summary>Cancels this tool invocation while it is running.</summary>
public IRelayCommand? StopCommand
{
get;
set => SetProperty(ref field, value);
}

[DataMember]
public bool IsSuccessful
Expand Down
103 changes: 102 additions & 1 deletion src/OneWare.Chat/ViewModels/ChatViewModel.cs
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ public partial class ChatViewModel : ExtendedTool, IChatManagerService
public const string IconKey = "Bootstrap.ChatLeft";

private readonly IMainDockService _mainDockService;
private readonly IAiFunctionProvider _aiFunctionProvider;
private readonly string _statePath;
private readonly string _historyRootPath;

Expand All @@ -35,6 +36,7 @@ public partial class ChatViewModel : ExtendedTool, IChatManagerService
// FIFO of messages sent locally so the echoed ChatUserMessageEvent can be matched
// (suppressed for normal/steered sends, or used to activate a queued message).
private readonly Queue<PendingLocalMessage> _pendingLocalMessages = new();
private readonly List<CancelledQueuedMessage> _cancelledQueuedMessages = [];

private const string DefaultWorkingStatus = "Working…";

Expand All @@ -43,6 +45,8 @@ private sealed record PendingLocalMessage(
ChatSendMode Mode,
ChatMessageUserViewModel? QueuedView);

private sealed record CancelledQueuedMessage(string Content, DateTimeOffset ExpiresAt);

private static readonly JsonSerializerOptions ChatStateSerializerOptions = new()
{
WriteIndented = true,
Expand All @@ -60,6 +64,7 @@ public ChatViewModel(IAiFunctionProvider aiFunctionProvider, IMainDockService ma
aiFunctionProvider.FunctionCompleted += OnFunctionCompleted;
aiFunctionProvider.FunctionProgress += OnFunctionProgress;

_aiFunctionProvider = aiFunctionProvider;
_mainDockService = mainDockService;

var chatDirectory = Path.Combine(paths.AppDataDirectory, "Chat");
Expand All @@ -73,8 +78,11 @@ public ChatViewModel(IAiFunctionProvider aiFunctionProvider, IMainDockService ma
SteerCommand = new AsyncRelayCommand(() => SendInternalAsync(ChatSendMode.Steer), CanSteerOrQueue);
QueueCommand = new AsyncRelayCommand(() => SendInternalAsync(ChatSendMode.Queue), CanSteerOrQueue);
AbortCommand = new AsyncRelayCommand(AbortAsync, CanAbort);
RemoveQueuedMessageCommand =
new AsyncRelayCommand<ChatMessageUserViewModel>(RemoveQueuedMessageAsync, CanRemoveQueuedMessage);
InitializeCurrentCommand = new AsyncRelayCommand(InitializeCurrentAsync);

QueuedMessages.CollectionChanged += (_, _) => RemoveQueuedMessageCommand.NotifyCanExecuteChanged();
applicationStateService.RegisterShutdownAction(SaveState);
}

Expand Down Expand Up @@ -211,6 +219,8 @@ public IChatService? SelectedChatService

public AsyncRelayCommand AbortCommand { get; }

public AsyncRelayCommand<ChatMessageUserViewModel> RemoveQueuedMessageCommand { get; }

public AsyncRelayCommand InitializeCurrentCommand { get; }

public override void InitializeContent()
Expand Down Expand Up @@ -270,6 +280,7 @@ private async Task NewChatAsync()
Messages.Clear();
QueuedMessages.Clear();
_pendingLocalMessages.Clear();
_cancelledQueuedMessages.Clear();
WorkingStatusText = DefaultWorkingStatus;
_assistantMessagesById.Clear();
_assistantReasoningById.Clear();
Expand Down Expand Up @@ -338,6 +349,7 @@ private async Task SendInternalAsync(ChatSendMode mode)
if (mode == ChatSendMode.Queue)
{
QueuedMessages.Remove(userMessage);
RemovePendingLocalMessage(userMessage);
}
else if (Messages.LastOrDefault() is ChatMessageAssistantViewModel { MessageId: "init" } initMessage)
{
Expand All @@ -361,16 +373,56 @@ private async Task AbortAsync()
{
if (SelectedChatService == null) return;

var queueCleared = QueuedMessages.Count == 0;
if (!queueCleared)
{
try
{
queueCleared = await SelectedChatService.ClearQueuedMessagesAsync();
}
catch (Exception ex)
{
AddErrorMessage($"Failed to clear queued messages: {ex.Message}");
}
}

try
{
await SelectedChatService.AbortAsync();
}
finally
{
IsBusy = false;
if (queueCleared)
CancelQueuedMessagesLocally();

IsBusy = !queueCleared && QueuedMessages.Count > 0;
}
}

private async Task RemoveQueuedMessageAsync(ChatMessageUserViewModel? message)
{
if (message == null || SelectedChatService == null || !CanRemoveQueuedMessage(message)) return;

bool removed;
try
{
removed = await SelectedChatService.RemoveMostRecentQueuedMessageAsync();
}
catch (Exception ex)
{
AddErrorMessage($"Failed to remove queued message: {ex.Message}");
return;
}

if (!removed || !QueuedMessages.Contains(message)) return;

QueuedMessages.Remove(message);
RemovePendingLocalMessage(message);
}

private bool CanRemoveQueuedMessage(ChatMessageUserViewModel? message) =>
message != null && ReferenceEquals(QueuedMessages.LastOrDefault(), message);

private bool CanSend() => IsConnected && !string.IsNullOrWhiteSpace(CurrentMessage);

private bool CanAbort() => IsConnected && IsBusy;
Expand All @@ -387,6 +439,43 @@ private bool TryDequeuePendingLocal(string content, out PendingLocalMessage pend
return false;
}

private bool TryDiscardCancelledQueuedMessage(string content)
{
var now = DateTimeOffset.Now;
_cancelledQueuedMessages.RemoveAll(x => x.ExpiresAt <= now);

var index = _cancelledQueuedMessages.FindIndex(x =>
string.Equals(x.Content, content, StringComparison.Ordinal));
if (index < 0) return false;

_cancelledQueuedMessages.RemoveAt(index);
return true;
}

private void CancelQueuedMessagesLocally()
{
var expiresAt = DateTimeOffset.Now.AddSeconds(30);
foreach (var pending in _pendingLocalMessages.Where(x => x.Mode == ChatSendMode.Queue))
_cancelledQueuedMessages.Add(new CancelledQueuedMessage(pending.Content, expiresAt));

var remaining = _pendingLocalMessages.Where(x => x.Mode != ChatSendMode.Queue).ToArray();
_pendingLocalMessages.Clear();
foreach (var pending in remaining)
_pendingLocalMessages.Enqueue(pending);

QueuedMessages.Clear();
}

private void RemovePendingLocalMessage(ChatMessageUserViewModel message)
{
var remaining = _pendingLocalMessages
.Where(x => !ReferenceEquals(x.QueuedView, message))
.ToArray();
_pendingLocalMessages.Clear();
foreach (var pending in remaining)
_pendingLocalMessages.Enqueue(pending);
}

private void AddMessage(IChatMessage message)
{
if (Messages.LastOrDefault() is ChatMessageAssistantViewModel { MessageId: "init" } initMessage)
Expand Down Expand Up @@ -544,6 +633,8 @@ private void OnEventReceived(object? sender, ChatEvent e)
return;
}

if (TryDiscardCancelledQueuedMessage(x.Content)) return;

// Originates from a remote session user; show it and mark busy.
AddMessage(new ChatMessageUserViewModel(x.Content));
IsBusy = true;
Expand Down Expand Up @@ -599,6 +690,9 @@ private void OnEventReceived(object? sender, ChatEvent e)
Dispatcher.UIThread.Post(() =>
{
Messages.Clear();
QueuedMessages.Clear();
_pendingLocalMessages.Clear();
_cancelledQueuedMessages.Clear();
_assistantMessagesById.Clear();
_assistantReasoningById.Clear();
if (SelectedChatService != null)
Expand All @@ -623,6 +717,9 @@ private void OnSessionReset(object? sender, EventArgs e)
Dispatcher.UIThread.Post(() =>
{
Messages.Clear();
QueuedMessages.Clear();
_pendingLocalMessages.Clear();
_cancelledQueuedMessages.Clear();
_assistantMessagesById.Clear();
_assistantReasoningById.Clear();

Expand All @@ -645,6 +742,9 @@ private void OnFunctionStarted(object? sender, AiFunctionStartedEvent function)
IsToolRunning = true,
ToolOutput = $"{function.Detail}"
};
newMessage.StopCommand = new RelayCommand(
() => _aiFunctionProvider.CancelFunction(newMessage.Id),
() => newMessage.IsToolRunning);
AddMessage(newMessage);
ContentAdded?.Invoke(this, EventArgs.Empty);
}
Expand All @@ -655,6 +755,7 @@ private void OnFunctionCompleted(object? sender, AiFunctionCompletedEvent functi
if (toolFinished == null) return;

toolFinished.IsToolRunning = false;
toolFinished.StopCommand = null;
toolFinished.IsSuccessful = function.Result;
if (!string.IsNullOrWhiteSpace(function.ToolOutput))
{
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@

<Expander Margin="0 2" VerticalAlignment="Top" IsExpanded="{Binding IsToolRunning, Mode=OneWay}">
<Expander.Header>
<Grid ColumnDefinitions="Auto, Auto" ColumnSpacing="5" VerticalAlignment="Center">
<Grid ColumnDefinitions="Auto, Auto, Auto" ColumnSpacing="5" VerticalAlignment="Center">
<StackPanel Orientation="Horizontal">
<TextBlock Text="{Binding ToolName}" />
<Panel IsVisible="{Binding !IsToolRunning}">
Expand All @@ -22,6 +22,13 @@

<controls:Spinner Grid.Column="1" Width="12" Height="12" VerticalAlignment="Center"
IsVisible="{Binding IsToolRunning}" />

<Button Grid.Column="2" VerticalAlignment="Center" Padding="2"
IsVisible="{Binding IsToolRunning}"
Command="{Binding StopCommand}"
ToolTip.Tip="Stop">
<Image Source="{DynamicResource VSImageLib2019.Stop_16x}" Height="12" />
</Button>
</Grid>
</Expander.Header>
<Border Margin="1 4 1 0" Background="{DynamicResource ThemeControlMidBrush}" Classes="RoundBorder"
Expand Down
19 changes: 19 additions & 0 deletions src/OneWare.Chat/Views/ChatView.axaml
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
xmlns:viewModels="clr-namespace:OneWare.Chat.ViewModels"
xmlns:chatMessages="clr-namespace:OneWare.Chat.ViewModels.ChatMessages"
xmlns:controls="clr-namespace:OneWare.Essentials.Controls;assembly=OneWare.Essentials"
xmlns:behaviors="clr-namespace:OneWare.Chat.Behaviors"
xmlns:behaviors1="clr-namespace:OneWare.Essentials.Behaviors;assembly=OneWare.Essentials"
Expand Down Expand Up @@ -93,6 +94,24 @@

<!-- Queued messages (dimmed, pinned below the conversation) -->
<ItemsControl Opacity="0.5" ItemsSource="{Binding QueuedMessages}">
<ItemsControl.ItemTemplate>
<DataTemplate x:DataType="chatMessages:ChatMessageUserViewModel">
<Border Margin="0 2" HorizontalAlignment="Right" CornerRadius="6"
Padding="8 4 4 4" Background="{DynamicResource ThemeControlMidBrush}">
<Grid ColumnDefinitions="*, Auto" ColumnSpacing="6">
<TextBlock Text="{Binding Message}" TextWrapping="Wrap" FontSize="13"
VerticalAlignment="Center" />
<Button Grid.Column="1"
Command="{Binding #ChatViewView.((viewModels:ChatViewModel)DataContext).RemoveQueuedMessageCommand}"
CommandParameter="{Binding}"
ToolTip.Tip="Remove queued message">
<Image Source="{DynamicResource MaterialDesign.Close}" Width="12"
Height="12" />
</Button>
</Grid>
</Border>
</DataTemplate>
</ItemsControl.ItemTemplate>
<ItemsControl.ItemsPanel>
<ItemsPanelTemplate>
<StackPanel Spacing="5" />
Expand Down
Loading
Loading