arcane-os 0.80.0 → 0.82.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +44 -0
- package/README.md +1 -1
- package/browser-runtime/ai/browser-wasm-llm-provider.mjs +97 -19
- package/browser-runtime/ai/dbopfs-model-parts.mjs +12 -5
- package/browser-runtime/ai/dbopfs-model-resources.mjs +2 -5
- package/browser-runtime/core/app-control.mjs +439 -0
- package/docs/reference/ai/browser-speech.md +11 -0
- package/docs/reference/ai/browser-wasm.md +21 -10
- package/docs/reference/cli.md +9 -1
- package/docs/reference/core-native-packaging.md +14 -0
- package/docs/reference/inventory/package-api.json +23 -9
- package/docs/reference/inventory/runtime-components.json +2 -1
- package/docs/reference/native-app-control.md +147 -0
- package/docs/reference/native-decisions.md +74 -16
- package/docs/reference/runtime-components.md +20 -0
- package/docs/reference/sdk-api.md +49 -6
- package/package.json +2 -1
- package/runtime/arcane/components/chat.html +140 -18
- package/runtime/arcane/modules/AI.js +10 -5
- package/src/cli/app-control.mjs +106 -0
- package/src/cli/main.mjs +5 -0
- package/src/core/app-control.mjs +23 -0
- package/src/core/hosts/windows/ArcaneAppControl.cs +525 -0
- package/src/core/hosts/windows/ArcaneCoreProcess.cs +2 -63
- package/src/core/hosts/windows/ArcaneFrameTransport.cs +80 -0
- package/src/core/hosts/windows/ArcaneHost.cs +45 -7
- package/src/core/hosts/windows/ArcaneLauncher.cs +5 -0
- package/src/core/hosts/windows/ArcaneWindowControl.cs +248 -0
- package/src/core/services/decisions.mjs +16 -2
- package/src/core/shared-host.mjs +7 -91
- package/src/core/socket-connection.mjs +109 -0
- package/src/local-ai/decision-tokenizer-worker.mjs +1 -1
- package/src/local-ai/decision-tokenizer.mjs +2 -2
- package/src/local-ai/decisions.mjs +33 -10
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
using System;
|
|
2
|
+
using System.Globalization;
|
|
3
|
+
using System.IO;
|
|
4
|
+
using System.Text;
|
|
5
|
+
using System.Threading.Tasks;
|
|
6
|
+
|
|
7
|
+
namespace Arcane.Core.Hosts.Windows
|
|
8
|
+
{
|
|
9
|
+
/// <summary>The shared native transport framing; callers own stream lifetime and ordered writes.</summary>
|
|
10
|
+
internal static class ArcaneFrameTransport
|
|
11
|
+
{
|
|
12
|
+
private static readonly Encoding Utf8 = new UTF8Encoding(false, true);
|
|
13
|
+
private static readonly Encoding Ascii = Encoding.GetEncoding(
|
|
14
|
+
"us-ascii", EncoderFallback.ExceptionFallback, DecoderFallback.ExceptionFallback);
|
|
15
|
+
|
|
16
|
+
internal static async Task WriteAsync(Stream stream, string json)
|
|
17
|
+
{
|
|
18
|
+
byte[] body = Utf8.GetBytes(json);
|
|
19
|
+
// Length belongs only to the Content-Length transport frame.
|
|
20
|
+
byte[] header = Ascii.GetBytes("Content-Length: " + body.Length.ToString(CultureInfo.InvariantCulture) + "\r\n\r\n");
|
|
21
|
+
await stream.WriteAsync(header, 0, header.Length).ConfigureAwait(false);
|
|
22
|
+
await stream.WriteAsync(body, 0, body.Length).ConfigureAwait(false);
|
|
23
|
+
await stream.FlushAsync().ConfigureAwait(false);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
internal static async Task<string> ReadAsync(Stream stream)
|
|
27
|
+
{
|
|
28
|
+
using (MemoryStream header = new MemoryStream())
|
|
29
|
+
using (MemoryStream body = new MemoryStream())
|
|
30
|
+
{
|
|
31
|
+
try
|
|
32
|
+
{
|
|
33
|
+
byte[] next = new byte[1];
|
|
34
|
+
byte[] separator = new byte[] { 13, 10, 13, 10 };
|
|
35
|
+
int matched = 0;
|
|
36
|
+
while (matched != separator.Length)
|
|
37
|
+
{
|
|
38
|
+
int read = await stream.ReadAsync(next, 0, 1).ConfigureAwait(false);
|
|
39
|
+
if (read == 0)
|
|
40
|
+
{
|
|
41
|
+
if (header.Length == 0) return null;
|
|
42
|
+
throw new EndOfStreamException("Core transport ended during a frame header.");
|
|
43
|
+
}
|
|
44
|
+
header.WriteByte(next[0]);
|
|
45
|
+
matched = next[0] == separator[matched] ? matched + 1 : next[0] == 13 ? 1 : 0;
|
|
46
|
+
}
|
|
47
|
+
byte[] headerBytes = header.ToArray();
|
|
48
|
+
string headerText = Ascii.GetString(headerBytes, 0, headerBytes.Length - separator.Length);
|
|
49
|
+
long? contentLength = null;
|
|
50
|
+
foreach (string line in headerText.Split(new string[] { "\r\n" }, StringSplitOptions.None))
|
|
51
|
+
{
|
|
52
|
+
if (!line.StartsWith("Content-Length:", StringComparison.OrdinalIgnoreCase)) continue;
|
|
53
|
+
long length;
|
|
54
|
+
if (contentLength.HasValue || !Int64.TryParse(line.Substring(15).Trim(), NumberStyles.None,
|
|
55
|
+
CultureInfo.InvariantCulture, out length))
|
|
56
|
+
throw new InvalidDataException("Core frame Content-Length is invalid.");
|
|
57
|
+
contentLength = length;
|
|
58
|
+
}
|
|
59
|
+
if (!contentLength.HasValue) throw new InvalidDataException("Core frame Content-Length is missing.");
|
|
60
|
+
long remaining = contentLength.Value;
|
|
61
|
+
byte[] buffer = new byte[8192];
|
|
62
|
+
while (remaining != 0)
|
|
63
|
+
{
|
|
64
|
+
int read = await stream.ReadAsync(buffer, 0, (int)Math.Min(remaining, buffer.Length)).ConfigureAwait(false);
|
|
65
|
+
if (read == 0) throw new EndOfStreamException("Core transport ended during a frame body.");
|
|
66
|
+
body.Write(buffer, 0, read);
|
|
67
|
+
remaining -= read;
|
|
68
|
+
}
|
|
69
|
+
return Utf8.GetString(body.ToArray());
|
|
70
|
+
}
|
|
71
|
+
catch (Exception error)
|
|
72
|
+
{
|
|
73
|
+
error.Data["frameHeader"] = header.ToArray();
|
|
74
|
+
error.Data["frameBody"] = body.ToArray();
|
|
75
|
+
throw;
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
}
|
|
@@ -16,6 +16,7 @@ namespace Arcane.Core.Hosts.Windows
|
|
|
16
16
|
/// <summary>Selected by the application launcher; no OS layout is inferred.</summary>
|
|
17
17
|
public sealed class ArcaneHostOptions
|
|
18
18
|
{
|
|
19
|
+
public string ApplicationId { get; set; }
|
|
19
20
|
public string ApplicationRoot { get; set; }
|
|
20
21
|
public string StartPath { get; set; }
|
|
21
22
|
public string OriginHost { get; set; }
|
|
@@ -23,6 +24,8 @@ namespace Arcane.Core.Hosts.Windows
|
|
|
23
24
|
public string Title { get; set; }
|
|
24
25
|
public string IconPath { get; set; }
|
|
25
26
|
public string ClassicClientSource { get; set; }
|
|
27
|
+
public string AppControlEndpoint { get; set; }
|
|
28
|
+
public string AppControlSource { get; set; }
|
|
26
29
|
public string CoreExecutable { get; set; }
|
|
27
30
|
public string CoreArguments { get; set; }
|
|
28
31
|
public string CoreWorkingDirectory { get; set; }
|
|
@@ -91,7 +94,7 @@ namespace Arcane.Core.Hosts.Windows
|
|
|
91
94
|
/// to retired documents; errors stay outside ordinary application content.
|
|
92
95
|
/// Callbacks must not synchronously wait for Ready, Completion or CloseAsync.
|
|
93
96
|
/// </summary>
|
|
94
|
-
public sealed class ArcaneHostForm : Form
|
|
97
|
+
public sealed partial class ArcaneHostForm : Form
|
|
95
98
|
{
|
|
96
99
|
private const string CancelRendererRequests = "{\"protocol\":\"arcane/1\",\"type\":\"control\",\"control\":\"requests.cancelAll\"}";
|
|
97
100
|
private readonly ArcaneHostOptions options;
|
|
@@ -124,6 +127,7 @@ namespace Arcane.Core.Hosts.Windows
|
|
|
124
127
|
private bool started;
|
|
125
128
|
private bool documentReady;
|
|
126
129
|
private bool deliveryScheduled;
|
|
130
|
+
private bool browserProcessExited;
|
|
127
131
|
private bool closing;
|
|
128
132
|
private bool closeAllowed;
|
|
129
133
|
|
|
@@ -212,6 +216,7 @@ namespace Arcane.Core.Hosts.Windows
|
|
|
212
216
|
string profile = Path.GetFullPath(options.ProfileDirectory);
|
|
213
217
|
Uri origin = new UriBuilder(Uri.UriSchemeHttps, options.OriginHost).Uri;
|
|
214
218
|
Uri start = new Uri(origin, options.StartPath);
|
|
219
|
+
StartAppControl();
|
|
215
220
|
iconLoading = LoadApplicationIconAsync();
|
|
216
221
|
iconLoading.ContinueWith(ObserveUnexpectedFailure, TaskContinuationOptions.OnlyOnFaulted);
|
|
217
222
|
core = ArcaneCoreProcess.Start(options.CoreExecutable, options.CoreArguments,
|
|
@@ -227,7 +232,13 @@ namespace Arcane.Core.Hosts.Windows
|
|
|
227
232
|
InstallDocumentBridge(generation);
|
|
228
233
|
// The selected generator uses replayRuntimeState:true. Both RPC
|
|
229
234
|
// installation and the shared event owner remain SDK-owned.
|
|
230
|
-
|
|
235
|
+
Task scripts = Task.WhenAll(InstallClassicClientAsync(browser), InstallAppControlAsync(browser));
|
|
236
|
+
try { await scripts; }
|
|
237
|
+
catch
|
|
238
|
+
{
|
|
239
|
+
if (scripts.Exception != null) throw scripts.Exception;
|
|
240
|
+
throw;
|
|
241
|
+
}
|
|
231
242
|
if (closing) return;
|
|
232
243
|
browser.NavigationStarting += NavigationStarting;
|
|
233
244
|
browser.NavigationCompleted += NavigationCompleted;
|
|
@@ -243,6 +254,11 @@ namespace Arcane.Core.Hosts.Windows
|
|
|
243
254
|
finally { initialized.TrySetResult(null); }
|
|
244
255
|
}
|
|
245
256
|
|
|
257
|
+
private async Task InstallClassicClientAsync(CoreWebView2 browser)
|
|
258
|
+
{
|
|
259
|
+
injectedScript = await browser.AddScriptToExecuteOnDocumentCreatedAsync(options.ClassicClientSource);
|
|
260
|
+
}
|
|
261
|
+
|
|
246
262
|
private void NavigationStarting(object sender, CoreWebView2NavigationStartingEventArgs args)
|
|
247
263
|
{
|
|
248
264
|
if (nextGeneration != 0 && navigationId == args.NavigationId) return;
|
|
@@ -551,11 +567,30 @@ namespace Arcane.Core.Hosts.Windows
|
|
|
551
567
|
{
|
|
552
568
|
error = new AggregateException("Reading WebView2 process-failure diagnostics failed.", error, diagnosticError);
|
|
553
569
|
}
|
|
554
|
-
|
|
570
|
+
if (kind == CoreWebView2ProcessFailedKind.BrowserProcessExited)
|
|
571
|
+
{
|
|
572
|
+
// WebView2 has closed this browser. Complete the owned host
|
|
573
|
+
// lifetime without trying to notify its vanished document.
|
|
574
|
+
browserProcessExited = true;
|
|
575
|
+
lock (stateLock)
|
|
576
|
+
{
|
|
577
|
+
if (transportFailure == null) transportFailure = error;
|
|
578
|
+
}
|
|
579
|
+
Report(error);
|
|
580
|
+
ready.TrySetException(error);
|
|
581
|
+
if (bridge != null) bridge.Stop(error);
|
|
582
|
+
if (activeBridge != null) activeBridge.Stop(error);
|
|
583
|
+
BeginClose();
|
|
584
|
+
return;
|
|
585
|
+
}
|
|
586
|
+
// Loss of the top-level renderer ends its bridge.
|
|
555
587
|
// GPU/utility/plugin helpers recover independently; subframe loss,
|
|
556
588
|
// unresponsiveness and unspecified failures remain full diagnostics.
|
|
557
|
-
if (kind == CoreWebView2ProcessFailedKind.
|
|
558
|
-
|
|
589
|
+
if (kind == CoreWebView2ProcessFailedKind.RenderProcessExited)
|
|
590
|
+
{
|
|
591
|
+
AppControlRendererExited();
|
|
592
|
+
CoreFailed(error);
|
|
593
|
+
}
|
|
559
594
|
else Report(error);
|
|
560
595
|
}
|
|
561
596
|
|
|
@@ -665,7 +700,7 @@ namespace Arcane.Core.Hosts.Windows
|
|
|
665
700
|
|
|
666
701
|
private void NotifyTransportFailure()
|
|
667
702
|
{
|
|
668
|
-
if (!documentReady || webView.CoreWebView2 == null || transportFailure == null
|
|
703
|
+
if (closing || browserProcessExited || !documentReady || webView.CoreWebView2 == null || transportFailure == null) return;
|
|
669
704
|
if (failureNotifiedGeneration == generation) return;
|
|
670
705
|
failureNotifiedGeneration = generation;
|
|
671
706
|
Task failureNotification = NotifyTransportFailureAsync(transportFailure);
|
|
@@ -711,6 +746,7 @@ namespace Arcane.Core.Hosts.Windows
|
|
|
711
746
|
if (!started) initialized.TrySetResult(null);
|
|
712
747
|
if (bridge != null) bridge.Stop(new InvalidOperationException("The application window is closing."));
|
|
713
748
|
if (activeBridge != null) activeBridge.Stop(new InvalidOperationException("The application window is closing."));
|
|
749
|
+
StopAppControl();
|
|
714
750
|
shutdown = DrainAndCloseAsync();
|
|
715
751
|
shutdown.ContinueWith(ObserveUnexpectedFailure, TaskContinuationOptions.OnlyOnFaulted);
|
|
716
752
|
}
|
|
@@ -737,6 +773,7 @@ namespace Arcane.Core.Hosts.Windows
|
|
|
737
773
|
catch (Exception error) { ReportTaskFailure(coreLifetime, error); }
|
|
738
774
|
}
|
|
739
775
|
}
|
|
776
|
+
await DrainAppControlAsync();
|
|
740
777
|
// Joining the independent image owner belongs after Core input has
|
|
741
778
|
// closed; image I/O must never delay accepted service work draining.
|
|
742
779
|
if (iconLoading != null)
|
|
@@ -761,10 +798,11 @@ namespace Arcane.Core.Hosts.Windows
|
|
|
761
798
|
ready.TrySetCanceled();
|
|
762
799
|
try
|
|
763
800
|
{
|
|
764
|
-
if (webView.CoreWebView2 != null)
|
|
801
|
+
if (!browserProcessExited && webView.CoreWebView2 != null)
|
|
765
802
|
{
|
|
766
803
|
webView.CoreWebView2.RemoveHostObjectFromScript("arcaneBridge");
|
|
767
804
|
if (injectedScript != null) webView.CoreWebView2.RemoveScriptToExecuteOnDocumentCreated(injectedScript);
|
|
805
|
+
if (appControlScript != null) webView.CoreWebView2.RemoveScriptToExecuteOnDocumentCreated(appControlScript);
|
|
768
806
|
}
|
|
769
807
|
}
|
|
770
808
|
catch (Exception error) { Report(error); }
|
|
@@ -85,8 +85,11 @@ namespace Arcane.Core.Hosts.Windows
|
|
|
85
85
|
? Path.GetFullPath((string)stateRoot)
|
|
86
86
|
: Path.Combine(defaultStateBase, "Arcane", appId);
|
|
87
87
|
OpenDiagnostics(Path.Combine(profileParent, "Diagnostics"));
|
|
88
|
+
Dictionary<string, object> appControl = launchContext.ContainsKey("appControl")
|
|
89
|
+
? RequiredRecord(launchContext, "appControl") : null;
|
|
88
90
|
ArcaneHostOptions options = new ArcaneHostOptions
|
|
89
91
|
{
|
|
92
|
+
ApplicationId = appId,
|
|
90
93
|
ApplicationRoot = Path.GetFullPath(Path.Combine(directory, RequiredString(manifest, "webRoot"))),
|
|
91
94
|
StartPath = RequiredString(manifest, "start"),
|
|
92
95
|
OriginHost = "arcane.local",
|
|
@@ -97,6 +100,8 @@ namespace Arcane.Core.Hosts.Windows
|
|
|
97
100
|
Resizable = OptionalWindowResizable(windowOptions),
|
|
98
101
|
IconPath = String.IsNullOrEmpty(icon) ? null : Path.Combine(directory, icon),
|
|
99
102
|
ClassicClientSource = File.ReadAllText(Path.Combine(directory, RequiredString(client, "source"))),
|
|
103
|
+
AppControlEndpoint = appControl == null ? null : RequiredString(appControl, "endpoint"),
|
|
104
|
+
AppControlSource = appControl == null ? null : File.ReadAllText(Path.Combine(directory, "arcane-app-control.js")),
|
|
100
105
|
CoreExecutable = Path.Combine(directory, "runtime", "ArcaneCore.exe"),
|
|
101
106
|
// Forward the actual host selection separately; Core keeps
|
|
102
107
|
// every explicit launch-record field unchanged.
|
|
@@ -0,0 +1,248 @@
|
|
|
1
|
+
using System;
|
|
2
|
+
using System.Collections.Generic;
|
|
3
|
+
using System.IO;
|
|
4
|
+
using System.Threading;
|
|
5
|
+
using System.Threading.Tasks;
|
|
6
|
+
using Microsoft.Web.WebView2.Core;
|
|
7
|
+
|
|
8
|
+
namespace Arcane.Core.Hosts.Windows
|
|
9
|
+
{
|
|
10
|
+
// The form owns UI-thread dispatch and document lifetime. The pipe owner
|
|
11
|
+
// handles only connections and framed requests, never another Core child.
|
|
12
|
+
public sealed partial class ArcaneHostForm
|
|
13
|
+
{
|
|
14
|
+
private const string AppControlDocumentKey = "Symbol.for('arcane-os.app-control.document')";
|
|
15
|
+
private readonly HashSet<Task> appControlDocumentTasks = new HashSet<Task>();
|
|
16
|
+
private ArcaneAppControl appControl;
|
|
17
|
+
private Task appControlClosing;
|
|
18
|
+
private string appControlScript;
|
|
19
|
+
private string appControlDocument;
|
|
20
|
+
private long appControlGeneration;
|
|
21
|
+
private long appControlDocumentEpoch;
|
|
22
|
+
private ulong appControlNavigationId;
|
|
23
|
+
private bool appControlNavigating;
|
|
24
|
+
|
|
25
|
+
private void StartAppControl()
|
|
26
|
+
{
|
|
27
|
+
if (options.AppControlEndpoint == null) return;
|
|
28
|
+
if (String.IsNullOrEmpty(options.AppControlSource))
|
|
29
|
+
throw new ArgumentException("Supply the SDK-generated app-control source for the selected endpoint.");
|
|
30
|
+
appControl = new ArcaneAppControl(options.AppControlEndpoint, DispatchAppControl, DeliverDiagnostic, Report);
|
|
31
|
+
appControl.Completion.ContinueWith(ObserveUnexpectedFailure, TaskContinuationOptions.OnlyOnFaulted);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
private async Task InstallAppControlAsync(CoreWebView2 browser)
|
|
35
|
+
{
|
|
36
|
+
if (appControl == null) return;
|
|
37
|
+
appControlScript = await browser.AddScriptToExecuteOnDocumentCreatedAsync(
|
|
38
|
+
"globalThis[" + AppControlDocumentKey + "] = globalThis.crypto.randomUUID();");
|
|
39
|
+
if (closing) return;
|
|
40
|
+
browser.NavigationStarting += AppControlNavigationStarting;
|
|
41
|
+
browser.NavigationCompleted += AppControlNavigationCompleted;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
private void StopAppControl()
|
|
45
|
+
{
|
|
46
|
+
if (appControl == null || appControlClosing != null) return;
|
|
47
|
+
appControlClosing = appControl.CloseAsync();
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
private async Task DrainAppControlAsync()
|
|
51
|
+
{
|
|
52
|
+
// Ingress stops in BeginClose. Join only after the existing Core
|
|
53
|
+
// cancellation/close has started, with the UI pump still alive.
|
|
54
|
+
if (appControlClosing != null)
|
|
55
|
+
{
|
|
56
|
+
try { await appControlClosing; }
|
|
57
|
+
catch (Exception error) { ReportTaskFailure(appControlClosing, error); }
|
|
58
|
+
}
|
|
59
|
+
Task documents = Task.WhenAll(appControlDocumentTasks);
|
|
60
|
+
try { await documents; }
|
|
61
|
+
catch (Exception error) { ReportTaskFailure(documents, error); }
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
private void AppControlNavigationStarting(object sender, CoreWebView2NavigationStartingEventArgs args)
|
|
65
|
+
{
|
|
66
|
+
appControlNavigationId = args.NavigationId;
|
|
67
|
+
appControlDocumentEpoch++;
|
|
68
|
+
appControlNavigating = true;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
private void AppControlNavigationCompleted(object sender, CoreWebView2NavigationCompletedEventArgs args)
|
|
72
|
+
{
|
|
73
|
+
if (closing || browserProcessExited || args.NavigationId != appControlNavigationId) return;
|
|
74
|
+
// A cancelled navigation may leave the old document alive. Read its
|
|
75
|
+
// actual creation marker instead of treating every attempt as new.
|
|
76
|
+
Task task = RefreshAppControlDocumentAsync(args.NavigationId, appControlDocumentEpoch);
|
|
77
|
+
appControlDocumentTasks.Add(task);
|
|
78
|
+
task.ContinueWith(AppControlDocumentSettled, CancellationToken.None,
|
|
79
|
+
TaskContinuationOptions.None, TaskScheduler.FromCurrentSynchronizationContext());
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
private void AppControlRendererExited()
|
|
83
|
+
{
|
|
84
|
+
appControlDocumentEpoch++;
|
|
85
|
+
appControlDocument = null;
|
|
86
|
+
appControlNavigating = true;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
private async Task RefreshAppControlDocumentAsync(ulong selectedNavigation, long selectedEpoch)
|
|
90
|
+
{
|
|
91
|
+
string json = await webView.CoreWebView2.ExecuteScriptAsync("globalThis[" + AppControlDocumentKey + "]");
|
|
92
|
+
if (closing || browserProcessExited || selectedNavigation != appControlNavigationId
|
|
93
|
+
|| selectedEpoch != appControlDocumentEpoch) return;
|
|
94
|
+
string current = ArcaneHost.Serializer().DeserializeObject(json) as string;
|
|
95
|
+
if (current == null) throw new InvalidOperationException("The current document has no app-control lifecycle marker.");
|
|
96
|
+
if (current != appControlDocument)
|
|
97
|
+
{
|
|
98
|
+
appControlDocument = current;
|
|
99
|
+
appControlGeneration++;
|
|
100
|
+
}
|
|
101
|
+
appControlNavigating = false;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
private void AppControlDocumentSettled(Task task)
|
|
105
|
+
{
|
|
106
|
+
appControlDocumentTasks.Remove(task);
|
|
107
|
+
if (task.IsFaulted) ReportTaskFailure(task, task.Exception);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
private Task<object> DispatchAppControl(string method, Dictionary<string, object> parameters,
|
|
111
|
+
CancellationToken cancellation)
|
|
112
|
+
{
|
|
113
|
+
AppControlRequest request = new AppControlRequest(this, method, parameters, cancellation);
|
|
114
|
+
try
|
|
115
|
+
{
|
|
116
|
+
// The form's owned message pump stays alive through control and
|
|
117
|
+
// Core drain. No foreground window or desktop input is selected.
|
|
118
|
+
BeginInvoke(new Action(request.Run));
|
|
119
|
+
}
|
|
120
|
+
catch (Exception error) { request.Completion.TrySetException(error); }
|
|
121
|
+
return request.Completion.Task;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
private bool AppControlDocumentReady()
|
|
125
|
+
{
|
|
126
|
+
return !closing && !browserProcessExited && !appControlNavigating
|
|
127
|
+
&& appControlDocument != null && webView.CoreWebView2 != null;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
private Dictionary<string, object> AppControlStatus()
|
|
131
|
+
{
|
|
132
|
+
return new Dictionary<string, object>
|
|
133
|
+
{
|
|
134
|
+
{ "app", new Dictionary<string, object> { { "id", options.ApplicationId }, { "name", options.Title } } },
|
|
135
|
+
{ "platform", "windows" }, { "host", "webview2" },
|
|
136
|
+
{ "url", webView.Source == null ? null : webView.Source.AbsoluteUri },
|
|
137
|
+
{ "documentGeneration", appControlGeneration }, { "ready", AppControlDocumentReady() },
|
|
138
|
+
{ "window", new Dictionary<string, object>
|
|
139
|
+
{ { "title", Text }, { "state", WindowState.ToString() },
|
|
140
|
+
{ "width", ClientSize.Width }, { "height", ClientSize.Height } } },
|
|
141
|
+
{ "operations", new string[] { "status", "inspect", "capture", "act" } }
|
|
142
|
+
};
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
private async Task<object> ExecuteAppControlAsync(string method, Dictionary<string, object> parameters,
|
|
146
|
+
CancellationToken cancellation)
|
|
147
|
+
{
|
|
148
|
+
cancellation.ThrowIfCancellationRequested();
|
|
149
|
+
if (method == "app.control.status") return AppControlStatus();
|
|
150
|
+
if (method != "app.control.inspect" && method != "app.control.capture" && method != "app.control.act")
|
|
151
|
+
throw new ArgumentException("Unknown app-control method: " + method);
|
|
152
|
+
if (!AppControlDocumentReady()) throw new InvalidOperationException("The app document is not ready for control.");
|
|
153
|
+
long selectedGeneration = appControlGeneration;
|
|
154
|
+
string selectedDocument = appControlDocument;
|
|
155
|
+
string selectedUrl = webView.Source == null ? null : webView.Source.AbsoluteUri;
|
|
156
|
+
object expected;
|
|
157
|
+
if (method == "app.control.act" && !parameters.ContainsKey("documentGeneration"))
|
|
158
|
+
throw new ArgumentException("An action requires the documentGeneration returned by status or inspect.");
|
|
159
|
+
if (parameters.TryGetValue("documentGeneration", out expected)
|
|
160
|
+
&& (!(expected is int || expected is long || expected is decimal || expected is double)
|
|
161
|
+
|| Convert.ToDouble(expected) != selectedGeneration))
|
|
162
|
+
throw new InvalidOperationException("The selected app document has been replaced.");
|
|
163
|
+
|
|
164
|
+
object result;
|
|
165
|
+
if (method == "app.control.capture")
|
|
166
|
+
{
|
|
167
|
+
using (MemoryStream image = new MemoryStream())
|
|
168
|
+
{
|
|
169
|
+
await webView.CoreWebView2.CapturePreviewAsync(CoreWebView2CapturePreviewImageFormat.Png, image);
|
|
170
|
+
result = new Dictionary<string, object>
|
|
171
|
+
{
|
|
172
|
+
{ "mimeType", "image/png" }, { "data", Convert.ToBase64String(image.ToArray()) }
|
|
173
|
+
};
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
else
|
|
177
|
+
{
|
|
178
|
+
string operation = method == "app.control.inspect" ? "inspect" : "act";
|
|
179
|
+
// The creation marker is checked inside the executing document.
|
|
180
|
+
// Native generation checks alone cannot stop a queued script
|
|
181
|
+
// from reaching a successor document during navigation.
|
|
182
|
+
string script = "(function(expected,operation,parameters){"
|
|
183
|
+
+ "if(globalThis[" + AppControlDocumentKey + "]!==expected)"
|
|
184
|
+
+ "return {ok:false,error:{code:'APP_CONTROL_DOCUMENT_REPLACED',message:'The selected app document has been replaced.'}};"
|
|
185
|
+
+ "return (" + options.AppControlSource + ")(operation,parameters);})("
|
|
186
|
+
+ ArcaneHost.Serializer().Serialize(selectedDocument) + ","
|
|
187
|
+
+ ArcaneHost.Serializer().Serialize(operation) + ","
|
|
188
|
+
+ ArcaneHost.Serializer().Serialize(parameters) + ")";
|
|
189
|
+
string json = await webView.CoreWebView2.ExecuteScriptAsync(script);
|
|
190
|
+
Dictionary<string, object> envelope = ArcaneHost.Serializer().DeserializeObject(json) as Dictionary<string, object>;
|
|
191
|
+
object ok;
|
|
192
|
+
if (envelope == null || !envelope.TryGetValue("ok", out ok) || !(ok is bool))
|
|
193
|
+
throw new InvalidDataException("The document returned no app-control result.");
|
|
194
|
+
if (!(bool)ok)
|
|
195
|
+
{
|
|
196
|
+
object detail;
|
|
197
|
+
envelope.TryGetValue("error", out detail);
|
|
198
|
+
IOException error = new IOException("The document app-control operation failed.");
|
|
199
|
+
error.Data["documentError"] = detail;
|
|
200
|
+
throw error;
|
|
201
|
+
}
|
|
202
|
+
if (!envelope.TryGetValue("result", out result))
|
|
203
|
+
throw new InvalidDataException("The document omitted the app-control result.");
|
|
204
|
+
}
|
|
205
|
+
// Completion is observed after dispatch even if a caller cancelled.
|
|
206
|
+
// A document change is reported with the actual returned result; it
|
|
207
|
+
// neither retries an action nor claims its effects were reversed.
|
|
208
|
+
if (!AppControlDocumentReady() || appControlGeneration != selectedGeneration || appControlDocument != selectedDocument)
|
|
209
|
+
{
|
|
210
|
+
IOException error = new IOException("The app document changed while the operation was completing.");
|
|
211
|
+
error.Data["result"] = result;
|
|
212
|
+
error.Data["documentGeneration"] = selectedGeneration;
|
|
213
|
+
error.Data["url"] = selectedUrl;
|
|
214
|
+
throw error;
|
|
215
|
+
}
|
|
216
|
+
Dictionary<string, object> record = result as Dictionary<string, object>;
|
|
217
|
+
if (record == null) throw new InvalidDataException("The app-control result must be an object.");
|
|
218
|
+
record["documentGeneration"] = selectedGeneration;
|
|
219
|
+
record["url"] = webView.Source == null ? null : webView.Source.AbsoluteUri;
|
|
220
|
+
return record;
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
private sealed class AppControlRequest
|
|
224
|
+
{
|
|
225
|
+
private readonly ArcaneHostForm window;
|
|
226
|
+
private readonly string method;
|
|
227
|
+
private readonly Dictionary<string, object> parameters;
|
|
228
|
+
private readonly CancellationToken cancellation;
|
|
229
|
+
internal readonly TaskCompletionSource<object> Completion = new TaskCompletionSource<object>();
|
|
230
|
+
|
|
231
|
+
internal AppControlRequest(ArcaneHostForm window, string method,
|
|
232
|
+
Dictionary<string, object> parameters, CancellationToken cancellation)
|
|
233
|
+
{
|
|
234
|
+
this.window = window;
|
|
235
|
+
this.method = method;
|
|
236
|
+
this.parameters = parameters;
|
|
237
|
+
this.cancellation = cancellation;
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
internal async void Run()
|
|
241
|
+
{
|
|
242
|
+
try { Completion.TrySetResult(await window.ExecuteAppControlAsync(method, parameters, cancellation)); }
|
|
243
|
+
catch (OperationCanceledException) { Completion.TrySetCanceled(); }
|
|
244
|
+
catch (Exception error) { Completion.TrySetException(error); }
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
}
|
|
@@ -10,6 +10,7 @@ function sameExecutionTarget(left, right) {
|
|
|
10
10
|
|
|
11
11
|
function sameActivation(left, right) {
|
|
12
12
|
if (!left || !right || !sameExecutionTarget(left.executionTarget, right.executionTarget) || left.assetProjectionId !== right.assetProjectionId) return false;
|
|
13
|
+
if (left.family !== right.family || left.model !== right.model || left.revision !== right.revision || left.dtype !== right.dtype) return false;
|
|
13
14
|
if (left.resourcePaths === right.resourcePaths) return true;
|
|
14
15
|
if (!left.resourcePaths || !right.resourcePaths) return false;
|
|
15
16
|
const entries = Object.entries(left.resourcePaths);
|
|
@@ -32,6 +33,10 @@ export function createNativeDecisionService(configuration = {}, {appRoot = proce
|
|
|
32
33
|
let closing;
|
|
33
34
|
let unsubscribe;
|
|
34
35
|
let selectedActivation = {
|
|
36
|
+
family: configuration.family === undefined ? 'laya' : configuration.family,
|
|
37
|
+
model: configuration.model === undefined ? 'onnx-community/laya-typed-decisions-ONNX' : configuration.model,
|
|
38
|
+
revision: configuration.revision === undefined ? 'main' : configuration.revision,
|
|
39
|
+
dtype: configuration.dtype === undefined ? 'fp32' : configuration.dtype,
|
|
35
40
|
executionTarget: configuration.executionTarget,
|
|
36
41
|
...(configuration.assetProjectionId == null ? {} : {assetProjectionId: configuration.assetProjectionId, resourcePaths: configuration.resourcePaths && {...configuration.resourcePaths}})
|
|
37
42
|
};
|
|
@@ -40,7 +45,7 @@ export function createNativeDecisionService(configuration = {}, {appRoot = proce
|
|
|
40
45
|
|
|
41
46
|
function current() {
|
|
42
47
|
const snapshot = model?.current() ?? {
|
|
43
|
-
family: 'laya', model: configuration.model ?? 'onnx-community/laya-typed-decisions-ONNX',
|
|
48
|
+
family: configuration.family ?? 'laya', model: configuration.model ?? 'onnx-community/laya-typed-decisions-ONNX',
|
|
44
49
|
revision: configuration.revision ?? 'main', dtype: configuration.dtype ?? 'fp32',
|
|
45
50
|
state: closing ? 'disposed' : 'unloaded', loaded: false, busy: Boolean(loading),
|
|
46
51
|
activeRequests: 0, progress: null, error: null, execution: null
|
|
@@ -66,12 +71,19 @@ export function createNativeDecisionService(configuration = {}, {appRoot = proce
|
|
|
66
71
|
|
|
67
72
|
function operationSignal(signal) { return signal ? AbortSignal.any([signal, lifetime.signal]) : lifetime.signal; }
|
|
68
73
|
|
|
69
|
-
function load({
|
|
74
|
+
function load({
|
|
75
|
+
family: requestedFamily, model: requestedModel, revision: requestedRevision, dtype: requestedDtype,
|
|
76
|
+
signal, executionTarget: requestedTarget, assetProjectionId: requestedProjection, resourcePaths: requestedPaths
|
|
77
|
+
} = {}) {
|
|
70
78
|
operationSignal(signal).throwIfAborted();
|
|
71
79
|
if (!context) throw new CoreError({code: 'CORE_NOT_READY', message: 'Register and start the decision service with Core before loading.'});
|
|
72
80
|
const projection = requestedProjection === undefined ? selectedActivation.assetProjectionId : requestedProjection;
|
|
73
81
|
const mapping = requestedPaths === undefined ? selectedActivation.resourcePaths : requestedPaths;
|
|
74
82
|
const selection = {
|
|
83
|
+
family: requestedFamily === undefined ? selectedActivation.family : requestedFamily,
|
|
84
|
+
model: requestedModel === undefined ? selectedActivation.model : requestedModel,
|
|
85
|
+
revision: requestedRevision === undefined ? selectedActivation.revision : requestedRevision,
|
|
86
|
+
dtype: requestedDtype === undefined ? selectedActivation.dtype : requestedDtype,
|
|
75
87
|
executionTarget: requestedTarget === undefined ? selectedActivation.executionTarget : requestedTarget,
|
|
76
88
|
...(projection == null ? {} : {assetProjectionId: projection, resourcePaths: mapping && typeof mapping === 'object' && !Array.isArray(mapping) ? {...mapping} : mapping})
|
|
77
89
|
};
|
|
@@ -116,6 +128,7 @@ export function createNativeDecisionService(configuration = {}, {appRoot = proce
|
|
|
116
128
|
);
|
|
117
129
|
model = createNativeDecisionModel({
|
|
118
130
|
...configuration, paths, onnx: localAI.getONNXRuntime(), modelAssets,
|
|
131
|
+
family: selection.family, model: selection.model, revision: selection.revision, dtype: selection.dtype,
|
|
119
132
|
executionTarget: selection.executionTarget, assetProjectionId: selection.assetProjectionId,
|
|
120
133
|
resourcePaths: selection.resourcePaths, signal: lifetime.signal
|
|
121
134
|
});
|
|
@@ -221,6 +234,7 @@ export function createNativeDecisionService(configuration = {}, {appRoot = proce
|
|
|
221
234
|
'decisions.status': current,
|
|
222
235
|
'decisions.load': function loadRequest(parameters, request) {
|
|
223
236
|
return load({
|
|
237
|
+
family: parameters?.family, model: parameters?.model, revision: parameters?.revision, dtype: parameters?.dtype,
|
|
224
238
|
executionTarget: parameters?.executionTarget, assetProjectionId: parameters?.assetProjectionId,
|
|
225
239
|
resourcePaths: parameters?.resourcePaths, signal: request.signal
|
|
226
240
|
});
|