diff --git a/CHANGELOG.md b/CHANGELOG.md index 18978165..1425881d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -24,9 +24,40 @@ Planned version: **1.5.4**. own session file, so profiles such as `prod.a` and `prod_a` no longer share one login (#620). - A `matches` condition with an invalid pattern, a regex timeout or an input longer than 1,048,576 characters fails the run and names the edge, instead of counting as "no match" (#680). +- Two active nodes can no longer share an output variable, regardless of case: saving, importing + or an MCP change with such a pair is rejected. A disabled node is ignored, so a disabled copy can + no longer take over the alias of the active one (#703). ### Fixed +- **Designer** + - Renaming an output variable updates every reference to it: templates in any field, a dynamic + target machine or credential, breakpoint conditions, decision cases and edge conditions (#689). + - Copying, pasting and duplicating give each copy its own output variable (`name_2`) and rewrite + the references inside the copy, so it no longer takes over the original's bindings (#703). + - Inserting an activity on a conditional edge keeps the condition in front of the new activity, + so the branch still runs only when the condition holds (#693). + - Deleting an edge from the command palette marks the workflow as changed and can be undone + (#694). + - Undo right after typing in a property field restores the value from before the edit (#595). + - A save, lock change, publish or run that finishes after you switched to another workflow no + longer acts on the workflow you opened in the meantime (#692). + - Undo and a restored draft also update open property panels: an edge switches back to its + expression, an agent's host list shows the restored values (#695). + - Text-file delete can switch to line-range and pattern mode again (#601). + - `waitForCondition` in `portOpen` mode saves the default port 443 it shows, and a probe without + host or valid port is flagged (#702). + - `scheduledTask` registration accepts only built-in or managed service accounts and no longer + suggests ordinary domain users with delegation (#705). + - The step details of a past run label the workflow's current configuration as such and show the + target machine the run actually used (#603). +- **Management pages and settings** + - Saving credentials, machines, users, global variables, maintenance windows, custom activities, + alerting rules and policies or a settings section locks the form until the server answers. A + late answer no longer closes or resets a record opened in the meantime (#690). +- **AI** + - The AI chats keep working when browser storage is full or blocked. A note says what survives a + reload; once the storage budget is reached, only the newest history is kept (#701). - **Running workflows** - A breakpoint condition can use the run's inputs (`{{manual.*}}`) (#685). - Input values that look like `{{…}}` stay plain text and no longer fail the step as unresolved @@ -52,7 +83,8 @@ Planned version: **1.5.4**. - The Windows Update health example applies `freshnessDays` (#734). The temp-file cleanup example writes a new archive per run and keeps the earlier ones (#723). The README showcase reports a failed probe instead of success (#724). The tech demo uses one name for its child - workflow (#722). + workflow (#722). The SCCM/AD example of the browser demo is valid PowerShell again, and CI + parses every demo fixture script (#698). - The `powerManagement` test moves to the invasive set and needs a disposable host, because `abort` cancels a pending host restart (#720). Acknowledgement files accept only GUID correlation IDs (#721). Verification checks every run in the window and fails when required @@ -66,6 +98,14 @@ Planned version: **1.5.4**. ### Security +- Copying nodes to the clipboard also masks recognised secrets in edge conditions and in lists, not + only in node fields (#574). +- Script generation sends the current script only to the AI profile the approval was given for. If + the profile, its endpoint or model changed in between, the request fails with + `409 LLM_RECIPIENT_CHANGED` and the dialog asks again. The workflow assistant also treats a model + change as a new recipient for its chat history (#688). +- A write query in the database viewer runs only after the confirmation phrase is typed; pressing + Run again while the confirmation is open no longer starts it (#691). - The CLI, the MCP server and the Python admin scripts under `scripts/` no longer follow redirects with the login token, so the token is never sent to another host (#711). - Logging out with the CLI also ends the MCP server's session right away (#712). diff --git a/docs/ai-features.md b/docs/ai-features.md index aef9e19d..e99e8e04 100644 --- a/docs/ai-features.md +++ b/docs/ai-features.md @@ -79,13 +79,15 @@ themselves require a service restart. A proxy does not replace a missing network The workflow assistant masks named secrets before the model call. With `ShareScriptsWithAssistant` enabled, however, the provider receives scripts, SQL, and arguments to the extent that secret detection does not mask them. This detection is heuristic. -Changing the profile, endpoint, or sharing switch excludes the previous -chat history from the next call. The global knowledge chat applies the same rule and also -treats a model change as a new context; the answer then notes that the earlier conversation -was not sent. +Changing the profile, its endpoint or model, or the sharing switch excludes the previous +chat history from the next call. The global knowledge chat applies the same rule; the answer +then notes that the earlier conversation was not sent. Script generation sends the current script only with the separate approval -`IncludeCurrentScript=true`. It receives upstream variables as a schema without their values. +`IncludeCurrentScript=true`, and only to the profile the approval was given for: if the active +profile, its endpoint or model changed in between, the request fails with +`409 LLM_RECIPIENT_CHANGED` and the editor asks again. It receives upstream variables as a +schema without their values. Generated scripts are shown in the editor before they are run. Proposals of the workflow assistant keep existing secrets and layouts when merged. Changes to masked conditions may have to be made manually. diff --git a/scripts/Test-ExampleWorkflowScripts.ps1 b/scripts/Test-ExampleWorkflowScripts.ps1 index 973252c4..86b05789 100644 --- a/scripts/Test-ExampleWorkflowScripts.ps1 +++ b/scripts/Test-ExampleWorkflowScripts.ps1 @@ -1,7 +1,8 @@ #requires -Version 5.1 # Runs the scripts of the shipped example workflows against synthetic evidence: the temp # cleanup keeps every run's archive, the host health check alerts on faults and missing -# evidence, and the Windows Update check applies freshnessDays. +# evidence, and the Windows Update check applies freshnessDays. The scripts of the browser +# demo fixtures are parsed only, never run. [CmdletBinding()] param() Set-StrictMode -Version 3.0 @@ -159,6 +160,22 @@ try { $fresh = (Invoke-StepScript -Script $tailScript -Capture 'updateFresh').updateFresh Assert-Example ($fresh -eq $case.Fresh) "Hotfix ages '$($case.Ages -join ',')' ($($script:hotfixMode)) gave updateFresh '$fresh' instead of '$($case.Fresh)'." } + + # --- browser demo fixtures: every runScript parses (they are exportable) -------------- + $fixtures = Join-Path $PSScriptRoot '..\src\nodepilot-ui\demo\seed\fixtures' + foreach ($file in Get-ChildItem -LiteralPath $fixtures -Filter '*.workflow.json') { + $definition = Get-Content -LiteralPath $file.FullName -Raw -Encoding UTF8 | ConvertFrom-Json + $scripts = @($definition.nodes | Where-Object { + $_.PSObject.Properties['data'] -and $_.data.PSObject.Properties['activityType'] -and $_.data.activityType -eq 'runScript' }) + Assert-Example ($scripts.Count -gt 0) "$($file.Name) has no runScript step to parse." + foreach ($node in $scripts) { + $tokens = $null + $parseErrors = $null + $null = [System.Management.Automation.Language.Parser]::ParseInput([string]$node.data.config.script, [ref]$tokens, [ref]$parseErrors) + $detail = $(if ($parseErrors.Count -gt 0) { $parseErrors[0].Message } else { '' }) + Assert-Example ($parseErrors.Count -eq 0) "$($file.Name) step '$($node.id)' does not parse: $detail" + } + } } finally { Remove-Item -LiteralPath $sandbox -Recurse -Force } diff --git a/src/NodePilot.Ai/AiContracts.cs b/src/NodePilot.Ai/AiContracts.cs index cc65cc2e..81eaf1a1 100644 --- a/src/NodePilot.Ai/AiContracts.cs +++ b/src/NodePilot.Ai/AiContracts.cs @@ -31,7 +31,8 @@ public sealed record GenerateScriptRequest( string? StepId, IReadOnlyList UpstreamVariables, string? CurrentScript, - bool IncludeCurrentScript = false); + bool IncludeCurrentScript = false, + string? ExpectedRecipientKey = null); /// /// Request body for POST /api/ai/generate-workflow. Just a free-text prompt — all activity diff --git a/src/NodePilot.Ai/Knowledge/KnowledgeAssistantService.cs b/src/NodePilot.Ai/Knowledge/KnowledgeAssistantService.cs index d080f35f..881ba781 100644 --- a/src/NodePilot.Ai/Knowledge/KnowledgeAssistantService.cs +++ b/src/NodePilot.Ai/Knowledge/KnowledgeAssistantService.cs @@ -1,7 +1,5 @@ using System.Diagnostics; using System.Runtime.CompilerServices; -using System.Security.Cryptography; -using System.Text; using Microsoft.Extensions.Options; using NodePilot.Core.Interfaces; @@ -62,8 +60,7 @@ public async IAsyncEnumerable StreamAskAsync( var options = llmOptions.CurrentValue; if (!options.TryResolveActiveProfile(out var profile)) throw LlmClientFactory.NoActiveProfile(); - var recipientKey = WorkflowAssistantService.HistoryContextKey(options.ActiveProfileId.Trim(), profile); - var contextKey = Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes($"{recipientKey}\n{profile.Model}")), 0, 8).ToLowerInvariant(); + var contextKey = LlmHistoryContext.Key(options.ActiveProfileId, profile); var historyDropped = request.History is { Count: > 0 } && !string.Equals(request.HistoryContextKey, contextKey, StringComparison.Ordinal); var turn = historyDropped ? request with { History = Array.Empty() } : request; diff --git a/src/NodePilot.Ai/LlmException.cs b/src/NodePilot.Ai/LlmException.cs index 7937b6e2..3f592f76 100644 --- a/src/NodePilot.Ai/LlmException.cs +++ b/src/NodePilot.Ai/LlmException.cs @@ -14,6 +14,7 @@ public enum LlmErrorKind RateLimited, UpstreamError, MalformedResponse, + RecipientChanged, } /// diff --git a/src/NodePilot.Ai/LlmHistoryContext.cs b/src/NodePilot.Ai/LlmHistoryContext.cs new file mode 100644 index 00000000..d41785ea --- /dev/null +++ b/src/NodePilot.Ai/LlmHistoryContext.cs @@ -0,0 +1,15 @@ +using System.Security.Cryptography; +using System.Text; + +namespace NodePilot.Ai; + +public static class LlmHistoryContext +{ + // The model is part of the recipient: behind a gateway, one endpoint routes to different providers. + public static string Key(string profileId, LlmProfileOptions profile) + { + var material = $"{profileId.Trim()}\n{profile.BaseUrl}\n{profile.Model}\n{profile.ShareScriptsWithAssistant}"; + var hash = SHA256.HashData(Encoding.UTF8.GetBytes(material)); + return Convert.ToHexString(hash, 0, 8).ToLowerInvariant(); + } +} diff --git a/src/NodePilot.Ai/ScriptGenerationService.cs b/src/NodePilot.Ai/ScriptGenerationService.cs index 8195d96a..71cb7685 100644 --- a/src/NodePilot.Ai/ScriptGenerationService.cs +++ b/src/NodePilot.Ai/ScriptGenerationService.cs @@ -2,6 +2,7 @@ using System.Runtime.CompilerServices; using System.Text; using System.Text.Json; +using Microsoft.Extensions.Options; namespace NodePilot.Ai; @@ -20,19 +21,30 @@ public sealed class ScriptGenerationService private static readonly JsonSerializerOptions IndentedOptions = new() { WriteIndented = true }; private readonly ILlmClientFactory _llmFactory; private readonly PromptCatalog _prompts; + private readonly IOptionsMonitor _options; // The factory, not a pre-built client: Create() resolves the active LLM profile and throws // when none is configured, so it has to run inside the call, after the controller's gate. - public ScriptGenerationService(ILlmClientFactory llmFactory, PromptCatalog prompts) + public ScriptGenerationService(ILlmClientFactory llmFactory, PromptCatalog prompts, + IOptionsMonitor options) { _llmFactory = llmFactory; _prompts = prompts; + _options = options; } public async IAsyncEnumerable StreamAsync( GenerateScriptRequest request, [EnumeratorCancellation] CancellationToken ct) { var sw = Stopwatch.StartNew(); + var options = _options.CurrentValue; + if (!options.TryResolveActiveProfile(out var profile)) + throw LlmClientFactory.NoActiveProfile(); + if (request.IncludeCurrentScript && !string.IsNullOrWhiteSpace(request.CurrentScript) + && !string.Equals(request.ExpectedRecipientKey, + LlmHistoryContext.Key(options.ActiveProfileId, profile), StringComparison.Ordinal)) + throw new LlmException(LlmErrorKind.RecipientChanged, + "The LLM recipient or sharing policy changed. Confirm the current recipient before sharing the script."); // Server-side hard cap, in case the frontend sends more variables than expected. var capped = request.UpstreamVariables; @@ -55,7 +67,7 @@ public async IAsyncEnumerable StreamAsync( int? promptTokens = null, completionTokens = null; string? finishReason = null; - var llm = _llmFactory.Create(); + var llm = _llmFactory.Create(profile); await foreach (var evt in llm.StreamAsync( new LlmRequest(_prompts.ScriptSystemPrompt, userPrompt), ct)) { diff --git a/src/NodePilot.Ai/WorkflowAssistantService.cs b/src/NodePilot.Ai/WorkflowAssistantService.cs index bd8afaa8..43277e64 100644 --- a/src/NodePilot.Ai/WorkflowAssistantService.cs +++ b/src/NodePilot.Ai/WorkflowAssistantService.cs @@ -91,7 +91,7 @@ public async IAsyncEnumerable StreamChatAsync( // Earlier turns may quote content the current provider must not see (scripts shared with // another profile, or before sharing was switched off). Only history from the same context // goes out again. - var contextKey = HistoryContextKey(options.ActiveProfileId.Trim(), profile); + var contextKey = LlmHistoryContext.Key(options.ActiveProfileId, profile); var historyDropped = request.History.Count > 0 && !string.Equals(request.HistoryContextKey, contextKey, StringComparison.Ordinal); var turn = historyDropped ? request with { History = Array.Empty() } : request; @@ -240,18 +240,6 @@ IEnumerable OnDelta(string delta) }; } - /// - /// Identifies what a chat history may have been shown: the profile id, its endpoint (an - /// operator can repoint a profile from a local model to a cloud one) and whether scripts were - /// shared. History is only sent again under the same key. - /// - internal static string HistoryContextKey(string profileId, LlmProfileOptions profile) - { - var material = $"{profileId}\n{profile.BaseUrl}\n{profile.ShareScriptsWithAssistant}"; - var hash = System.Security.Cryptography.SHA256.HashData(Encoding.UTF8.GetBytes(material)); - return Convert.ToHexString(hash, 0, 8).ToLowerInvariant(); - } - /// /// Fetches enabled custom activities keyed by their custom:<key> type. Empty when /// no store is configured. @@ -566,8 +554,8 @@ public sealed record ToolResultEvent(string ToolId, string ToolName, string Resu /// by this, not by , for a /// throughput that matches what the LLM server reports. /// - /// The key the client sends back so - /// the next turn's history is accepted (see WorkflowAssistantService.HistoryContextKey). + /// The key the client sends back so the next turn's history + /// is accepted only for the same provider and sharing policy. /// The request's history was left out /// because it came from another profile or sharing setting. /// Workflow assistant only: scripts, SQL and arguments were sent diff --git a/src/NodePilot.Api/Ai/LlmErrorCodes.cs b/src/NodePilot.Api/Ai/LlmErrorCodes.cs index 70a3694e..53985fef 100644 --- a/src/NodePilot.Api/Ai/LlmErrorCodes.cs +++ b/src/NodePilot.Api/Ai/LlmErrorCodes.cs @@ -10,6 +10,7 @@ internal static class LlmErrorCodes { public static string For(LlmException ex) => ex.Kind switch { + LlmErrorKind.RecipientChanged => "LLM_RECIPIENT_CHANGED", LlmErrorKind.Unreachable => "LLM_UNREACHABLE", LlmErrorKind.Timeout => "LLM_TIMEOUT", LlmErrorKind.Unauthorized => "LLM_UNAUTHORIZED", diff --git a/src/NodePilot.Api/Ai/LlmErrorResults.cs b/src/NodePilot.Api/Ai/LlmErrorResults.cs index 78e4e51f..5a884b1c 100644 --- a/src/NodePilot.Api/Ai/LlmErrorResults.cs +++ b/src/NodePilot.Api/Ai/LlmErrorResults.cs @@ -25,6 +25,7 @@ public static ActionResult MapLlmException( var code = LlmErrorCodes.For(ex); return ex.Kind switch { + LlmErrorKind.RecipientChanged => controller.Conflict(new { code, message = ex.Message }), LlmErrorKind.Unreachable => controller.LlmServiceUnavailable(code, ex.Message), LlmErrorKind.Timeout => controller.LlmServiceUnavailable(code, ex.Message), LlmErrorKind.Unauthorized => controller.LlmServiceUnavailable(code, diff --git a/src/NodePilot.Api/Controllers/AiKnowledgeController.cs b/src/NodePilot.Api/Controllers/AiKnowledgeController.cs index f339f39c..8b321628 100644 --- a/src/NodePilot.Api/Controllers/AiKnowledgeController.cs +++ b/src/NodePilot.Api/Controllers/AiKnowledgeController.cs @@ -79,10 +79,13 @@ public ActionResult Capabilities() var toolSourcesEnabled = enabled && llm.TryResolveActiveProfile(out var activeProfile) && activeProfile.EnableToolCalling; - var scriptContextTargetHost = llmUsable && User.IsPrivileged() - && llm.TryResolveActiveProfile(out var scriptProfile) - ? DisplayHost(scriptProfile.BaseUrl) - : null; + string? scriptContextTargetHost = null; + string? scriptContextRecipientKey = null; + if (llmUsable && User.IsPrivileged() && llm.TryResolveActiveProfile(out var scriptProfile)) + { + scriptContextTargetHost = DisplayHost(scriptProfile.BaseUrl); + scriptContextRecipientKey = LlmHistoryContext.Key(llm.ActiveProfileId, scriptProfile); + } return Ok(new KnowledgeCapabilitiesDto( Enabled: enabled, Llm: llmUsable, @@ -90,7 +93,8 @@ public ActionResult Capabilities() Operational: toolSourcesEnabled && k.OperationalEnabled, SourceCode: toolSourcesEnabled && k.SourceCodeEnabled && User.IsPrivileged(), Db: toolSourcesEnabled && k.DbEnabled && User.IsAdmin(), - ScriptContextTargetHost: scriptContextTargetHost)); + ScriptContextTargetHost: scriptContextTargetHost, + ScriptContextRecipientKey: scriptContextRecipientKey)); } private static string? DisplayHost(string? baseUrl) diff --git a/src/NodePilot.Api/Dtos/AiDtos.cs b/src/NodePilot.Api/Dtos/AiDtos.cs index 6fd513df..f07695ed 100644 --- a/src/NodePilot.Api/Dtos/AiDtos.cs +++ b/src/NodePilot.Api/Dtos/AiDtos.cs @@ -33,4 +33,5 @@ public sealed record KnowledgeCapabilitiesDto( bool Operational, bool SourceCode, bool Db, - string? ScriptContextTargetHost); + string? ScriptContextTargetHost, + string? ScriptContextRecipientKey); diff --git a/src/NodePilot.Core/WorkflowDefinitions/WorkflowAnalyzer.cs b/src/NodePilot.Core/WorkflowDefinitions/WorkflowAnalyzer.cs index 15816eca..668c6266 100644 --- a/src/NodePilot.Core/WorkflowDefinitions/WorkflowAnalyzer.cs +++ b/src/NodePilot.Core/WorkflowDefinitions/WorkflowAnalyzer.cs @@ -69,7 +69,6 @@ public static AnalysisResult Analyze(JsonElement definition) if (FindCycle(doc.Adjacency) is { Count: > 0 } cycle) findings.Add(new Finding("error", "cycle", null, $"Cycle detected: {string.Join(" → ", cycle)} → {cycle[0]}. The engine has no inDegree fallback; cyclic graphs Fail.")); - AddDuplicateOutputVariableFindings(doc, findings); AddDuplicatePublishedParamFindings(doc, findings); AddUnresolvedReferenceFindings(doc, definition, findings); @@ -278,30 +277,6 @@ private static void AddUnresolvedReferenceFindings( } } - private static void AddDuplicateOutputVariableFindings(WorkflowDefinitionDocument doc, List findings) - { - var seen = new Dictionary(StringComparer.OrdinalIgnoreCase); - foreach (var node in doc.Nodes) - { - if (doc.DisabledNodeIds.Contains(node.Id)) continue; - if (IsAnnotation(node.Type)) continue; - var outputVariable = node.Data.OutputVariable; - if (string.IsNullOrWhiteSpace(outputVariable)) continue; - - if (seen.TryGetValue(outputVariable, out var firstNodeId)) - { - findings.Add(new Finding( - "error", - "dup-output-variable", - node.Id, - $"outputVariable '{outputVariable}' is already used by node '{firstNodeId}' — downstream references {{{{{outputVariable}.output}}}} are ambiguous.")); - } - else - { - seen[outputVariable] = node.Id; - } - } - } private static void AddStartJobRunspaceFindings(WorkflowDefinitionDocument doc, List findings) { diff --git a/src/NodePilot.Core/WorkflowDefinitions/WorkflowDefinitionDocument.cs b/src/NodePilot.Core/WorkflowDefinitions/WorkflowDefinitionDocument.cs index 06b9b4f9..7d0fbb4f 100644 --- a/src/NodePilot.Core/WorkflowDefinitions/WorkflowDefinitionDocument.cs +++ b/src/NodePilot.Core/WorkflowDefinitions/WorkflowDefinitionDocument.cs @@ -192,8 +192,12 @@ public static Dictionary BuildOutputVariableAliasMap(IReadOnlyLi var dict = new Dictionary(StringComparer.OrdinalIgnoreCase); foreach (var node in allNodes) { - if (node.Data.OutputVariable is { Length: > 0 } variable && variable != node.Id) - dict[variable] = node.Id; + if (node.Data.Disabled) continue; + if (node.Data.OutputVariable is { Length: > 0 } variable) + { + if (!dict.TryAdd(variable, node.Id)) + throw new InvalidOperationException($"Duplicate active output variable '{variable}'."); + } } return dict; diff --git a/src/NodePilot.Core/WorkflowDefinitions/WorkflowDefinitionStructuralValidator.cs b/src/NodePilot.Core/WorkflowDefinitions/WorkflowDefinitionStructuralValidator.cs index 335f1c13..3e05d124 100644 --- a/src/NodePilot.Core/WorkflowDefinitions/WorkflowDefinitionStructuralValidator.cs +++ b/src/NodePilot.Core/WorkflowDefinitions/WorkflowDefinitionStructuralValidator.cs @@ -18,7 +18,7 @@ public static WorkflowDefinitionValidationResult Validate(JsonElement definition return WorkflowDefinitionValidationResult.Invalid("root must be a JSON object"); var nodeIds = new HashSet(StringComparer.Ordinal); - var outputVariables = new HashSet(StringComparer.Ordinal); + var outputVariables = new HashSet(StringComparer.OrdinalIgnoreCase); var activityTypeByNodeId = new Dictionary(StringComparer.Ordinal); if (definition.TryGetProperty("nodes", out var nodes)) { @@ -162,8 +162,11 @@ private static WorkflowDefinitionValidationResult ValidateNode( return WorkflowDefinitionValidationResult.Invalid(error!); } - if (data.TryGetProperty("outputVariable", out var outputVariable) && IsNonEmptyString(outputVariable)) - outputVariables.Add(outputVariable.GetString()!); + var disabled = data.TryGetProperty("disabled", out var disabledValue) && disabledValue.ValueKind == JsonValueKind.True; + if (!disabled && data.TryGetProperty("outputVariable", out var outputVariable) && IsNonEmptyString(outputVariable) + && !outputVariables.Add(outputVariable.GetString()!)) + return WorkflowDefinitionValidationResult.Invalid( + $"duplicate active output variable '{outputVariable.GetString()}'", "dup-output-variable", id); return WorkflowDefinitionValidationResult.Valid; } diff --git a/src/nodepilot-docs-ui/content/de/activities-reference.md b/src/nodepilot-docs-ui/content/de/activities-reference.md index df81d8fe..8570ef16 100644 --- a/src/nodepilot-docs-ui/content/de/activities-reference.md +++ b/src/nodepilot-docs-ui/content/de/activities-reference.md @@ -179,7 +179,7 @@ Bei Shutdown und Restart ist `force` standardmäßig `true`. Mit `force=false` m **Remote.** Erfordert meist Admin auf dem Ziel. -- **Config:** `action` (get/start/stop/enable/disable/unregister/register, default `get`), `taskName`, `taskPath` (default `\`). Register-only: `program`, `arguments`, `workingDirectory`, `triggerType` (once/daily/weekly/atLogon/atStartup), `startTime`, `daysOfWeek[]`, `weeksInterval`, `daysInterval`, `runAsUser` (default SYSTEM), `runLevel` (limited/highest), `description`, `force` +- **Config:** `action` (get/start/stop/enable/disable/unregister/register, default `get`), `taskName`, `taskPath` (default `\`). Register-only: `program`, `arguments`, `workingDirectory`, `triggerType` (once/daily/weekly/atLogon/atStartup), `startTime`, `daysOfWeek[]`, `weeksInterval`, `daysInterval`, `runAsUser` (Default SYSTEM; ein eingebautes Konto oder ein Managed Service Account wie `DOMAIN\gmsa$`, kein normaler Domänenbenutzer), `runLevel` (limited/highest), `description`, `force` - **Outputs:** `param.taskName`, `param.state`, `param.lastRunTime`, `param.lastTaskResult`, `param.nextRunTime` - **Provider-Fallback:** Aktionen auf vorhandenen Tasks verwenden zuerst die PowerShell-Cmdlets. Nur wenn der ScheduledTasks-CIM-Provider mit `0x80041318` fehlschlägt, greift die Activity innerhalb derselben lokalen/WinRM-PowerShell-Session auf die Task-Scheduler-Automation-API zurück. `register` bleibt ausschließlich Cmdlet-basiert. diff --git a/src/nodepilot-docs-ui/content/de/ai-features.md b/src/nodepilot-docs-ui/content/de/ai-features.md index 64f45479..c701e0a6 100644 --- a/src/nodepilot-docs-ui/content/de/ai-features.md +++ b/src/nodepilot-docs-ui/content/de/ai-features.md @@ -23,7 +23,7 @@ Die AI-Funktionen sind standardmäßig deaktiviert. Die zugehörigen Schaltfläc - Fehlerkorrektur oder Vereinfachung - Anpassung an verfügbare Workflow-Variablen -Ein vorhandenes Script und verfügbare Variablen werden als Kontext berücksichtigt. Das Ergebnis kann am Cursor eingefügt oder als vollständiger Ersatz übernommen werden. +Ein vorhandenes Script geht nur mit, wenn es im Dialog für das dort angezeigte KI-Ziel bestätigt wurde; ändert sich das KI-Profil vor dem Senden, fragt der Dialog erneut. Verfügbare Variablen werden ohne ihre Werte als Kontext berücksichtigt. Das Ergebnis kann am Cursor eingefügt oder als vollständiger Ersatz übernommen werden. Die Übernahme ändert nur den Inhalt im Editor. Vor dem Speichern und Ausführen ist eine Prüfung auf Befehle, Pfade, Berechtigungen und verwendete Variablen erforderlich. @@ -148,7 +148,7 @@ Weitere Felder und Ausgaben enthält die [`llmQuery`-Referenz](activities-refere - Quellcodewissen im globalen Chat ist auf Admin und Operator beschränkt; Datenbankwissen mit Raw-SQL steht ausschließlich globalen Admins zur Verfügung. Folder-Rechte erhöhen einen Operator nicht in diese Capability. Workflow-Definitionen und statische Workflow-Analysen bleiben Admin und Operator mit Folder-RBAC vorbehalten; ein Viewer erhält aus der Betriebsquelle nur die geplanten Ausführungszeitpunkte. - Folder-RBAC begrenzt den Zugriff auf Workflow-Daten. - Secrets werden vor Modellanfragen redigiert. Auch Literalwerte in Decision-Cases und Condition-Gruppen werden redigiert, deshalb kann der Assistent eine Case-Liste oder Condition-Gruppe mit einem solchen Wert nicht ändern: der Vorschlag behält das Original, und ein Hinweis nennt den Node oder die Kante zum manuellen Anpassen. -- Der Workflow-Assistent liest Skripte, SQL-Queries und Programm-Argumente im Klartext, der eingestellte KI-Anbieter sieht sie also. Ein Wert mit erkanntem Secret-Muster (etwa `PASSWORD = '…'` oder `ConvertTo-SecureString '…'`) geht vollständig maskiert raus. Die Erkennung ist eine Heuristik und findet nicht jedes eingebettete Passwort. Ein Administrator kann das pro LLM-Profil unter Einstellungen → Integrationen → LLM abschalten („Skripte, SQL und Argumente an den KI-Chat geben"). Ändert sich das Profil, sein Endpunkt oder diese Einstellung, sendet der Chat den bisherigen Verlauf nicht mehr mit. Der globale Chat folgt derselben Regel, auch bei einem Modellwechsel, und vermerkt unter der Antwort, dass der bisherige Verlauf nicht mitgesendet wurde. +- Der Workflow-Assistent liest Skripte, SQL-Queries und Programm-Argumente im Klartext, der eingestellte KI-Anbieter sieht sie also. Ein Wert mit erkanntem Secret-Muster (etwa `PASSWORD = '…'` oder `ConvertTo-SecureString '…'`) geht vollständig maskiert raus. Die Erkennung ist eine Heuristik und findet nicht jedes eingebettete Passwort. Ein Administrator kann das pro LLM-Profil unter Einstellungen → Integrationen → LLM abschalten („Skripte, SQL und Argumente an den KI-Chat geben"). Ändert sich das Profil, sein Endpunkt, sein Modell oder diese Einstellung, sendet der Chat den bisherigen Verlauf nicht mehr mit. Der globale Chat folgt derselben Regel und vermerkt unter der Antwort, dass der bisherige Verlauf nicht mitgesendet wurde. - Generierte Scripts und Workflow-Änderungen erfordern immer eine fachliche Prüfung. - AI-Aktionen und übernommene Vorschläge werden im Audit-Log erfasst. diff --git a/src/nodepilot-docs-ui/content/en/activities-reference.md b/src/nodepilot-docs-ui/content/en/activities-reference.md index b053f121..56606e20 100644 --- a/src/nodepilot-docs-ui/content/en/activities-reference.md +++ b/src/nodepilot-docs-ui/content/en/activities-reference.md @@ -174,7 +174,7 @@ For shutdown and restart, `force` defaults to `true`. With `force=false`, `delay **Remote.** Usually requires admin rights on the target. -- **Config:** `action` (get/start/stop/enable/disable/unregister/register, default `get`), `taskName`, `taskPath` (default `\`). Register only: `program`, `arguments`, `workingDirectory`, `triggerType` (once/daily/weekly/atLogon/atStartup), `startTime`, `daysOfWeek[]`, `weeksInterval`, `daysInterval`, `runAsUser` (default SYSTEM), `runLevel` (limited/highest), `description`, `force` +- **Config:** `action` (get/start/stop/enable/disable/unregister/register, default `get`), `taskName`, `taskPath` (default `\`). Register only: `program`, `arguments`, `workingDirectory`, `triggerType` (once/daily/weekly/atLogon/atStartup), `startTime`, `daysOfWeek[]`, `weeksInterval`, `daysInterval`, `runAsUser` (default SYSTEM; a built-in account or a managed service account such as `DOMAIN\gmsa$`, not an ordinary domain user), `runLevel` (limited/highest), `description`, `force` - **Outputs:** `param.taskName`, `param.state`, `param.lastRunTime`, `param.lastTaskResult`, `param.nextRunTime` - **Provider fallback:** actions on existing tasks use the PowerShell cmdlets first. Only if the ScheduledTasks CIM provider fails with `0x80041318` does the activity fall back to the Task Scheduler automation API within the same local/WinRM PowerShell session. `register` stays cmdlet-based exclusively. diff --git a/src/nodepilot-docs-ui/content/en/ai-features.md b/src/nodepilot-docs-ui/content/en/ai-features.md index 5ccec03d..047dc1ae 100644 --- a/src/nodepilot-docs-ui/content/en/ai-features.md +++ b/src/nodepilot-docs-ui/content/en/ai-features.md @@ -23,7 +23,7 @@ The AI features are disabled by default. The corresponding buttons (script edito - Fixing errors or simplifying - Adapting to the available workflow variables -An existing script and the available variables are taken into account as context. The result can be inserted at the cursor or accepted as a complete replacement. +An existing script is sent only after it is confirmed in the dialog for the AI target shown there; if the AI profile changes before sending, the dialog asks again. Available variables are taken into account as context, without their values. The result can be inserted at the cursor or accepted as a complete replacement. Accepting it only changes the content in the editor. Before saving and running, a review of the commands, paths, permissions and variables used is required. @@ -148,7 +148,7 @@ Further fields and outputs are in the [`llmQuery` reference](activities-referenc - Source-code knowledge in the global chat is limited to Admin and Operator; database knowledge with raw SQL is available to global admins only. Folder permissions do not elevate an operator into that capability. Workflow definitions and static workflow analyses remain reserved for Admin and Operator with folder RBAC; a viewer receives only the scheduled execution times from the operational source. - Folder RBAC limits access to workflow data. - Secrets are redacted before model requests. Literal values in decision cases and condition groups are redacted too, so the assistant cannot change a case list or condition group that contains one: the proposal keeps the original and a note names the node or edge to adjust by hand. -- The workflow assistant reads scripts, SQL queries and program arguments in clear, so the configured AI provider sees them. A value with a recognised secret pattern (for example `PASSWORD = '…'` or `ConvertTo-SecureString '…'`) is sent fully masked. Recognition is a heuristic and does not catch every embedded password. An administrator can switch this off per LLM profile under Settings → Integrations → LLM ("Share scripts, SQL and arguments with the AI chat"). If the profile, its endpoint or this setting changes, the chat no longer sends the earlier conversation. The global chat applies the same rule, also on a model change, and notes under the answer that the earlier conversation was not sent. +- The workflow assistant reads scripts, SQL queries and program arguments in clear, so the configured AI provider sees them. A value with a recognised secret pattern (for example `PASSWORD = '…'` or `ConvertTo-SecureString '…'`) is sent fully masked. Recognition is a heuristic and does not catch every embedded password. An administrator can switch this off per LLM profile under Settings → Integrations → LLM ("Share scripts, SQL and arguments with the AI chat"). If the profile, its endpoint, its model or this setting changes, the chat no longer sends the earlier conversation. The global chat applies the same rule and notes under the answer that the earlier conversation was not sent. - Generated scripts and workflow changes always require a domain review. - AI actions and applied proposals are recorded in the audit log. diff --git a/src/nodepilot-ui/CLAUDE.md b/src/nodepilot-ui/CLAUDE.md index 532a62bf..921610b6 100644 --- a/src/nodepilot-ui/CLAUDE.md +++ b/src/nodepilot-ui/CLAUDE.md @@ -13,6 +13,9 @@ Applies to `src/nodepilot-ui/`. Project-wide rules are in the root `CLAUDE.md`. - UI strings via `react-i18next` (namespaces per `src/i18n/locales/{de,en}/`, default DE) — new visible strings in **both** languages. - **Colours always via design tokens/CSS variables** — never hardcode Tailwind colour literals (`text-gray-900`, `bg-white` break the dark skins). Native ` setIncludeCurrentScript(e.target.checked)} - disabled={submitting} + onChange={(e) => setConsentedRecipient(e.target.checked ? scriptContextRecipientKey ?? null : null)} + disabled={submitting || !scriptContextRecipientKey} className="w-4 h-4 mt-0.5 rounded border-outline-variant accent-primary shrink-0" /> diff --git a/src/nodepilot-ui/src/components/ai/AiWorkflowChatPanel.tsx b/src/nodepilot-ui/src/components/ai/AiWorkflowChatPanel.tsx index 3c78fd9d..21b2af56 100644 --- a/src/nodepilot-ui/src/components/ai/AiWorkflowChatPanel.tsx +++ b/src/nodepilot-ui/src/components/ai/AiWorkflowChatPanel.tsx @@ -1,3 +1,4 @@ +import { ChatPersistenceNotice } from './ChatPersistenceNotice'; import { Add, Checkbox, @@ -440,6 +441,7 @@ export function AiWorkflowChatPanel({ )} {/* Error + Retry */} + {error && (
{error} diff --git a/src/nodepilot-ui/src/components/ai/ChatPersistenceNotice.tsx b/src/nodepilot-ui/src/components/ai/ChatPersistenceNotice.tsx new file mode 100644 index 00000000..af31c890 --- /dev/null +++ b/src/nodepilot-ui/src/components/ai/ChatPersistenceNotice.tsx @@ -0,0 +1,12 @@ +import { useTranslation } from 'react-i18next'; +import { useChatPersistenceStatus } from '../../stores/chatPersistenceStatus'; + +export function ChatPersistenceNotice() { + const { t } = useTranslation('ai'); + const unavailable = useChatPersistenceStatus((state) => state.unavailable); + const trimmed = useChatPersistenceStatus((state) => state.trimmed); + if (!unavailable && !trimmed) return null; + return

+ {t(unavailable ? 'chat.persistenceUnavailable' : 'chat.persistenceTrimmed')} +

; +} diff --git a/src/nodepilot-ui/src/components/ai/KnowledgeChat.tsx b/src/nodepilot-ui/src/components/ai/KnowledgeChat.tsx index c4bcc60e..35cde1ab 100644 --- a/src/nodepilot-ui/src/components/ai/KnowledgeChat.tsx +++ b/src/nodepilot-ui/src/components/ai/KnowledgeChat.tsx @@ -13,6 +13,7 @@ import { Markdown } from '../common/Markdown'; import { CopyButton } from '../common/CopyButton'; import { UsageFooter } from './UsageFooter'; import { ChatThreadMenu } from './ChatThreadMenu'; +import { ChatPersistenceNotice } from './ChatPersistenceNotice'; import { useAiChatStore, aiChatScopeKey, aiChatFullKey, type ChatMessage, type ChatThreadMeta, @@ -311,6 +312,7 @@ export function KnowledgeChat({ compact = false }: Readonly<{ compact?: boolean
{/* Error + Retry */} + {error && (
{t('ai:chat.errorPrefix', { message: error })} diff --git a/src/nodepilot-ui/src/components/alerting/AlertingRuleEditor.tsx b/src/nodepilot-ui/src/components/alerting/AlertingRuleEditor.tsx index 497225a8..be362f34 100644 --- a/src/nodepilot-ui/src/components/alerting/AlertingRuleEditor.tsx +++ b/src/nodepilot-ui/src/components/alerting/AlertingRuleEditor.tsx @@ -16,6 +16,7 @@ import { type NotificationChannel, } from '../../lib/eventFields'; import { toast } from '../../stores/toastStore'; +import { useAsyncIdentity } from '../../hooks/useAsyncIdentity'; type Scope = 'Global' | 'Folders' | 'Workflows'; @@ -88,6 +89,7 @@ export function AlertingRuleEditor({ const { t } = useTranslation(['alerts', 'common']); const queryClient = useQueryClient(); const [form, setForm] = useState(() => fromRule(rule)); + const captureSession = useAsyncIdentity(rule); const [testResult, setTestResult] = useState(null); const [previewResult, setPreviewResult] = useState(null); @@ -157,14 +159,15 @@ export function AlertingRuleEditor({ }; const saveMutation = useMutation({ - mutationFn: async (f: FormState) => { + mutationFn: async ({ form: f }: { form: FormState; current: () => boolean }) => { const body = buildBody(f); if (rule) await alertingApi.update(rule.id, body); else await alertingApi.create(body); }, - onSuccess: () => { + onSuccess: (_result, request) => { queryClient.invalidateQueries({ queryKey: ['alerting-rules'] }); - onClose(); + // A closed editor must not close the one opened after it. + if (request.current()) onClose(); }, onError: (err: Error) => toast.error(t('common:saveFailed', { message: err.message })), }); @@ -222,6 +225,8 @@ export function AlertingRuleEditor({

{rule ? t('alerts:editTitle') : t('alerts:createTitle')}

+ {/* Locked while saving, so the response never discards edits made after the submit. */} +
{/* Name + description + enabled */}
@@ -388,6 +393,7 @@ export function AlertingRuleEditor({
)}
+
- diff --git a/src/nodepilot-ui/src/components/alerting/SystemPolicyEditor.tsx b/src/nodepilot-ui/src/components/alerting/SystemPolicyEditor.tsx index 38b02ed1..a91a3b9b 100644 --- a/src/nodepilot-ui/src/components/alerting/SystemPolicyEditor.tsx +++ b/src/nodepilot-ui/src/components/alerting/SystemPolicyEditor.tsx @@ -5,6 +5,7 @@ import { useTranslation } from 'react-i18next'; import { api } from '../../api/client'; import { sharedFoldersApi } from '../../api/sharedFolders'; import { ModalShell } from '../common/ModalShell'; +import { useAsyncIdentity } from '../../hooks/useAsyncIdentity'; import { AlertRouteFields, routeSecretForSave, type AlertRouteBase } from './AlertRouteFields'; import { ConditionBuilder, type ExprNode } from '../designer/ConditionBuilder'; import { NOTIFICATION_CHANNELS, type NotificationChannel } from '../../lib/eventFields'; @@ -90,6 +91,7 @@ export function SystemPolicyEditor({ const { t } = useTranslation(['alerts', 'common']); const qc = useQueryClient(); const [form, setForm] = useState(() => fromPolicy(source, policy)); + const captureSession = useAsyncIdentity(policy); const [testResult, setTestResult] = useState(null); const [previewResult, setPreviewResult] = useState(null); @@ -158,12 +160,13 @@ export function SystemPolicyEditor({ }; const saveMutation = useMutation({ - mutationFn: async (f: FormState) => { + mutationFn: async ({ form: f }: { form: FormState; current: () => boolean }) => { const body = buildBody(f); if (policy) await systemAlertingApi.update(policy.id, body); else await systemAlertingApi.create(body); }, - onSuccess: () => { invalidate(); onClose(); }, + // A closed editor must not close the one opened after it. + onSuccess: (_result, request) => { invalidate(); if (request.current()) onClose(); }, onError: (err: Error) => toast.error(t('alerts:system.editor.saveFailed', { message: err.message })), }); @@ -205,6 +208,8 @@ export function SystemPolicyEditor({ {policy ? t('alerts:system.editPolicy') : t('alerts:system.newPolicy')}

{label}

+ {/* Locked while saving, so the response never discards edits made after the submit. */} +
@@ -373,6 +378,7 @@ export function SystemPolicyEditor({
)}
+
- diff --git a/src/nodepilot-ui/src/components/common/TypedPhraseConfirmDialog.tsx b/src/nodepilot-ui/src/components/common/TypedPhraseConfirmDialog.tsx index 69a854c1..69bfd842 100644 --- a/src/nodepilot-ui/src/components/common/TypedPhraseConfirmDialog.tsx +++ b/src/nodepilot-ui/src/components/common/TypedPhraseConfirmDialog.tsx @@ -1,5 +1,6 @@ import { Close, SecurityServices } from '@carbon/icons-react'; import { useTranslation } from 'react-i18next'; +import { useRef } from 'react'; /** * Type-the-phrase confirmation dialog for the two actions that grant write access to @@ -24,6 +25,7 @@ export function TypedPhraseConfirmDialog({ }>) { const { t } = useTranslation(['common']); const ok = input === phrase; + const panel = useRef(null); return (
e.stopPropagation()} - onKeyDown={(e) => { e.stopPropagation(); if (e.key === 'Escape' && !e.defaultPrevented) onCancel(); }} - role="presentation" + onKeyDown={(e) => { + e.stopPropagation(); + if (e.key === 'Escape' && !e.defaultPrevented) onCancel(); + if (e.key === 'Tab') { + const controls = panel.current?.querySelectorAll('button:not(:disabled), input:not(:disabled)'); + if (!controls?.length) return; + const first = controls[0]; + const last = controls[controls.length - 1]; + if (e.shiftKey && document.activeElement === first) { e.preventDefault(); last.focus(); } + else if (!e.shiftKey && document.activeElement === last) { e.preventDefault(); first.focus(); } + } + }} + role="dialog" + aria-modal="true" + aria-label={title} >

@@ -69,7 +85,7 @@ export function TypedPhraseConfirmDialog({ {t('common:cancel')}

); -} \ No newline at end of file +} diff --git a/src/nodepilot-ui/src/components/designer/EditorOverlays.tsx b/src/nodepilot-ui/src/components/designer/EditorOverlays.tsx index 41f76c5b..4aa274f4 100644 --- a/src/nodepilot-ui/src/components/designer/EditorOverlays.tsx +++ b/src/nodepilot-ui/src/components/designer/EditorOverlays.tsx @@ -60,6 +60,7 @@ export interface EditorActions { groupSelection: () => void; selected: { type: 'node' | 'edge'; id: string } | null; deleteNodeById: (id: string) => void; + deleteEdgeById: (id: string) => void; selectAll: () => void; navigateNode: (dir: 'next' | 'prev') => void; handleQuickEditSave: (nodeId: string, configPatch: Record) => void; @@ -122,7 +123,7 @@ export function EditorOverlays({ const { showRunDialog, confirmRunWithParams, closeRunDialog, lastExecutionList } = execution; const { undo, redo, copySelection, pasteBuffer, duplicateSelection, groupSelection, selected, - deleteNodeById, selectAll, navigateNode, handleQuickEditSave, jumpToNode, jumpToEdge, + deleteNodeById, deleteEdgeById, selectAll, navigateNode, handleQuickEditSave, jumpToNode, jumpToEdge, toggleFullscreen, zoomToSelection, fitViewAll, exportPng, exportJson, } = actions; const { simulation, runSimulation, clearSimulation } = sim; @@ -235,7 +236,7 @@ export function EditorOverlays({ deleteSelected: () => { if (!selected) return; if (selected.type === 'node') deleteNodeById(selected.id); - else setEdges((eds) => eds.filter((e) => e.id !== selected.id)); + else deleteEdgeById(selected.id); }, selectAll, navigateNode, // Overlays / panels @@ -433,6 +434,7 @@ function ScriptDoubleClickEditor({ onRun={workflowId ? runStepTest : undefined} onAiGenerate={aiScript?.generate} aiTargetHost={aiScript?.targetHost} + aiRecipientKey={aiScript?.recipientKey} /> ); diff --git a/src/nodepilot-ui/src/components/designer/ScriptEditorDialog.tsx b/src/nodepilot-ui/src/components/designer/ScriptEditorDialog.tsx index 5e9e93f9..dace052b 100644 --- a/src/nodepilot-ui/src/components/designer/ScriptEditorDialog.tsx +++ b/src/nodepilot-ui/src/components/designer/ScriptEditorDialog.tsx @@ -29,6 +29,7 @@ import { monaco, MONO_FONT_STACK } from '../../lib/monacoSetup'; import { isMinimalSkin, useThemeStore } from '../../stores/themeStore'; import { confirmDialog } from '../../stores/confirmStore'; import { AiPromptDialog } from '../ai/AiPromptDialog'; +import { ApiError } from '../../api/client'; import type { StepTestResult } from '../../types/api'; interface AvailableVar { @@ -59,9 +60,10 @@ interface Props { * closes right after submit and the code types into the editor; errors show as a banner * there. Insert mode defaults to "insert at cursor" and can be switched to replace-all. */ - onAiGenerate?: (prompt: string, currentScript: string | null, onToken: (text: string) => void, signal: AbortSignal) => Promise; + onAiGenerate?: (prompt: string, currentScript: string | null, onToken: (text: string) => void, signal: AbortSignal, expectedRecipientKey: string | null) => Promise; /** Sanitized active LLM host shown beside the one-shot script-context consent. */ aiTargetHost?: string | null; + aiRecipientKey?: string | null; } const FONT_SIZE_KEY = 'nodepilot.scriptEditor.fontSize'; @@ -239,7 +241,7 @@ function parseExposedVars(code: string): string[] { export function ScriptEditorDialog({ value, onChange, onClose, onRun, availableVars = [], upstreamRefs = [], outputVariableName, - title = 'PowerShell Script Editor', onAiGenerate, aiTargetHost, + title = 'PowerShell Script Editor', onAiGenerate, aiTargetHost, aiRecipientKey, }: Readonly) { const { t } = useTranslation(['ai', 'editor']); const aiDialogTitle = t('ai:scriptDialog.title'); @@ -406,7 +408,7 @@ export function ScriptEditorDialog({ * In replace-all mode the editor is cleared on the first token, so an error before any token * leaves the old content intact. `signal` cancels the stream. */ - const handleAiSubmit = useCallback(async (prompt: string, replaceAll: boolean, includeCurrentScript: boolean) => { + const handleAiSubmit = useCallback(async (prompt: string, replaceAll: boolean, includeCurrentScript: boolean, expectedRecipientKey: string | null) => { if (!onAiGenerate) return; if (aiAbortRef.current) return; // a generation is already running, so no second stream const editor = editorRef.current; @@ -506,7 +508,7 @@ export function ScriptEditorDialog({ try { // Editor contents leave the browser only after the dialog's one-shot consent. - await onAiGenerate(prompt, includeCurrentScript ? original : null, onToken, ac.signal); + await onAiGenerate(prompt, includeCurrentScript ? original : null, onToken, ac.signal, expectedRecipientKey); cleanup(); } catch (err: unknown) { const aborted = (err instanceof DOMException || err instanceof Error) && err.name === 'AbortError'; @@ -519,6 +521,7 @@ export function ScriptEditorDialog({ } cleanup(); if (!aborted) { + if (err instanceof ApiError && err.code === 'LLM_RECIPIENT_CHANGED') setAiDialogOpen(true); const message = err instanceof Error ? err.message : String(err); setAiError(restored ? `${message} ${t('ai:scriptDialog.originalRestored')}` : message); } @@ -957,6 +960,7 @@ export function ScriptEditorDialog({ defaultReplaceAll={false} showScriptContextConsent={code.trim().length > 0} scriptContextTargetHost={aiTargetHost} + scriptContextRecipientKey={aiRecipientKey} onSubmit={handleAiSubmit} onClose={() => setAiDialogOpen(false)} /> diff --git a/src/nodepilot-ui/src/components/designer/execution/ExecutionPanelParts.tsx b/src/nodepilot-ui/src/components/designer/execution/ExecutionPanelParts.tsx index 7580b020..7c7c398f 100644 --- a/src/nodepilot-ui/src/components/designer/execution/ExecutionPanelParts.tsx +++ b/src/nodepilot-ui/src/components/designer/execution/ExecutionPanelParts.tsx @@ -68,9 +68,8 @@ export function formatMs(ms: number): string { /* ---- Shared components ---- */ /** - * Static input block: reads the workflow definition, finds the node by stepId, and - * renders its config, target machine, credential override and output variable. Reads - * from the definition rather than the execution row so the execution record stays lean. + * Current-configuration reference: reads the workflow definition and finds the node by stepId. + * This is deliberately labelled separately from recorded execution values. * Templates like `{{step.param.x}}` are shown as-is; during a debug run the * PausedVariablesInspector shows the resolved values. */ @@ -128,9 +127,10 @@ export function StepInputBlock({ workflowId, stepId }: Readonly<{ workflowId: st
- {t('execution.inspector.input')} + {t('execution.inspector.currentConfiguration')}
+

{t('execution.inspector.currentConfigurationHint')}

{(machineName || credentialName || data.outputVariable) && (
diff --git a/src/nodepilot-ui/src/components/designer/execution/HistoryTab.tsx b/src/nodepilot-ui/src/components/designer/execution/HistoryTab.tsx index 61c9cb5c..2135ffb6 100644 --- a/src/nodepilot-ui/src/components/designer/execution/HistoryTab.tsx +++ b/src/nodepilot-ui/src/components/designer/execution/HistoryTab.tsx @@ -590,6 +590,11 @@ function StepTimeline({ steps, executionStart, workflowId, executionId, onScrubT {isOpen && (
+ {r.step.targetMachine && ( +

+ {t('execution.inspector.recordedTarget')}: {r.step.targetMachine} +

+ )} {['aiAgent', 'aiAgentTeam'].includes(r.step.stepType) ? : } diff --git a/src/nodepilot-ui/src/components/designer/properties/activities/AiAgentConfig.tsx b/src/nodepilot-ui/src/components/designer/properties/activities/AiAgentConfig.tsx index 813008ba..312ebaaa 100644 --- a/src/nodepilot-ui/src/components/designer/properties/activities/AiAgentConfig.tsx +++ b/src/nodepilot-ui/src/components/designer/properties/activities/AiAgentConfig.tsx @@ -287,7 +287,14 @@ function ToolEditor({ tools, update }: { tools: AgentToolSelection[]; update: (t } function Lines({ label, value, onChange }: { label: string; value: string[]; onChange: (values: string[]) => void }) { - const [draft, setDraft] = useState(value.join('\n')); - return