From 35910b84aa6a6b8714fbeccd88f8f84949d3da12 Mon Sep 17 00:00:00 2001 From: telli Date: Sun, 17 May 2026 00:41:39 -0700 Subject: [PATCH 1/6] Improve Companion runtime console --- src/OpenClaw.Client/OpenClawHttpClient.cs | 85 ++ src/OpenClaw.Companion/App.axaml | 7 +- src/OpenClaw.Companion/App.axaml.cs | 4 +- src/OpenClaw.Companion/Models/ChatMessage.cs | 17 + .../Services/IConfirmationDialogService.cs | 12 + .../WindowConfirmationDialogService.cs | 76 ++ .../Styles/CompanionStyles.axaml | 66 ++ .../MainWindowViewModel.Approvals.cs | 20 + .../MainWindowViewModel.Automations.cs | 286 ++++++ .../MainWindowViewModel.Dashboard.cs | 114 +++ .../MainWindowViewModel.ManagedGateway.cs | 6 + .../MainWindowViewModel.Payments.cs | 229 +++++ .../ViewModels/MainWindowViewModel.Plugins.cs | 98 ++ .../MainWindowViewModel.Profiles.cs | 185 ++++ .../MainWindowViewModel.Providers.cs | 98 ++ ...inWindowViewModel.RuntimeConsoleHelpers.cs | 114 +++ .../MainWindowViewModel.RuntimeEvents.cs | 135 +++ .../MainWindowViewModel.Sessions.cs | 202 +++++ .../MainWindowViewModel.Workflows.cs | 245 +++++ .../ViewModels/MainWindowViewModel.cs | 5 + src/OpenClaw.Companion/Views/MainWindow.axaml | 840 ++++++++---------- src/OpenClaw.Tests/CompanionCanvasUiTests.cs | 40 +- .../CompanionRuntimeConsoleTests.cs | 96 ++ 23 files changed, 2527 insertions(+), 453 deletions(-) create mode 100644 src/OpenClaw.Companion/Services/IConfirmationDialogService.cs create mode 100644 src/OpenClaw.Companion/Services/WindowConfirmationDialogService.cs create mode 100644 src/OpenClaw.Companion/Styles/CompanionStyles.axaml create mode 100644 src/OpenClaw.Companion/ViewModels/MainWindowViewModel.Automations.cs create mode 100644 src/OpenClaw.Companion/ViewModels/MainWindowViewModel.Dashboard.cs create mode 100644 src/OpenClaw.Companion/ViewModels/MainWindowViewModel.Payments.cs create mode 100644 src/OpenClaw.Companion/ViewModels/MainWindowViewModel.Plugins.cs create mode 100644 src/OpenClaw.Companion/ViewModels/MainWindowViewModel.Profiles.cs create mode 100644 src/OpenClaw.Companion/ViewModels/MainWindowViewModel.Providers.cs create mode 100644 src/OpenClaw.Companion/ViewModels/MainWindowViewModel.RuntimeConsoleHelpers.cs create mode 100644 src/OpenClaw.Companion/ViewModels/MainWindowViewModel.RuntimeEvents.cs create mode 100644 src/OpenClaw.Companion/ViewModels/MainWindowViewModel.Sessions.cs create mode 100644 src/OpenClaw.Companion/ViewModels/MainWindowViewModel.Workflows.cs create mode 100644 src/OpenClaw.Tests/CompanionRuntimeConsoleTests.cs diff --git a/src/OpenClaw.Client/OpenClawHttpClient.cs b/src/OpenClaw.Client/OpenClawHttpClient.cs index b42106b7..fa692764 100644 --- a/src/OpenClaw.Client/OpenClawHttpClient.cs +++ b/src/OpenClaw.Client/OpenClawHttpClient.cs @@ -29,6 +29,7 @@ public sealed class OpenClawHttpClient : IDisposable private readonly Uri _integrationSessionSearchUri; private readonly Uri _integrationProfilesUri; private readonly Uri _integrationToolPresetsUri; + private readonly Uri _integrationWorkflowsUri; private readonly Uri _integrationAutomationsUri; private readonly Uri _integrationRuntimeEventsUri; private readonly Uri _integrationMessagesUri; @@ -105,6 +106,7 @@ public OpenClawHttpClient(string baseUrl, string? authToken, HttpClient? httpCli _integrationSessionSearchUri = new Uri(baseUri, "/api/integration/session-search"); _integrationProfilesUri = new Uri(baseUri, "/api/integration/profiles"); _integrationToolPresetsUri = new Uri(baseUri, "/api/integration/tool-presets"); + _integrationWorkflowsUri = new Uri(baseUri, "/api/integration/workflows"); _integrationAutomationsUri = new Uri(baseUri, "/api/integration/automations"); _integrationRuntimeEventsUri = new Uri(baseUri, "/api/integration/runtime-events"); _integrationMessagesUri = new Uri(baseUri, "/api/integration/messages"); @@ -687,6 +689,50 @@ public async Task DeleteAutomationAsync(string automationId, C return await SendAsync(req, CoreJsonContext.Default.MutationResponse, cancellationToken); } + public Task GetAutomationRunsAsync(string automationId, CancellationToken cancellationToken) + => GetAsync(BuildAutomationRunsUri(automationId), CoreJsonContext.Default.IntegrationAutomationRunsResponse, cancellationToken); + + public Task GetAutomationRunAsync(string automationId, string runId, CancellationToken cancellationToken) + => GetAsync(BuildAutomationRunDetailUri(automationId, runId), CoreJsonContext.Default.IntegrationAutomationRunDetailResponse, cancellationToken); + + public async Task ReplayAutomationRunAsync(string automationId, string runId, CancellationToken cancellationToken) + { + using var req = new HttpRequestMessage(HttpMethod.Post, BuildAutomationRunReplayUri(automationId, runId)); + return await SendAsync(req, CoreJsonContext.Default.MutationResponse, cancellationToken); + } + + public async Task ClearAutomationQuarantineAsync(string automationId, CancellationToken cancellationToken) + { + using var req = new HttpRequestMessage(HttpMethod.Post, BuildAutomationQuarantineClearUri(automationId)); + return await SendAsync(req, CoreJsonContext.Default.MutationResponse, cancellationToken); + } + + public Task ListWorkflowsAsync(CancellationToken cancellationToken) + => GetAsync(_integrationWorkflowsUri, CoreJsonContext.Default.IntegrationWorkflowsResponse, cancellationToken); + + public async Task RunWorkflowAsync(string workflowId, AgentWorkflowRequest request, CancellationToken cancellationToken) + { + using var req = new HttpRequestMessage(HttpMethod.Post, BuildWorkflowRunsUri(workflowId)) + { + Content = BuildJsonContent(request, CoreJsonContext.Default.AgentWorkflowRequest) + }; + + return await SendAsync(req, CoreJsonContext.Default.AgentWorkflowRunResult, cancellationToken); + } + + public Task GetWorkflowRunAsync(string workflowId, string runId, CancellationToken cancellationToken) + => GetAsync(BuildWorkflowRunUri(workflowId, runId), CoreJsonContext.Default.AgentWorkflowRunSnapshot, cancellationToken); + + public async Task RespondWorkflowRunAsync(string workflowId, string runId, AgentWorkflowResponse response, CancellationToken cancellationToken) + { + using var req = new HttpRequestMessage(HttpMethod.Post, BuildWorkflowRunResponsesUri(workflowId, runId)) + { + Content = BuildJsonContent(response, CoreJsonContext.Default.AgentWorkflowResponse) + }; + + return await SendAsync(req, CoreJsonContext.Default.AgentWorkflowRunSnapshot, cancellationToken); + } + public Task QueryRuntimeEventsAsync( RuntimeEventQuery query, CancellationToken cancellationToken) @@ -1401,6 +1447,45 @@ private Uri BuildAutomationUri(string automationId) private Uri BuildAutomationRunUri(string automationId) => new($"{BuildAutomationUri(automationId).AbsoluteUri}/run", UriKind.Absolute); + private Uri BuildAutomationRunsUri(string automationId) + => new($"{BuildAutomationUri(automationId).AbsoluteUri}/runs", UriKind.Absolute); + + private Uri BuildAutomationRunDetailUri(string automationId, string runId) + { + if (string.IsNullOrWhiteSpace(runId)) + throw new ArgumentException("Automation run id is required.", nameof(runId)); + + return new Uri($"{BuildAutomationRunsUri(automationId).AbsoluteUri}/{Uri.EscapeDataString(runId)}", UriKind.Absolute); + } + + private Uri BuildAutomationRunReplayUri(string automationId, string runId) + => new($"{BuildAutomationRunDetailUri(automationId, runId).AbsoluteUri}/replay", UriKind.Absolute); + + private Uri BuildAutomationQuarantineClearUri(string automationId) + => new($"{BuildAutomationUri(automationId).AbsoluteUri}/quarantine/clear", UriKind.Absolute); + + private Uri BuildWorkflowUri(string workflowId) + { + if (string.IsNullOrWhiteSpace(workflowId)) + throw new ArgumentException("Workflow id is required.", nameof(workflowId)); + + return new Uri($"{_integrationWorkflowsUri.AbsoluteUri.TrimEnd('/')}/{Uri.EscapeDataString(workflowId)}", UriKind.Absolute); + } + + private Uri BuildWorkflowRunsUri(string workflowId) + => new($"{BuildWorkflowUri(workflowId).AbsoluteUri}/runs", UriKind.Absolute); + + private Uri BuildWorkflowRunUri(string workflowId, string runId) + { + if (string.IsNullOrWhiteSpace(runId)) + throw new ArgumentException("Workflow run id is required.", nameof(runId)); + + return new Uri($"{BuildWorkflowRunsUri(workflowId).AbsoluteUri}/{Uri.EscapeDataString(runId)}", UriKind.Absolute); + } + + private Uri BuildWorkflowRunResponsesUri(string workflowId, string runId) + => new($"{BuildWorkflowRunUri(workflowId, runId).AbsoluteUri}/responses", UriKind.Absolute); + private Uri BuildAutomationTemplatesUri(bool admin) { var baseUri = admin ? _adminAutomationsUri : _integrationAutomationsUri; diff --git a/src/OpenClaw.Companion/App.axaml b/src/OpenClaw.Companion/App.axaml index 2993e45e..0c7f2739 100644 --- a/src/OpenClaw.Companion/App.axaml +++ b/src/OpenClaw.Companion/App.axaml @@ -14,7 +14,8 @@ - - - + + + + diff --git a/src/OpenClaw.Companion/App.axaml.cs b/src/OpenClaw.Companion/App.axaml.cs index ac6c411c..2d8b93f0 100644 --- a/src/OpenClaw.Companion/App.axaml.cs +++ b/src/OpenClaw.Companion/App.axaml.cs @@ -34,10 +34,12 @@ public override void OnFrameworkInitializationCompleted() var viewModel = new MainWindowViewModel(settings, _client, managedGateway: _managedGateway); viewModel.AttachDesktopNotifier(new DesktopNotifier()); - desktop.MainWindow = new MainWindow + var mainWindow = new MainWindow { DataContext = viewModel, }; + viewModel.AttachConfirmationDialogService(new WindowConfirmationDialogService(mainWindow)); + desktop.MainWindow = mainWindow; viewModel.StartApprovalsPolling(); var initializeLocalGatewayTask = viewModel.InitializeLocalGatewayAsync(); diff --git a/src/OpenClaw.Companion/Models/ChatMessage.cs b/src/OpenClaw.Companion/Models/ChatMessage.cs index 350ef0ba..e3b8f9a3 100644 --- a/src/OpenClaw.Companion/Models/ChatMessage.cs +++ b/src/OpenClaw.Companion/Models/ChatMessage.cs @@ -19,4 +19,21 @@ public sealed record ChatMessage ChatRole.Assistant => "OpenClaw", _ => "System" }; + + public bool IsUser => Role == ChatRole.User; + public bool IsAssistant => Role == ChatRole.Assistant; + public bool IsSystem => Role == ChatRole.System && !IsToolEvent && !IsError; + public bool IsToolEvent => Role == ChatRole.System && + (Text.StartsWith("Agent invoked tool:", StringComparison.OrdinalIgnoreCase) || + Text.Contains("tool approval", StringComparison.OrdinalIgnoreCase) || + Text.Contains("requires operator approval", StringComparison.OrdinalIgnoreCase)); + + public bool IsError => Role == ChatRole.System && + (Text.StartsWith("Error:", StringComparison.OrdinalIgnoreCase) || + Text.Contains(" failed", StringComparison.OrdinalIgnoreCase) || + Text.Contains("unavailable", StringComparison.OrdinalIgnoreCase) || + Text.Contains("blocked", StringComparison.OrdinalIgnoreCase) || + Text.Contains("denied", StringComparison.OrdinalIgnoreCase)); + + public bool IsStreamingPlaceholder => Role == ChatRole.Assistant && string.IsNullOrWhiteSpace(Text); } diff --git a/src/OpenClaw.Companion/Services/IConfirmationDialogService.cs b/src/OpenClaw.Companion/Services/IConfirmationDialogService.cs new file mode 100644 index 00000000..0ff000fb --- /dev/null +++ b/src/OpenClaw.Companion/Services/IConfirmationDialogService.cs @@ -0,0 +1,12 @@ +namespace OpenClaw.Companion.Services; + +public interface IConfirmationDialogService +{ + Task ConfirmAsync(string title, string message, string confirmText, string cancelText, CancellationToken cancellationToken); +} + +public sealed class DenyConfirmationDialogService : IConfirmationDialogService +{ + public Task ConfirmAsync(string title, string message, string confirmText, string cancelText, CancellationToken cancellationToken) + => Task.FromResult(false); +} diff --git a/src/OpenClaw.Companion/Services/WindowConfirmationDialogService.cs b/src/OpenClaw.Companion/Services/WindowConfirmationDialogService.cs new file mode 100644 index 00000000..de1cfcda --- /dev/null +++ b/src/OpenClaw.Companion/Services/WindowConfirmationDialogService.cs @@ -0,0 +1,76 @@ +using Avalonia.Controls; +using Avalonia.Layout; + +namespace OpenClaw.Companion.Services; + +public sealed class WindowConfirmationDialogService(Window owner) : IConfirmationDialogService +{ + public async Task ConfirmAsync( + string title, + string message, + string confirmText, + string cancelText, + CancellationToken cancellationToken) + { + if (cancellationToken.IsCancellationRequested) + return false; + + var dialog = new Window + { + Title = title, + Width = 420, + SizeToContent = SizeToContent.Height, + WindowStartupLocation = WindowStartupLocation.CenterOwner, + CanResize = false + }; + + var result = false; + var confirmButton = new Button + { + Content = confirmText, + MinWidth = 96, + HorizontalContentAlignment = HorizontalAlignment.Center + }; + var cancelButton = new Button + { + Content = cancelText, + MinWidth = 96, + HorizontalContentAlignment = HorizontalAlignment.Center + }; + + confirmButton.Click += (_, _) => + { + result = true; + dialog.Close(); + }; + cancelButton.Click += (_, _) => + { + result = false; + dialog.Close(); + }; + + dialog.Content = new StackPanel + { + Margin = new Avalonia.Thickness(20), + Spacing = 16, + Children = + { + new TextBlock + { + Text = message, + TextWrapping = Avalonia.Media.TextWrapping.Wrap + }, + new StackPanel + { + Orientation = Orientation.Horizontal, + HorizontalAlignment = HorizontalAlignment.Right, + Spacing = 8, + Children = { cancelButton, confirmButton } + } + } + }; + + await dialog.ShowDialog(owner); + return result && !cancellationToken.IsCancellationRequested; + } +} diff --git a/src/OpenClaw.Companion/Styles/CompanionStyles.axaml b/src/OpenClaw.Companion/Styles/CompanionStyles.axaml new file mode 100644 index 00000000..80cbcce1 --- /dev/null +++ b/src/OpenClaw.Companion/Styles/CompanionStyles.axaml @@ -0,0 +1,66 @@ + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/OpenClaw.Companion/ViewModels/MainWindowViewModel.Approvals.cs b/src/OpenClaw.Companion/ViewModels/MainWindowViewModel.Approvals.cs index 1bed8ea5..b22eb989 100644 --- a/src/OpenClaw.Companion/ViewModels/MainWindowViewModel.Approvals.cs +++ b/src/OpenClaw.Companion/ViewModels/MainWindowViewModel.Approvals.cs @@ -114,6 +114,7 @@ private void NotifyPendingApprovalsChanged() OnPropertyChanged(nameof(QueueSeverity)); OnPropertyChanged(nameof(QueueSeverityIsLight)); OnPropertyChanged(nameof(QueueSeverityIsHeavy)); + OnPropertyChanged(nameof(PendingApprovalsBadge)); } [RelayCommand(CanExecute = nameof(CanRefreshApprovals))] @@ -363,6 +364,25 @@ internal void MergePendingApprovals(IReadOnlyList latest) private async Task SubmitApprovalDecisionAsync(PendingApprovalItem item, bool approved) { + if (!approved) + { + var confirmed = await ConfirmMutationAsync( + "Deny approval", + $"Deny '{item.ToolName}' from {item.Origin}?", + "Deny"); + if (!confirmed) + return; + } + else if (item.IsMutation) + { + var confirmed = await ConfirmMutationAsync( + "Approve mutation", + $"Approve mutation tool '{item.ToolName}' from {item.Origin}? Review the arguments before continuing.", + "Approve"); + if (!confirmed) + return; + } + using var client = CreateAdminClient(out var error); if (client is null) { diff --git a/src/OpenClaw.Companion/ViewModels/MainWindowViewModel.Automations.cs b/src/OpenClaw.Companion/ViewModels/MainWindowViewModel.Automations.cs new file mode 100644 index 00000000..21bbf029 --- /dev/null +++ b/src/OpenClaw.Companion/ViewModels/MainWindowViewModel.Automations.cs @@ -0,0 +1,286 @@ +using System.Collections.ObjectModel; +using CommunityToolkit.Mvvm.ComponentModel; +using CommunityToolkit.Mvvm.Input; +using OpenClaw.Core.Models; + +namespace OpenClaw.Companion.ViewModels; + +public sealed partial class MainWindowViewModel +{ + [ObservableProperty] + private bool _isAutomationsBusy; + + [ObservableProperty] + private string _automationsStatus = "Automations not loaded."; + + [ObservableProperty] + private AutomationRow? _selectedAutomation; + + [ObservableProperty] + private AutomationRunRow? _selectedAutomationRun; + + [ObservableProperty] + private string _automationDetail = "Select an automation to inspect run state."; + + public ObservableCollection AutomationRows { get; } = []; + public ObservableCollection AutomationTemplateRows { get; } = []; + public ObservableCollection AutomationRunRows { get; } = []; + public bool HasAutomationRows => AutomationRows.Count > 0; + public bool HasAutomationTemplateRows => AutomationTemplateRows.Count > 0; + public bool HasAutomationRunRows => AutomationRunRows.Count > 0; + + partial void OnSelectedAutomationChanged(AutomationRow? value) + { + if (value is not null) + _ = LoadAutomationDetailAsync(value.AutomationId); + } + + [RelayCommand] + private async Task LoadAutomationsAsync() + { + if (IsAutomationsBusy) + return; + + IsAutomationsBusy = true; + try + { + if (!RequireIntegrationClient(out var client, status => AutomationsStatus = status) || client is null) + return; + + var automations = await client.ListAutomationsAsync(CancellationToken.None); + var templates = await client.ListAutomationTemplatesAsync(CancellationToken.None); + ReplaceItems(AutomationRows, automations.Items.Select(AutomationRow.FromDefinition)); + ReplaceItems(AutomationTemplateRows, templates.Items.Select(AutomationTemplateRow.FromTemplate)); + OnPropertyChanged(nameof(HasAutomationRows)); + OnPropertyChanged(nameof(HasAutomationTemplateRows)); + AutomationsStatus = $"Loaded {AutomationRows.Count} automation(s) and {AutomationTemplateRows.Count} template(s)."; + } + catch (Exception ex) + { + AutomationsStatus = $"Automations load failed: {ex.Message}"; + } + finally + { + IsAutomationsBusy = false; + } + } + + [RelayCommand] + private async Task RunSelectedAutomationDryRunAsync() + { + if (SelectedAutomation is null) + return; + + await RunAutomationCoreAsync(SelectedAutomation.AutomationId, dryRun: true); + } + + [RelayCommand] + private async Task RunSelectedAutomationLiveAsync() + { + if (SelectedAutomation is null) + return; + + var confirmed = await ConfirmMutationAsync( + "Run automation", + $"Run automation '{SelectedAutomation.Name}' live? This can enqueue runtime work and delivery.", + "Run live"); + if (!confirmed) + return; + + await RunAutomationCoreAsync(SelectedAutomation.AutomationId, dryRun: false); + } + + [RelayCommand] + private async Task DeleteSelectedAutomationAsync() + { + if (SelectedAutomation is null) + return; + + var confirmed = await ConfirmMutationAsync( + "Delete automation", + $"Delete automation '{SelectedAutomation.Name}'? This cannot be undone from the Companion.", + "Delete"); + if (!confirmed) + return; + + try + { + if (!RequireIntegrationClient(out var client, status => AutomationsStatus = status) || client is null) + return; + + var result = await client.DeleteAutomationAsync(SelectedAutomation.AutomationId, CancellationToken.None); + AutomationsStatus = result.Success ? result.Message : result.Error ?? "Automation delete failed."; + await LoadAutomationsAsync(); + } + catch (Exception ex) + { + AutomationsStatus = $"Automation delete failed: {ex.Message}"; + } + } + + [RelayCommand] + private async Task ReplaySelectedAutomationRunAsync() + { + if (SelectedAutomation is null || SelectedAutomationRun is null) + return; + + var confirmed = await ConfirmMutationAsync( + "Replay automation run", + $"Replay run '{SelectedAutomationRun.RunId}' for automation '{SelectedAutomation.Name}'?", + "Replay"); + if (!confirmed) + return; + + try + { + if (!RequireIntegrationClient(out var client, status => AutomationsStatus = status) || client is null) + return; + + var result = await client.ReplayAutomationRunAsync(SelectedAutomation.AutomationId, SelectedAutomationRun.RunId, CancellationToken.None); + AutomationsStatus = result.Success ? result.Message : result.Error ?? "Automation replay failed."; + await LoadAutomationDetailAsync(SelectedAutomation.AutomationId); + } + catch (Exception ex) + { + AutomationsStatus = $"Automation replay failed: {ex.Message}"; + } + } + + [RelayCommand] + private async Task ClearSelectedAutomationQuarantineAsync() + { + if (SelectedAutomation is null) + return; + + var confirmed = await ConfirmMutationAsync( + "Clear quarantine", + $"Clear quarantine for automation '{SelectedAutomation.Name}'?", + "Clear"); + if (!confirmed) + return; + + try + { + if (!RequireIntegrationClient(out var client, status => AutomationsStatus = status) || client is null) + return; + + var result = await client.ClearAutomationQuarantineAsync(SelectedAutomation.AutomationId, CancellationToken.None); + AutomationsStatus = result.Success ? result.Message : result.Error ?? "Clear quarantine failed."; + await LoadAutomationDetailAsync(SelectedAutomation.AutomationId); + } + catch (Exception ex) + { + AutomationsStatus = $"Clear quarantine failed: {ex.Message}"; + } + } + + private async Task RunAutomationCoreAsync(string automationId, bool dryRun) + { + try + { + if (!RequireIntegrationClient(out var client, status => AutomationsStatus = status) || client is null) + return; + + var result = await client.RunAutomationAsync(automationId, dryRun, CancellationToken.None); + AutomationsStatus = result.Success ? result.Message : result.Error ?? "Automation run failed."; + await LoadAutomationDetailAsync(automationId); + } + catch (Exception ex) + { + AutomationsStatus = $"Automation run failed: {ex.Message}"; + } + } + + private async Task LoadAutomationDetailAsync(string automationId) + { + try + { + if (!RequireIntegrationClient(out var client, status => AutomationDetail = status) || client is null) + return; + + var detail = await client.GetAutomationAsync(automationId, CancellationToken.None); + var runs = await client.GetAutomationRunsAsync(automationId, CancellationToken.None); + AutomationDetail = detail.Automation is null + ? "Automation not found." + : string.Join(Environment.NewLine, new[] + { + $"Name: {detail.Automation.Name}", + $"Schedule: {detail.Automation.Schedule}", + $"Enabled: {detail.Automation.Enabled}", + $"Delivery: {detail.Automation.DeliveryChannelId}", + $"Health: {detail.RunState?.HealthState ?? "unknown"}", + $"Lifecycle: {detail.RunState?.LifecycleState ?? "never"}", + $"Last run: {FormatTimestamp(detail.RunState?.LastRunAtUtc)}", + $"Next retry: {FormatTimestamp(detail.RunState?.NextRetryAtUtc)}", + $"Quarantine: {detail.RunState?.QuarantineReason ?? "none"}" + }); + ReplaceItems(AutomationRunRows, runs.Items.Select(AutomationRunRow.FromRecord)); + OnPropertyChanged(nameof(HasAutomationRunRows)); + } + catch (Exception ex) + { + AutomationDetail = $"Automation detail load failed: {ex.Message}"; + } + } +} + +public sealed class AutomationRow +{ + public required string AutomationId { get; init; } + public required string Name { get; init; } + public required string Schedule { get; init; } + public required string Delivery { get; init; } + public required string State { get; init; } + public required string Tags { get; init; } + + public static AutomationRow FromDefinition(AutomationDefinition item) + => new() + { + AutomationId = item.Id, + Name = string.IsNullOrWhiteSpace(item.Name) ? item.Id : item.Name, + Schedule = item.Schedule, + Delivery = item.DeliveryChannelId, + State = item.IsDraft ? "draft" : item.Enabled ? "enabled" : "disabled", + Tags = string.Join(", ", item.Tags) + }; +} + +public sealed class AutomationTemplateRow +{ + public required string Key { get; init; } + public required string Label { get; init; } + public required string Category { get; init; } + public required string Description { get; init; } + public required string Availability { get; init; } + + public static AutomationTemplateRow FromTemplate(AutomationTemplate item) + => new() + { + Key = item.Key, + Label = item.Label, + Category = item.Category, + Description = item.Description, + Availability = item.Available ? "available" : item.Reason ?? "unavailable" + }; +} + +public sealed class AutomationRunRow +{ + public required string RunId { get; init; } + public required string Trigger { get; init; } + public required string Lifecycle { get; init; } + public required string Verification { get; init; } + public required string Started { get; init; } + public required string Summary { get; init; } + + public static AutomationRunRow FromRecord(AutomationRunRecord item) + => new() + { + RunId = item.RunId, + Trigger = item.TriggerSource, + Lifecycle = item.LifecycleState, + Verification = item.VerificationStatus, + Started = item.StartedAtUtc.ToLocalTime().ToString("g"), + Summary = item.VerificationSummary ?? item.MessagePreview ?? "" + }; +} diff --git a/src/OpenClaw.Companion/ViewModels/MainWindowViewModel.Dashboard.cs b/src/OpenClaw.Companion/ViewModels/MainWindowViewModel.Dashboard.cs new file mode 100644 index 00000000..c28b3e29 --- /dev/null +++ b/src/OpenClaw.Companion/ViewModels/MainWindowViewModel.Dashboard.cs @@ -0,0 +1,114 @@ +using System.Collections.ObjectModel; +using CommunityToolkit.Mvvm.ComponentModel; +using CommunityToolkit.Mvvm.Input; +using OpenClaw.Core.Models; + +namespace OpenClaw.Companion.ViewModels; + +public sealed partial class MainWindowViewModel +{ + [ObservableProperty] + private bool _isDashboardBusy; + + [ObservableProperty] + private string _dashboardStatus = "Dashboard not loaded."; + + [ObservableProperty] + private int _dashboardActiveSessions; + + [ObservableProperty] + private int _dashboardPendingApprovals; + + [ObservableProperty] + private int _dashboardProviders; + + [ObservableProperty] + private int _dashboardPlugins; + + [ObservableProperty] + private int _dashboardAutomations; + + [ObservableProperty] + private int _dashboardChannelsReady; + + [ObservableProperty] + private string _dashboardLastRefreshed = "never"; + + public string DashboardLocalGatewayStatus => LocalGatewayIsHealthy ? "Local gateway healthy" : LocalGatewayStatus; + public bool HasDashboardApprovalHistory => DashboardApprovalHistory.Count > 0; + public bool HasDashboardEvents => DashboardRecentEvents.Count > 0; + public bool HasDashboardChannels => DashboardChannels.Count > 0; + + public ObservableCollection DashboardApprovalHistory { get; } = []; + public ObservableCollection DashboardRecentEvents { get; } = []; + public ObservableCollection DashboardChannels { get; } = []; + + [RelayCommand] + private async Task LoadDashboardAsync() + { + if (IsDashboardBusy) + return; + + IsDashboardBusy = true; + try + { + if (!RequireIntegrationClient(out var client, status => DashboardStatus = status) || client is null) + return; + + var response = await client.GetIntegrationDashboardAsync(CancellationToken.None); + DashboardActiveSessions = response.Status.ActiveSessions; + DashboardPendingApprovals = response.Status.PendingApprovals; + DashboardProviders = response.Providers.Routes.Count; + DashboardPlugins = response.Plugins.Items.Count; + DashboardAutomations = response.Operator.Automations.Total; + DashboardChannelsReady = response.Operator.Channels.Ready; + DashboardLastRefreshed = DateTimeOffset.Now.ToString("g"); + DashboardStatus = "Runtime dashboard loaded."; + + ReplaceItems(DashboardApprovalHistory, response.ApprovalHistory.Items.Take(8).Select(ApprovalHistoryItem.FromEntry)); + ReplaceItems(DashboardRecentEvents, response.Events.Items.Take(8).Select(RuntimeEventRow.FromEntry)); + ReplaceItems(DashboardChannels, response.Operator.Channels.Items.Select(ChannelReadinessRow.FromDto)); + OnPropertyChanged(nameof(HasDashboardApprovalHistory)); + OnPropertyChanged(nameof(HasDashboardEvents)); + OnPropertyChanged(nameof(HasDashboardChannels)); + } + catch (Exception ex) + { + DashboardStatus = $"Dashboard load failed: {ex.Message}"; + } + finally + { + IsDashboardBusy = false; + } + } +} + +public sealed class ChannelReadinessRow +{ + public required string ChannelId { get; init; } + public required string DisplayName { get; init; } + public required string Mode { get; init; } + public required string Status { get; init; } + public required string Detail { get; init; } + public bool Enabled { get; init; } + public bool Ready { get; init; } + + public static ChannelReadinessRow FromDto(ChannelReadinessDto item) + => new() + { + ChannelId = item.ChannelId, + DisplayName = item.DisplayName, + Mode = item.Mode, + Status = item.Status, + Enabled = item.Enabled, + Ready = item.Ready, + Detail = BuildDetail(item) + }; + + private static string BuildDetail(ChannelReadinessDto item) + { + var missing = item.MissingRequirements.Count == 0 ? "" : $"Missing: {string.Join(", ", item.MissingRequirements)}"; + var warnings = item.Warnings.Count == 0 ? "" : $"Warnings: {string.Join(", ", item.Warnings)}"; + return string.Join(" · ", new[] { missing, warnings }.Where(static text => text.Length > 0)); + } +} diff --git a/src/OpenClaw.Companion/ViewModels/MainWindowViewModel.ManagedGateway.cs b/src/OpenClaw.Companion/ViewModels/MainWindowViewModel.ManagedGateway.cs index 8acbd0f2..02e6477c 100644 --- a/src/OpenClaw.Companion/ViewModels/MainWindowViewModel.ManagedGateway.cs +++ b/src/OpenClaw.Companion/ViewModels/MainWindowViewModel.ManagedGateway.cs @@ -405,12 +405,18 @@ partial void OnSetupProviderChanged(string value) if (!_isLoadingSettings) SaveSettings(); + + OnPropertyChanged(nameof(SetupProviderSummary)); + OnPropertyChanged(nameof(EmbeddedLocalModelDisabledReason)); + OnPropertyChanged(nameof(HasEmbeddedLocalModelDisabledReason)); } partial void OnSetupModelChanged(string value) { if (!_isLoadingSettings) SaveSettings(); + + OnPropertyChanged(nameof(SetupProviderSummary)); } partial void OnSetupModelPresetChanged(string value) diff --git a/src/OpenClaw.Companion/ViewModels/MainWindowViewModel.Payments.cs b/src/OpenClaw.Companion/ViewModels/MainWindowViewModel.Payments.cs new file mode 100644 index 00000000..861ea238 --- /dev/null +++ b/src/OpenClaw.Companion/ViewModels/MainWindowViewModel.Payments.cs @@ -0,0 +1,229 @@ +using System.Collections.ObjectModel; +using CommunityToolkit.Mvvm.ComponentModel; +using CommunityToolkit.Mvvm.Input; +using OpenClaw.Payments.Abstractions; + +namespace OpenClaw.Companion.ViewModels; + +public sealed partial class MainWindowViewModel +{ + [ObservableProperty] + private bool _isPaymentsBusy; + + [ObservableProperty] + private string _paymentsStatus = "Payment Lab not loaded."; + + [ObservableProperty] + private string _paymentProvider = ""; + + [ObservableProperty] + private string _paymentEnvironment = PaymentEnvironments.Test; + + [ObservableProperty] + private string _paymentSetupSummary = "Load setup status before running payment actions."; + + [ObservableProperty] + private string _virtualCardMerchantName = ""; + + [ObservableProperty] + private string _virtualCardMerchantUrl = ""; + + [ObservableProperty] + private string _virtualCardAmountMinor = ""; + + [ObservableProperty] + private string _virtualCardCurrency = "USD"; + + [ObservableProperty] + private string _virtualCardFundingSourceId = ""; + + [ObservableProperty] + private string _machinePaymentMerchantName = ""; + + [ObservableProperty] + private string _machinePaymentAmountMinor = ""; + + [ObservableProperty] + private string _machinePaymentCurrency = "USD"; + + [ObservableProperty] + private string _machinePaymentFundingSourceId = ""; + + [ObservableProperty] + private string _paymentStatusLookupId = ""; + + [ObservableProperty] + private string _paymentResultText = ""; + + public ObservableCollection FundingSourceRows { get; } = []; + public bool HasFundingSourceRows => FundingSourceRows.Count > 0; + + [RelayCommand] + private async Task LoadPaymentSetupAsync() + { + if (IsPaymentsBusy) + return; + + IsPaymentsBusy = true; + try + { + if (!RequireIntegrationClient(out var client, status => PaymentsStatus = status) || client is null) + return; + + var setup = await client.GetPaymentSetupStatusAsync(EmptyToNull(PaymentProvider), CancellationToken.None); + PaymentSetupSummary = string.Join(Environment.NewLine, new[] + { + $"Provider: {setup.ProviderId}", + $"Enabled: {setup.Enabled}", + $"Installed: {setup.Installed}", + $"Mode: {setup.Mode}", + $"Status: {setup.Status}", + $"Message: {setup.Message ?? ""}", + $"Requirements: {(setup.Requirements.Count == 0 ? "none" : string.Join("; ", setup.Requirements.Select(r => $"{r.Name}={(r.Satisfied ? "ok" : "missing")}")))}" + }); + + var funding = setup.Enabled + ? await client.ListPaymentFundingSourcesAsync(EmptyToNull(PaymentProvider), EmptyToNull(PaymentEnvironment), CancellationToken.None) + : new List(); + ReplaceItems(FundingSourceRows, funding.Select(FundingSourceRow.FromFundingSource)); + OnPropertyChanged(nameof(HasFundingSourceRows)); + PaymentsStatus = setup.Enabled ? "Payment setup loaded." : "Payments are disabled by configuration."; + } + catch (Exception ex) + { + PaymentsStatus = $"Payment setup load failed: {ex.Message}"; + } + finally + { + IsPaymentsBusy = false; + } + } + + [RelayCommand] + private async Task IssueVirtualCardAsync() + { + if (!long.TryParse(VirtualCardAmountMinor, out var amountMinor) || amountMinor <= 0) + { + PaymentsStatus = "Virtual card amount must be a positive minor-unit integer."; + return; + } + + var confirmed = await ConfirmMutationAsync( + "Issue virtual card", + $"Issue a {VirtualCardCurrency} {amountMinor} minor-unit virtual card for '{VirtualCardMerchantName}'? Payment Lab actions are experimental and approval-gated by policy.", + "Issue"); + if (!confirmed) + return; + + try + { + if (!RequireIntegrationClient(out var client, status => PaymentsStatus = status) || client is null) + return; + + var handle = await client.IssueVirtualCardAsync(new VirtualCardRequest + { + ProviderId = EmptyToNull(PaymentProvider), + Environment = PaymentEnvironment, + FundingSourceId = EmptyToNull(VirtualCardFundingSourceId), + MerchantName = VirtualCardMerchantName, + MerchantUrl = EmptyToNull(VirtualCardMerchantUrl), + AmountMinor = amountMinor, + Currency = VirtualCardCurrency + }, yes: true, CancellationToken.None); + PaymentResultText = $"Virtual card issued: {handle.HandleId} · {handle.Status} · last4 {handle.Last4 ?? "masked"}"; + PaymentsStatus = "Virtual card request completed."; + } + catch (Exception ex) + { + PaymentsStatus = $"Virtual card request failed: {ex.Message}"; + } + } + + [RelayCommand] + private async Task ExecuteMachinePaymentAsync() + { + if (!long.TryParse(MachinePaymentAmountMinor, out var amountMinor) || amountMinor <= 0) + { + PaymentsStatus = "Payment amount must be a positive minor-unit integer."; + return; + } + + var confirmed = await ConfirmMutationAsync( + "Execute payment", + $"Execute a {MachinePaymentCurrency} {amountMinor} minor-unit machine payment for '{MachinePaymentMerchantName}'?", + "Execute"); + if (!confirmed) + return; + + try + { + if (!RequireIntegrationClient(out var client, status => PaymentsStatus = status) || client is null) + return; + + var result = await client.ExecuteMachinePaymentAsync(new MachinePaymentRequest + { + ProviderId = EmptyToNull(PaymentProvider), + Environment = PaymentEnvironment, + FundingSourceId = EmptyToNull(MachinePaymentFundingSourceId), + Challenge = new MachinePaymentChallenge + { + MerchantName = MachinePaymentMerchantName, + AmountMinor = amountMinor, + Currency = MachinePaymentCurrency, + ProviderId = EmptyToNull(PaymentProvider) + } + }, yes: true, CancellationToken.None); + PaymentResultText = $"Payment {result.PaymentId}: {result.Status} · {result.MerchantName} · {result.AmountMinor} {result.Currency}"; + PaymentsStatus = "Payment execution completed."; + } + catch (Exception ex) + { + PaymentsStatus = $"Payment execution failed: {ex.Message}"; + } + } + + [RelayCommand] + private async Task LookupPaymentStatusAsync() + { + if (string.IsNullOrWhiteSpace(PaymentStatusLookupId)) + { + PaymentsStatus = "Payment id is required."; + return; + } + + try + { + if (!RequireIntegrationClient(out var client, status => PaymentsStatus = status) || client is null) + return; + + var status = await client.GetPaymentStatusAsync(PaymentStatusLookupId, EmptyToNull(PaymentProvider), EmptyToNull(PaymentEnvironment), CancellationToken.None); + PaymentResultText = $"Payment {status.PaymentId}: {status.Status} · {status.MerchantName ?? ""} · {status.AmountMinor?.ToString() ?? ""} {status.Currency ?? ""}"; + PaymentsStatus = "Payment status loaded."; + } + catch (Exception ex) + { + PaymentsStatus = $"Payment status lookup failed: {ex.Message}"; + } + } +} + +public sealed class FundingSourceRow +{ + public required string FundingSourceId { get; init; } + public required string Provider { get; init; } + public required string DisplayName { get; init; } + public required string Type { get; init; } + public required string Mode { get; init; } + public required string Detail { get; init; } + + public static FundingSourceRow FromFundingSource(FundingSource item) + => new() + { + FundingSourceId = item.FundingSourceId, + Provider = item.ProviderId, + DisplayName = item.DisplayName, + Type = item.Type, + Mode = item.TestMode ? "test" : "live", + Detail = $"{item.Currency ?? ""} · last4 {item.Last4 ?? "n/a"} · {(item.Available ? "available" : "unavailable")}" + }; +} diff --git a/src/OpenClaw.Companion/ViewModels/MainWindowViewModel.Plugins.cs b/src/OpenClaw.Companion/ViewModels/MainWindowViewModel.Plugins.cs new file mode 100644 index 00000000..59bdf66e --- /dev/null +++ b/src/OpenClaw.Companion/ViewModels/MainWindowViewModel.Plugins.cs @@ -0,0 +1,98 @@ +using System.Collections.ObjectModel; +using CommunityToolkit.Mvvm.ComponentModel; +using CommunityToolkit.Mvvm.Input; +using OpenClaw.Core.Models; + +namespace OpenClaw.Companion.ViewModels; + +public sealed partial class MainWindowViewModel +{ + [ObservableProperty] + private bool _isPluginsBusy; + + [ObservableProperty] + private string _pluginsStatus = "Plugins and channels not loaded."; + + public ObservableCollection PluginRows { get; } = []; + public ObservableCollection CompatibilityRows { get; } = []; + public ObservableCollection PluginChannelRows { get; } = []; + public bool HasPluginRows => PluginRows.Count > 0; + public bool HasCompatibilityRows => CompatibilityRows.Count > 0; + public bool HasPluginChannelRows => PluginChannelRows.Count > 0; + + [RelayCommand] + private async Task LoadPluginsAsync() + { + if (IsPluginsBusy) + return; + + IsPluginsBusy = true; + try + { + if (!RequireIntegrationClient(out var client, status => PluginsStatus = status) || client is null) + return; + + var plugins = await client.GetIntegrationPluginsAsync(CancellationToken.None); + var catalog = await client.GetCompatibilityCatalogAsync(null, null, null, CancellationToken.None); + var dashboard = await client.GetIntegrationDashboardAsync(CancellationToken.None); + + ReplaceItems(PluginRows, plugins.Items.Select(PluginRow.FromSnapshot)); + ReplaceItems(CompatibilityRows, catalog.Catalog.Items.Take(100).Select(CompatibilityRow.FromEntry)); + ReplaceItems(PluginChannelRows, dashboard.Operator.Channels.Items.Select(ChannelReadinessRow.FromDto)); + OnPropertyChanged(nameof(HasPluginRows)); + OnPropertyChanged(nameof(HasCompatibilityRows)); + OnPropertyChanged(nameof(HasPluginChannelRows)); + PluginsStatus = $"Loaded {PluginRows.Count} plugin(s), {CompatibilityRows.Count} compatibility item(s), and {PluginChannelRows.Count} channel(s)."; + } + catch (Exception ex) + { + PluginsStatus = $"Plugins load failed: {ex.Message}"; + } + finally + { + IsPluginsBusy = false; + } + } +} + +public sealed class PluginRow +{ + public required string PluginId { get; init; } + public required string Origin { get; init; } + public required string Status { get; init; } + public required string Trust { get; init; } + public required string Compatibility { get; init; } + public required string SurfaceCounts { get; init; } + public required string Detail { get; init; } + + public static PluginRow FromSnapshot(PluginHealthSnapshot item) + => new() + { + PluginId = item.PluginId, + Origin = item.Origin, + Status = item.Quarantined ? "quarantined" : item.Disabled ? "disabled" : item.Loaded ? "loaded" : "not loaded", + Trust = item.TrustLevel, + Compatibility = item.CompatibilityStatus, + SurfaceCounts = $"tools {item.ToolCount} · channels {item.ChannelCount} · providers {item.ProviderCount}", + Detail = item.LastError ?? item.PendingReason ?? item.TrustReason + }; +} + +public sealed class CompatibilityRow +{ + public required string Subject { get; init; } + public required string Status { get; init; } + public required string Kind { get; init; } + public required string Category { get; init; } + public required string Summary { get; init; } + + public static CompatibilityRow FromEntry(CompatibilityCatalogEntry item) + => new() + { + Subject = item.Subject, + Status = item.CompatibilityStatus, + Kind = item.Kind, + Category = item.Category, + Summary = item.Summary + }; +} diff --git a/src/OpenClaw.Companion/ViewModels/MainWindowViewModel.Profiles.cs b/src/OpenClaw.Companion/ViewModels/MainWindowViewModel.Profiles.cs new file mode 100644 index 00000000..fc2af466 --- /dev/null +++ b/src/OpenClaw.Companion/ViewModels/MainWindowViewModel.Profiles.cs @@ -0,0 +1,185 @@ +using System.Collections.ObjectModel; +using CommunityToolkit.Mvvm.ComponentModel; +using CommunityToolkit.Mvvm.Input; +using OpenClaw.Core.Models; + +namespace OpenClaw.Companion.ViewModels; + +public sealed partial class MainWindowViewModel +{ + [ObservableProperty] + private bool _isProfilesBusy; + + [ObservableProperty] + private string _profilesStatus = "Memory and profiles not loaded."; + + [ObservableProperty] + private string _memorySearchText = ""; + + [ObservableProperty] + private ProfileRow? _selectedProfile; + + [ObservableProperty] + private string _selectedProfileDetail = "Select a profile to inspect detail."; + + public ObservableCollection ProfileRows { get; } = []; + public ObservableCollection MemoryNoteRows { get; } = []; + public ObservableCollection LearningProposalRows { get; } = []; + public bool HasProfileRows => ProfileRows.Count > 0; + public bool HasMemoryNoteRows => MemoryNoteRows.Count > 0; + public bool HasLearningProposalRows => LearningProposalRows.Count > 0; + + partial void OnSelectedProfileChanged(ProfileRow? value) + { + if (value is not null) + _ = LoadProfileDetailAsync(value.ActorId); + } + + [RelayCommand] + private async Task LoadProfilesAsync() + { + if (IsProfilesBusy) + return; + + IsProfilesBusy = true; + try + { + if (!RequireIntegrationClient(out var client, status => ProfilesStatus = status) || client is null) + return; + + var profiles = await client.ListProfilesAsync(CancellationToken.None); + var notes = await client.ListMemoryNotesAsync(prefix: null, memoryClass: null, projectId: null, limit: 50, CancellationToken.None); + var proposals = await client.ListLearningProposalsAsync(status: null, kind: null, CancellationToken.None); + ReplaceItems(ProfileRows, profiles.Items.Select(ProfileRow.FromProfile)); + ReplaceItems(MemoryNoteRows, notes.Items.Select(MemoryNoteRow.FromItem)); + ReplaceItems(LearningProposalRows, proposals.Items.Select(LearningProposalRow.FromProposal)); + OnPropertyChanged(nameof(HasProfileRows)); + OnPropertyChanged(nameof(HasMemoryNoteRows)); + OnPropertyChanged(nameof(HasLearningProposalRows)); + ProfilesStatus = $"Loaded {ProfileRows.Count} profile(s), {MemoryNoteRows.Count} memory note(s), and {LearningProposalRows.Count} proposal(s)."; + } + catch (Exception ex) + { + ProfilesStatus = $"Memory and profiles load failed: {ex.Message}"; + } + finally + { + IsProfilesBusy = false; + } + } + + [RelayCommand] + private async Task SearchMemoryNotesAsync() + { + if (IsProfilesBusy || string.IsNullOrWhiteSpace(MemorySearchText)) + return; + + IsProfilesBusy = true; + try + { + if (!RequireIntegrationClient(out var client, status => ProfilesStatus = status) || client is null) + return; + + var notes = await client.SearchMemoryNotesAsync(MemorySearchText, memoryClass: null, projectId: null, limit: 50, CancellationToken.None); + ReplaceItems(MemoryNoteRows, notes.Items.Select(MemoryNoteRow.FromItem)); + OnPropertyChanged(nameof(HasMemoryNoteRows)); + ProfilesStatus = MemoryNoteRows.Count == 0 ? "No memory notes matched the search." : $"{MemoryNoteRows.Count} memory note(s) matched."; + } + catch (Exception ex) + { + ProfilesStatus = $"Memory search failed: {ex.Message}"; + } + finally + { + IsProfilesBusy = false; + } + } + + private async Task LoadProfileDetailAsync(string actorId) + { + try + { + if (!RequireIntegrationClient(out var client, status => SelectedProfileDetail = status) || client is null) + return; + + var detail = await client.GetProfileAsync(actorId, CancellationToken.None); + SelectedProfileDetail = detail.Profile is null + ? "Profile not found." + : string.Join(Environment.NewLine, new[] + { + $"Actor: {detail.Profile.ActorId}", + $"Channel: {detail.Profile.ChannelId}", + $"Sender: {detail.Profile.SenderId}", + $"Updated: {FormatUtc(detail.Profile.UpdatedAtUtc)}", + $"Summary: {detail.Profile.Summary}", + $"Tone: {detail.Profile.Tone}", + $"Facts: {detail.Profile.Facts.Count}", + $"Preferences: {JoinCompact(detail.Profile.Preferences)}", + $"Active projects: {JoinCompact(detail.Profile.ActiveProjects)}" + }); + } + catch (Exception ex) + { + SelectedProfileDetail = $"Profile detail load failed: {ex.Message}"; + } + } +} + +public sealed class ProfileRow +{ + public required string ActorId { get; init; } + public required string Channel { get; init; } + public required string Sender { get; init; } + public required string Updated { get; init; } + public required string Summary { get; init; } + + public static ProfileRow FromProfile(UserProfile item) + => new() + { + ActorId = item.ActorId, + Channel = item.ChannelId, + Sender = item.SenderId, + Updated = item.UpdatedAtUtc.ToLocalTime().ToString("g"), + Summary = item.Summary + }; +} + +public sealed class MemoryNoteRow +{ + public required string Key { get; init; } + public required string Class { get; init; } + public required string Project { get; init; } + public required string Updated { get; init; } + public required string Preview { get; init; } + + public static MemoryNoteRow FromItem(MemoryNoteItem item) + => new() + { + Key = item.DisplayKey, + Class = item.MemoryClass, + Project = item.ProjectId ?? "", + Updated = item.UpdatedAtUtc.ToLocalTime().ToString("g"), + Preview = item.Preview + }; +} + +public sealed class LearningProposalRow +{ + public required string ProposalId { get; init; } + public required string Kind { get; init; } + public required string Status { get; init; } + public required string Risk { get; init; } + public required string Title { get; init; } + public required string Summary { get; init; } + + public static LearningProposalRow FromProposal(LearningProposal item) + => new() + { + ProposalId = item.Id, + Kind = item.Kind, + Status = item.Status, + Risk = item.RiskLevel, + Title = item.Title, + Summary = item.Summary + }; +} diff --git a/src/OpenClaw.Companion/ViewModels/MainWindowViewModel.Providers.cs b/src/OpenClaw.Companion/ViewModels/MainWindowViewModel.Providers.cs new file mode 100644 index 00000000..b061ce6b --- /dev/null +++ b/src/OpenClaw.Companion/ViewModels/MainWindowViewModel.Providers.cs @@ -0,0 +1,98 @@ +using System.Collections.ObjectModel; +using CommunityToolkit.Mvvm.ComponentModel; +using CommunityToolkit.Mvvm.Input; +using OpenClaw.Core.Models; + +namespace OpenClaw.Companion.ViewModels; + +public sealed partial class MainWindowViewModel +{ + [ObservableProperty] + private bool _isProvidersBusy; + + [ObservableProperty] + private string _providersStatus = "Models and providers not loaded."; + + [ObservableProperty] + private string _modelProfilesSummary = "Model profiles not loaded."; + + public ObservableCollection ProviderRouteRows { get; } = []; + public ObservableCollection ToolPresetRows { get; } = []; + public bool HasProviderRouteRows => ProviderRouteRows.Count > 0; + public bool HasToolPresetRows => ToolPresetRows.Count > 0; + + [RelayCommand] + private async Task LoadProvidersAsync() + { + if (IsProvidersBusy) + return; + + IsProvidersBusy = true; + try + { + if (!RequireIntegrationClient(out var client, status => ProvidersStatus = status) || client is null) + return; + + var providers = await client.GetIntegrationProvidersAsync(50, CancellationToken.None); + var presets = await client.ListToolPresetsAsync(CancellationToken.None); + ReplaceItems(ProviderRouteRows, providers.Routes.Select(ProviderRouteRow.FromSnapshot)); + ReplaceItems(ToolPresetRows, presets.Items.Select(ToolPresetRow.FromPreset)); + OnPropertyChanged(nameof(HasProviderRouteRows)); + OnPropertyChanged(nameof(HasToolPresetRows)); + ModelProfilesSummary = providers.ModelProfiles is null + ? "No model profile status reported." + : $"Routes: {providers.Routes.Count}; policies: {providers.Policies.Count}; recent turns: {providers.RecentTurns.Count}."; + ProvidersStatus = $"Loaded {ProviderRouteRows.Count} provider route(s) and {ToolPresetRows.Count} tool preset(s)."; + } + catch (Exception ex) + { + ProvidersStatus = $"Providers load failed: {ex.Message}"; + } + finally + { + IsProvidersBusy = false; + } + } +} + +public sealed class ProviderRouteRow +{ + public required string Provider { get; init; } + public required string Model { get; init; } + public required string Profile { get; init; } + public required string Circuit { get; init; } + public required string Usage { get; init; } + public required string Issues { get; init; } + + public static ProviderRouteRow FromSnapshot(ProviderRouteHealthSnapshot item) + => new() + { + Provider = item.ProviderId, + Model = item.ModelId, + Profile = item.ProfileId ?? (item.IsDefaultRoute ? "default" : ""), + Circuit = item.CircuitState, + Usage = $"{item.Requests} requests · {item.Errors} errors · {item.Retries} retries", + Issues = item.ValidationIssues.Length == 0 ? item.LastError ?? "" : string.Join(", ", item.ValidationIssues) + }; +} + +public sealed class ToolPresetRow +{ + public required string PresetId { get; init; } + public required string Surface { get; init; } + public required string Autonomy { get; init; } + public required string Approval { get; init; } + public required string Tools { get; init; } + public required string Description { get; init; } + + public static ToolPresetRow FromPreset(ResolvedToolPreset item) + => new() + { + PresetId = item.PresetId, + Surface = item.Surface, + Autonomy = item.EffectiveAutonomyMode, + Approval = item.RequireToolApproval ? "approval required" : "no global approval", + Tools = $"{item.AllowedTools.Count} allowed · {item.ApprovalRequiredTools.Count} approval tools", + Description = item.Description + }; +} diff --git a/src/OpenClaw.Companion/ViewModels/MainWindowViewModel.RuntimeConsoleHelpers.cs b/src/OpenClaw.Companion/ViewModels/MainWindowViewModel.RuntimeConsoleHelpers.cs new file mode 100644 index 00000000..b754de05 --- /dev/null +++ b/src/OpenClaw.Companion/ViewModels/MainWindowViewModel.RuntimeConsoleHelpers.cs @@ -0,0 +1,114 @@ +using System.Collections.ObjectModel; +using System.Text.Json; +using CommunityToolkit.Mvvm.ComponentModel; +using CommunityToolkit.Mvvm.Input; +using OpenClaw.Client; +using OpenClaw.Companion.Services; + +namespace OpenClaw.Companion.ViewModels; + +public sealed partial class MainWindowViewModel +{ + private IConfirmationDialogService _confirmationDialogService = new DenyConfirmationDialogService(); + + [ObservableProperty] + private int _selectedSectionIndex; + + [ObservableProperty] + private bool _connectionSettingsExpanded; + + public string GatewayStatusBadge => IsConnected ? "Connected" : "Disconnected"; + public string LocalGatewayHealthBadge => LocalGatewayIsHealthy ? "Healthy" : "Local offline"; + public string PendingApprovalsBadge => $"{PendingApprovalsCount} pending"; + public string SetupProviderSummary => string.IsNullOrWhiteSpace(SetupProvider) ? "provider not set" : $"{SetupProvider} / {SetupModel}"; + public bool HasMessages => Messages.Count > 0; + public bool HasNoMessages => Messages.Count == 0; + public bool HasEmbeddedLocalModelDisabledReason => !string.IsNullOrWhiteSpace(EmbeddedLocalModelDisabledReason); + public string EmbeddedLocalModelDisabledReason => IsEmbeddedSetupProvider() + ? "" + : "Choose the Embedded provider before using local model package commands."; + + internal void AttachConfirmationDialogService(IConfirmationDialogService dialogService) + => _confirmationDialogService = dialogService; + + partial void OnIsConnectedChanged(bool value) => OnPropertyChanged(nameof(GatewayStatusBadge)); + + partial void OnLocalGatewayIsHealthyChanged(bool value) + { + OnPropertyChanged(nameof(LocalGatewayHealthBadge)); + OnPropertyChanged(nameof(DashboardLocalGatewayStatus)); + } + + partial void OnLocalGatewayStatusChanged(string value) => OnPropertyChanged(nameof(DashboardLocalGatewayStatus)); + + [RelayCommand] + private void NavigateToSection(string? section) + { + SelectedSectionIndex = section?.Trim().ToLowerInvariant() switch + { + "home" => 0, + "setup" => 1, + "chat" => 2, + "canvas" => 3, + "sessions" => 4, + "approvals" => 5, + "workflows" => 6, + "automations" => 7, + "runtimeevents" or "runtime events" => 8, + "plugins" or "plugins & channels" => 9, + "profiles" or "memory" or "memory & profiles" => 10, + "providers" or "models" or "models & providers" => 11, + "admin" => 12, + "whatsapp" => 13, + "payments" or "payment lab" => 14, + _ => SelectedSectionIndex + }; + } + + private OpenClawHttpClient? CreateIntegrationClient(out string? error) + => CreateAdminClient(out error); + + private bool RequireIntegrationClient(out OpenClawHttpClient? client, Action setStatus) + { + client = CreateIntegrationClient(out var error); + if (client is not null) + return true; + + setStatus(error ?? "Invalid gateway URL."); + return false; + } + + private async Task ConfirmMutationAsync(string title, string message, string confirmText = "Continue") + => await _confirmationDialogService.ConfirmAsync(title, message, confirmText, "Cancel", CancellationToken.None); + + private static void ReplaceItems(ObservableCollection target, IEnumerable items) + { + target.Clear(); + foreach (var item in items) + target.Add(item); + } + + private static string FormatTimestamp(DateTimeOffset? value) + => value is null ? "never" : value.Value.ToLocalTime().ToString("g"); + + private static string FormatUtc(DateTimeOffset? value) + => value is null ? "" : value.Value.ToUniversalTime().ToString("u"); + + private static string JoinCompact(IEnumerable? values) + { + var items = values?.Where(static value => !string.IsNullOrWhiteSpace(value)).ToArray() ?? []; + return items.Length == 0 ? "none" : string.Join(", ", items); + } + + private static string ToIndentedJson(T value) + { + try + { + return JsonSerializer.Serialize(value, new JsonSerializerOptions { WriteIndented = true }); + } + catch + { + return value?.ToString() ?? ""; + } + } +} diff --git a/src/OpenClaw.Companion/ViewModels/MainWindowViewModel.RuntimeEvents.cs b/src/OpenClaw.Companion/ViewModels/MainWindowViewModel.RuntimeEvents.cs new file mode 100644 index 00000000..300cf3f9 --- /dev/null +++ b/src/OpenClaw.Companion/ViewModels/MainWindowViewModel.RuntimeEvents.cs @@ -0,0 +1,135 @@ +using System.Collections.ObjectModel; +using System.Text.Json; +using CommunityToolkit.Mvvm.ComponentModel; +using CommunityToolkit.Mvvm.Input; +using OpenClaw.Core.Models; + +namespace OpenClaw.Companion.ViewModels; + +public sealed partial class MainWindowViewModel +{ + [ObservableProperty] + private bool _isRuntimeEventsBusy; + + [ObservableProperty] + private string _runtimeEventsStatus = "Runtime events not loaded."; + + [ObservableProperty] + private string _runtimeEventsSessionId = ""; + + [ObservableProperty] + private string _runtimeEventsChannelId = ""; + + [ObservableProperty] + private string _runtimeEventsSenderId = ""; + + [ObservableProperty] + private string _runtimeEventsComponent = ""; + + [ObservableProperty] + private string _runtimeEventsAction = ""; + + [ObservableProperty] + private string _runtimeEventsFromUtc = ""; + + [ObservableProperty] + private string _runtimeEventsToUtc = ""; + + [ObservableProperty] + private int _runtimeEventsLimit = 100; + + [ObservableProperty] + private RuntimeEventRow? _selectedRuntimeEvent; + + public ObservableCollection RuntimeEventRows { get; } = []; + public bool HasRuntimeEventRows => RuntimeEventRows.Count > 0; + public string SelectedRuntimeEventJson => SelectedRuntimeEvent?.RawJson ?? "Select an event to inspect metadata."; + + partial void OnSelectedRuntimeEventChanged(RuntimeEventRow? value) + => OnPropertyChanged(nameof(SelectedRuntimeEventJson)); + + [RelayCommand] + private async Task LoadRuntimeEventsAsync() + { + if (IsRuntimeEventsBusy) + return; + + IsRuntimeEventsBusy = true; + try + { + if (!RequireIntegrationClient(out var client, status => RuntimeEventsStatus = status) || client is null) + return; + + var query = new RuntimeEventQuery + { + SessionId = EmptyToNull(RuntimeEventsSessionId), + ChannelId = EmptyToNull(RuntimeEventsChannelId), + SenderId = EmptyToNull(RuntimeEventsSenderId), + Component = EmptyToNull(RuntimeEventsComponent), + Action = EmptyToNull(RuntimeEventsAction), + FromUtc = TryParseDate(RuntimeEventsFromUtc), + ToUtc = TryParseDate(RuntimeEventsToUtc), + Limit = Math.Clamp(RuntimeEventsLimit, 1, 500) + }; + var response = await client.QueryRuntimeEventsAsync(query, CancellationToken.None); + ReplaceItems(RuntimeEventRows, response.Items.Select(RuntimeEventRow.FromEntry)); + OnPropertyChanged(nameof(HasRuntimeEventRows)); + RuntimeEventsStatus = RuntimeEventRows.Count == 0 + ? "No runtime events match the current filters." + : $"{RuntimeEventRows.Count} runtime event{(RuntimeEventRows.Count == 1 ? "" : "s")} loaded."; + } + catch (Exception ex) + { + RuntimeEventsStatus = $"Runtime events load failed: {ex.Message}"; + } + finally + { + IsRuntimeEventsBusy = false; + } + } + + private static DateTimeOffset? TryParseDate(string? value) + => DateTimeOffset.TryParse(value, out var parsed) ? parsed : null; +} + +public sealed class RuntimeEventRow +{ + public required string Id { get; init; } + public required string Timestamp { get; init; } + public required string Component { get; init; } + public required string Action { get; init; } + public required string Severity { get; init; } + public required string SessionId { get; init; } + public required string ChannelId { get; init; } + public required string Actor { get; init; } + public required string Summary { get; init; } + public required string RawJson { get; init; } + + public static RuntimeEventRow FromEntry(RuntimeEventEntry entry) + => new() + { + Id = entry.Id, + Timestamp = entry.TimestampUtc.ToLocalTime().ToString("g"), + Component = entry.Component, + Action = entry.Action, + Severity = entry.Severity, + SessionId = entry.SessionId ?? "", + ChannelId = entry.ChannelId ?? "", + Actor = string.IsNullOrWhiteSpace(entry.SenderId) ? entry.ChannelId ?? "" : entry.SenderId!, + Summary = entry.Summary, + RawJson = JsonSerializer.Serialize(new + { + entry.Id, + entry.TimestampUtc, + entry.SessionId, + entry.ChannelId, + entry.SenderId, + entry.CorrelationId, + entry.Component, + entry.Action, + entry.Severity, + entry.Summary, + entry.Metadata + }, new JsonSerializerOptions { WriteIndented = true }) + }; +} diff --git a/src/OpenClaw.Companion/ViewModels/MainWindowViewModel.Sessions.cs b/src/OpenClaw.Companion/ViewModels/MainWindowViewModel.Sessions.cs new file mode 100644 index 00000000..101f2ece --- /dev/null +++ b/src/OpenClaw.Companion/ViewModels/MainWindowViewModel.Sessions.cs @@ -0,0 +1,202 @@ +using System.Collections.ObjectModel; +using CommunityToolkit.Mvvm.ComponentModel; +using CommunityToolkit.Mvvm.Input; +using OpenClaw.Core.Models; + +namespace OpenClaw.Companion.ViewModels; + +public sealed partial class MainWindowViewModel +{ + [ObservableProperty] + private bool _isSessionsBusy; + + [ObservableProperty] + private string _sessionsStatus = "Sessions not loaded."; + + [ObservableProperty] + private string _sessionsSearchText = ""; + + [ObservableProperty] + private string _sessionsChannelId = ""; + + [ObservableProperty] + private string _sessionsSenderId = ""; + + [ObservableProperty] + private string _sessionsState = ""; + + [ObservableProperty] + private string _sessionsTag = ""; + + [ObservableProperty] + private bool _sessionsStarredOnly; + + [ObservableProperty] + private int _sessionsPage = 1; + + [ObservableProperty] + private bool _sessionsHasMore; + + [ObservableProperty] + private SessionRow? _selectedSession; + + [ObservableProperty] + private string _selectedSessionDetail = "Select a session to inspect metadata and timeline."; + + public ObservableCollection SessionRows { get; } = []; + public ObservableCollection SessionTimelineRows { get; } = []; + public bool HasSessionRows => SessionRows.Count > 0; + public bool HasSessionTimelineRows => SessionTimelineRows.Count > 0; + + partial void OnSelectedSessionChanged(SessionRow? value) + { + if (value is not null) + _ = LoadSelectedSessionAsync(value); + } + + [RelayCommand] + private async Task LoadSessionsAsync() + { + if (IsSessionsBusy) + return; + + IsSessionsBusy = true; + try + { + if (!RequireIntegrationClient(out var client, status => SessionsStatus = status) || client is null) + return; + + var query = new SessionListQuery + { + Search = EmptyToNull(SessionsSearchText), + ChannelId = EmptyToNull(SessionsChannelId), + SenderId = EmptyToNull(SessionsSenderId), + State = TryParseSessionState(SessionsState), + Starred = SessionsStarredOnly ? true : null, + Tag = EmptyToNull(SessionsTag) + }; + var response = await client.ListSessionsAsync(SessionsPage, pageSize: 25, query, CancellationToken.None); + var snippets = await LoadSessionSnippetsAsync(client); + var rows = response.Active.Select(item => SessionRow.FromSummary(item, "active", snippets)) + .Concat(response.Persisted.Items.Select(item => SessionRow.FromSummary(item, "persisted", snippets))) + .GroupBy(static row => row.SessionId, StringComparer.Ordinal) + .Select(static group => group.First()) + .ToList(); + + SessionsHasMore = response.Persisted.HasMore; + ReplaceItems(SessionRows, rows); + OnPropertyChanged(nameof(HasSessionRows)); + SessionsStatus = rows.Count == 0 + ? "No sessions match the current filters." + : $"{rows.Count} session{(rows.Count == 1 ? "" : "s")} loaded."; + } + catch (Exception ex) + { + SessionsStatus = $"Sessions load failed: {ex.Message}"; + } + finally + { + IsSessionsBusy = false; + } + } + + [RelayCommand] + private async Task NextSessionsPageAsync() + { + if (!SessionsHasMore) + return; + + SessionsPage++; + await LoadSessionsAsync(); + } + + [RelayCommand] + private async Task PreviousSessionsPageAsync() + { + if (SessionsPage <= 1) + return; + + SessionsPage--; + await LoadSessionsAsync(); + } + + private async Task> LoadSessionSnippetsAsync(OpenClaw.Client.OpenClawHttpClient client) + { + if (string.IsNullOrWhiteSpace(SessionsSearchText)) + return new Dictionary(StringComparer.Ordinal); + + var search = await client.SearchSessionsAsync(new SessionSearchQuery + { + Text = SessionsSearchText, + ChannelId = EmptyToNull(SessionsChannelId), + SenderId = EmptyToNull(SessionsSenderId), + Limit = 25, + SnippetLength = 180 + }, CancellationToken.None); + + return search.Result.Items + .GroupBy(static item => item.SessionId, StringComparer.Ordinal) + .ToDictionary(static group => group.Key, static group => group.First().Snippet, StringComparer.Ordinal); + } + + private async Task LoadSelectedSessionAsync(SessionRow row) + { + try + { + if (!RequireIntegrationClient(out var client, status => SelectedSessionDetail = status) || client is null) + return; + + var detail = await client.GetSessionAsync(row.SessionId, CancellationToken.None); + var timeline = await client.GetSessionTimelineAsync(row.SessionId, 100, CancellationToken.None); + SelectedSessionDetail = string.Join(Environment.NewLine, new[] + { + $"Session: {row.SessionId}", + $"Channel: {row.ChannelId}", + $"Sender: {row.SenderId}", + $"State: {row.State}", + $"Active: {detail.IsActive}", + $"Branches: {detail.BranchCount}", + $"History turns: {row.HistoryTurns}", + $"Tokens: {row.TotalTokens}" + }); + ReplaceItems(SessionTimelineRows, timeline.Events.Select(RuntimeEventRow.FromEntry)); + OnPropertyChanged(nameof(HasSessionTimelineRows)); + } + catch (Exception ex) + { + SelectedSessionDetail = $"Session detail load failed: {ex.Message}"; + } + } + + private static SessionState? TryParseSessionState(string? state) + => Enum.TryParse(state, ignoreCase: true, out var parsed) ? parsed : null; +} + +public sealed class SessionRow +{ + public required string SessionId { get; init; } + public required string ChannelId { get; init; } + public required string SenderId { get; init; } + public required string State { get; init; } + public required string Source { get; init; } + public required string LastActivity { get; init; } + public required string Snippet { get; init; } + public int HistoryTurns { get; init; } + public long TotalTokens { get; init; } + public bool IsActive { get; init; } + + public static SessionRow FromSummary(SessionSummary item, string source, IReadOnlyDictionary snippets) + => new() + { + SessionId = item.Id, + ChannelId = item.ChannelId, + SenderId = item.SenderId, + State = item.State.ToString(), + Source = source, + LastActivity = item.LastActiveAt.ToLocalTime().ToString("g"), + HistoryTurns = item.HistoryTurns, + TotalTokens = item.TotalInputTokens + item.TotalOutputTokens, + IsActive = item.IsActive, + Snippet = snippets.TryGetValue(item.Id, out var snippet) ? snippet : "" + }; +} diff --git a/src/OpenClaw.Companion/ViewModels/MainWindowViewModel.Workflows.cs b/src/OpenClaw.Companion/ViewModels/MainWindowViewModel.Workflows.cs new file mode 100644 index 00000000..8f7c5a76 --- /dev/null +++ b/src/OpenClaw.Companion/ViewModels/MainWindowViewModel.Workflows.cs @@ -0,0 +1,245 @@ +using System.Collections.ObjectModel; +using System.Text.Json; +using CommunityToolkit.Mvvm.ComponentModel; +using CommunityToolkit.Mvvm.Input; +using OpenClaw.Core.Models; + +namespace OpenClaw.Companion.ViewModels; + +public sealed partial class MainWindowViewModel +{ + [ObservableProperty] + private bool _isWorkflowsBusy; + + [ObservableProperty] + private string _workflowsStatus = "Workflows not loaded."; + + [ObservableProperty] + private WorkflowRow? _selectedWorkflow; + + [ObservableProperty] + private string _workflowInput = ""; + + [ObservableProperty] + private string _workflowRunId = ""; + + [ObservableProperty] + private string _workflowRunDetail = "Run or load a workflow to inspect events and pending inputs."; + + [ObservableProperty] + private WorkflowPendingInputRow? _selectedWorkflowPendingInput; + + [ObservableProperty] + private string _workflowResponseJson = "{}"; + + public ObservableCollection WorkflowRows { get; } = []; + public ObservableCollection WorkflowEventRows { get; } = []; + public ObservableCollection WorkflowPendingInputs { get; } = []; + public bool HasWorkflowRows => WorkflowRows.Count > 0; + public bool HasWorkflowEventRows => WorkflowEventRows.Count > 0; + public bool HasWorkflowPendingInputs => WorkflowPendingInputs.Count > 0; + + [RelayCommand] + private async Task LoadWorkflowsAsync() + { + if (IsWorkflowsBusy) + return; + + IsWorkflowsBusy = true; + try + { + if (!RequireIntegrationClient(out var client, status => WorkflowsStatus = status) || client is null) + return; + + var response = await client.ListWorkflowsAsync(CancellationToken.None); + ReplaceItems(WorkflowRows, response.Items.Select(WorkflowRow.FromSummary)); + OnPropertyChanged(nameof(HasWorkflowRows)); + WorkflowsStatus = WorkflowRows.Count == 0 ? "No workflows configured." : $"{WorkflowRows.Count} workflow(s) loaded."; + } + catch (Exception ex) + { + WorkflowsStatus = $"Workflows load failed: {ex.Message}"; + } + finally + { + IsWorkflowsBusy = false; + } + } + + [RelayCommand] + private async Task RunSelectedWorkflowAsync() + { + if (SelectedWorkflow is null) + return; + if (string.IsNullOrWhiteSpace(WorkflowInput)) + { + WorkflowsStatus = "Workflow input is required."; + return; + } + + var confirmed = await ConfirmMutationAsync( + "Run workflow", + $"Run workflow '{SelectedWorkflow.DisplayName}' through the integration API?", + "Run"); + if (!confirmed) + return; + + try + { + if (!RequireIntegrationClient(out var client, status => WorkflowsStatus = status) || client is null) + return; + + var result = await client.RunWorkflowAsync(SelectedWorkflow.WorkflowId, new AgentWorkflowRequest + { + Input = WorkflowInput, + ChannelId = "companion", + SenderId = string.IsNullOrWhiteSpace(Username) ? "operator" : Username + }, CancellationToken.None); + + WorkflowRunId = result.RunId; + WorkflowRunDetail = BuildWorkflowRunText(result.WorkflowId, result.RunId, result.Status, result.BackendId, result.Output, result.Error); + ReplaceItems(WorkflowEventRows, result.Events.Select(WorkflowEventRow.FromEvent)); + ReplaceItems(WorkflowPendingInputs, []); + OnPropertyChanged(nameof(HasWorkflowEventRows)); + OnPropertyChanged(nameof(HasWorkflowPendingInputs)); + WorkflowsStatus = $"Workflow run '{result.RunId}' returned {result.Status}."; + } + catch (Exception ex) + { + WorkflowsStatus = $"Workflow run failed: {ex.Message}"; + } + } + + [RelayCommand] + private async Task LoadWorkflowRunAsync() + { + if (SelectedWorkflow is null || string.IsNullOrWhiteSpace(WorkflowRunId)) + return; + + try + { + if (!RequireIntegrationClient(out var client, status => WorkflowsStatus = status) || client is null) + return; + + var snapshot = await client.GetWorkflowRunAsync(SelectedWorkflow.WorkflowId, WorkflowRunId, CancellationToken.None); + ApplyWorkflowSnapshot(snapshot); + WorkflowsStatus = $"Workflow run '{snapshot.RunId}' loaded."; + } + catch (Exception ex) + { + WorkflowsStatus = $"Workflow run load failed: {ex.Message}"; + } + } + + [RelayCommand] + private async Task RespondWorkflowInputAsync() + { + if (SelectedWorkflow is null || SelectedWorkflowPendingInput is null || string.IsNullOrWhiteSpace(WorkflowRunId)) + return; + + var confirmed = await ConfirmMutationAsync( + "Send workflow response", + $"Send response to port '{SelectedWorkflowPendingInput.PortId}'?", + "Send"); + if (!confirmed) + return; + + try + { + if (!RequireIntegrationClient(out var client, status => WorkflowsStatus = status) || client is null) + return; + + JsonElement? payload = null; + if (!string.IsNullOrWhiteSpace(WorkflowResponseJson)) + { + using var doc = JsonDocument.Parse(WorkflowResponseJson); + payload = doc.RootElement.Clone(); + } + + var snapshot = await client.RespondWorkflowRunAsync(SelectedWorkflow.WorkflowId, WorkflowRunId, new AgentWorkflowResponse + { + PortId = SelectedWorkflowPendingInput.PortId, + Payload = payload, + ActorId = string.IsNullOrWhiteSpace(Username) ? "companion" : Username + }, CancellationToken.None); + ApplyWorkflowSnapshot(snapshot); + WorkflowsStatus = $"Workflow response sent to '{SelectedWorkflowPendingInput.PortId}'."; + } + catch (Exception ex) + { + WorkflowsStatus = $"Workflow response failed: {ex.Message}"; + } + } + + private void ApplyWorkflowSnapshot(AgentWorkflowRunSnapshot snapshot) + { + WorkflowRunId = snapshot.RunId; + WorkflowRunDetail = BuildWorkflowRunText(snapshot.WorkflowId, snapshot.RunId, snapshot.Status, snapshot.BackendId, snapshot.Output, snapshot.Error); + ReplaceItems(WorkflowEventRows, snapshot.Events.Select(WorkflowEventRow.FromEvent)); + ReplaceItems(WorkflowPendingInputs, snapshot.PendingInputs.Select(WorkflowPendingInputRow.FromInput)); + OnPropertyChanged(nameof(HasWorkflowEventRows)); + OnPropertyChanged(nameof(HasWorkflowPendingInputs)); + } + + private static string BuildWorkflowRunText(string workflowId, string runId, string status, string? backendId, string? output, string? error) + => string.Join(Environment.NewLine, new[] + { + $"Workflow: {workflowId}", + $"Run: {runId}", + $"Status: {status}", + $"Backend: {backendId ?? "default"}", + $"Output: {output ?? ""}", + $"Error: {error ?? ""}" + }); +} + +public sealed class WorkflowRow +{ + public required string WorkflowId { get; init; } + public required string DisplayName { get; init; } + public required string Kind { get; init; } + public required string WorkflowName { get; init; } + public required string Status { get; init; } + + public static WorkflowRow FromSummary(AgentWorkflowBackendSummary item) + => new() + { + WorkflowId = item.Id, + DisplayName = string.IsNullOrWhiteSpace(item.DisplayName) ? item.Id : item.DisplayName, + Kind = item.Kind, + WorkflowName = item.WorkflowName, + Status = item.Enabled ? "enabled" : "disabled" + }; +} + +public sealed class WorkflowEventRow +{ + public required string Timestamp { get; init; } + public required string Type { get; init; } + public required string Status { get; init; } + public required string Summary { get; init; } + + public static WorkflowEventRow FromEvent(AgentWorkflowEvent item) + => new() + { + Timestamp = item.TimestampUtc.ToLocalTime().ToString("g"), + Type = item.Type, + Status = item.Status ?? "", + Summary = item.Summary + }; +} + +public sealed class WorkflowPendingInputRow +{ + public required string PortId { get; init; } + public required string Summary { get; init; } + public required string PayloadPreview { get; init; } + + public static WorkflowPendingInputRow FromInput(AgentWorkflowPendingInput item) + => new() + { + PortId = item.PortId, + Summary = item.Summary ?? "", + PayloadPreview = item.Payload?.GetRawText() ?? "" + }; +} diff --git a/src/OpenClaw.Companion/ViewModels/MainWindowViewModel.cs b/src/OpenClaw.Companion/ViewModels/MainWindowViewModel.cs index 78d2b869..4fc9c84b 100644 --- a/src/OpenClaw.Companion/ViewModels/MainWindowViewModel.cs +++ b/src/OpenClaw.Companion/ViewModels/MainWindowViewModel.cs @@ -104,6 +104,11 @@ public MainWindowViewModel( _client.OnTextMessage += HandleInboundText; _client.OnEnvelopeReceived += HandleCanvasEnvelope; _client.OnError += err => AddSystemMessage($"Error: {err}"); + Messages.CollectionChanged += (_, _) => + { + OnPropertyChanged(nameof(HasMessages)); + OnPropertyChanged(nameof(HasNoMessages)); + }; LoadSettings(); RefreshManagedGatewayStateCore(); diff --git a/src/OpenClaw.Companion/Views/MainWindow.axaml b/src/OpenClaw.Companion/Views/MainWindow.axaml index 43b1ec68..1af20968 100644 --- a/src/OpenClaw.Companion/Views/MainWindow.axaml +++ b/src/OpenClaw.Companion/Views/MainWindow.axaml @@ -4,87 +4,197 @@ xmlns:models="using:OpenClaw.Companion.Models" xmlns:d="http://schemas.microsoft.com/expression/blend/2008" xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006" - mc:Ignorable="d" d:DesignWidth="800" d:DesignHeight="450" + mc:Ignorable="d" d:DesignWidth="1280" d:DesignHeight="820" x:Class="OpenClaw.Companion.Views.MainWindow" x:DataType="vm:MainWindowViewModel" Icon="/Assets/avalonia-logo.ico" - Title="OpenClaw.Companion"> + Title="OpenClaw.NET Companion"> - - - - - - - - - - - + + + + + + + + + + + + + + + + + - - - - - - - - - - + +