@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
|
@@ -327,6 +327,7 @@ export const displayToolName = (name: string, locale: string) => {
|
|
|
327
327
|
|
|
328
328
|
const BUILT_IN_TOOL_ICONS = new Map<string, string>([
|
|
329
329
|
["card", "ti ti-layout-cards"],
|
|
330
|
+
["map", "ti ti-map-search"],
|
|
330
331
|
["cloud_card", "ti ti-layout-cards"],
|
|
331
332
|
["chart", "ti ti-chart-bar"],
|
|
332
333
|
["survey", "ti ti-forms"],
|
|
@@ -10,6 +10,7 @@ const toolNames = i18n.define({
|
|
|
10
10
|
en: {
|
|
11
11
|
calculate: "Calculate",
|
|
12
12
|
card: "Card",
|
|
13
|
+
map: "Places and maps",
|
|
13
14
|
chart: "Chart",
|
|
14
15
|
survey: "Survey",
|
|
15
16
|
text_editor: "Text editor",
|
|
@@ -86,6 +87,7 @@ const toolNames = i18n.define({
|
|
|
86
87
|
de: {
|
|
87
88
|
calculate: "Rechnen",
|
|
88
89
|
card: "Karte",
|
|
90
|
+
map: "Orte und Karten",
|
|
89
91
|
chart: "Diagramm",
|
|
90
92
|
survey: "Umfrage",
|
|
91
93
|
text_editor: "Texteditor",
|
|
@@ -70,11 +70,12 @@ const resultTarget = (block: ToolBlock): string | null => {
|
|
|
70
70
|
};
|
|
71
71
|
|
|
72
72
|
const isResult = (block: ToolBlock, phase: AiTurnPhase, codePresentations: boolean): boolean => {
|
|
73
|
+
if (block.name === "map" && block.result && typeof block.result === "object" && "map" in block.result) return true;
|
|
73
74
|
if (isFailedTool(block) || block.status === "rejected" || block.status === "awaiting_approval") return false;
|
|
74
75
|
// A running delivery reserves its place once its arguments arrived: a new version of an earlier result must find the
|
|
75
76
|
// earlier place before it takes one of its own.
|
|
76
77
|
const delivered = block.status === "completed" || (live(phase) && block.status === "running" && block.args !== undefined);
|
|
77
|
-
if (block.name === "present" || isCardToolName(block.name) || isChartToolName(block.name)) return delivered;
|
|
78
|
+
if (block.name === "map" || block.name === "present" || isCardToolName(block.name) || isChartToolName(block.name)) return delivered;
|
|
78
79
|
if (block.name === "code_present") return codePresentations && delivered;
|
|
79
80
|
return hasCapabilityTable(block);
|
|
80
81
|
};
|
|
@@ -23,6 +23,7 @@ import { CompactionBlockView, CompactToolRow, SurveyToolView, TextEditorToolView
|
|
|
23
23
|
import { CapabilityTablePreview } from "./capability-table";
|
|
24
24
|
import { CloudChartBlock } from "./chart-block";
|
|
25
25
|
import { PresentToolBlock } from "./file-tools";
|
|
26
|
+
import { AiMapToolBlock } from "./map-widget";
|
|
26
27
|
import { useAiChatActions } from "./message-actions";
|
|
27
28
|
import { aiToolIcon, displayToolName, isCardToolName, isChartToolName, isRecord, isSurveyToolName } from "./message-utils";
|
|
28
29
|
import { aiChatMessages } from "./messages";
|
|
@@ -438,6 +439,9 @@ function AiTurnResultView(props: { result: Accessor<AiTurnResult | undefined>; r
|
|
|
438
439
|
<Show when={block()}>
|
|
439
440
|
{(current) => (
|
|
440
441
|
<Switch>
|
|
442
|
+
<Match when={current().name === "map"}>
|
|
443
|
+
<AiMapToolBlock block={current()} />
|
|
444
|
+
</Match>
|
|
441
445
|
<Match when={current().name === "present"}>
|
|
442
446
|
<PresentToolBlock block={current()} />
|
|
443
447
|
</Match>
|
|
@@ -653,7 +657,18 @@ export function AiTurnView(props: { segment: Accessor<AiTurnSegment>; disclosure
|
|
|
653
657
|
const chatActions = useAiChatActions();
|
|
654
658
|
const layout = () => props.segment().layout;
|
|
655
659
|
const results = createMemo(() => new Map(layout().results.map((result) => [result.id, result])));
|
|
656
|
-
const
|
|
660
|
+
const isMapFollowup = (id: string) => {
|
|
661
|
+
const block = results().get(id)?.block;
|
|
662
|
+
return (
|
|
663
|
+
block?.name === "map" &&
|
|
664
|
+
typeof block.result === "object" &&
|
|
665
|
+
block.result !== null &&
|
|
666
|
+
"anchor" in block.result &&
|
|
667
|
+
block.result.anchor === false
|
|
668
|
+
);
|
|
669
|
+
};
|
|
670
|
+
const resultIds = createMemo(() => [...results().keys()].filter((id) => !isMapFollowup(id)));
|
|
671
|
+
const mapFollowupIds = createMemo(() => [...results().keys()].filter(isMapFollowup));
|
|
657
672
|
const actions = createMemo(() => new Map(layout().actions.map((action) => [action.id, action])));
|
|
658
673
|
const actionIds = createMemo(() => [...actions().keys()]);
|
|
659
674
|
const live = () => isLive(props.segment().phase);
|
|
@@ -709,6 +724,7 @@ export function AiTurnView(props: { segment: Accessor<AiTurnSegment>; disclosure
|
|
|
709
724
|
earlier={() => props.segment().earlier}
|
|
710
725
|
/>
|
|
711
726
|
</Show>
|
|
727
|
+
<For each={mapFollowupIds()}>{(id) => <AiTurnResultView result={() => results().get(id)} receipt={() => false} />}</For>
|
|
712
728
|
<Show when={props.segment().scheduledTask?.stop ? undefined : props.segment().scheduledTask?.reason}>
|
|
713
729
|
{(reason) => (
|
|
714
730
|
<Chat.Activity
|
package/src/ai/default-tools.ts
CHANGED
|
@@ -56,6 +56,7 @@ import {
|
|
|
56
56
|
CloudAiLocalWriteInputSchema,
|
|
57
57
|
CloudAiLocalWriteOutputSchema,
|
|
58
58
|
} from "./local-tool-contracts";
|
|
59
|
+
import { createCloudAiMapTool } from "./map-tool";
|
|
59
60
|
import { createCloudAiMarkdownToPdfTool } from "./markdown-pdf-tool";
|
|
60
61
|
import { createAiTodoTool } from "./todo-tool";
|
|
61
62
|
import { defineAiTool } from "./tools";
|
|
@@ -303,6 +304,7 @@ export const CLOUD_AI_DEFERRED_BUILTIN_TOOL_NAMES = new Set<string>([
|
|
|
303
304
|
"markdown_to_pdf",
|
|
304
305
|
"html_to_pdf",
|
|
305
306
|
"present",
|
|
307
|
+
"map",
|
|
306
308
|
"read_cloud_resource",
|
|
307
309
|
]);
|
|
308
310
|
|
|
@@ -314,6 +316,7 @@ export const createConfiguredDefaultCloudAiTools = async (config?: {
|
|
|
314
316
|
}) => {
|
|
315
317
|
const tools: AiRuntimeTool[] = [
|
|
316
318
|
...createDefaultCloudAiTools(),
|
|
319
|
+
createCloudAiMapTool(),
|
|
317
320
|
...createCodeSourceTools(),
|
|
318
321
|
createCloudAiListFilesTool(),
|
|
319
322
|
createCloudAiReadFileTool(),
|
package/src/ai/executor.ts
CHANGED
|
@@ -547,7 +547,7 @@ const createEventMapper = (
|
|
|
547
547
|
args: "args" in patch ? patch.args : existing?.args,
|
|
548
548
|
status: patch.status ?? existing?.status ?? "running",
|
|
549
549
|
at: patch.at ?? existing?.at,
|
|
550
|
-
result: "result" in patch ? patch.result : existing?.result,
|
|
550
|
+
result: patch.isError && existing?.result !== undefined ? existing.result : "result" in patch ? patch.result : existing?.result,
|
|
551
551
|
isError: "isError" in patch ? patch.isError : existing?.isError,
|
|
552
552
|
approval: approval && !allowRememberedApprovals ? { ...approval, allowAlways: false, allowChat: false } : approval,
|
|
553
553
|
review: patch.review ?? existing?.review,
|
|
@@ -1080,6 +1080,11 @@ export class AiTurnExecutor {
|
|
|
1080
1080
|
const dynamicToolRuntimeContext = {
|
|
1081
1081
|
turnId,
|
|
1082
1082
|
reportToolProgress: (callId: string, message: string) => pipeline.reportToolProgress(callId, message),
|
|
1083
|
+
reportToolResult: (callId: string, result: unknown) => pipeline.reportToolResult(callId, result),
|
|
1084
|
+
maxToolResultChars: resolveAiToolResultMaxChars({
|
|
1085
|
+
contextWindow: resolved.provider.contextWindow,
|
|
1086
|
+
configuredMaxChars: settings.maxToolResultChars,
|
|
1087
|
+
}),
|
|
1083
1088
|
reportWebsiteReceipts: (callId: string, receipts: AiWebsiteReceipt[]) => pipeline.reportWebsiteReceipts(callId, receipts),
|
|
1084
1089
|
localToolsEnabled: activeTools.some((tool) => tool.def.name === "local_upload"),
|
|
1085
1090
|
attachedFilePaths: new Set(config.files?.attached.map((file) => file.path) ?? []),
|
|
@@ -2501,6 +2506,13 @@ class StreamPipeline {
|
|
|
2501
2506
|
await this.maybeSnapshot();
|
|
2502
2507
|
}
|
|
2503
2508
|
|
|
2509
|
+
async reportToolResult(callId: string, result: unknown): Promise<void> {
|
|
2510
|
+
const block = this.blocks.find((block) => block.kind === "tool" && block.callId === callId);
|
|
2511
|
+
if (!block || block.kind !== "tool" || block.status !== "running") return;
|
|
2512
|
+
await this.emitOp(this.mapper.setTool(callId, { result }));
|
|
2513
|
+
await this.maybeSnapshot();
|
|
2514
|
+
}
|
|
2515
|
+
|
|
2504
2516
|
async reportToolProgress(callId: string, progress: string): Promise<void> {
|
|
2505
2517
|
const block = this.blocks.find((block) => block.kind === "tool" && block.callId === callId);
|
|
2506
2518
|
if (!block || block.kind !== "tool" || block.progress === progress) return;
|
|
@@ -0,0 +1,209 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import {
|
|
3
|
+
GeoAddressSchema,
|
|
4
|
+
GeoAvailabilityDataSchema,
|
|
5
|
+
GeoCategorySchema,
|
|
6
|
+
GeoCostsSchema,
|
|
7
|
+
GeoPlaceSchema,
|
|
8
|
+
GeoPlaceSearchInputSchema,
|
|
9
|
+
GeoPointSchema,
|
|
10
|
+
} from "../contracts/geo";
|
|
11
|
+
import {
|
|
12
|
+
GeoRouteChangesSchema,
|
|
13
|
+
GeoRouteOptionsSchema,
|
|
14
|
+
GeoRoutePlanSchema,
|
|
15
|
+
GeoRouteSchema,
|
|
16
|
+
GeoRouteUpdateDataSchema,
|
|
17
|
+
} from "../contracts/geo-route";
|
|
18
|
+
|
|
19
|
+
/** Provider website text is optional and is not guaranteed to be a valid URL. */
|
|
20
|
+
export const aiMapWebsite = (value: string | undefined): string | undefined => {
|
|
21
|
+
try {
|
|
22
|
+
const url = new URL(value ?? "");
|
|
23
|
+
return url.protocol === "https:" || url.protocol === "http:" ? value : undefined;
|
|
24
|
+
} catch {
|
|
25
|
+
return undefined;
|
|
26
|
+
}
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
const label = z.string().trim().min(1).max(160);
|
|
30
|
+
const web = z.object({ placeId: z.string().max(2048), description: z.string().max(600), url: z.url().max(2000) }).strict();
|
|
31
|
+
// A map may accumulate two full Places result pages. Subsequent searches replace the secondary group.
|
|
32
|
+
export const AiMapSchema = z
|
|
33
|
+
.object({
|
|
34
|
+
id: z.uuid(),
|
|
35
|
+
conversationId: z.string(),
|
|
36
|
+
revision: z.number().int().positive(),
|
|
37
|
+
title: label,
|
|
38
|
+
selectionId: z.string().optional(),
|
|
39
|
+
timeZone: z.string().optional(),
|
|
40
|
+
kind: z.enum(["places", "route"]),
|
|
41
|
+
pending: z.boolean().optional(),
|
|
42
|
+
pendingTurnId: z.uuid().optional(),
|
|
43
|
+
retryUsesMarks: z.boolean().optional(),
|
|
44
|
+
originCallId: z.string().optional(),
|
|
45
|
+
anchorSeq: z.number().int().positive().optional(),
|
|
46
|
+
viewedAt: z.iso.datetime().optional(),
|
|
47
|
+
lastSearch: z
|
|
48
|
+
.object({
|
|
49
|
+
categories: z.array(GeoCategorySchema),
|
|
50
|
+
name: GeoPlaceSearchInputSchema.shape.name,
|
|
51
|
+
near: z.string().optional(),
|
|
52
|
+
placeId: z.string().optional(),
|
|
53
|
+
radiusMeters: z.number().optional(),
|
|
54
|
+
title: label,
|
|
55
|
+
along: z.object({ fromKm: z.number(), toKm: z.number(), bufferMeters: z.number().optional() }).optional(),
|
|
56
|
+
})
|
|
57
|
+
.optional(),
|
|
58
|
+
searchAddress: z.object({ text: z.string(), address: GeoAddressSchema }).strict().optional(),
|
|
59
|
+
availability: GeoAvailabilityDataSchema,
|
|
60
|
+
groups: z.array(z.object({ title: label, places: z.array(GeoPlaceSchema).max(20) }).strict()).max(2),
|
|
61
|
+
web: z.array(web).max(40),
|
|
62
|
+
route: GeoRouteSchema.optional(),
|
|
63
|
+
plan: GeoRoutePlanSchema.optional(),
|
|
64
|
+
preview: z.object({ result: GeoRouteUpdateDataSchema, changes: GeoRouteChangesSchema, title: label }).strict().optional(),
|
|
65
|
+
undo: z.object({ route: GeoRouteSchema, title: label }).strict().optional(),
|
|
66
|
+
receipt: z.object({ title: label, at: z.iso.datetime() }).strict().optional(),
|
|
67
|
+
candidates: z.array(GeoAddressSchema).max(5).optional(),
|
|
68
|
+
ambiguousStopId: z.string().optional(),
|
|
69
|
+
picker: z.array(GeoPlaceSchema).max(5).optional(),
|
|
70
|
+
retryInsertStopId: z.uuid().optional(),
|
|
71
|
+
issue: z
|
|
72
|
+
.object({
|
|
73
|
+
code: z.string(),
|
|
74
|
+
scope: z.enum(["person", "cloud"]).optional(),
|
|
75
|
+
resetsAt: z.string().optional(),
|
|
76
|
+
retryAfterSeconds: z.number().optional(),
|
|
77
|
+
costs: GeoCostsSchema.optional(),
|
|
78
|
+
requiredCredits: z.number().nonnegative().optional(),
|
|
79
|
+
availableCredits: z.number().optional(),
|
|
80
|
+
turnId: z.uuid().optional(),
|
|
81
|
+
requiresCostConfirmation: z.literal(true).optional(),
|
|
82
|
+
shortfallCredits: z.number().nonnegative().optional(),
|
|
83
|
+
})
|
|
84
|
+
.strict()
|
|
85
|
+
.optional(),
|
|
86
|
+
})
|
|
87
|
+
.strict();
|
|
88
|
+
export type AiMap = z.infer<typeof AiMapSchema>;
|
|
89
|
+
|
|
90
|
+
const search = z
|
|
91
|
+
.object({
|
|
92
|
+
categories: z.array(GeoCategorySchema).min(1).max(5),
|
|
93
|
+
name: GeoPlaceSearchInputSchema.shape.name,
|
|
94
|
+
near: z.string().min(1).max(300).optional(),
|
|
95
|
+
placeId: z.string().max(2048).optional(),
|
|
96
|
+
radiusMeters: z.number().min(1).max(50000).optional(),
|
|
97
|
+
along: z
|
|
98
|
+
.object({ fromKm: z.number().nonnegative(), toKm: z.number().positive(), bufferMeters: z.number().min(100).max(5000).optional() })
|
|
99
|
+
.strict()
|
|
100
|
+
.optional(),
|
|
101
|
+
title: label,
|
|
102
|
+
})
|
|
103
|
+
.strict();
|
|
104
|
+
// Model-facing inputs cannot inject points, polylines, or a provider result. These are resolved on the server.
|
|
105
|
+
const stop = z
|
|
106
|
+
.object({
|
|
107
|
+
id: z.string().min(1).max(40),
|
|
108
|
+
address: z.string().min(1).max(300).optional(),
|
|
109
|
+
placeId: z.string().max(2048).optional(),
|
|
110
|
+
label: label.optional(),
|
|
111
|
+
})
|
|
112
|
+
.strict()
|
|
113
|
+
.refine((value) => Boolean(value.address) !== Boolean(value.placeId), "Supply exactly one address or verified place ID");
|
|
114
|
+
export const AiMapCommandSchema = z.discriminatedUnion("type", [
|
|
115
|
+
z.object({ type: z.literal("places"), search, web: z.array(web).max(20).optional() }).strict(),
|
|
116
|
+
z
|
|
117
|
+
.object({
|
|
118
|
+
type: z.literal("route"),
|
|
119
|
+
stops: z
|
|
120
|
+
.array(stop)
|
|
121
|
+
.min(2)
|
|
122
|
+
.max(26)
|
|
123
|
+
.refine((stops) => new Set(stops.map((s) => s.id)).size === stops.length, "Stop IDs must be unique"),
|
|
124
|
+
options: GeoRouteOptionsSchema.optional(),
|
|
125
|
+
title: label,
|
|
126
|
+
})
|
|
127
|
+
.strict(),
|
|
128
|
+
z.object({ type: z.literal("search"), search, insert: z.boolean().optional(), web: z.array(web).max(20).optional() }).strict(),
|
|
129
|
+
z.object({ type: z.literal("describe"), web: z.array(web).max(40) }).strict(),
|
|
130
|
+
z
|
|
131
|
+
.object({
|
|
132
|
+
type: z.literal("preview"),
|
|
133
|
+
change: z.discriminatedUnion("type", [
|
|
134
|
+
z.object({ type: z.literal("insert"), placeId: z.string().max(2048), afterStopId: z.string().optional() }).strict(),
|
|
135
|
+
z.object({ type: z.literal("move"), stopId: z.string(), toIndex: z.number().int().min(0).max(25) }).strict(),
|
|
136
|
+
z.object({ type: z.literal("optimize") }).strict(),
|
|
137
|
+
z
|
|
138
|
+
.object({
|
|
139
|
+
type: z.literal("options"),
|
|
140
|
+
options: GeoRouteOptionsSchema,
|
|
141
|
+
leg: z.object({ fromStopId: z.string(), toStopId: z.string() }).strict().optional(),
|
|
142
|
+
})
|
|
143
|
+
.strict(),
|
|
144
|
+
]),
|
|
145
|
+
})
|
|
146
|
+
.strict(),
|
|
147
|
+
z.object({ type: z.literal("choose"), index: z.number().int().min(0).max(4) }).strict(),
|
|
148
|
+
z.object({ type: z.literal("accept") }).strict(),
|
|
149
|
+
z.object({ type: z.literal("discard") }).strict(),
|
|
150
|
+
z.object({ type: z.literal("undo") }).strict(),
|
|
151
|
+
z.object({ type: z.literal("retry") }).strict(),
|
|
152
|
+
]);
|
|
153
|
+
export const AiMapBrowserCommandSchema = z.union([
|
|
154
|
+
AiMapCommandSchema,
|
|
155
|
+
z.object({ type: z.literal("location"), destinationId: z.string(), point: GeoPointSchema }).strict(),
|
|
156
|
+
z
|
|
157
|
+
.object({ type: z.literal("repair"), stopId: z.string(), address: z.string().trim().min(1).max(300), point: GeoPointSchema.optional() })
|
|
158
|
+
.strict(),
|
|
159
|
+
]);
|
|
160
|
+
export type AiMapCommand = z.infer<typeof AiMapBrowserCommandSchema>;
|
|
161
|
+
export const AiMapToolInputSchema = z
|
|
162
|
+
.object({ mapId: z.uuid().optional(), revision: z.number().int().positive().optional(), command: AiMapCommandSchema })
|
|
163
|
+
.strict();
|
|
164
|
+
export const AiMapReferenceSchema = AiMapSchema.pick({
|
|
165
|
+
id: true,
|
|
166
|
+
conversationId: true,
|
|
167
|
+
revision: true,
|
|
168
|
+
title: true,
|
|
169
|
+
kind: true,
|
|
170
|
+
pending: true,
|
|
171
|
+
anchorSeq: true,
|
|
172
|
+
}).extend({ reference: z.literal(true) });
|
|
173
|
+
export const AiMapToolResultSchema = z
|
|
174
|
+
.object({ map: z.union([AiMapSchema, AiMapReferenceSchema]), anchor: z.boolean(), summary: z.unknown().optional() })
|
|
175
|
+
.strict();
|
|
176
|
+
/** Geometry belongs to the durable map, never to the model context or streaming result budget. */
|
|
177
|
+
export const aiMapToolResult = (map: AiMap, anchor: boolean): z.infer<typeof AiMapToolResultSchema> => ({
|
|
178
|
+
anchor,
|
|
179
|
+
map: {
|
|
180
|
+
reference: true,
|
|
181
|
+
id: map.id,
|
|
182
|
+
conversationId: map.conversationId,
|
|
183
|
+
revision: map.revision,
|
|
184
|
+
title: map.title,
|
|
185
|
+
kind: map.kind,
|
|
186
|
+
pending: map.pending,
|
|
187
|
+
anchorSeq: map.anchorSeq,
|
|
188
|
+
},
|
|
189
|
+
summary: {
|
|
190
|
+
mapId: map.id,
|
|
191
|
+
revision: map.revision,
|
|
192
|
+
places: map.groups.map((g) => ({
|
|
193
|
+
title: g.title.slice(0, 64),
|
|
194
|
+
places: g.places.map((p, i) => ({
|
|
195
|
+
id: map.groups.indexOf(g) === 0 ? String(i + 1) : String.fromCharCode(65 + i),
|
|
196
|
+
name: p.name.slice(0, 48),
|
|
197
|
+
})),
|
|
198
|
+
})),
|
|
199
|
+
candidates: map.candidates?.map((p, index) => ({ index, address: p.formatted.slice(0, 96) })),
|
|
200
|
+
picker: map.picker?.map((p, i) => ({ id: `pick-${i + 1}`, name: p.name.slice(0, 48), atMeters: p.atMeters })),
|
|
201
|
+
stops: map.route?.stops.map((s) => ({ id: s.id, label: s.label?.slice(0, 48) })),
|
|
202
|
+
distanceMeters: map.route?.distanceMeters,
|
|
203
|
+
durationSeconds: map.route?.durationSeconds,
|
|
204
|
+
preview: map.preview ? { title: map.preview.title, delta: map.preview.result.delta } : undefined,
|
|
205
|
+
issue: map.issue,
|
|
206
|
+
},
|
|
207
|
+
});
|
|
208
|
+
export type AiMapToolResult = z.infer<typeof AiMapToolResultSchema>;
|
|
209
|
+
export const AiMapActionSchema = z.object({ revision: z.number().int().positive(), command: AiMapBrowserCommandSchema }).strict();
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import type { AiMapCommand } from "./map-contracts";
|
|
2
|
+
import { type AiMapContext, applyAiMap } from "./map-service";
|
|
3
|
+
import { type AiMapStored, aiMaps } from "./map-store";
|
|
4
|
+
|
|
5
|
+
/** Lock and check the revision before provider work; a concurrent or stale click cannot spend against this map. */
|
|
6
|
+
export async function executeStoredAiMap(
|
|
7
|
+
conversationId: string,
|
|
8
|
+
ownerUserId: string,
|
|
9
|
+
before: AiMapStored,
|
|
10
|
+
command: AiMapCommand,
|
|
11
|
+
context: AiMapContext,
|
|
12
|
+
) {
|
|
13
|
+
return aiMaps.mutate(conversationId, ownerUserId, before.map.revision, before.map.id, (current) => applyAiMap(current, command, context));
|
|
14
|
+
}
|
|
@@ -0,0 +1,276 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import { type CapabilityClientError, type CapabilityHttpOptions, invokeCapabilityWithDataSchema } from "../capabilities/client";
|
|
3
|
+
import { CAPABILITY_MAX_REQUEST_BYTES } from "../contracts/capabilities";
|
|
4
|
+
import { cloudResourceHref } from "../contracts/resource-href";
|
|
5
|
+
|
|
6
|
+
const hashSchema = z.string().regex(/^sha256:[a-f0-9]{64}$/);
|
|
7
|
+
const shortId = z.string().regex(/^[A-Za-z0-9]{6}$/);
|
|
8
|
+
const offset = z.number().int().nonnegative();
|
|
9
|
+
const pendingSchema = z
|
|
10
|
+
.object({
|
|
11
|
+
kind: z.enum(["create", "append"]),
|
|
12
|
+
key: z.string().min(1).max(200),
|
|
13
|
+
end: offset,
|
|
14
|
+
beforeHash: hashSchema,
|
|
15
|
+
afterHash: hashSchema,
|
|
16
|
+
})
|
|
17
|
+
.strict();
|
|
18
|
+
|
|
19
|
+
/** Persist under the signed-in user and source key; retain saved checkpoints to prevent duplicates. */
|
|
20
|
+
export const MapNotebookSaveCheckpointSchema = z
|
|
21
|
+
.object({
|
|
22
|
+
version: z.literal(1),
|
|
23
|
+
sourceKey: z.string().min(1).max(256),
|
|
24
|
+
notebookId: shortId,
|
|
25
|
+
contentHash: hashSchema,
|
|
26
|
+
total: offset,
|
|
27
|
+
completed: offset,
|
|
28
|
+
attempt: offset,
|
|
29
|
+
noteId: shortId.optional(),
|
|
30
|
+
pending: pendingSchema.optional(),
|
|
31
|
+
})
|
|
32
|
+
.strict();
|
|
33
|
+
export type MapNotebookSaveCheckpoint = z.infer<typeof MapNotebookSaveCheckpointSchema>;
|
|
34
|
+
export type MapNotebookSaveResult = {
|
|
35
|
+
status: "saved" | "cancelled" | "conflict" | "unknown" | "error";
|
|
36
|
+
checkpoint?: MapNotebookSaveCheckpoint;
|
|
37
|
+
notebookHref: string;
|
|
38
|
+
noteId?: string;
|
|
39
|
+
noteHref?: string;
|
|
40
|
+
/** Stable helper code or the original capability error code; caller supplies localized UI text. */
|
|
41
|
+
code?: string;
|
|
42
|
+
message?: string;
|
|
43
|
+
};
|
|
44
|
+
export type MapNotebookSaveOptions = {
|
|
45
|
+
/** Exact server export, with its existing first # title. Refetch the same export on resume. */
|
|
46
|
+
content: string;
|
|
47
|
+
notebookId: string;
|
|
48
|
+
/** Map ID + revision (and conversation if needed), stable across explicit resumes. */
|
|
49
|
+
sourceKey: string;
|
|
50
|
+
checkpoint?: MapNotebookSaveCheckpoint;
|
|
51
|
+
/** Must durably persist before resolving. Called before EVERY mutation and after every receipt. */
|
|
52
|
+
onCheckpoint: (checkpoint: MapNotebookSaveCheckpoint) => void | Promise<void>;
|
|
53
|
+
signal?: AbortSignal;
|
|
54
|
+
/** Normal public capability transport; also supplies a fake HTTP seam for tests. */
|
|
55
|
+
client?: CapabilityHttpOptions;
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
const encoder = new TextEncoder();
|
|
59
|
+
const hash = async (content: string): Promise<string> => {
|
|
60
|
+
const digest = await crypto.subtle.digest("SHA-256", encoder.encode(content));
|
|
61
|
+
return `sha256:${Array.from(new Uint8Array(digest), (byte) => byte.toString(16).padStart(2, "0")).join("")}`;
|
|
62
|
+
};
|
|
63
|
+
const fits = (input: unknown): boolean => encoder.encode(JSON.stringify({ input })).byteLength <= CAPABILITY_MAX_REQUEST_BYTES;
|
|
64
|
+
// Offsets and public character caps use UTF-16. Keep a supplementary character in one request.
|
|
65
|
+
const boundary = (content: string, end: number): number =>
|
|
66
|
+
end > 0 && end < content.length && /[\uD800-\uDBFF]/.test(content[end - 1]!) && /[\uDC00-\uDFFF]/.test(content[end]!) ? end - 1 : end;
|
|
67
|
+
|
|
68
|
+
const createEnd = (content: string, notebookId: string): number => {
|
|
69
|
+
let low = 0;
|
|
70
|
+
let high = Math.min(content.length, 200_000);
|
|
71
|
+
while (low < high) {
|
|
72
|
+
const mid = Math.ceil((low + high) / 2);
|
|
73
|
+
if (fits({ notebookId, content: content.slice(0, mid) })) low = mid;
|
|
74
|
+
else high = mid - 1;
|
|
75
|
+
}
|
|
76
|
+
return boundary(content, low);
|
|
77
|
+
};
|
|
78
|
+
type Append = { kind: "append"; content: string; separator: "none" };
|
|
79
|
+
const appendBatch = (content: string, start: number, noteId: string, ifContentHash: string) => {
|
|
80
|
+
const operations: Append[] = [];
|
|
81
|
+
let end = start;
|
|
82
|
+
while (end < content.length && operations.length < 20) {
|
|
83
|
+
const next = boundary(content, Math.min(end + 10_000, content.length));
|
|
84
|
+
const operation: Append = { kind: "append", content: content.slice(end, next), separator: "none" };
|
|
85
|
+
if (!fits({ noteId, ifContentHash, blockLimit: 0, operations: [...operations, operation] })) break;
|
|
86
|
+
operations.push(operation);
|
|
87
|
+
end = next;
|
|
88
|
+
}
|
|
89
|
+
return { end, input: { noteId, ifContentHash, blockLimit: 0, operations } };
|
|
90
|
+
};
|
|
91
|
+
// The platform treats a valid owning-app 4xx as no effect. Unknown/claim errors are exceptions.
|
|
92
|
+
const definitelyRejected = (error: CapabilityClientError): boolean =>
|
|
93
|
+
error.status >= 400 &&
|
|
94
|
+
error.status < 500 &&
|
|
95
|
+
![
|
|
96
|
+
"IDEMPOTENCY_IN_PROGRESS",
|
|
97
|
+
"IDEMPOTENCY_UNCERTAIN",
|
|
98
|
+
"IDEMPOTENCY_CONFLICT",
|
|
99
|
+
"ACTION_OUTCOME_UNKNOWN",
|
|
100
|
+
"REQUEST_CANCELLED",
|
|
101
|
+
"DEADLINE_EXCEEDED",
|
|
102
|
+
"INVALID_APP_RESPONSE",
|
|
103
|
+
"RESPONSE_TOO_LARGE",
|
|
104
|
+
].includes(error.code);
|
|
105
|
+
|
|
106
|
+
const noteSchema = z.object({ id: shortId, notebookId: shortId });
|
|
107
|
+
const readSchema = noteSchema.extend({ contentHash: hashSchema });
|
|
108
|
+
const editSchema = z.object({ beforeHash: hashSchema, afterHash: hashSchema });
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* One bounded save, with no automatic retries. The app invokes this again for explicit resume.
|
|
112
|
+
* Unknown creates are never repeated, even after the platform's 24-hour claim retention.
|
|
113
|
+
* note.edit forbids idempotency headers; full-content hashes detect changed prefixes. An uncertain append at the old hash stays unknown.
|
|
114
|
+
* This helper uses ordinary capabilities and never approves an Action on the caller's behalf.
|
|
115
|
+
*/
|
|
116
|
+
export async function saveMapNotebook(options: MapNotebookSaveOptions): Promise<MapNotebookSaveResult> {
|
|
117
|
+
const { content, notebookId, sourceKey, signal, client } = options;
|
|
118
|
+
const notebookHref = cloudResourceHref({ type: "notebooks.notebook", id: notebookId });
|
|
119
|
+
let checkpoint: MapNotebookSaveCheckpoint | undefined;
|
|
120
|
+
const result = (status: MapNotebookSaveResult["status"], code?: string, message?: string): MapNotebookSaveResult => ({
|
|
121
|
+
status,
|
|
122
|
+
checkpoint,
|
|
123
|
+
notebookHref,
|
|
124
|
+
code,
|
|
125
|
+
message,
|
|
126
|
+
...(checkpoint?.noteId
|
|
127
|
+
? { noteId: checkpoint.noteId, noteHref: cloudResourceHref({ type: "notebooks.note", id: checkpoint.noteId }) }
|
|
128
|
+
: {}),
|
|
129
|
+
});
|
|
130
|
+
const persist = async (next: MapNotebookSaveCheckpoint) => {
|
|
131
|
+
checkpoint = next;
|
|
132
|
+
// Never hand mutable internal state to the storage/UI callback.
|
|
133
|
+
await options.onCheckpoint(structuredClone(next));
|
|
134
|
+
};
|
|
135
|
+
const read = (id: string) =>
|
|
136
|
+
invokeCapabilityWithDataSchema(
|
|
137
|
+
{
|
|
138
|
+
kind: "query",
|
|
139
|
+
appId: "notebooks",
|
|
140
|
+
capabilityId: "note.read",
|
|
141
|
+
input: { id, contentLimit: 1 },
|
|
142
|
+
signal,
|
|
143
|
+
},
|
|
144
|
+
readSchema,
|
|
145
|
+
client,
|
|
146
|
+
);
|
|
147
|
+
try {
|
|
148
|
+
shortId.parse(notebookId);
|
|
149
|
+
z.string().min(1).max(256).parse(sourceKey);
|
|
150
|
+
// The exporter supplies a title; otherwise note.create may prepend one and change the prefix.
|
|
151
|
+
const titleEnd = content.indexOf("\n");
|
|
152
|
+
if (!/^# [^\r\n\s][^\r\n]*/.test(content)) return result("error", "invalid_title");
|
|
153
|
+
const contentHash = await hash(content);
|
|
154
|
+
if (options.checkpoint) {
|
|
155
|
+
checkpoint = MapNotebookSaveCheckpointSchema.parse(options.checkpoint);
|
|
156
|
+
if (
|
|
157
|
+
checkpoint.sourceKey !== sourceKey ||
|
|
158
|
+
checkpoint.notebookId !== notebookId ||
|
|
159
|
+
checkpoint.contentHash !== contentHash ||
|
|
160
|
+
checkpoint.total !== content.length ||
|
|
161
|
+
checkpoint.completed > content.length ||
|
|
162
|
+
(!checkpoint.noteId && checkpoint.completed !== 0)
|
|
163
|
+
)
|
|
164
|
+
return result("conflict", "source_changed");
|
|
165
|
+
const pending = checkpoint.pending;
|
|
166
|
+
if (
|
|
167
|
+
pending &&
|
|
168
|
+
(pending.end <= checkpoint.completed ||
|
|
169
|
+
pending.end > content.length ||
|
|
170
|
+
pending.beforeHash !== (await hash(content.slice(0, checkpoint.completed))) ||
|
|
171
|
+
pending.afterHash !== (await hash(content.slice(0, pending.end))) ||
|
|
172
|
+
(pending.kind === "create") === Boolean(checkpoint.noteId))
|
|
173
|
+
)
|
|
174
|
+
return result("conflict", "invalid_checkpoint");
|
|
175
|
+
} else {
|
|
176
|
+
checkpoint = { version: 1, sourceKey, notebookId, contentHash, total: content.length, completed: 0, attempt: 0 };
|
|
177
|
+
}
|
|
178
|
+
if (signal?.aborted) return result("cancelled", "cancelled");
|
|
179
|
+
if (checkpoint.pending?.kind === "create") return result("unknown", "create_outcome_unknown");
|
|
180
|
+
if (checkpoint.noteId) {
|
|
181
|
+
const current = await read(checkpoint.noteId);
|
|
182
|
+
if (!current.ok) return result(signal?.aborted ? "cancelled" : "unknown", current.error.code, current.error.message);
|
|
183
|
+
if (current.data.data.id !== checkpoint.noteId || current.data.data.notebookId !== notebookId)
|
|
184
|
+
return result("conflict", "note_moved");
|
|
185
|
+
const currentHash = current.data.data.contentHash;
|
|
186
|
+
if (checkpoint.pending && currentHash === checkpoint.pending.afterHash) {
|
|
187
|
+
await persist({ ...checkpoint, completed: checkpoint.pending.end, pending: undefined });
|
|
188
|
+
} else if (currentHash === (await hash(content.slice(0, checkpoint.completed)))) {
|
|
189
|
+
// The service checks the hash before publication, without compare-and-swap.
|
|
190
|
+
// The original request could still publish later: the old hash is NOT proof of no effect.
|
|
191
|
+
if (checkpoint.pending) return result("unknown", "append_outcome_unknown");
|
|
192
|
+
} else return result("conflict", "content_changed");
|
|
193
|
+
}
|
|
194
|
+
// Every iteration consumes a nonempty prefix: no retries, no work independent of source size.
|
|
195
|
+
while (checkpoint.completed < content.length) {
|
|
196
|
+
if (signal?.aborted) return result("cancelled", "cancelled");
|
|
197
|
+
const beforeHash = await hash(content.slice(0, checkpoint.completed));
|
|
198
|
+
const batch = checkpoint.noteId ? appendBatch(content, checkpoint.completed, checkpoint.noteId, beforeHash) : undefined;
|
|
199
|
+
const end = batch?.end ?? createEnd(content, notebookId);
|
|
200
|
+
if (end <= checkpoint.completed || (!checkpoint.noteId && end < (titleEnd < 0 ? content.length : titleEnd + 1))) {
|
|
201
|
+
return result("error", "request_too_large");
|
|
202
|
+
}
|
|
203
|
+
const afterHash = await hash(content.slice(0, end));
|
|
204
|
+
// Source digest scopes creates across reloads; offset + attempt identifies each batch.
|
|
205
|
+
const key = `map-note:${await hash(`${sourceKey}:${notebookId}:${contentHash}`)}:${checkpoint.completed}:${checkpoint.attempt}`;
|
|
206
|
+
const pending = { kind: checkpoint.noteId ? ("append" as const) : ("create" as const), key, end, beforeHash, afterHash };
|
|
207
|
+
try {
|
|
208
|
+
await persist({ ...checkpoint, pending, attempt: checkpoint.attempt + 1 });
|
|
209
|
+
} catch (cause) {
|
|
210
|
+
// This attempt is known unsent, even if storage wrote and then threw.
|
|
211
|
+
// Keep its incremented identity; a caller can persist this safe checkpoint and resume.
|
|
212
|
+
checkpoint = { ...checkpoint, pending: undefined };
|
|
213
|
+
return result("error", "checkpoint_failed", cause instanceof Error ? cause.message : String(cause));
|
|
214
|
+
}
|
|
215
|
+
if (signal?.aborted) {
|
|
216
|
+
// Nothing was dispatched; the next explicit resume can safely use a new attempt.
|
|
217
|
+
await persist({ ...checkpoint, pending: undefined });
|
|
218
|
+
return result("cancelled", "cancelled");
|
|
219
|
+
}
|
|
220
|
+
if (!batch) {
|
|
221
|
+
const created = await invokeCapabilityWithDataSchema(
|
|
222
|
+
{
|
|
223
|
+
kind: "action",
|
|
224
|
+
appId: "notebooks",
|
|
225
|
+
capabilityId: "note.create",
|
|
226
|
+
input: { notebookId, content: content.slice(0, end) },
|
|
227
|
+
idempotencyKey: key,
|
|
228
|
+
signal,
|
|
229
|
+
},
|
|
230
|
+
noteSchema,
|
|
231
|
+
client,
|
|
232
|
+
);
|
|
233
|
+
if (!created.ok) {
|
|
234
|
+
if (definitelyRejected(created.error)) {
|
|
235
|
+
await persist({ ...checkpoint, pending: undefined });
|
|
236
|
+
return result("error", created.error.code, created.error.message);
|
|
237
|
+
}
|
|
238
|
+
return result(signal?.aborted ? "cancelled" : "unknown", created.error.code, created.error.message);
|
|
239
|
+
}
|
|
240
|
+
// Persist the known ID before querying or performing another mutation.
|
|
241
|
+
await persist({ ...checkpoint, noteId: created.data.data.id, completed: end, pending: undefined });
|
|
242
|
+
if (created.data.data.notebookId !== notebookId) return result("conflict", "note_moved");
|
|
243
|
+
const current = await read(created.data.data.id);
|
|
244
|
+
if (!current.ok) return result(signal?.aborted ? "cancelled" : "unknown", current.error.code, current.error.message);
|
|
245
|
+
if (current.data.data.contentHash !== afterHash || current.data.data.notebookId !== notebookId)
|
|
246
|
+
return result("conflict", "content_changed");
|
|
247
|
+
} else {
|
|
248
|
+
const edited = await invokeCapabilityWithDataSchema(
|
|
249
|
+
{
|
|
250
|
+
kind: "action",
|
|
251
|
+
appId: "notebooks",
|
|
252
|
+
capabilityId: "note.edit",
|
|
253
|
+
input: batch.input,
|
|
254
|
+
signal,
|
|
255
|
+
},
|
|
256
|
+
editSchema,
|
|
257
|
+
client,
|
|
258
|
+
);
|
|
259
|
+
if (!edited.ok) {
|
|
260
|
+
if (definitelyRejected(edited.error)) {
|
|
261
|
+
await persist({ ...checkpoint, pending: undefined });
|
|
262
|
+
return result(edited.error.status === 409 ? "conflict" : "error", edited.error.code, edited.error.message);
|
|
263
|
+
}
|
|
264
|
+
return result(signal?.aborted ? "cancelled" : "unknown", edited.error.code, edited.error.message);
|
|
265
|
+
}
|
|
266
|
+
if (edited.data.data.beforeHash !== beforeHash || edited.data.data.afterHash !== afterHash)
|
|
267
|
+
return result("conflict", "unexpected_receipt");
|
|
268
|
+
await persist({ ...checkpoint, completed: end, pending: undefined });
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
return result("saved");
|
|
272
|
+
} catch (cause) {
|
|
273
|
+
// Includes persistence failures. Never proceed to a dependent mutation after a failed callback.
|
|
274
|
+
return result(signal?.aborted ? "cancelled" : "error", "save_failed", cause instanceof Error ? cause.message : String(cause));
|
|
275
|
+
}
|
|
276
|
+
}
|