@k2b/cloud 0.45.0 → 0.46.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/package.json +5 -2
- package/src/_internal/app-queries.ts +73 -0
- package/src/_internal/capabilities.ts +137 -9
- package/src/_internal/define-app.ts +2 -0
- package/src/_internal/registry-validation.ts +17 -4
- package/src/_internal/registry.ts +32 -4
- package/src/_internal/resource-principals.ts +36 -0
- package/src/ai/capabilities.ts +4 -2
- package/src/ai/chat/map-messages.ts +270 -0
- package/src/ai/chat/map-notebook.browser-harness.tsx +28 -0
- package/src/ai/chat/map-notebook.tsx +301 -0
- package/src/ai/chat/map-widget.browser-harness.tsx +71 -0
- package/src/ai/chat/map-widget.css +107 -0
- package/src/ai/chat/map-widget.tsx +1279 -0
- package/src/ai/chat/message-utils.ts +1 -0
- package/src/ai/chat/tool-names.ts +2 -0
- package/src/ai/chat/turn-layout.ts +2 -1
- package/src/ai/chat/turn-view.tsx +17 -1
- package/src/ai/default-tools.ts +3 -0
- package/src/ai/executor.ts +13 -1
- package/src/ai/map-contracts.ts +209 -0
- package/src/ai/map-execution.ts +14 -0
- package/src/ai/map-notebook-save.ts +276 -0
- package/src/ai/map-notebook.ts +32 -0
- package/src/ai/map-opening-hours.ts +40 -0
- package/src/ai/map-routes.ts +84 -0
- package/src/ai/map-service.ts +288 -0
- package/src/ai/map-store.ts +137 -0
- package/src/ai/map-tool.ts +73 -0
- package/src/ai/migrate.ts +3 -0
- package/src/ai/protocol.ts +3 -1
- package/src/ai/routes.ts +2 -0
- package/src/ai/store.ts +19 -6
- package/src/ai/tools.ts +14 -0
- package/src/ai/types.ts +3 -0
- package/src/api/capabilities.ts +90 -12
- package/src/api/mcp.ts +4 -2
- package/src/browser/notifications.ts +25 -8
- package/src/capabilities/discussion-providers.ts +99 -0
- package/src/capabilities/executions.ts +2 -2
- package/src/capabilities/migrate.ts +10 -1
- package/src/capabilities/resource-readers.ts +58 -0
- package/src/capabilities/server-invoke.ts +23 -13
- package/src/capabilities/server.ts +17 -6
- package/src/capabilities/testing.ts +13 -0
- package/src/contracts/capabilities.ts +137 -4
- package/src/contracts/discussions.ts +91 -0
- package/src/contracts/geo-document.ts +60 -0
- package/src/contracts/geo-snapshot.ts +86 -0
- package/src/contracts/geo.ts +14 -0
- package/src/contracts/index.ts +2 -0
- package/src/contracts/outgoing-mail.ts +1 -1
- package/src/contracts/resource-types.ts +21 -0
- package/src/geo/AddressAutocomplete.tsx +213 -0
- package/src/geo/messages.ts +29 -0
- package/src/geo/navigation.ts +25 -0
- package/src/geo/ui.ts +2 -0
- package/src/services/accounts/app.ts +28 -6
- package/src/services/accounts/entities.ts +11 -4
- package/src/services/accounts/groups.ts +10 -4
- package/src/services/accounts/local-groups.ts +87 -38
- package/src/services/accounts/machine-group-management.ts +94 -0
- package/src/services/geo/cache.ts +2 -1
- package/src/services/geo/geoapify.ts +3 -0
- package/src/services/identity/invocation-token.ts +110 -0
- package/src/services/pdf/geo-snapshot.ts +107 -0
- package/src/services/pdf/index.ts +2 -0
- package/src/ssr/AdminArea.tsx +2 -2
- package/src/ssr/AdminLayout.tsx +1 -1
- package/src/ssr/AdminSidebar.tsx +1 -1
- package/src/styles/global.css +2 -0
|
@@ -0,0 +1,270 @@
|
|
|
1
|
+
import { i18n } from "@k2b/stdlib";
|
|
2
|
+
|
|
3
|
+
const messages = i18n.define({
|
|
4
|
+
baseLocale: "en",
|
|
5
|
+
messages: {
|
|
6
|
+
en: {
|
|
7
|
+
places: "Places",
|
|
8
|
+
museum: "Museum",
|
|
9
|
+
changeStop: "Change stop",
|
|
10
|
+
without: "avoiding",
|
|
11
|
+
route: "Route",
|
|
12
|
+
searched: "Places searched",
|
|
13
|
+
calculated: "Route calculated",
|
|
14
|
+
loading: "Loading places and route …",
|
|
15
|
+
here: "Route here",
|
|
16
|
+
nearby: "More nearby",
|
|
17
|
+
save: "Save as Notebook",
|
|
18
|
+
locate: "I am here",
|
|
19
|
+
unknown: "Opening hours unknown",
|
|
20
|
+
source: "Place and hours",
|
|
21
|
+
description: "Description",
|
|
22
|
+
all: "All",
|
|
23
|
+
less: "Show fewer",
|
|
24
|
+
jump: "Jump to map",
|
|
25
|
+
noPlaces: "No places found.",
|
|
26
|
+
expand: "Search within 10 km",
|
|
27
|
+
retry: "Try again",
|
|
28
|
+
failed: "Places or route could not be loaded. Your question and answer are kept.",
|
|
29
|
+
off: "Places and maps are not configured.",
|
|
30
|
+
configure: "Configure places and maps",
|
|
31
|
+
budget: "Map allowance reached",
|
|
32
|
+
person: "Daily allowance",
|
|
33
|
+
cloud: "Monthly budget",
|
|
34
|
+
reset: "Available again",
|
|
35
|
+
limits: "Change allowances",
|
|
36
|
+
insert: "Insert stop",
|
|
37
|
+
optimize: "Optimize stop order",
|
|
38
|
+
options: "Options",
|
|
39
|
+
along: "Eat along the route",
|
|
40
|
+
nav: "Open navigation app",
|
|
41
|
+
discard: "Discard",
|
|
42
|
+
cancel: "Cancel",
|
|
43
|
+
preview: "Preview for",
|
|
44
|
+
undo: "Undo",
|
|
45
|
+
before: "Before",
|
|
46
|
+
after: "After",
|
|
47
|
+
distance: "Distance",
|
|
48
|
+
duration: "Travel time",
|
|
49
|
+
choose: "Which place did you mean?",
|
|
50
|
+
useStop: "Use this stop",
|
|
51
|
+
stopSearch: "Find a place near the route",
|
|
52
|
+
search: "Search",
|
|
53
|
+
category: "Category",
|
|
54
|
+
placeName: "Place name (optional)",
|
|
55
|
+
placeNamePlaceholder: "e.g. waterfall",
|
|
56
|
+
waterfall: "Attractions",
|
|
57
|
+
cafe: "Cafés",
|
|
58
|
+
restaurant: "Restaurants",
|
|
59
|
+
whole: "Whole tour",
|
|
60
|
+
mode: "Travel mode",
|
|
61
|
+
car: "Car",
|
|
62
|
+
bike: "Bike",
|
|
63
|
+
walk: "Walk",
|
|
64
|
+
avoid: "Avoid",
|
|
65
|
+
highways: "Highways",
|
|
66
|
+
tolls: "Tolls",
|
|
67
|
+
ferries: "Ferries",
|
|
68
|
+
scope: "Apply to",
|
|
69
|
+
calculate: "Calculate preview",
|
|
70
|
+
prefs: "Geoapify avoids where possible; this is not guaranteed.",
|
|
71
|
+
from: "From km",
|
|
72
|
+
to: "To km",
|
|
73
|
+
corridor: "Distance from route",
|
|
74
|
+
noRoute: "No usable route. Change a stop or the options.",
|
|
75
|
+
noLocation: "Location denied · allow it in your browser",
|
|
76
|
+
locationFailed: "Location unavailable. Enter a starting place.",
|
|
77
|
+
start: "Starting place",
|
|
78
|
+
go: "Plan route",
|
|
79
|
+
conflict: "This map has changed. Load its current state and choose your change again.",
|
|
80
|
+
reload: "Load current state",
|
|
81
|
+
pending: "Saving …",
|
|
82
|
+
changed: "Changed",
|
|
83
|
+
stops: "Stops",
|
|
84
|
+
noTraffic: "Times without traffic, calculated",
|
|
85
|
+
resumeRoute: "Continue calculation",
|
|
86
|
+
routeLimit:
|
|
87
|
+
"This route needs more credits than the configured limit. Use fewer stops, select address suggestions, or ask an administrator to raise the budget.",
|
|
88
|
+
budgetNumbers: ({ required, available, missing }: { required: string; available: string; missing: string }) =>
|
|
89
|
+
`Required: ${required} credits. Available: ${available}. Missing: ${missing}.`,
|
|
90
|
+
recordedCosts: ({ settled, unknown }: { settled: string; unknown: string }) =>
|
|
91
|
+
`Last attempt: ${settled} credits charged. Reserved with an unknown outcome: ${unknown}.`,
|
|
92
|
+
noInvoiceCap: "Cloud reserves estimated costs; Geoapify does not guarantee a cap on its side.",
|
|
93
|
+
cost: "The route cost more than estimated. Calculation stopped. Continuing can incur further costs.",
|
|
94
|
+
notebook: "Choose notebook",
|
|
95
|
+
inspectNotebook: "Inspect notebook",
|
|
96
|
+
noNotebooks: "No writable notebook found.",
|
|
97
|
+
saved: "Saved in Notebook",
|
|
98
|
+
previousSave: "This map has an earlier save receipt. Open the notebook to find the note before starting another save.",
|
|
99
|
+
resumeSave: "Resume save",
|
|
100
|
+
reconcileSave: "Check save outcome",
|
|
101
|
+
partialNote: "Open partial note",
|
|
102
|
+
saveProgress: "Map save progress",
|
|
103
|
+
saveLoading: "Preparing save …",
|
|
104
|
+
saveCancelled: "Saving stopped. Any saved part is kept. You can resume here.",
|
|
105
|
+
savePaused: "This save is unfinished. Resume to check the note and continue.",
|
|
106
|
+
saveStorage: "Save progress could not be stored in this browser. Allow local storage and try again before continuing.",
|
|
107
|
+
saveLockUnavailable: "This browser cannot protect the save across tabs. Use a browser with Web Locks support.",
|
|
108
|
+
saveLocked: "This map is being saved in another tab. Wait for it to finish, then check again.",
|
|
109
|
+
saveOwnerChanged: "The signed-in account has changed. Reload the page before saving.",
|
|
110
|
+
saveFailed: "The map could not be saved. Try again or open the notebook to check it.",
|
|
111
|
+
saveSourceChanged: "The map source has changed. Reload the map. Open any partial note before starting another save.",
|
|
112
|
+
saveContentChanged: "The note has changed. Open it to review your changes; this save cannot continue.",
|
|
113
|
+
saveInvalid: "The saved progress or map source cannot be used. Open the notebook to recover any saved part.",
|
|
114
|
+
saveTooLarge: "The map title cannot fit in a save request. Shorten it before saving.",
|
|
115
|
+
saveAppendUnknown: "The last part may still be saving. Check the outcome again later. It will not be sent twice.",
|
|
116
|
+
unknownSave: "The note may have been created. Open the notebook to recover it. This save will not be sent again.",
|
|
117
|
+
},
|
|
118
|
+
de: {
|
|
119
|
+
places: "Orte",
|
|
120
|
+
museum: "Museum",
|
|
121
|
+
changeStop: "Stopp ändern",
|
|
122
|
+
without: "ohne",
|
|
123
|
+
route: "Route",
|
|
124
|
+
searched: "Orte gesucht",
|
|
125
|
+
calculated: "Route berechnet",
|
|
126
|
+
loading: "Orte und Route werden geladen …",
|
|
127
|
+
here: "Route hierher",
|
|
128
|
+
nearby: "Mehr in der Nähe",
|
|
129
|
+
save: "Als Notebook speichern",
|
|
130
|
+
locate: "Ich bin hier",
|
|
131
|
+
unknown: "Öffnungszeiten unbekannt",
|
|
132
|
+
source: "Ort und Zeiten",
|
|
133
|
+
description: "Beschreibung",
|
|
134
|
+
all: "Alle",
|
|
135
|
+
less: "Weniger zeigen",
|
|
136
|
+
jump: "Zur Karte",
|
|
137
|
+
noPlaces: "Keine Orte gefunden.",
|
|
138
|
+
expand: "Umkreis 10 km",
|
|
139
|
+
retry: "Erneut versuchen",
|
|
140
|
+
failed: "Die Orte oder Route konnten nicht geladen werden. Deine Frage und die Antwort bleiben stehen.",
|
|
141
|
+
off: "Orte und Karten sind nicht eingerichtet.",
|
|
142
|
+
configure: "Orte und Karten einrichten",
|
|
143
|
+
budget: "Kartengrenze erreicht",
|
|
144
|
+
person: "Tagesgrenze",
|
|
145
|
+
cloud: "Monatsbudget",
|
|
146
|
+
reset: "Wieder verfügbar",
|
|
147
|
+
limits: "Grenzen ändern",
|
|
148
|
+
insert: "Stopp einfügen",
|
|
149
|
+
optimize: "Reihenfolge optimieren",
|
|
150
|
+
options: "Optionen",
|
|
151
|
+
along: "Unterwegs essen",
|
|
152
|
+
nav: "In Navi-App öffnen",
|
|
153
|
+
discard: "Verwerfen",
|
|
154
|
+
cancel: "Abbrechen",
|
|
155
|
+
preview: "Vorschau für",
|
|
156
|
+
undo: "Rückgängig",
|
|
157
|
+
before: "Bisher",
|
|
158
|
+
after: "Neu",
|
|
159
|
+
distance: "Strecke",
|
|
160
|
+
duration: "Fahrzeit",
|
|
161
|
+
choose: "Welcher Ort ist gemeint?",
|
|
162
|
+
useStop: "Als Stopp übernehmen",
|
|
163
|
+
stopSearch: "Ort nahe der Strecke suchen",
|
|
164
|
+
search: "Suchen",
|
|
165
|
+
category: "Kategorie",
|
|
166
|
+
placeName: "Ortsname (optional)",
|
|
167
|
+
placeNamePlaceholder: "z. B. Wasserfall",
|
|
168
|
+
waterfall: "Sehenswürdigkeiten",
|
|
169
|
+
cafe: "Cafés",
|
|
170
|
+
restaurant: "Restaurants",
|
|
171
|
+
whole: "Ganze Tour",
|
|
172
|
+
mode: "Verkehrsmittel",
|
|
173
|
+
car: "Auto",
|
|
174
|
+
bike: "Rad",
|
|
175
|
+
walk: "Zu Fuß",
|
|
176
|
+
avoid: "Meiden",
|
|
177
|
+
highways: "Autobahn",
|
|
178
|
+
tolls: "Maut",
|
|
179
|
+
ferries: "Fähren",
|
|
180
|
+
scope: "Gilt für",
|
|
181
|
+
calculate: "Vorschau berechnen",
|
|
182
|
+
prefs: "Geoapify meidet so gut es geht; garantiert ist es nicht.",
|
|
183
|
+
from: "Von km",
|
|
184
|
+
to: "Bis km",
|
|
185
|
+
corridor: "Abstand zur Strecke",
|
|
186
|
+
noRoute: "Keine befahrbare Strecke. Ändere einen Stopp oder die Optionen.",
|
|
187
|
+
noLocation: "Standort nicht erlaubt · im Browser freigeben",
|
|
188
|
+
locationFailed: "Standort nicht verfügbar. Gib einen Startort ein.",
|
|
189
|
+
start: "Startort",
|
|
190
|
+
go: "Route planen",
|
|
191
|
+
conflict: "Diese Karte wurde inzwischen geändert. Aktuellen Stand laden und die Änderung erneut wählen.",
|
|
192
|
+
reload: "Aktuellen Stand laden",
|
|
193
|
+
pending: "Wird gespeichert …",
|
|
194
|
+
changed: "Geändert",
|
|
195
|
+
stops: "Stopps",
|
|
196
|
+
noTraffic: "Zeiten ohne Verkehr, gerechnet",
|
|
197
|
+
resumeRoute: "Berechnung fortsetzen",
|
|
198
|
+
routeLimit:
|
|
199
|
+
"Diese Route benötigt mehr Credits als die eingestellte Grenze. Wähle weniger Stopps oder Adressvorschläge, oder lass das Budget durch einen Administrator erhöhen.",
|
|
200
|
+
budgetNumbers: ({ required, available, missing }) => `Benötigt: ${required} Credits. Verfügbar: ${available}. Fehlend: ${missing}.`,
|
|
201
|
+
recordedCosts: ({ settled, unknown }) => `Letzter Versuch: ${settled} Credits gebucht. Mit unklarem Ergebnis reserviert: ${unknown}.`,
|
|
202
|
+
noInvoiceCap: "Cloud reserviert geschätzte Kosten; Geoapify garantiert auf seiner Seite keine Obergrenze.",
|
|
203
|
+
cost: "Die Route war teurer als geschätzt. Die Berechnung wurde gestoppt. Beim Fortsetzen können weitere Kosten entstehen.",
|
|
204
|
+
notebook: "Notebook auswählen",
|
|
205
|
+
inspectNotebook: "Notebook prüfen",
|
|
206
|
+
noNotebooks: "Kein beschreibbares Notebook gefunden.",
|
|
207
|
+
saved: "Im Notebook gespeichert",
|
|
208
|
+
previousSave:
|
|
209
|
+
"Für diese Karte liegt ein früherer Speicherbeleg vor. Öffne das Notebook, um die Notiz zu finden, bevor du erneut speicherst.",
|
|
210
|
+
resumeSave: "Speichern fortsetzen",
|
|
211
|
+
reconcileSave: "Speicherstand prüfen",
|
|
212
|
+
partialNote: "Teilweise gespeicherte Notiz öffnen",
|
|
213
|
+
saveProgress: "Fortschritt beim Speichern der Karte",
|
|
214
|
+
saveLoading: "Speichern wird vorbereitet …",
|
|
215
|
+
saveCancelled: "Speichern gestoppt. Bereits gespeicherte Teile bleiben erhalten. Du kannst hier fortsetzen.",
|
|
216
|
+
savePaused: "Das Speichern ist noch nicht abgeschlossen. Beim Fortsetzen wird die Notiz geprüft.",
|
|
217
|
+
saveStorage:
|
|
218
|
+
"Der Fortschritt konnte in diesem Browser nicht gespeichert werden. Erlaube lokalen Speicher und versuche es erneut, bevor du fortsetzt.",
|
|
219
|
+
saveLockUnavailable:
|
|
220
|
+
"Dieser Browser kann das Speichern nicht über mehrere Tabs absichern. Verwende einen Browser mit Web-Locks-Unterstützung.",
|
|
221
|
+
saveLocked: "Diese Karte wird in einem anderen Tab gespeichert. Warte, bis er fertig ist, und prüfe erneut.",
|
|
222
|
+
saveOwnerChanged: "Das angemeldete Konto hat sich geändert. Lade die Seite vor dem Speichern neu.",
|
|
223
|
+
saveFailed: "Die Karte konnte nicht gespeichert werden. Versuche es erneut oder prüfe das Notebook.",
|
|
224
|
+
saveSourceChanged:
|
|
225
|
+
"Die Kartenquelle hat sich geändert. Lade die Karte neu. Öffne eine teilweise gespeicherte Notiz, bevor du erneut speicherst.",
|
|
226
|
+
saveContentChanged:
|
|
227
|
+
"Die Notiz hat sich geändert. Öffne sie, um deine Änderungen zu prüfen. Dieser Speichervorgang kann nicht fortgesetzt werden.",
|
|
228
|
+
saveInvalid:
|
|
229
|
+
"Der gespeicherte Fortschritt oder die Kartenquelle ist nicht verwendbar. Öffne das Notebook, um bereits gespeicherte Teile zu finden.",
|
|
230
|
+
saveTooLarge: "Der Kartentitel passt nicht in eine Speicheranfrage. Kürze ihn vor dem Speichern.",
|
|
231
|
+
saveAppendUnknown:
|
|
232
|
+
"Der letzte Teil wird möglicherweise noch gespeichert. Prüfe den Stand später erneut. Er wird nicht doppelt gesendet.",
|
|
233
|
+
unknownSave:
|
|
234
|
+
"Die Notiz wurde möglicherweise erstellt. Öffne das Notebook, um sie zu finden. Dieser Speichervorgang wird nicht erneut gesendet.",
|
|
235
|
+
},
|
|
236
|
+
},
|
|
237
|
+
});
|
|
238
|
+
|
|
239
|
+
export const mapMessages = (locale: string) => messages.resolve([locale]).t;
|
|
240
|
+
|
|
241
|
+
/** Helper codes are internal; owning capabilities may supply a final human message. */
|
|
242
|
+
export const mapSaveMessage = (locale: string, code?: string, message?: string): string => {
|
|
243
|
+
const t = mapMessages(locale);
|
|
244
|
+
switch (code) {
|
|
245
|
+
case "source_changed":
|
|
246
|
+
return t.saveSourceChanged;
|
|
247
|
+
case "checkpoint_failed":
|
|
248
|
+
case "persistence_failed":
|
|
249
|
+
return t.saveStorage;
|
|
250
|
+
case "content_changed":
|
|
251
|
+
case "note_moved":
|
|
252
|
+
case "unexpected_receipt":
|
|
253
|
+
return t.saveContentChanged;
|
|
254
|
+
case "invalid_checkpoint":
|
|
255
|
+
case "invalid_title":
|
|
256
|
+
return t.saveInvalid;
|
|
257
|
+
case "request_too_large":
|
|
258
|
+
return t.saveTooLarge;
|
|
259
|
+
case "create_outcome_unknown":
|
|
260
|
+
return t.unknownSave;
|
|
261
|
+
case "append_outcome_unknown":
|
|
262
|
+
return t.saveAppendUnknown;
|
|
263
|
+
case "cancelled":
|
|
264
|
+
return t.saveCancelled;
|
|
265
|
+
case "save_failed":
|
|
266
|
+
return t.saveFailed;
|
|
267
|
+
default:
|
|
268
|
+
return message || t.savePaused;
|
|
269
|
+
}
|
|
270
|
+
};
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
/** Focused save fixture: real controls and persistence without starting the map renderer. */
|
|
2
|
+
import { Button, LocaleProvider, useLocale } from "@k2b/ui";
|
|
3
|
+
import { createSignal, Show } from "solid-js";
|
|
4
|
+
import { render } from "solid-js/web";
|
|
5
|
+
import type { AiMap } from "../map-contracts";
|
|
6
|
+
import { mapMessages } from "./map-messages";
|
|
7
|
+
import { MapNotebookSave } from "./map-notebook";
|
|
8
|
+
|
|
9
|
+
const snapshot: { map: AiMap } = JSON.parse(document.getElementById("snapshot")!.textContent!);
|
|
10
|
+
function SaveFixture() {
|
|
11
|
+
const [saving, setSaving] = createSignal(false);
|
|
12
|
+
const locale = useLocale();
|
|
13
|
+
return (
|
|
14
|
+
<section class="ai-map">
|
|
15
|
+
<Show when={saving()} fallback={<Button onClick={() => setSaving(true)}>{mapMessages(locale()).save}</Button>}>
|
|
16
|
+
<MapNotebookSave map={snapshot.map} onCancel={() => setSaving(false)} />
|
|
17
|
+
</Show>
|
|
18
|
+
</section>
|
|
19
|
+
);
|
|
20
|
+
}
|
|
21
|
+
render(
|
|
22
|
+
() => (
|
|
23
|
+
<LocaleProvider locale={document.documentElement.lang}>
|
|
24
|
+
<SaveFixture />
|
|
25
|
+
</LocaleProvider>
|
|
26
|
+
),
|
|
27
|
+
document.getElementById("fixture-mount")!,
|
|
28
|
+
);
|
|
@@ -0,0 +1,301 @@
|
|
|
1
|
+
import { Button, ButtonLink, ProgressBar, Select, useLocale } from "@k2b/ui";
|
|
2
|
+
import { createEffect, createSignal, onCleanup, onMount, Show } from "solid-js";
|
|
3
|
+
import { z } from "zod";
|
|
4
|
+
import { invokeCapabilityWithDataSchema } from "../../capabilities/client";
|
|
5
|
+
import { cloudResourceHref } from "../../contracts/resource-href";
|
|
6
|
+
import type { AiMap } from "../map-contracts";
|
|
7
|
+
import { type MapNotebookSaveCheckpoint, MapNotebookSaveCheckpointSchema, saveMapNotebook } from "../map-notebook-save";
|
|
8
|
+
import { mapMessages, mapSaveMessage } from "./map-messages";
|
|
9
|
+
|
|
10
|
+
const notebooks = z.array(z.object({ id: z.string(), name: z.string() }));
|
|
11
|
+
const legacySchema = z.object({
|
|
12
|
+
status: z.enum(["pending", "unknown", "saved"]),
|
|
13
|
+
notebookId: z.string().regex(/^[A-Za-z0-9]{6}$/),
|
|
14
|
+
href: z.string().optional(),
|
|
15
|
+
});
|
|
16
|
+
const snapshotSchema = z.object({ content: z.string(), revision: z.number().int(), ownerUserId: z.string().min(1) });
|
|
17
|
+
const storedSchema = z
|
|
18
|
+
.object({
|
|
19
|
+
checkpoint: MapNotebookSaveCheckpointSchema,
|
|
20
|
+
writeId: z.uuid().optional(),
|
|
21
|
+
status: z.enum(["paused", "saved", "cancelled", "conflict", "unknown", "error"]),
|
|
22
|
+
code: z.string().optional(),
|
|
23
|
+
message: z.string().optional(),
|
|
24
|
+
})
|
|
25
|
+
.strict()
|
|
26
|
+
.refine(
|
|
27
|
+
(stored) =>
|
|
28
|
+
stored.status !== "saved" ||
|
|
29
|
+
Boolean(stored.checkpoint.noteId && !stored.checkpoint.pending && stored.checkpoint.completed === stored.checkpoint.total),
|
|
30
|
+
);
|
|
31
|
+
|
|
32
|
+
export function MapNotebookSave(props: { map: AiMap; onCancel: () => void }) {
|
|
33
|
+
const source = { id: props.map.id, conversationId: props.map.conversationId, revision: props.map.revision };
|
|
34
|
+
const sourceChanged = () =>
|
|
35
|
+
props.map.id !== source.id || props.map.conversationId !== source.conversationId || props.map.revision !== source.revision;
|
|
36
|
+
const locale = useLocale();
|
|
37
|
+
const t = () => mapMessages(locale());
|
|
38
|
+
const [selected, setSelected] = createSignal<string | null>(null);
|
|
39
|
+
const [error, setError] = createSignal("");
|
|
40
|
+
const [busy, setBusy] = createSignal(false);
|
|
41
|
+
const [ready, setReady] = createSignal(false);
|
|
42
|
+
const [checkpoint, setCheckpoint] = createSignal<MapNotebookSaveCheckpoint>();
|
|
43
|
+
const [legacy, setLegacy] = createSignal(false);
|
|
44
|
+
const [status, setStatus] = createSignal<z.infer<typeof storedSchema>["status"]>();
|
|
45
|
+
let controller: AbortController | undefined;
|
|
46
|
+
let storageKey = "";
|
|
47
|
+
let sourceKey = "";
|
|
48
|
+
let unpersisted: z.infer<typeof storedSchema> | undefined;
|
|
49
|
+
let observedRaw: string | null = null;
|
|
50
|
+
let unpersistedBases: (string | null)[] = [];
|
|
51
|
+
const noteHref = () => (checkpoint()?.noteId ? cloudResourceHref({ type: "notebooks.note", id: checkpoint()!.noteId! }) : undefined);
|
|
52
|
+
const notebookHref = () => (selected() ? cloudResourceHref({ type: "notebooks.notebook", id: selected()! }) : undefined);
|
|
53
|
+
const unknownCreate = () => checkpoint()?.pending?.kind === "create";
|
|
54
|
+
const saved = () => status() === "saved" && !sourceChanged();
|
|
55
|
+
const failureMessage = (cause: unknown) =>
|
|
56
|
+
cause instanceof Error && !(cause instanceof z.ZodError) && !(cause instanceof SyntaxError) && !(cause instanceof TypeError)
|
|
57
|
+
? cause.message
|
|
58
|
+
: t().saveFailed;
|
|
59
|
+
const snapshot = async (signal: AbortSignal) => {
|
|
60
|
+
const response = await fetch(`/api/ai/conversations/${encodeURIComponent(source.conversationId)}/maps/${source.id}/export`, {
|
|
61
|
+
signal,
|
|
62
|
+
});
|
|
63
|
+
if (!response.ok) throw new Error(t().saveFailed);
|
|
64
|
+
// WebKit can reject invalid JSON with a DOMException named SyntaxError,
|
|
65
|
+
// which is not instanceof SyntaxError. Normalize at the response boundary.
|
|
66
|
+
const payload: unknown = await response.json().catch(() => {
|
|
67
|
+
throw new Error(t().saveFailed);
|
|
68
|
+
});
|
|
69
|
+
const value = snapshotSchema.parse(payload);
|
|
70
|
+
if (value.revision !== source.revision) throw new Error(t().conflict);
|
|
71
|
+
return value;
|
|
72
|
+
};
|
|
73
|
+
const restore = () => {
|
|
74
|
+
// A failed write may have reached storage. Only replace our own observed values;
|
|
75
|
+
// a different receipt belongs to a later lock holder and always takes precedence.
|
|
76
|
+
// Each write has its own identity, even when two tabs begin with identical helper checkpoints.
|
|
77
|
+
const raw = localStorage.getItem(storageKey);
|
|
78
|
+
observedRaw = raw;
|
|
79
|
+
const pending = unpersisted && unpersistedBases.includes(raw) ? unpersisted : undefined;
|
|
80
|
+
if (!pending) {
|
|
81
|
+
unpersisted = undefined;
|
|
82
|
+
unpersistedBases = [];
|
|
83
|
+
}
|
|
84
|
+
if (raw === null && !pending) {
|
|
85
|
+
// The old flow used an unscoped session receipt and different create keys.
|
|
86
|
+
// Do not silently abandon it or attribute it to a new owner: recover in Notebook.
|
|
87
|
+
const previous = sessionStorage.getItem(`cloud.ai.map-save:${source.id}:${source.revision}`);
|
|
88
|
+
if (previous !== null) {
|
|
89
|
+
const value = legacySchema.parse(JSON.parse(previous));
|
|
90
|
+
setSelected(value.notebookId);
|
|
91
|
+
setLegacy(true);
|
|
92
|
+
setError(t().previousSave);
|
|
93
|
+
}
|
|
94
|
+
return;
|
|
95
|
+
}
|
|
96
|
+
const stored = pending ?? storedSchema.parse(JSON.parse(raw!));
|
|
97
|
+
if (stored.checkpoint.sourceKey !== sourceKey) throw new Error(t().saveStorage);
|
|
98
|
+
setCheckpoint(stored.checkpoint);
|
|
99
|
+
setSelected(stored.checkpoint.notebookId);
|
|
100
|
+
setStatus(stored.status);
|
|
101
|
+
if (stored.status !== "saved")
|
|
102
|
+
setError(stored.checkpoint.pending?.kind === "create" ? t().unknownSave : mapSaveMessage(locale(), stored.code, stored.message));
|
|
103
|
+
};
|
|
104
|
+
const persist = (next: MapNotebookSaveCheckpoint, state: z.infer<typeof storedSchema>["status"], code?: string, message?: string) => {
|
|
105
|
+
setCheckpoint(next);
|
|
106
|
+
setSelected(next.notebookId);
|
|
107
|
+
if (!unpersisted) unpersistedBases = [observedRaw];
|
|
108
|
+
unpersisted = { checkpoint: next, writeId: crypto.randomUUID(), status: state, code, message };
|
|
109
|
+
const serialized = JSON.stringify(unpersisted);
|
|
110
|
+
const before = localStorage.getItem(storageKey);
|
|
111
|
+
unpersistedBases = [before, serialized];
|
|
112
|
+
localStorage.setItem(storageKey, serialized);
|
|
113
|
+
observedRaw = serialized;
|
|
114
|
+
unpersisted = undefined;
|
|
115
|
+
unpersistedBases = [];
|
|
116
|
+
};
|
|
117
|
+
const initialize = async () => {
|
|
118
|
+
if (busy()) return;
|
|
119
|
+
setBusy(true);
|
|
120
|
+
setError("");
|
|
121
|
+
controller = new AbortController();
|
|
122
|
+
try {
|
|
123
|
+
const value = await snapshot(controller.signal);
|
|
124
|
+
// Identity comes from the authorized export, never from a browser account hint.
|
|
125
|
+
const nextSourceKey = JSON.stringify([value.ownerUserId, source.conversationId, source.id, source.revision]);
|
|
126
|
+
if (sourceKey && nextSourceKey !== sourceKey) throw new Error(t().saveOwnerChanged);
|
|
127
|
+
sourceKey = nextSourceKey;
|
|
128
|
+
storageKey = `cloud.ai.map-save:v1:${sourceKey}`;
|
|
129
|
+
try {
|
|
130
|
+
restore();
|
|
131
|
+
} catch {
|
|
132
|
+
throw new Error(t().saveStorage);
|
|
133
|
+
}
|
|
134
|
+
if (!navigator.locks) throw new Error(t().saveLockUnavailable);
|
|
135
|
+
setReady(true);
|
|
136
|
+
} catch (cause) {
|
|
137
|
+
setError(controller.signal.aborted ? t().saveCancelled : failureMessage(cause));
|
|
138
|
+
} finally {
|
|
139
|
+
setBusy(false);
|
|
140
|
+
}
|
|
141
|
+
};
|
|
142
|
+
createEffect(() => {
|
|
143
|
+
if (sourceChanged()) controller?.abort();
|
|
144
|
+
});
|
|
145
|
+
onMount(() => void initialize());
|
|
146
|
+
onCleanup(() => controller?.abort());
|
|
147
|
+
const save = async () => {
|
|
148
|
+
if (sourceChanged() || !ready() || !selected() || busy() || legacy() || saved() || unknownCreate() || status() === "conflict") return;
|
|
149
|
+
setBusy(true);
|
|
150
|
+
setError("");
|
|
151
|
+
controller = new AbortController();
|
|
152
|
+
const signal = controller.signal;
|
|
153
|
+
try {
|
|
154
|
+
await navigator.locks.request(storageKey, { ifAvailable: true }, async (lock) => {
|
|
155
|
+
if (!lock) {
|
|
156
|
+
setError(t().saveLocked);
|
|
157
|
+
return;
|
|
158
|
+
}
|
|
159
|
+
// Another tab may have advanced or completed this save since the panel opened.
|
|
160
|
+
try {
|
|
161
|
+
restore();
|
|
162
|
+
if (unpersisted) persist(unpersisted.checkpoint, unpersisted.status, unpersisted.code, unpersisted.message);
|
|
163
|
+
} catch {
|
|
164
|
+
setReady(false);
|
|
165
|
+
throw new Error(t().saveStorage);
|
|
166
|
+
}
|
|
167
|
+
if (legacy() || saved() || unknownCreate() || status() === "conflict") return;
|
|
168
|
+
setError("");
|
|
169
|
+
const value = await snapshot(signal);
|
|
170
|
+
if (JSON.stringify([value.ownerUserId, source.conversationId, source.id, value.revision]) !== sourceKey)
|
|
171
|
+
throw new Error(t().saveOwnerChanged);
|
|
172
|
+
let persistenceFailed = false;
|
|
173
|
+
const result = await saveMapNotebook({
|
|
174
|
+
content: value.content,
|
|
175
|
+
notebookId: selected()!,
|
|
176
|
+
sourceKey,
|
|
177
|
+
checkpoint: checkpoint(),
|
|
178
|
+
signal,
|
|
179
|
+
onCheckpoint: (next) => {
|
|
180
|
+
try {
|
|
181
|
+
persist(next, "paused");
|
|
182
|
+
} catch {
|
|
183
|
+
persistenceFailed = true;
|
|
184
|
+
throw new Error(t().saveStorage);
|
|
185
|
+
}
|
|
186
|
+
},
|
|
187
|
+
});
|
|
188
|
+
setCheckpoint(result.checkpoint);
|
|
189
|
+
setStatus(result.status);
|
|
190
|
+
setError(
|
|
191
|
+
persistenceFailed
|
|
192
|
+
? t().saveStorage
|
|
193
|
+
: result.status === "saved"
|
|
194
|
+
? ""
|
|
195
|
+
: result.checkpoint?.pending?.kind === "create"
|
|
196
|
+
? t().unknownSave
|
|
197
|
+
: result.status === "cancelled"
|
|
198
|
+
? t().saveCancelled
|
|
199
|
+
: mapSaveMessage(locale(), result.code, result.message),
|
|
200
|
+
);
|
|
201
|
+
if (result.checkpoint) {
|
|
202
|
+
try {
|
|
203
|
+
persist(
|
|
204
|
+
result.checkpoint,
|
|
205
|
+
result.status,
|
|
206
|
+
persistenceFailed ? "persistence_failed" : result.status === "cancelled" ? "cancelled" : result.code,
|
|
207
|
+
persistenceFailed ? undefined : result.message,
|
|
208
|
+
);
|
|
209
|
+
} catch {
|
|
210
|
+
setStatus("error");
|
|
211
|
+
setError(t().saveStorage);
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
});
|
|
215
|
+
} catch (cause) {
|
|
216
|
+
setError(signal.aborted ? t().saveCancelled : failureMessage(cause));
|
|
217
|
+
} finally {
|
|
218
|
+
setBusy(false);
|
|
219
|
+
}
|
|
220
|
+
};
|
|
221
|
+
const action = () =>
|
|
222
|
+
unknownCreate() ? t().save : checkpoint() ? (checkpoint()?.pending ? t().reconcileSave : t().resumeSave) : t().save;
|
|
223
|
+
return (
|
|
224
|
+
<div class="ai-map__save">
|
|
225
|
+
<Show
|
|
226
|
+
when={!saved()}
|
|
227
|
+
fallback={
|
|
228
|
+
<div role="status">
|
|
229
|
+
<ButtonLink href={noteHref()!} variant="ghost">
|
|
230
|
+
{t().saved}
|
|
231
|
+
</ButtonLink>
|
|
232
|
+
</div>
|
|
233
|
+
}
|
|
234
|
+
>
|
|
235
|
+
<Select
|
|
236
|
+
label={t().notebook}
|
|
237
|
+
value={selected()}
|
|
238
|
+
onValueChange={setSelected}
|
|
239
|
+
disabled={sourceChanged() || !ready() || busy() || legacy() || Boolean(checkpoint())}
|
|
240
|
+
loadOptions={async (query, signal) => {
|
|
241
|
+
const result = await invokeCapabilityWithDataSchema(
|
|
242
|
+
{
|
|
243
|
+
kind: "query",
|
|
244
|
+
appId: "notebooks",
|
|
245
|
+
capabilityId: "notebook.list",
|
|
246
|
+
input: { query, minimumPermission: "write", limit: 100 },
|
|
247
|
+
signal,
|
|
248
|
+
},
|
|
249
|
+
notebooks,
|
|
250
|
+
);
|
|
251
|
+
if (!result.ok) throw new Error(result.error.message);
|
|
252
|
+
return result.data.data.map((n) => ({ value: n.id, label: n.name }));
|
|
253
|
+
}}
|
|
254
|
+
/>
|
|
255
|
+
<Show when={checkpoint()}>
|
|
256
|
+
<div role="status" aria-live="polite">
|
|
257
|
+
<ProgressBar
|
|
258
|
+
label={t().saveProgress}
|
|
259
|
+
value={checkpoint()!.total ? (checkpoint()!.completed / checkpoint()!.total) * 100 : 0}
|
|
260
|
+
showValue
|
|
261
|
+
/>
|
|
262
|
+
</div>
|
|
263
|
+
</Show>
|
|
264
|
+
<div class="ai-map__actions">
|
|
265
|
+
<Show
|
|
266
|
+
when={ready()}
|
|
267
|
+
fallback={
|
|
268
|
+
<Button size="sm" disabled={busy() || sourceChanged()} onClick={() => void initialize()}>
|
|
269
|
+
{busy() ? t().saveLoading : t().retry}
|
|
270
|
+
</Button>
|
|
271
|
+
}
|
|
272
|
+
>
|
|
273
|
+
<Button
|
|
274
|
+
size="sm"
|
|
275
|
+
disabled={sourceChanged() || !selected() || busy() || legacy() || unknownCreate() || status() === "conflict"}
|
|
276
|
+
onClick={() => void save()}
|
|
277
|
+
>
|
|
278
|
+
{busy() ? t().pending : action()}
|
|
279
|
+
</Button>
|
|
280
|
+
</Show>
|
|
281
|
+
<Button size="sm" variant="ghost" onClick={() => (busy() ? controller?.abort() : props.onCancel())}>
|
|
282
|
+
{t().cancel}
|
|
283
|
+
</Button>
|
|
284
|
+
</div>
|
|
285
|
+
</Show>
|
|
286
|
+
<Show when={sourceChanged() || error()}>
|
|
287
|
+
<p role="alert">{sourceChanged() ? t().saveSourceChanged : error()}</p>
|
|
288
|
+
</Show>
|
|
289
|
+
<Show when={noteHref() && !saved()}>
|
|
290
|
+
<ButtonLink size="sm" variant="ghost" href={noteHref()!}>
|
|
291
|
+
{t().partialNote}
|
|
292
|
+
</ButtonLink>
|
|
293
|
+
</Show>
|
|
294
|
+
<Show when={notebookHref() && (checkpoint() || legacy()) && !saved()}>
|
|
295
|
+
<ButtonLink size="sm" variant="ghost" href={notebookHref()!}>
|
|
296
|
+
{t().inspectNotebook}
|
|
297
|
+
</ButtonLink>
|
|
298
|
+
</Show>
|
|
299
|
+
</div>
|
|
300
|
+
);
|
|
301
|
+
}
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import { LocaleProvider } from "@k2b/ui";
|
|
2
|
+
import { createMemo, createSignal, For } from "solid-js";
|
|
3
|
+
import { render } from "solid-js/web";
|
|
4
|
+
import type { AiMap, AiMapCommand, AiMapToolResult } from "../map-contracts";
|
|
5
|
+
import type { AiTurnBlock } from "../protocol";
|
|
6
|
+
import { AiReaderTimeZoneProvider } from "./action-sentences";
|
|
7
|
+
import { AiChatActionsProvider } from "./presentation";
|
|
8
|
+
import { createAiToolDisclosureState } from "./tool-disclosure";
|
|
9
|
+
import { layoutAiTurn } from "./turn-layout";
|
|
10
|
+
import { AiTurnView } from "./turn-view";
|
|
11
|
+
|
|
12
|
+
const snapshot: { map: AiMap; followups: AiMapToolResult[] } = JSON.parse(document.getElementById("snapshot")!.textContent!);
|
|
13
|
+
const initial = snapshot.map;
|
|
14
|
+
const block = (map: unknown, anchor: boolean, id = "original"): AiTurnBlock => ({
|
|
15
|
+
kind: "tool",
|
|
16
|
+
id,
|
|
17
|
+
callId: id,
|
|
18
|
+
name: "map",
|
|
19
|
+
status: initial.pending ? "running" : "completed",
|
|
20
|
+
args: {},
|
|
21
|
+
result: { map, anchor },
|
|
22
|
+
});
|
|
23
|
+
const text = (id: string, followup = false): AiTurnBlock => ({
|
|
24
|
+
kind: "text",
|
|
25
|
+
id,
|
|
26
|
+
text: followup
|
|
27
|
+
? "Ich habe dieselbe Karte ergänzt. Deine bisherigen Antworten bleiben erhalten."
|
|
28
|
+
: "Hier sind die verifizierten Orte. Die Namen und die Karte sind fiktive Testdaten.",
|
|
29
|
+
});
|
|
30
|
+
const [turns, setTurns] = createSignal<AiTurnBlock[][]>([
|
|
31
|
+
[block(initial, true), text("answer")],
|
|
32
|
+
...snapshot.followups.map((result, i) => [block(result.map, false, `followup-${i + 1}`), text(`followup-text-${i + 1}`, true)]),
|
|
33
|
+
]);
|
|
34
|
+
const mount = document.getElementById("fixture-mount")!;
|
|
35
|
+
Object.assign(window, {
|
|
36
|
+
mapFollowup: async (command: AiMapCommand) => {
|
|
37
|
+
const response = await fetch(`/fixtures/${initial.conversationId}/followup`, {
|
|
38
|
+
method: "POST",
|
|
39
|
+
headers: { "content-type": "application/json" },
|
|
40
|
+
body: JSON.stringify(command),
|
|
41
|
+
});
|
|
42
|
+
if (!response.ok) throw new Error("FIXTURE_FOLLOWUP_FAILED");
|
|
43
|
+
const result: AiMapToolResult = await response.json();
|
|
44
|
+
setTurns([...turns(), [block(result.map, false, `followup-${turns().length}`), text(`followup-text-${turns().length}`, true)]]);
|
|
45
|
+
},
|
|
46
|
+
});
|
|
47
|
+
mount.replaceChildren();
|
|
48
|
+
render(
|
|
49
|
+
() => (
|
|
50
|
+
<LocaleProvider locale={document.documentElement.lang}>
|
|
51
|
+
<AiReaderTimeZoneProvider value="Europe/Berlin">
|
|
52
|
+
<AiChatActionsProvider actions={{}}>
|
|
53
|
+
<For each={turns()}>
|
|
54
|
+
{(blocks) => {
|
|
55
|
+
const segment = createMemo(() => ({
|
|
56
|
+
id: blocks[0]!.id,
|
|
57
|
+
turnId: blocks[0]!.id,
|
|
58
|
+
phase: "completed" as const,
|
|
59
|
+
layout: layoutAiTurn(blocks, { phase: "completed" }),
|
|
60
|
+
earlier: false,
|
|
61
|
+
duration: () => null,
|
|
62
|
+
}));
|
|
63
|
+
return <AiTurnView segment={segment} disclosureState={createAiToolDisclosureState()} />;
|
|
64
|
+
}}
|
|
65
|
+
</For>
|
|
66
|
+
</AiChatActionsProvider>
|
|
67
|
+
</AiReaderTimeZoneProvider>
|
|
68
|
+
</LocaleProvider>
|
|
69
|
+
),
|
|
70
|
+
mount,
|
|
71
|
+
);
|