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
2 changes: 1 addition & 1 deletion build/props/DynamicData.props
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<Project DefaultTargets="Build" xmlns="http://schemas.microsoft.com/developer/msbuild/2003">
<ItemGroup>
<PackageReference Include="DynamicData" Version="9.4.33"/>
<PackageReference Include="DynamicData" Version="9.5.0"/>
</ItemGroup>
</Project>
2 changes: 1 addition & 1 deletion build/props/Microsoft.Extensions.AI.Abstractions.props
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<Project DefaultTargets="Build" xmlns="http://schemas.microsoft.com/developer/msbuild/2003">
<ItemGroup>
<PackageReference Include="Microsoft.Extensions.AI.Abstractions " Version="10.2.0"/>
<PackageReference Include="Microsoft.Extensions.AI.Abstractions " Version="10.11.0"/>
</ItemGroup>
</Project>
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<Project DefaultTargets="Build" xmlns="http://schemas.microsoft.com/developer/msbuild/2003">
<ItemGroup>
<PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" Version="10.0.9"/>
<PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" Version="10.0.13"/>
</ItemGroup>
</Project>
2 changes: 1 addition & 1 deletion build/props/Microsoft.Extensions.DependencyInjection.props
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<Project DefaultTargets="Build" xmlns="http://schemas.microsoft.com/developer/msbuild/2003">
<ItemGroup>
<PackageReference Include="Microsoft.Extensions.DependencyInjection" Version="10.0.9"/>
<PackageReference Include="Microsoft.Extensions.DependencyInjection" Version="10.0.13"/>
</ItemGroup>
</Project>
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<Project DefaultTargets="Build" xmlns="http://schemas.microsoft.com/developer/msbuild/2003">
<ItemGroup>
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="10.0.9"/>
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="10.0.13"/>
</ItemGroup>
</Project>
2 changes: 1 addition & 1 deletion build/props/Microsoft.Extensions.Logging.props
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<Project DefaultTargets="Build" xmlns="http://schemas.microsoft.com/developer/msbuild/2003">
<ItemGroup>
<PackageReference Include="Microsoft.Extensions.Logging" Version="10.0.9"/>
<PackageReference Include="Microsoft.Extensions.Logging" Version="10.0.13"/>
</ItemGroup>
</Project>
2 changes: 1 addition & 1 deletion build/props/SharpCompress.props
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<Project DefaultTargets="Build" xmlns="http://schemas.microsoft.com/developer/msbuild/2003">
<ItemGroup>
<PackageReference Include="SharpCompress" Version="0.50.4"/>
<PackageReference Include="SharpCompress" Version="0.50.5"/>
</ItemGroup>
</Project>
2 changes: 1 addition & 1 deletion build/props/XUnit.props
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,6 @@
<PackageReference Include="xunit.extensibility.execution" Version="2.9.3"/>
<PackageReference Include="xunit.runner.console" Version="2.9.3"/>
<PackageReference Include="xunit.runner.visualstudio" Version="3.1.5"/>
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="18.0.0"/>
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="18.11.0"/>
</ItemGroup>
</Project>
4 changes: 3 additions & 1 deletion docs/PluginDevelopment.md
Original file line number Diff line number Diff line change
Expand Up @@ -387,11 +387,13 @@ Provides all app path locations: `AppDataDirectory`, `ProjectsDirectory`, `Packa

#### `IAiFunctionProvider` (src/OneWare.Essentials/Services/IAiFunctionProvider.cs)

- `RegisterFunction(IOneWareAiFunction)`: register an AI tool.
- `RegisterFunction(IOneWareAiFunction)`: register an AI tool. Tools registered while chats are open are
pushed to their live sessions too.
- `RegisterAgent(OneWareAiAgent)`: register a custom agent (see "AI agents and skills").
- `RegisterSkill(OneWareAiSkill)`: register an on-demand skill document.
- `RegisterSkillDirectory(string)`: register a directory of skills shipped with the plugin.
- `GetTools()`: return the registered tools for chat or automation.
- `ToolsChanged` event: raised when the result of `GetTools()` changes.
- `FunctionStarted`, `FunctionProgress`, `FunctionCompleted` events identify each concurrent
invocation by its unique ID.
- Set `OneWareAiFunction.IsReadOnly` on tools that only read state, so read-only chat agents
Expand Down
3 changes: 3 additions & 0 deletions src/OneWare.Chat/Services/AiFunctionProvider.cs
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ public class AiFunctionProvider(
public event EventHandler<AiFunctionStartedEvent>? FunctionStarted;
public event EventHandler<AiFunctionCompletedEvent>? FunctionCompleted;
public event EventHandler<AiFunctionProgressEvent>? FunctionProgress;
public event EventHandler? ToolsChanged;

public void RegisterFunction(IOneWareAiFunction function)
{
Expand All @@ -42,6 +43,8 @@ public void RegisterFunction(IOneWareAiFunction function)
_registeredFunctions.RemoveAll(x => string.Equals(x.Name, function.Name, StringComparison.Ordinal));
_registeredFunctions.Add(function);
}

ToolsChanged?.Invoke(this, EventArgs.Empty);
}

public void RegisterPromptAddition(string promptAddition)
Expand Down
8 changes: 5 additions & 3 deletions src/OneWare.Chat/ViewModels/ChatSessionViewModel.cs
Original file line number Diff line number Diff line change
Expand Up @@ -249,7 +249,8 @@ private async Task<bool> ActivateAsync()
if (!string.Equals(sessions.CurrentSessionId, targetSessionId, StringComparison.Ordinal))
await sessions.LoadSessionAsync(targetSessionId);
IsActivated = true;
ShowReplacementSessionIfNeeded(targetSessionId);
if (ShowReplacementSessionIfNeeded(targetSessionId))
AddErrorMessage("The previous session could not be restored, so this chat continues in a new session. The original chat stays in the history.");
return true;
}

Expand Down Expand Up @@ -540,13 +541,13 @@ private void ReleaseConnectionWaiters(bool connected)
/// A session that no longer exists in the backend is replaced by a new one; the transcript of
/// the requested session must not stay visible, otherwise it would be saved into the new session.
/// </summary>
private void ShowReplacementSessionIfNeeded(string requestedSessionId)
private bool ShowReplacementSessionIfNeeded(string requestedSessionId)
{
if (Service is not IChatServiceWithSessions sessions ||
string.IsNullOrWhiteSpace(sessions.CurrentSessionId) ||
string.Equals(sessions.CurrentSessionId, requestedSessionId, StringComparison.Ordinal))
{
return;
return false;
}

Messages.Clear();
Expand All @@ -557,6 +558,7 @@ private void ShowReplacementSessionIfNeeded(string requestedSessionId)
_pendingSubAgentTools.Clear();

_host.OnSessionChanged(this);
return true;
}

private async Task NewChatAsync()
Expand Down
144 changes: 135 additions & 9 deletions src/OneWare.Chat/ViewModels/ChatViewModel.cs
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
using System.Text.Json;
using System.Text.Json.Serialization;
using System.Text.RegularExpressions;
using Avalonia.Controls.Notifications;
using Avalonia.Threading;
using CommunityToolkit.Mvvm.Input;
using OneWare.Chat.Services;
Expand All @@ -27,6 +28,15 @@ public class ChatViewModel : ExtendedTool, IChatManagerService, IChatSessionHost

public const int DefaultMaxSessionHistory = 50;

/// <summary>Service that opens stored chats whose own service is no longer registered.</summary>
public const string LegacyFallbackServiceName = "GitHub Copilot";

/// <summary>Former names of chat services, mapped to their current name.</summary>
private static readonly Dictionary<string, string> LegacyServiceNames = new(StringComparer.Ordinal)
{
["OneWare Cloud"] = "OneWare Agents"
};

private readonly IMainDockService _mainDockService;
private readonly IAiFunctionProvider _aiFunctionProvider;
private readonly ISettingsService _settingsService;
Expand Down Expand Up @@ -430,15 +440,107 @@ private static async Task DisposeInstanceAsync(IChatService instance)
(string.Equals(t.CurrentSessionId, sessionId, StringComparison.Ordinal) ||
string.Equals(t.RememberedSessionId, sessionId, StringComparison.Ordinal)));

private async Task OpenOverviewItemAsync(ChatOverviewItem item)
private IChatService? FindService(string serviceName) =>
ChatServices.FirstOrDefault(s => string.Equals(s.Name, serviceName, StringComparison.Ordinal));

private static void ReportOpenFailure(string chatName, string reason, Exception? exception = null)
{
if (item.Session != null && _sessions.Contains(item.Session))
ContainerLocator.Container.Resolve<Microsoft.Extensions.Logging.ILogger>()
?.Warning($"Opening chat '{chatName}' failed: {reason}", exception);

try
{
Show(item.Session);
return;
ContainerLocator.Container.Resolve<IWindowService>()
.ShowNotification("Chat could not be opened", $"{chatName}: {reason}", NotificationType.Warning);
}
catch
{
// No window service (e.g. in tests); the warning above is logged.
}
}

/// <summary>
/// Moves a stored chat to the history of another service, so it is saved, listed and pruned there from now on.
/// </summary>
private ChatSessionHistoryItem MoveHistoryItem(ChatSessionHistoryItem item, string serviceName)
{
var targetDirectory = GetServiceHistoryDirectory(serviceName);
var targetPath = Path.Combine(targetDirectory, Path.GetFileName(item.FilePath));

try
{
ChatSessionFile? file;
using (var stream = File.OpenRead(item.FilePath))
file = JsonSerializer.Deserialize<ChatSessionFile>(stream, ChatStateSerializerOptions);
if (file == null) throw new InvalidDataException("The chat file is empty.");

file.ServiceName = serviceName;
Directory.CreateDirectory(targetDirectory);
using (var stream = File.Open(targetPath, FileMode.Create, FileAccess.Write, FileShare.None))
JsonSerializer.Serialize(stream, file, ChatStateSerializerOptions);

if (!string.Equals(Path.GetFullPath(targetPath), Path.GetFullPath(item.FilePath), StringComparison.Ordinal))
File.Delete(item.FilePath);
}
catch (Exception e)
{
ContainerLocator.Container.Resolve<Microsoft.Extensions.Logging.ILogger>()
?.Warning($"Moving chat '{item.Name}' to {serviceName} failed", e);
// Keep the original file; the chat is saved to the new service on its next transcript store.
targetPath = item.FilePath;
}

if (_historyByService.TryGetValue(item.ServiceName, out var oldItems))
{
oldItems.Remove(item);
if (oldItems.Count == 0) _historyByService.Remove(item.ServiceName);
}

var moved = new ChatSessionHistoryItem
{
ServiceName = serviceName,
SessionId = item.SessionId,
Name = item.Name,
UpdatedAt = item.UpdatedAt,
FilePath = targetPath
};

if (!_historyByService.TryGetValue(serviceName, out var items))
{
items = [];
_historyByService[serviceName] = items;
}

if (item.HistoryItem != null) await OpenHistoryItemAsync(item.HistoryItem);
items.RemoveAll(x => string.Equals(x.SessionId, moved.SessionId, StringComparison.Ordinal));
items.Add(moved);
items.Sort((a, b) => b.UpdatedAt.CompareTo(a.UpdatedAt));
RefreshOverview();
return moved;
}

private async Task OpenOverviewItemAsync(ChatOverviewItem item)
{
try
{
if (item.Session != null && _sessions.Contains(item.Session))
{
Show(item.Session);
return;
}

if (item.HistoryItem != null)
{
await OpenHistoryItemAsync(item.HistoryItem);
return;
}

ReportOpenFailure(item.Title, "The chat is no longer available.");
RefreshOverview();
}
catch (Exception e)
{
ReportOpenFailure(item.Title, e.Message, e);
}
}

private async Task OpenHistoryItemAsync(ChatSessionHistoryItem item)
Expand All @@ -449,10 +551,32 @@ private async Task OpenHistoryItemAsync(ChatSessionHistoryItem item)
return;
}

var service = ChatServices.FirstOrDefault(s => string.Equals(s.Name, item.ServiceName, StringComparison.Ordinal));
if (service == null) return;
var service = FindService(item.ServiceName);
if (service == null)
{
// Chats of renamed services continue in their new service, chats of removed ones in GitHub Copilot.
service = (LegacyServiceNames.TryGetValue(item.ServiceName, out var renamed) ? FindService(renamed) : null)
?? FindService(LegacyFallbackServiceName);
if (service == null)
{
ReportOpenFailure(item.Name,
$"The chat service '{item.ServiceName}' is not available and there is no fallback service.");
return;
}

ContainerLocator.Container.Resolve<Microsoft.Extensions.Logging.ILogger>()
?.Warning($"Chat service '{item.ServiceName}' is not available, opening chat '{item.Name}' with {service.Name}");
item = MoveHistoryItem(item, service.Name);

TryReadSessionMessages(item, out var states);
if (FindLoaded(item.ServiceName, item.SessionId) is { } moved)
{
Show(moved);
return;
}
}

if (!TryReadSessionMessages(item, out var states))
ReportOpenFailure(item.Name, "The stored messages of this chat could not be read.");

LeaveCurrent();
var session = CreateSession(service, item.SessionId, states, item.Name, out var blocking);
Expand Down Expand Up @@ -1085,8 +1209,10 @@ private static bool TryReadSessionMessages(ChatSessionHistoryItem item, out List
messages = file.Messages;
return true;
}
catch
catch (Exception e)
{
ContainerLocator.Container.Resolve<Microsoft.Extensions.Logging.ILogger>()
?.Warning($"Reading chat '{item.Name}' failed", e);
return false;
}
}
Expand Down
4 changes: 2 additions & 2 deletions src/OneWare.CloudIntegration/OneWare.CloudIntegration.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@
</ItemGroup>

<ItemGroup>
<PackageReference Include="Microsoft.AspNetCore.SignalR.Client" Version="10.0.2" />
<PackageReference Include="System.IdentityModel.Tokens.Jwt" Version="8.15.0" />
<PackageReference Include="Microsoft.AspNetCore.SignalR.Client" Version="10.0.13" />
<PackageReference Include="System.IdentityModel.Tokens.Jwt" Version="8.23.0" />
</ItemGroup>
</Project>
Original file line number Diff line number Diff line change
Expand Up @@ -249,8 +249,8 @@
{
try
{
var plaintext = ProtectedData.Unprotect(File.ReadAllBytes(tokenPath), null,

Check warning on line 252 in src/OneWare.CloudIntegration/Services/OneWareCloudLoginService.cs

View workflow job for this annotation

GitHub Actions / build

This call site is reachable on all platforms. 'ProtectedData.Unprotect(byte[], byte[]?, DataProtectionScope)' is only supported on: 'windows'. (https://learn.microsoft.com/dotnet/fundamentals/code-analysis/quality-rules/ca1416)
DataProtectionScope.CurrentUser);

Check warning on line 253 in src/OneWare.CloudIntegration/Services/OneWareCloudLoginService.cs

View workflow job for this annotation

GitHub Actions / build

This call site is reachable on all platforms. 'DataProtectionScope.CurrentUser' is only supported on: 'windows'. (https://learn.microsoft.com/dotnet/fundamentals/code-analysis/quality-rules/ca1416)
return Encoding.UTF8.GetString(plaintext);
}
catch (Exception e)
Expand Down Expand Up @@ -330,6 +330,10 @@
// The platform credential store can be slow (keyring/dbus, DPAPI), keep it off the UI thread
await Task.Run(() => StoreRefreshToken(userId, refreshToken));

// Tokens are refreshed every few minutes; only write the settings file when the account changed.
if (_settingService.GetSettingValue<string>(OneWareCloudIntegrationModule.OneWareAccountUserIdKey) == userId)
return;

_settingService.SetSettingValue(OneWareCloudIntegrationModule.OneWareAccountUserIdKey, userId);
_settingService.Save(_paths.SettingsPath);
}
Expand Down
Loading
Loading