arcane-os 0.43.0 → 0.45.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 +35 -0
- package/README.md +1 -1
- package/browser-runtime/ai/twin-cloud.mjs +234 -0
- package/docs/reference/ai/twin-cloud.md +124 -0
- package/docs/reference/runtime-components.md +42 -3
- package/docs/reference/runtime-modules.md +93 -1
- package/package.json +2 -2
- package/runtime/arcane/components/chat.html +27 -6
- package/runtime/arcane/components/file-manager.html +111 -5
- package/runtime/arcane/components/markdown-document.html +31 -4
- package/runtime/arcane/components/markdown-editor.html +113 -4
- package/runtime/arcane/entities/Chat.js +6 -4
- package/runtime/arcane/modules/MarkdownMedia.js +211 -0
- package/runtime/arcane/modules/PersistentAIChatSession.js +22 -8
- package/runtime/arcane/modules/PrintView.js +51 -12
|
@@ -0,0 +1,211 @@
|
|
|
1
|
+
import Is from 'strong-type';
|
|
2
|
+
|
|
3
|
+
const is = new Is(false);
|
|
4
|
+
const referencePrefix = 'arcane-media:';
|
|
5
|
+
|
|
6
|
+
/** Return the local record address, or null for an ordinary image URL. */
|
|
7
|
+
export function parseMarkdownMediaReference(reference) {
|
|
8
|
+
if (!is.string(reference) || !reference.startsWith(referencePrefix)) {
|
|
9
|
+
return null;
|
|
10
|
+
}
|
|
11
|
+
const address = reference.substring(referencePrefix.length);
|
|
12
|
+
const separator = address.indexOf('/');
|
|
13
|
+
if (separator < 0) {
|
|
14
|
+
throw new TypeError('A Markdown media reference needs a table and filename.');
|
|
15
|
+
}
|
|
16
|
+
return {
|
|
17
|
+
tableName: decodeURIComponent(address.substring(0, separator)),
|
|
18
|
+
fileName: decodeURIComponent(address.substring(separator + 1))
|
|
19
|
+
};
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Save a complete image in the app's existing DBOPFS database. The returned
|
|
24
|
+
* reference belongs in Markdown; the JSON-compatible record belongs in its
|
|
25
|
+
* separate table so the existing database backup can include it unchanged.
|
|
26
|
+
* Applications own filename reuse, entry association, and export selection.
|
|
27
|
+
* The promise settles the durable write; this operation has no abort option.
|
|
28
|
+
*/
|
|
29
|
+
export async function saveMarkdownMedia({
|
|
30
|
+
blob,
|
|
31
|
+
tableName = 'markdown-media',
|
|
32
|
+
fileName = `${crypto.randomUUID()}.json`
|
|
33
|
+
}) {
|
|
34
|
+
const reference = `${referencePrefix}${encodeURIComponent(tableName)}/${encodeURIComponent(fileName)}`;
|
|
35
|
+
const encoding = new Promise(function encodeImage(resolve, reject) {
|
|
36
|
+
const reader = new FileReader();
|
|
37
|
+
reader.onload = function imageEncoded() {
|
|
38
|
+
resolve(reader.result);
|
|
39
|
+
};
|
|
40
|
+
reader.onerror = function imageEncodingFailed() {
|
|
41
|
+
reject(reader.error || new Error('Image encoding failed.'));
|
|
42
|
+
};
|
|
43
|
+
reader.onabort = function imageEncodingAborted() {
|
|
44
|
+
reject(new DOMException('Image encoding was aborted.', 'AbortError'));
|
|
45
|
+
};
|
|
46
|
+
reader.readAsDataURL(blob);
|
|
47
|
+
});
|
|
48
|
+
const [dataUrl, {default: DBOPFS}] = await Promise.all([
|
|
49
|
+
encoding,
|
|
50
|
+
import('./DBOPFS.js')
|
|
51
|
+
]);
|
|
52
|
+
const mediaType = blob.type || 'application/octet-stream';
|
|
53
|
+
await new DBOPFS().set(tableName, fileName, {mediaType, dataUrl});
|
|
54
|
+
return {
|
|
55
|
+
reference,
|
|
56
|
+
tableName,
|
|
57
|
+
fileName,
|
|
58
|
+
mediaType
|
|
59
|
+
};
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** Read the complete local image for display or application-owned export. */
|
|
63
|
+
export async function readMarkdownMedia(reference) {
|
|
64
|
+
const address = parseMarkdownMediaReference(reference);
|
|
65
|
+
if (!address) {
|
|
66
|
+
throw new TypeError('Expected an arcane-media: reference.');
|
|
67
|
+
}
|
|
68
|
+
const {default: DBOPFS} = await import('./DBOPFS.js');
|
|
69
|
+
const stored = await new DBOPFS().get(address.tableName, address.fileName);
|
|
70
|
+
if (stored === null || stored === undefined) {
|
|
71
|
+
throw new DOMException(`Markdown image was not found: ${reference}`, 'NotFoundError');
|
|
72
|
+
}
|
|
73
|
+
// DBOPFS parses JSONL/NDJSON as record arrays; restoring a parsed backup can
|
|
74
|
+
// wrap its one record again. Never choose one image from multiple records.
|
|
75
|
+
let record = is.string(stored) ? JSON.parse(stored) : stored;
|
|
76
|
+
while (is.array(record) && record.length === 1) record = record[0];
|
|
77
|
+
const encoded = /^data:[^,]*;base64,([\s\S]*)$/u.exec(record?.dataUrl);
|
|
78
|
+
if (!encoded) {
|
|
79
|
+
throw new TypeError(`Markdown image has an unreadable data URL: ${reference}`);
|
|
80
|
+
}
|
|
81
|
+
// Decode locally; a stored string is never passed to Fetch or a network API.
|
|
82
|
+
const decoded = atob(encoded[1]);
|
|
83
|
+
const content = Uint8Array.from(decoded, function imageCharacter(character) {
|
|
84
|
+
return character.charCodeAt(0);
|
|
85
|
+
});
|
|
86
|
+
return new Blob([content], {type: record.mediaType});
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Resolve only local Markdown IMG references beneath a rendered root (including
|
|
91
|
+
* an IMG root). Markdown source and ordinary URLs are never changed. Independent
|
|
92
|
+
* reads start together; ready waits for decoding or display cancellation.
|
|
93
|
+
* Failures reject with AggregateError.failures [{image, reference, reason}];
|
|
94
|
+
* successful siblings remain visible until their owner is destroyed.
|
|
95
|
+
*
|
|
96
|
+
* destroy() prevents late assignments and releases owned object URLs. A print
|
|
97
|
+
* owner calls retain() before copying the rendered DOM and releases its returned
|
|
98
|
+
* idempotent callback after printing, allowing URLs to outlive the source view.
|
|
99
|
+
* Abort promptly rejects display readiness. An already-started DBOPFS read
|
|
100
|
+
* remains observed and settles independently; its late result is not displayed.
|
|
101
|
+
*/
|
|
102
|
+
export function hydrateMarkdownMedia(root, {signal} = {}) {
|
|
103
|
+
const images = [...root.querySelectorAll('img')];
|
|
104
|
+
if (root.localName === 'img') images.unshift(root);
|
|
105
|
+
const owned = [];
|
|
106
|
+
const displayCancellations = new Set();
|
|
107
|
+
let destroyed = false;
|
|
108
|
+
let retainers = 0;
|
|
109
|
+
let destructionReason;
|
|
110
|
+
|
|
111
|
+
function releaseURLs() {
|
|
112
|
+
if (!destroyed || retainers > 0) return;
|
|
113
|
+
for (const {image, url} of owned) {
|
|
114
|
+
if (image.getAttribute('src') === url) image.removeAttribute('src');
|
|
115
|
+
URL.revokeObjectURL(url);
|
|
116
|
+
}
|
|
117
|
+
owned.length = 0;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function destroy() {
|
|
121
|
+
if (destroyed) return;
|
|
122
|
+
destroyed = true;
|
|
123
|
+
destructionReason = signal?.aborted
|
|
124
|
+
? signal.reason
|
|
125
|
+
: new DOMException('Markdown media display was destroyed.', 'AbortError');
|
|
126
|
+
signal?.removeEventListener('abort', destroy);
|
|
127
|
+
for (const cancel of displayCancellations) cancel();
|
|
128
|
+
displayCancellations.clear();
|
|
129
|
+
releaseURLs();
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
function retain() {
|
|
133
|
+
if (destroyed) throw destructionReason;
|
|
134
|
+
retainers += 1;
|
|
135
|
+
let released = false;
|
|
136
|
+
return function releaseMarkdownMedia() {
|
|
137
|
+
if (released) return;
|
|
138
|
+
released = true;
|
|
139
|
+
retainers -= 1;
|
|
140
|
+
releaseURLs();
|
|
141
|
+
};
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
function waitForDisplay(operation) {
|
|
145
|
+
return new Promise(function observeDisplayOperation(resolve, reject) {
|
|
146
|
+
let settled = false;
|
|
147
|
+
function settle(callback, value) {
|
|
148
|
+
if (settled) return;
|
|
149
|
+
settled = true;
|
|
150
|
+
displayCancellations.delete(cancelDisplay);
|
|
151
|
+
callback(value);
|
|
152
|
+
}
|
|
153
|
+
function cancelDisplay() {
|
|
154
|
+
settle(reject, destructionReason);
|
|
155
|
+
}
|
|
156
|
+
displayCancellations.add(cancelDisplay);
|
|
157
|
+
// Both outcomes remain observed after display cancellation; this
|
|
158
|
+
// wait does not claim to cancel the underlying storage or decode.
|
|
159
|
+
Promise.resolve(operation).then(
|
|
160
|
+
function displayOperationFinished(value) {settle(resolve, value);},
|
|
161
|
+
function displayOperationFailed(error) {settle(reject, error);}
|
|
162
|
+
);
|
|
163
|
+
if (destroyed) cancelDisplay();
|
|
164
|
+
});
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
async function loadImage(image, reference) {
|
|
168
|
+
if (destroyed) throw destructionReason;
|
|
169
|
+
const blob = await waitForDisplay(readMarkdownMedia(reference));
|
|
170
|
+
if (destroyed) throw destructionReason;
|
|
171
|
+
const url = URL.createObjectURL(blob);
|
|
172
|
+
owned.push({image, url});
|
|
173
|
+
image.setAttribute('src', url);
|
|
174
|
+
await waitForDisplay(image.decode());
|
|
175
|
+
if (destroyed) throw destructionReason;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
signal?.addEventListener('abort', destroy, {once: true});
|
|
179
|
+
if (signal?.aborted) destroy();
|
|
180
|
+
const pending = [];
|
|
181
|
+
for (const image of images) {
|
|
182
|
+
const reference = image.getAttribute('src');
|
|
183
|
+
if (!is.string(reference) || !reference.startsWith(referencePrefix)) continue;
|
|
184
|
+
// Remove the custom scheme before the first asynchronous storage wait.
|
|
185
|
+
if (!destroyed) image.removeAttribute('src');
|
|
186
|
+
pending.push({image, reference, operation: loadImage(image, reference)});
|
|
187
|
+
}
|
|
188
|
+
const ready = Promise.allSettled(pending.map(function imageOperation(item) {
|
|
189
|
+
return item.operation;
|
|
190
|
+
})).then(function imagesSettled(results) {
|
|
191
|
+
const failures = [];
|
|
192
|
+
for (const [index, result] of results.entries()) {
|
|
193
|
+
if (result.status === 'rejected') {
|
|
194
|
+
failures.push({
|
|
195
|
+
image: pending[index].image,
|
|
196
|
+
reference: pending[index].reference,
|
|
197
|
+
reason: result.reason
|
|
198
|
+
});
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
if (failures.length > 0) {
|
|
202
|
+
const error = new AggregateError(failures.map(function imageFailure(failure) {
|
|
203
|
+
return failure.reason;
|
|
204
|
+
}), 'Some local Markdown images could not be displayed.');
|
|
205
|
+
error.failures = failures;
|
|
206
|
+
throw error;
|
|
207
|
+
}
|
|
208
|
+
if (destroyed) throw destructionReason;
|
|
209
|
+
});
|
|
210
|
+
return {ready, destroy, retain};
|
|
211
|
+
}
|
|
@@ -556,6 +556,7 @@ class PersistentAIChatSession{
|
|
|
556
556
|
/** Persists one model-authored opening while retaining none of its bootstrap request. */
|
|
557
557
|
async open(input){
|
|
558
558
|
const settings=normalizeOpening(input);
|
|
559
|
+
const retainOpening=this.#entity.persist===true;
|
|
559
560
|
const name=this.aiName;
|
|
560
561
|
if(this.#pending){
|
|
561
562
|
throw coded(new Error('A chat request is already active for this session.'),'AI_CHAT_BUSY');
|
|
@@ -565,7 +566,7 @@ class PersistentAIChatSession{
|
|
|
565
566
|
try{
|
|
566
567
|
await this.ready();
|
|
567
568
|
if(this.#historyError)throw this.#historyError;
|
|
568
|
-
if(this.#entity.persist!==true){
|
|
569
|
+
if(!retainOpening||this.#entity.persist!==true){
|
|
569
570
|
throw coded(
|
|
570
571
|
new Error('The model-authored chat opening requires durable chat persistence.'),
|
|
571
572
|
'AI_CHAT_PERSISTENCE_UNAVAILABLE',
|
|
@@ -576,6 +577,15 @@ class PersistentAIChatSession{
|
|
|
576
577
|
settings.message,
|
|
577
578
|
{request:settings.request,signal:settings.signal},
|
|
578
579
|
);
|
|
580
|
+
if(this.#entity.persist!==true){
|
|
581
|
+
throw coded(
|
|
582
|
+
new Error(
|
|
583
|
+
'The model-authored chat opening requires durable chat persistence.',
|
|
584
|
+
{cause:prepared.response},
|
|
585
|
+
),
|
|
586
|
+
'AI_CHAT_PERSISTENCE_UNAVAILABLE',
|
|
587
|
+
);
|
|
588
|
+
}
|
|
579
589
|
await this.#entity.addAIMessage(
|
|
580
590
|
prepared.response.message.content,
|
|
581
591
|
{extractMemory:false,persist:true,name},
|
|
@@ -597,6 +607,7 @@ class PersistentAIChatSession{
|
|
|
597
607
|
|
|
598
608
|
async #requestTurn(input,streamHandlers=null){
|
|
599
609
|
const settings=normalizeSend(input);
|
|
610
|
+
const retainTurn=settings.messagePersist&&Boolean(this.#entity.persist);
|
|
600
611
|
const name=this.aiName;
|
|
601
612
|
if(this.#pending){
|
|
602
613
|
throw coded(new Error('A chat request is already active for this session.'),'AI_CHAT_BUSY');
|
|
@@ -621,7 +632,7 @@ class PersistentAIChatSession{
|
|
|
621
632
|
if(persistence===undefined||submittedIds.has(message.tool_call_id)){
|
|
622
633
|
throw coded(new TypeError('A tool message has no unique pending structural tool call.'),'AI_CHAT_INVALID_TOOL_MESSAGE');
|
|
623
634
|
}
|
|
624
|
-
if(persistence!==settings.messagePersist){
|
|
635
|
+
if(retainTurn&&persistence!==settings.messagePersist){
|
|
625
636
|
throw coded(
|
|
626
637
|
new TypeError('A tool result must use the persistence of its assistant tool call.'),
|
|
627
638
|
'AI_CHAT_INCOHERENT_PERSISTENCE',
|
|
@@ -674,10 +685,10 @@ class PersistentAIChatSession{
|
|
|
674
685
|
);
|
|
675
686
|
}
|
|
676
687
|
}
|
|
677
|
-
if(!
|
|
688
|
+
if(!retainTurn||!this.#entity.persist){
|
|
678
689
|
prepared.rollback();
|
|
679
690
|
prepared=null;
|
|
680
|
-
return assistantDisplayResponse(result,name);
|
|
691
|
+
return {...assistantDisplayResponse(result,name),retained:false};
|
|
681
692
|
}
|
|
682
693
|
await this.#entity.addTurn({
|
|
683
694
|
assistantMessage:result.message,
|
|
@@ -706,10 +717,13 @@ class PersistentAIChatSession{
|
|
|
706
717
|
}
|
|
707
718
|
const transcript=this.#entity.transcript;
|
|
708
719
|
const assistantRecord=transcript.at(-1);
|
|
709
|
-
return
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
720
|
+
return {
|
|
721
|
+
...assistantDisplayResponse(
|
|
722
|
+
committed,name,
|
|
723
|
+
assistantRecord?.role==='assistant'?assistantRecord.timestamp:undefined,
|
|
724
|
+
),
|
|
725
|
+
retained:true,
|
|
726
|
+
};
|
|
713
727
|
}catch(error){
|
|
714
728
|
prepared?.rollback();
|
|
715
729
|
throw error;
|
|
@@ -5,8 +5,9 @@ const registries=new WeakMap();
|
|
|
5
5
|
let fontSequence=0;
|
|
6
6
|
|
|
7
7
|
/**
|
|
8
|
-
* Print current rendered DOM.
|
|
9
|
-
*
|
|
8
|
+
* Print current rendered DOM. prepare(signal) may await explicit-print readiness;
|
|
9
|
+
* other callbacks are synchronous. retain() may return a resource release function.
|
|
10
|
+
* print() reports a browser request, never an output.
|
|
10
11
|
* Native beforeprint cannot await readiness or cancel the browser's dialog.
|
|
11
12
|
*/
|
|
12
13
|
export function createPrintView({
|
|
@@ -17,7 +18,8 @@ export function createPrintView({
|
|
|
17
18
|
priority=0,
|
|
18
19
|
signal,
|
|
19
20
|
onError,
|
|
20
|
-
retain
|
|
21
|
+
retain,
|
|
22
|
+
prepare
|
|
21
23
|
}){
|
|
22
24
|
if(!host?.ownerDocument||!is.function(content)||!is.function(title)
|
|
23
25
|
||!is.function(active)||!is.finite(priority)){
|
|
@@ -27,8 +29,11 @@ export function createPrintView({
|
|
|
27
29
|
||(retain!==undefined&&!is.function(retain))){
|
|
28
30
|
throw new TypeError('Print error and resource-retention callbacks must be functions.');
|
|
29
31
|
}
|
|
32
|
+
if(prepare!==undefined&&!is.function(prepare)){
|
|
33
|
+
throw new TypeError('A print preparation callback must be a function.');
|
|
34
|
+
}
|
|
30
35
|
const registry=documentRegistry(host.ownerDocument);
|
|
31
|
-
const view={host,content,title,active,priority,onError,retain,destroyed:false,pending:null};
|
|
36
|
+
const view={host,content,title,active,priority,onError,retain,prepare,destroyed:false,pending:null};
|
|
32
37
|
registry.views.add(view);
|
|
33
38
|
signal?.addEventListener('abort',destroy,{once:true});
|
|
34
39
|
if(signal?.aborted){
|
|
@@ -98,6 +103,10 @@ function documentRegistry(document){
|
|
|
98
103
|
}
|
|
99
104
|
const session=registry.session||startSession(registry,view);
|
|
100
105
|
if(!session.stage){
|
|
106
|
+
if(view.prepare){
|
|
107
|
+
session.content=readContent(view);
|
|
108
|
+
session.title=readTitle(view);
|
|
109
|
+
}
|
|
101
110
|
capture(registry,session);
|
|
102
111
|
}
|
|
103
112
|
session.requested=true;
|
|
@@ -153,14 +162,8 @@ function startSession(registry,view){
|
|
|
153
162
|
if(session.release&&!is.function(session.release)){
|
|
154
163
|
throw new TypeError('A print resource-retention callback must return a release function.');
|
|
155
164
|
}
|
|
156
|
-
session.content=view
|
|
157
|
-
|
|
158
|
-
throw new TypeError('Print content must be a rendered DOM Node.');
|
|
159
|
-
}
|
|
160
|
-
session.title=view.title();
|
|
161
|
-
if(!is.string(session.title)){
|
|
162
|
-
throw new TypeError('A print title must be a string.');
|
|
163
|
-
}
|
|
165
|
+
session.content=readContent(view);
|
|
166
|
+
session.title=readTitle(view);
|
|
164
167
|
return session;
|
|
165
168
|
}catch(error){
|
|
166
169
|
finishSession(registry,session);
|
|
@@ -168,6 +171,22 @@ function startSession(registry,view){
|
|
|
168
171
|
}
|
|
169
172
|
}
|
|
170
173
|
|
|
174
|
+
function readContent(view){
|
|
175
|
+
const content=view.content();
|
|
176
|
+
if(!content||!is.function(content.cloneNode)){
|
|
177
|
+
throw new TypeError('Print content must be a rendered DOM Node.');
|
|
178
|
+
}
|
|
179
|
+
return content;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
function readTitle(view){
|
|
183
|
+
const title=view.title();
|
|
184
|
+
if(!is.string(title)){
|
|
185
|
+
throw new TypeError('A print title must be a string.');
|
|
186
|
+
}
|
|
187
|
+
return title;
|
|
188
|
+
}
|
|
189
|
+
|
|
171
190
|
async function printRendered(registry,view){
|
|
172
191
|
if(registry.session){
|
|
173
192
|
throw new Error('A rendered print request is already being prepared or displayed.');
|
|
@@ -186,6 +205,23 @@ async function printRendered(registry,view){
|
|
|
186
205
|
finishSession(registry,session);
|
|
187
206
|
return false;
|
|
188
207
|
}
|
|
208
|
+
if(view.prepare){
|
|
209
|
+
await abortable(view.prepare(signal),signal);
|
|
210
|
+
if(session.error){
|
|
211
|
+
throw session.error;
|
|
212
|
+
}
|
|
213
|
+
if(session.requested){
|
|
214
|
+
return true;
|
|
215
|
+
}
|
|
216
|
+
if(signal.aborted||!available(view)){
|
|
217
|
+
finishSession(registry,session);
|
|
218
|
+
return false;
|
|
219
|
+
}
|
|
220
|
+
// Read and capture together: a changing view can reject pending
|
|
221
|
+
// content synchronously without an unobserved readiness gap.
|
|
222
|
+
session.content=readContent(view);
|
|
223
|
+
session.title=readTitle(view);
|
|
224
|
+
}
|
|
189
225
|
capture(registry,session);
|
|
190
226
|
const images=Array.from(session.stage.shadowRoot.querySelectorAll('img'));
|
|
191
227
|
await Promise.all(images.map(function readyImage(image){
|
|
@@ -224,6 +260,9 @@ async function printRendered(registry,view){
|
|
|
224
260
|
if(cancelled){
|
|
225
261
|
return false;
|
|
226
262
|
}
|
|
263
|
+
if(view.prepare&&session.error){
|
|
264
|
+
throw session.error;
|
|
265
|
+
}
|
|
227
266
|
throw error;
|
|
228
267
|
}
|
|
229
268
|
}
|