@k2b/cloud 0.28.0 → 0.29.1
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 +9 -6
- package/src/_internal/cli-plugins.ts +18 -0
- package/src/_internal/define-app.ts +21 -5
- package/src/_internal/registry-validation.ts +30 -1
- package/src/_internal/runtime-context.ts +1 -0
- package/src/access/PermissionEditor.tsx +11 -4
- package/src/ai/assistant-models.ts +25 -4
- package/src/ai/audio-tool.ts +3 -10
- package/src/ai/capabilities.ts +35 -9
- package/src/ai/chat/blocks.tsx +259 -197
- package/src/ai/chat/builtin-tools.tsx +3 -1
- package/src/ai/chat/composer-adapter.ts +8 -0
- package/src/ai/chat/live-turn.browser-harness.tsx +16 -3
- package/src/ai/chat/message-actions.tsx +1 -1
- package/src/ai/chat/message-utils.ts +28 -6
- package/src/ai/chat/messages.ts +20 -24
- package/src/ai/chat/presentation.tsx +1 -8
- package/src/ai/chat/tool-disclosure.tsx +21 -14
- package/src/ai/chat/tool-groups.ts +64 -37
- package/src/ai/chat/tool-names.ts +157 -0
- package/src/ai/chat/turn-layout.ts +14 -2
- package/src/ai/chat/turn-view.tsx +132 -70
- package/src/ai/chat-tasks.ts +16 -3
- package/src/ai/code-mode-skill.ts +4 -4
- package/src/ai/compaction.ts +22 -3
- package/src/ai/default-tools.ts +9 -2
- package/src/ai/executor.ts +127 -40
- package/src/ai/file-media-type.ts +4 -0
- package/src/ai/files-store.ts +17 -10
- package/src/ai/http.ts +11 -10
- package/src/ai/index.ts +1 -0
- package/src/ai/memories.ts +2 -0
- package/src/ai/memory-learning.ts +59 -5
- package/src/ai/memory-workflow-evidence.ts +35 -10
- package/src/ai/projects-routes.ts +5 -1
- package/src/ai/projects.ts +5 -1
- package/src/ai/protocol.ts +43 -5
- package/src/ai/provider.ts +16 -1
- package/src/ai/quota-provider.ts +4 -1
- package/src/ai/routes.ts +20 -10
- package/src/ai/settings.ts +54 -17
- package/src/ai/skill-seeds.ts +11 -3
- package/src/ai/store.ts +76 -33
- package/src/ai/structured.ts +2 -1
- package/src/ai/system-prompt.ts +5 -1
- package/src/ai/transcription.ts +58 -13
- package/src/ai/turn-failure.ts +5 -1
- package/src/ai/turn-guidance.ts +505 -0
- package/src/ai/types.ts +3 -1
- package/src/ai/ui.tsx +1 -0
- package/src/api/admin-core-settings.ts +16 -1
- package/src/api/admin-outgoing-mail.ts +18 -0
- package/src/api/me.ts +42 -1
- package/src/browser/FileChooser.tsx +71 -295
- package/src/browser/FileSaver.tsx +407 -0
- package/src/browser/app-badges.ts +16 -0
- package/src/browser/choose-files.browser-harness.tsx +22 -6
- package/src/browser/choose-files.tsx +23 -36
- package/src/browser/file-chooser-messages.ts +64 -0
- package/src/browser/file-providers.ts +258 -8
- package/src/browser/files.tsx +26 -3
- package/src/browser/notifications.ts +37 -1
- package/src/browser/provider-folder.tsx +301 -0
- package/src/browser/provider-list.ts +39 -0
- package/src/browser/save-files.browser-harness.tsx +35 -0
- package/src/browser/save-files.tsx +107 -0
- package/src/cli/admin/outgoing-mail.ts +30 -2
- package/src/contracts/app.ts +10 -0
- package/src/contracts/commands.ts +3 -1
- package/src/contracts/notification-group.ts +6 -0
- package/src/contracts/notification-types.ts +6 -0
- package/src/contracts/outgoing-mail.ts +56 -11
- package/src/contracts/registry.ts +1 -0
- package/src/contracts/user-notifications.ts +74 -0
- package/src/server/middleware/request-logger.ts +11 -2
- package/src/services/accounts/avatar.ts +5 -2
- package/src/services/image-metadata.ts +438 -0
- package/src/services/index.ts +1 -0
- package/src/services/logging/redaction.ts +50 -0
- package/src/services/mandates/index.ts +4 -2
- package/src/services/mandates/policy.ts +20 -0
- package/src/services/notifications/browser.ts +48 -15
- package/src/services/notifications/dispatcher.ts +31 -1
- package/src/services/notifications/platform.ts +32 -4
- package/src/services/notifications/preview.ts +23 -0
- package/src/services/notifications/quiet.ts +153 -0
- package/src/services/notifications/user.ts +6 -0
- package/src/services/outbox.ts +24 -9
- package/src/services/outgoing-mail/index.ts +33 -5
- package/src/services/outgoing-mail/messages.ts +60 -19
- package/src/services/outgoing-mail/store.ts +63 -4
- package/src/services/pdf/template-preview.ts +2 -1
- package/src/services/settings/app.ts +3 -0
- package/src/services/settings/store.ts +4 -1
- package/src/services/weather/geo.ts +6 -2
- package/src/shared/ai-platform-prompt.ts +2 -2
- package/src/shared/network-address.ts +1 -0
- package/src/shared/redirect.ts +2 -1
- package/src/shared/template-rendering.ts +120 -7
- package/src/ssr/AppBadge.tsx +30 -0
- package/src/ssr/AppLaunchpadPanel.tsx +47 -26
- package/src/ssr/IslandError.tsx +33 -0
- package/src/ssr/Layout.tsx +3 -0
- package/src/ssr/LayoutHelp.tsx +4 -2
- package/src/ssr/RailApps.island.tsx +9 -2
- package/src/ssr/app-badges.ts +93 -0
- package/src/ssr/rail-messages.ts +4 -0
- package/src/ssr/rail-navigation.ts +2 -0
- package/src/styles/effects.css +165 -0
- package/src/styles/file-chooser.css +25 -0
- package/src/styles/utilities-markdown-table.css +5 -1
- package/src/styles/utilities-navigation.css +41 -0
|
@@ -10,14 +10,20 @@ import { AiChatActionsProvider, createAiChatTimeline } from "./presentation";
|
|
|
10
10
|
* The live chat timeline for a browser test, folded from wire events like the
|
|
11
11
|
* controller does. `window.emit(event)` applies one event,
|
|
12
12
|
* `window.steer(text)` appends a pending steer as the controller does,
|
|
13
|
-
* `window.reconnect(on)` sets whether the stream reconnects,
|
|
14
|
-
* `window.continued` lists the messages that **Continue** sent
|
|
13
|
+
* `window.reconnect(on)` sets whether the stream reconnects,
|
|
14
|
+
* `window.continued` lists the messages that **Continue** sent,
|
|
15
|
+
* `window.approvals` lists the approval decisions, and
|
|
16
|
+
* `window.opened` lists the chat files the timeline opened. Chat files load from `/files?path=`.
|
|
15
17
|
*/
|
|
16
18
|
const [state, setState] = createStore(emptyProjection());
|
|
17
19
|
const [reconnecting, setReconnecting] = createSignal(false);
|
|
18
20
|
const continued: string[] = [];
|
|
21
|
+
const opened: string[] = [];
|
|
22
|
+
const approvals: unknown[] = [];
|
|
19
23
|
Object.assign(window, {
|
|
20
24
|
continued,
|
|
25
|
+
approvals,
|
|
26
|
+
opened,
|
|
21
27
|
reconnect: (on: boolean) => setReconnecting(on),
|
|
22
28
|
emit: (event: AiWireEvent) => setState(reconcile(reduceProjection(state, event), { key: "id", merge: true })),
|
|
23
29
|
steer: (text: string) =>
|
|
@@ -32,7 +38,14 @@ root.style.cssText = "display: flex; flex-direction: column; height: 100dvh; max
|
|
|
32
38
|
document.body.append(root);
|
|
33
39
|
render(
|
|
34
40
|
() => (
|
|
35
|
-
<AiChatActionsProvider
|
|
41
|
+
<AiChatActionsProvider
|
|
42
|
+
actions={{
|
|
43
|
+
onContinueTurn: (message) => void continued.push(message),
|
|
44
|
+
onApproval: async (request, input) => void approvals.push({ callId: request.callId, ...input }),
|
|
45
|
+
fileUrl: (path) => `/files?path=${encodeURIComponent(path)}`,
|
|
46
|
+
onOpenFile: (path) => void opened.push(path),
|
|
47
|
+
}}
|
|
48
|
+
>
|
|
36
49
|
{(() => {
|
|
37
50
|
const items = createAiChatTimeline({
|
|
38
51
|
messages: createMemo(() => visibleMessages(state)),
|
|
@@ -218,7 +218,7 @@ const openAssistantResponseInfo = (entries: AiStoredMessage[], locale: string) =
|
|
|
218
218
|
{(tool) => (
|
|
219
219
|
<li>
|
|
220
220
|
<i class={aiToolIcon(tool.name)} aria-hidden="true" />
|
|
221
|
-
{displayToolName(tool.name)}
|
|
221
|
+
{displayToolName(tool.name, locale)}
|
|
222
222
|
<Show when={tool.count > 1}>
|
|
223
223
|
<span class="text-dimmed tabular-nums">×{tool.count}</span>
|
|
224
224
|
</Show>
|
|
@@ -3,12 +3,14 @@ import { fileIcons } from "@k2b/stdlib";
|
|
|
3
3
|
import { formatBytes as sharedFormatBytes } from "../../shared/format";
|
|
4
4
|
import { type AiAttachmentRef, parseAiAttachmentMarkers } from "../attachments";
|
|
5
5
|
import { AI_AUDIO_EXTENSIONS } from "../audio-format";
|
|
6
|
+
import { aiProjectFilePathFromMount, aiSkillFilePathFromMount } from "../file-mount";
|
|
6
7
|
import { AI_IMAGE_INPUT_MAX_BYTES, AI_TURN_ATTACHMENT_MAX_ITEMS } from "../limits";
|
|
7
8
|
import { type AiResourceMarker, parseAiResourceMarker } from "../resource-markers";
|
|
8
9
|
import { assistantVisibleTextFromMessage } from "../timeline";
|
|
9
10
|
import type { AiStoredMessage, AiUserContentPart } from "../types";
|
|
10
11
|
import { AI_IMAGE_MEDIA_TYPES, isAiImageMediaType } from "../types";
|
|
11
12
|
import { aiChatMessages } from "./messages";
|
|
13
|
+
import { aiBuiltinToolName } from "./tool-names";
|
|
12
14
|
|
|
13
15
|
type AssistantToolResultMessage = Extract<Message, { role: "tool_result" }>;
|
|
14
16
|
|
|
@@ -144,8 +146,9 @@ export const describeResponseEnd = (entry: Pick<AiStoredMessage, "loopDoneReason
|
|
|
144
146
|
interrupted: t.infoEndInterrupted,
|
|
145
147
|
error: t.infoEndFailed,
|
|
146
148
|
};
|
|
149
|
+
const stopped = entry.stopReason && ["error", "aborted", "interrupted"].includes(entry.stopReason);
|
|
147
150
|
const parts = [
|
|
148
|
-
entry.loopDoneReason ? run[entry.loopDoneReason] : null,
|
|
151
|
+
entry.loopDoneReason && !(entry.loopDoneReason === "stop" && stopped) ? run[entry.loopDoneReason] : null,
|
|
149
152
|
entry.stopReason ? (Object.hasOwn(message, entry.stopReason) ? message[entry.stopReason] : entry.stopReason) : null,
|
|
150
153
|
].filter((part): part is string => Boolean(part));
|
|
151
154
|
return [...new Set(parts)].join(" · ") || t.infoUnknown;
|
|
@@ -312,11 +315,10 @@ export const isSurveyToolName = (name: string) => name === "survey" || name ===
|
|
|
312
315
|
|
|
313
316
|
export const isTextEditorToolName = (name: string) => name === "text_editor" || name === "cloud_text_editor";
|
|
314
317
|
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
if (
|
|
319
|
-
if (name === "local_bash") return "Local Bash";
|
|
318
|
+
/** A tool's name for people: the localized name of a built-in tool, otherwise its name in words. */
|
|
319
|
+
export const displayToolName = (name: string, locale: string) => {
|
|
320
|
+
const builtin = aiBuiltinToolName(name, locale);
|
|
321
|
+
if (builtin) return builtin;
|
|
320
322
|
const words = name.split("_").filter(Boolean).join(" ");
|
|
321
323
|
return `${words.slice(0, 1).toUpperCase()}${words.slice(1)}`;
|
|
322
324
|
};
|
|
@@ -340,6 +342,7 @@ const BUILT_IN_TOOL_ICONS = new Map<string, string>([
|
|
|
340
342
|
["web_search", "ti ti-search"],
|
|
341
343
|
["web_extract", "ti ti-world-download"],
|
|
342
344
|
["view_image", "ti ti-photo-spark"],
|
|
345
|
+
["code_check", "ti ti-checklist"],
|
|
343
346
|
["memory", "ti ti-brain"],
|
|
344
347
|
["search_project", "ti ti-folder-search"],
|
|
345
348
|
["read_project_knowledge", "ti ti-notebook"],
|
|
@@ -353,6 +356,25 @@ const BUILT_IN_TOOL_ICONS = new Map<string, string>([
|
|
|
353
356
|
["read_cloud_resource", "ti ti-ai-gateway"],
|
|
354
357
|
]);
|
|
355
358
|
|
|
359
|
+
/** What an app check found: errors stop it from passing, warnings are counted separately. */
|
|
360
|
+
export type AiCodeCheckOutcome = { passed: boolean; errors: number; warnings: number };
|
|
361
|
+
|
|
362
|
+
export const codeCheckOutcome = (name: string, result: unknown): AiCodeCheckOutcome | null => {
|
|
363
|
+
if (name !== "code_check" || !isRecord(result) || typeof result.passed !== "boolean") return null;
|
|
364
|
+
const issues = Array.isArray(result.issues) ? result.issues.filter(isRecord) : [];
|
|
365
|
+
const warnings = issues.filter((issue) => issue.severity === "warning").length;
|
|
366
|
+
return { passed: result.passed, errors: issues.length - warnings, warnings };
|
|
367
|
+
};
|
|
368
|
+
|
|
369
|
+
/** The chat image a view_image call looked at. Project and skill files are not chat files and have no preview here. */
|
|
370
|
+
export const viewedChatImage = (name: string, result: unknown): { path: string; description: string } | null => {
|
|
371
|
+
if (name !== "view_image" || !isRecord(result)) return null;
|
|
372
|
+
const { path, mediaType, description } = result;
|
|
373
|
+
if (typeof path !== "string" || typeof mediaType !== "string" || !isAiImageMediaType(mediaType)) return null;
|
|
374
|
+
if (aiProjectFilePathFromMount(path) !== null || aiSkillFilePathFromMount(path) !== null) return null;
|
|
375
|
+
return { path, description: typeof description === "string" ? description.trim() : "" };
|
|
376
|
+
};
|
|
377
|
+
|
|
356
378
|
export const aiToolIcon = (name: string, appIcon?: string | null): string => {
|
|
357
379
|
if (appIcon) return appIcon;
|
|
358
380
|
return BUILT_IN_TOOL_ICONS.get(name) ?? (/__(?:query|action)__/.test(name) ? "ti ti-ai-gateway" : "ti ti-tool");
|
package/src/ai/chat/messages.ts
CHANGED
|
@@ -25,6 +25,7 @@ const messages = i18n.define({
|
|
|
25
25
|
turnErrorTitle: "The answer was interrupted.",
|
|
26
26
|
turnErrorReason: ({ code, limitMinutes }: AiTurnError) => {
|
|
27
27
|
if (code === "model_unavailable") return "The model service did not answer.";
|
|
28
|
+
if (code === "provider_stopped") return "The model provider ended this answer early.";
|
|
28
29
|
if (code === "quota_exhausted") return "Your AI usage limit for this period is reached. You can continue once it resets.";
|
|
29
30
|
if (code === "context_full") return "This chat is too long for the model. Start a new chat to continue; this one stays as it is.";
|
|
30
31
|
if (code === "time_limit")
|
|
@@ -71,6 +72,7 @@ const messages = i18n.define({
|
|
|
71
72
|
stepWritingCode: "Writing code",
|
|
72
73
|
stepRunningCode: "Running code",
|
|
73
74
|
stepCheckingApp: "Checking the app",
|
|
75
|
+
stepCheckingRun: "Checking the run",
|
|
74
76
|
stepCode: "Working with code",
|
|
75
77
|
stepPlan: "Updating the working plan",
|
|
76
78
|
stepPdf: "Creating a PDF",
|
|
@@ -87,23 +89,18 @@ const messages = i18n.define({
|
|
|
87
89
|
stepFailed: "failed",
|
|
88
90
|
stepRejected: "rejected",
|
|
89
91
|
stepWaiting: "waiting",
|
|
92
|
+
checkPassed: "passed",
|
|
93
|
+
checkNotPassed: "not passed",
|
|
94
|
+
checkFindings: ({ count }: { count: number }) => (count === 1 ? "1 finding" : `${count} findings`),
|
|
95
|
+
checkWarnings: ({ count }: { count: number }) => (count === 1 ? "1 warning" : `${count} warnings`),
|
|
96
|
+
openImage: ({ name }: { name: string }) => `Open image ${name}`,
|
|
90
97
|
steered: "Conversation steered",
|
|
91
|
-
reasoning: "Reasoning",
|
|
92
|
-
groupPlan: "Updated working plan",
|
|
93
|
-
groupCode: "Worked with code",
|
|
94
|
-
groupCloud: "Used Cloud integration",
|
|
95
|
-
groupReadFiles: "Read files",
|
|
96
|
-
groupWriteFiles: "Wrote files",
|
|
97
|
-
groupPdf: "Created PDFs",
|
|
98
98
|
groupLoad: "Loaded tools and guidance",
|
|
99
|
-
groupWeb: "Searched the web",
|
|
100
|
-
groupMemory: "Used memory",
|
|
101
|
-
groupDelivered: "Delivered results",
|
|
102
|
-
groupTools: "Used tools",
|
|
103
99
|
groupFailed: ({ count }: { count: number }) => `${count} failed`,
|
|
104
100
|
groupRejected: ({ count }: { count: number }) => `${count} rejected`,
|
|
105
101
|
receiptDone: ({ title }: { title: string }) => `${title} done`,
|
|
106
102
|
receiptApproved: ({ title }: { title: string }) => `Approved: ${title}`,
|
|
103
|
+
receiptRunning: ({ title }: { title: string }) => `Running: ${title}`,
|
|
107
104
|
approvalNeeded: ({ title }: { title: string }) => `Approval needed: ${title}`,
|
|
108
105
|
answerReady: "Answer ready",
|
|
109
106
|
receiptRejected: ({ title }: { title: string }) => `Rejected: ${title}`,
|
|
@@ -114,7 +111,7 @@ const messages = i18n.define({
|
|
|
114
111
|
streamAccessDenied: "You no longer have access to this chat.",
|
|
115
112
|
streamNotFound: "This chat is no longer available.",
|
|
116
113
|
assistant: "Assistant",
|
|
117
|
-
|
|
114
|
+
approvalRunsAfter: "Runs only after you approve it",
|
|
118
115
|
approvalRequired: ({ title }: { title: string }) => `Approval required: ${title}`,
|
|
119
116
|
reject: "Reject",
|
|
120
117
|
approved: "Approved",
|
|
@@ -286,6 +283,7 @@ const messages = i18n.define({
|
|
|
286
283
|
`${count === 1 ? "1 message" : `${count} messages`} summarized · ${date}`,
|
|
287
284
|
compactedHint: "From here on, the model sees this summary instead of the messages above.",
|
|
288
285
|
compactedEmpty: "No visible content",
|
|
286
|
+
malformedImage: "This image could not be read. Export it again or choose another file.",
|
|
289
287
|
},
|
|
290
288
|
de: {
|
|
291
289
|
backgroundRun: "Hintergrundlauf",
|
|
@@ -309,6 +307,7 @@ const messages = i18n.define({
|
|
|
309
307
|
turnErrorTitle: "Die Antwort wurde abgebrochen.",
|
|
310
308
|
turnErrorReason: ({ code, limitMinutes }) => {
|
|
311
309
|
if (code === "model_unavailable") return "Der KI-Dienst hat nicht geantwortet.";
|
|
310
|
+
if (code === "provider_stopped") return "Der KI-Anbieter hat diese Antwort vorzeitig beendet.";
|
|
312
311
|
if (code === "quota_exhausted")
|
|
313
312
|
return "Dein KI-Kontingent für diesen Zeitraum ist aufgebraucht. Du kannst weiterarbeiten, sobald es sich erneuert.";
|
|
314
313
|
if (code === "context_full")
|
|
@@ -361,6 +360,7 @@ const messages = i18n.define({
|
|
|
361
360
|
stepWritingCode: "Schreibt Code",
|
|
362
361
|
stepRunningCode: "Führt Code aus",
|
|
363
362
|
stepCheckingApp: "Prüft die App",
|
|
363
|
+
stepCheckingRun: "Prüft den Lauf",
|
|
364
364
|
stepCode: "Arbeitet mit Code",
|
|
365
365
|
stepPlan: "Aktualisiert den Arbeitsplan",
|
|
366
366
|
stepPdf: "Erstellt ein PDF",
|
|
@@ -377,23 +377,18 @@ const messages = i18n.define({
|
|
|
377
377
|
stepFailed: "fehlgeschlagen",
|
|
378
378
|
stepRejected: "abgelehnt",
|
|
379
379
|
stepWaiting: "wartet",
|
|
380
|
+
checkPassed: "bestanden",
|
|
381
|
+
checkNotPassed: "nicht bestanden",
|
|
382
|
+
checkFindings: ({ count }) => (count === 1 ? "1 Befund" : `${count} Befunde`),
|
|
383
|
+
checkWarnings: ({ count }) => (count === 1 ? "1 Warnung" : `${count} Warnungen`),
|
|
384
|
+
openImage: ({ name }) => `Bild ${name} öffnen`,
|
|
380
385
|
steered: "Richtung geändert",
|
|
381
|
-
reasoning: "Überlegungen",
|
|
382
|
-
groupPlan: "Arbeitsplan aktualisiert",
|
|
383
|
-
groupCode: "Mit Code gearbeitet",
|
|
384
|
-
groupCloud: "Cloud-Integration verwendet",
|
|
385
|
-
groupReadFiles: "Dateien gelesen",
|
|
386
|
-
groupWriteFiles: "Dateien geschrieben",
|
|
387
|
-
groupPdf: "PDFs erstellt",
|
|
388
386
|
groupLoad: "Werkzeuge und Wissen geladen",
|
|
389
|
-
groupWeb: "Im Web recherchiert",
|
|
390
|
-
groupMemory: "Gedächtnis genutzt",
|
|
391
|
-
groupDelivered: "Ergebnisse bereitgestellt",
|
|
392
|
-
groupTools: "Werkzeuge verwendet",
|
|
393
387
|
groupFailed: ({ count }) => `${count} fehlgeschlagen`,
|
|
394
388
|
groupRejected: ({ count }) => `${count} abgelehnt`,
|
|
395
389
|
receiptDone: ({ title }) => `${title} erledigt`,
|
|
396
390
|
receiptApproved: ({ title }) => `Freigegeben: ${title}`,
|
|
391
|
+
receiptRunning: ({ title }) => `Wird ausgeführt: ${title}`,
|
|
397
392
|
approvalNeeded: ({ title }) => `Freigabe nötig: ${title}`,
|
|
398
393
|
answerReady: "Antwort fertig",
|
|
399
394
|
receiptRejected: ({ title }) => `Abgelehnt: ${title}`,
|
|
@@ -404,7 +399,7 @@ const messages = i18n.define({
|
|
|
404
399
|
streamAccessDenied: "Du hast keinen Zugriff mehr auf diesen Chat.",
|
|
405
400
|
streamNotFound: "Dieser Chat ist nicht mehr verfügbar.",
|
|
406
401
|
assistant: "Assistent",
|
|
407
|
-
|
|
402
|
+
approvalRunsAfter: "Wird erst nach deiner Freigabe ausgeführt",
|
|
408
403
|
approvalRequired: ({ title }) => `Freigabe erforderlich: ${title}`,
|
|
409
404
|
reject: "Ablehnen",
|
|
410
405
|
approved: "Freigegeben",
|
|
@@ -575,6 +570,7 @@ const messages = i18n.define({
|
|
|
575
570
|
compactedCount: ({ count, date }) => `${count === 1 ? "1 Nachricht" : `${count} Nachrichten`} zusammengefasst · ${date}`,
|
|
576
571
|
compactedHint: "Ab hier sieht das Modell diese Zusammenfassung statt der Nachrichten darüber.",
|
|
577
572
|
compactedEmpty: "Kein sichtbarer Inhalt",
|
|
573
|
+
malformedImage: "Dieses Bild konnte nicht gelesen werden. Exportiere es erneut oder wähle eine andere Datei.",
|
|
578
574
|
},
|
|
579
575
|
},
|
|
580
576
|
});
|
|
@@ -20,7 +20,7 @@ import { type AiChatActions, AiChatActionsProvider, createAssistantMessageAction
|
|
|
20
20
|
import { isRecord, isSurveyToolName, textFromMessage } from "./message-utils";
|
|
21
21
|
import { aiChatMessages } from "./messages";
|
|
22
22
|
import { type AiToolDisclosureState, createAiToolDisclosureState } from "./tool-disclosure";
|
|
23
|
-
import { type AiTurnLayout, type AiTurnPhase, layoutAiTurn, waitsForUser } from "./turn-layout";
|
|
23
|
+
import { type AiTurnLayout, type AiTurnPhase, layoutAiTurn, storedPhase, waitsForUser } from "./turn-layout";
|
|
24
24
|
import { TurnNavigator } from "./turn-navigator";
|
|
25
25
|
import { activeTimelineSeq } from "./turn-navigator-utils";
|
|
26
26
|
import { type AiTurnDuration, type AiTurnSegment, AiTurnView } from "./turn-view";
|
|
@@ -102,13 +102,6 @@ const leadingSteerOpener = (blocks: readonly AssistantBlock[]): string | null =>
|
|
|
102
102
|
return opener;
|
|
103
103
|
};
|
|
104
104
|
|
|
105
|
-
/** History phase of a finished loop, from how its loop ended. */
|
|
106
|
-
const storedPhase = (entries: readonly AiStoredMessage[]): AiTurnPhase => {
|
|
107
|
-
const reason = entries.findLast((entry) => entry.loopDoneReason)?.loopDoneReason;
|
|
108
|
-
if (!reason || reason === "stop") return "completed";
|
|
109
|
-
return reason === "aborted" ? "stopped" : "failed";
|
|
110
|
-
};
|
|
111
|
-
|
|
112
105
|
/** Responses with a work line, rich places, or a failure notice span the message column so their right edges align. */
|
|
113
106
|
const isWideLayout = (layout: AiTurnLayout, error?: AiTurnError) =>
|
|
114
107
|
layout.showWork || layout.results.length > 0 || layout.actions.length > 0 || Boolean(error);
|
|
@@ -1,28 +1,35 @@
|
|
|
1
1
|
import { Chat, type ChatActivityProps } from "@k2b/ui";
|
|
2
|
-
import { createContext, type JSX, splitProps, useContext } from "solid-js";
|
|
2
|
+
import { createContext, createSignal, type JSX, type Signal, splitProps, useContext } from "solid-js";
|
|
3
3
|
|
|
4
4
|
export type AiToolDisclosureState = {
|
|
5
5
|
get: (blockId: string) => boolean | undefined;
|
|
6
6
|
set: (blockId: string, open: boolean) => void;
|
|
7
7
|
};
|
|
8
8
|
|
|
9
|
+
/**
|
|
10
|
+
* Disclosure choices by block id. Reactive, so a disclosure that its reader opens renders its body at once, also when
|
|
11
|
+
* nothing else in the turn changes; choices survive a remount and, in session storage, a reload.
|
|
12
|
+
*/
|
|
9
13
|
export const createAiToolDisclosureState = (): AiToolDisclosureState => {
|
|
10
|
-
const
|
|
14
|
+
const signals = new Map<string, Signal<boolean | undefined>>();
|
|
11
15
|
const storageKey = (blockId: string) => `cloud.ai.tool-disclosure:${blockId}`;
|
|
16
|
+
const signal = (blockId: string): Signal<boolean | undefined> => {
|
|
17
|
+
const existing = signals.get(blockId);
|
|
18
|
+
if (existing) return existing;
|
|
19
|
+
let saved: boolean | undefined;
|
|
20
|
+
try {
|
|
21
|
+
if (window.sessionStorage.getItem(storageKey(blockId)) === "open") saved = true;
|
|
22
|
+
} catch {
|
|
23
|
+
/* SSR and disabled storage keep the in-memory state. */
|
|
24
|
+
}
|
|
25
|
+
const created = createSignal(saved);
|
|
26
|
+
signals.set(blockId, created);
|
|
27
|
+
return created;
|
|
28
|
+
};
|
|
12
29
|
return {
|
|
13
|
-
get: (blockId) =>
|
|
14
|
-
if (!openByBlockId.has(blockId)) {
|
|
15
|
-
try {
|
|
16
|
-
const saved = window.sessionStorage.getItem(storageKey(blockId));
|
|
17
|
-
if (saved === "open") openByBlockId.set(blockId, true);
|
|
18
|
-
} catch {
|
|
19
|
-
/* SSR and disabled storage keep the in-memory state. */
|
|
20
|
-
}
|
|
21
|
-
}
|
|
22
|
-
return openByBlockId.get(blockId);
|
|
23
|
-
},
|
|
30
|
+
get: (blockId) => signal(blockId)[0](),
|
|
24
31
|
set: (blockId, open) => {
|
|
25
|
-
|
|
32
|
+
signal(blockId)[1](open);
|
|
26
33
|
try {
|
|
27
34
|
if (open) window.sessionStorage.setItem(storageKey(blockId), "open");
|
|
28
35
|
else window.sessionStorage.removeItem(storageKey(blockId));
|
|
@@ -1,10 +1,18 @@
|
|
|
1
|
+
import { aiSkillFilePathFromMount } from "../file-mount";
|
|
1
2
|
import type { AiTurnBlock } from "../protocol";
|
|
2
3
|
import { aiChatMessages } from "./messages";
|
|
3
4
|
|
|
4
5
|
type Tool = Extract<AiTurnBlock, { kind: "tool" }>;
|
|
5
6
|
type Text = Extract<AiTurnBlock, { kind: "text" }>;
|
|
6
7
|
export type AiWorkEntry = Exclude<AiTurnBlock, { kind: "text" } | { kind: "steer_message" }>;
|
|
7
|
-
|
|
8
|
+
/**
|
|
9
|
+
* One row of the expanded work, all at one indent: an intermediate text, a step, or a group that folds housekeeping
|
|
10
|
+
* steps and opens in place.
|
|
11
|
+
*/
|
|
12
|
+
export type AiWorkItem =
|
|
13
|
+
| { kind: "text"; block: Text }
|
|
14
|
+
| { kind: "step"; entry: AiWorkEntry }
|
|
15
|
+
| { kind: "housekeeping"; id: string; entries: AiWorkEntry[] };
|
|
8
16
|
|
|
9
17
|
/** A failed call, including a code run that reported an error. A rejected approval is a decision, not a failure. */
|
|
10
18
|
export function isFailedTool(tool: Tool): boolean {
|
|
@@ -20,46 +28,65 @@ export function isFailedTool(tool: Tool): boolean {
|
|
|
20
28
|
);
|
|
21
29
|
}
|
|
22
30
|
|
|
31
|
+
const HOUSEKEEPING_TOOLS = new Set([
|
|
32
|
+
"load_skill",
|
|
33
|
+
"search_skills",
|
|
34
|
+
"load_tools",
|
|
35
|
+
"search_tools",
|
|
36
|
+
"list_apps",
|
|
37
|
+
"search_help",
|
|
38
|
+
"read_help",
|
|
39
|
+
"search_project",
|
|
40
|
+
"read_project_knowledge",
|
|
41
|
+
]);
|
|
42
|
+
|
|
43
|
+
/** Preparation the reader rarely needs: loading tools, skills, and knowledge, and reading a loaded skill's files. */
|
|
44
|
+
export const isHousekeepingTool = (tool: Tool): boolean => {
|
|
45
|
+
if (HOUSEKEEPING_TOOLS.has(tool.name)) return true;
|
|
46
|
+
if (tool.name !== "read_file" && tool.name !== "list_files") return false;
|
|
47
|
+
const path = tool.args !== null && typeof tool.args === "object" && "path" in tool.args ? tool.args.path : undefined;
|
|
48
|
+
return typeof path === "string" && aiSkillFilePathFromMount(path.trim()) !== null;
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
const isStep = (entry: AiWorkEntry) => entry.kind === "tool" || entry.kind === "compaction";
|
|
52
|
+
|
|
23
53
|
/**
|
|
24
|
-
*
|
|
25
|
-
*
|
|
54
|
+
* The rows of the expanded work, in the original order. A run of at least two housekeeping steps, with the reasoning
|
|
55
|
+
* between them, folds into one group, unless it would hold all but one of the work's steps: then the steps show
|
|
56
|
+
* directly, so the work line never opens to a summary of itself. Entries listed in `shown` already show as rows of
|
|
57
|
+
* their own and never fold into a group, so nothing a reader sees folds away under them. A group is keyed by its first
|
|
58
|
+
* tool, whose id is the same live and in history.
|
|
26
59
|
*/
|
|
27
|
-
export function groupWorkBlocks(blocks: readonly AiTurnBlock[]):
|
|
28
|
-
const
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
60
|
+
export function groupWorkBlocks(blocks: readonly AiTurnBlock[], shown: ReadonlySet<string> = new Set()): AiWorkItem[] {
|
|
61
|
+
const entries = blocks.filter((block): block is AiWorkEntry | Text => block.kind !== "steer_message");
|
|
62
|
+
const steps = entries.filter((entry) => entry.kind !== "text" && isStep(entry)).length;
|
|
63
|
+
const items: AiWorkItem[] = [];
|
|
64
|
+
let run: AiWorkEntry[] = [];
|
|
65
|
+
let trailing: AiWorkEntry[] = [];
|
|
66
|
+
const flush = () => {
|
|
67
|
+
if (run.length > 0) {
|
|
68
|
+
const runSteps = run.filter(isStep).length;
|
|
69
|
+
if (runSteps >= 2 && steps - runSteps >= 2) items.push({ kind: "housekeeping", id: run[0]!.id, entries: run });
|
|
70
|
+
else for (const entry of run) items.push({ kind: "step", entry });
|
|
71
|
+
}
|
|
72
|
+
// Reasoning after the last housekeeping step belongs to what comes next.
|
|
73
|
+
for (const entry of trailing) items.push({ kind: "step", entry });
|
|
74
|
+
run = [];
|
|
75
|
+
trailing = [];
|
|
76
|
+
};
|
|
77
|
+
for (const entry of entries) {
|
|
78
|
+
const foldable = !shown.has(entry.id);
|
|
79
|
+
if (foldable && entry.kind === "tool" && isHousekeepingTool(entry)) {
|
|
80
|
+
run.push(...trailing, entry);
|
|
81
|
+
trailing = [];
|
|
82
|
+
} else if (foldable && entry.kind === "thinking" && run.length > 0) trailing.push(entry);
|
|
83
|
+
else {
|
|
84
|
+
flush();
|
|
85
|
+
items.push(entry.kind === "text" ? { kind: "text", block: entry } : { kind: "step", entry });
|
|
34
86
|
}
|
|
35
|
-
const last = groups.at(-1);
|
|
36
|
-
if (last?.kind === "steps") last.entries.push(block);
|
|
37
|
-
else groups.push({ kind: "steps", id: block.id, entries: [block] });
|
|
38
|
-
}
|
|
39
|
-
for (const group of groups) {
|
|
40
|
-
if (group.kind === "steps") group.id = (group.entries.find((entry) => entry.kind === "tool") ?? group.entries[0]!).id;
|
|
41
87
|
}
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
/** What a group of tools did, in categories; failures and rejections are counted separately. */
|
|
46
|
-
export function summarizeToolGroup(tools: readonly Tool[], locale: string): string {
|
|
47
|
-
const t = aiChatMessages(locale);
|
|
48
|
-
const categories = tools.map((tool) => {
|
|
49
|
-
if (tool.name === "todo_write") return t.groupPlan;
|
|
50
|
-
if (tool.name === "present" || tool.name === "code_present" || tool.name === "card" || tool.name === "cloud_card")
|
|
51
|
-
return t.groupDelivered;
|
|
52
|
-
if (tool.name.startsWith("code_")) return t.groupCode;
|
|
53
|
-
if (tool.presentation?.kind === "capability" || tool.name === "read_cloud_resource") return t.groupCloud;
|
|
54
|
-
if (["read_file", "list_files", "view_image"].includes(tool.name)) return t.groupReadFiles;
|
|
55
|
-
if (tool.name === "write_file") return t.groupWriteFiles;
|
|
56
|
-
if (["markdown_to_pdf", "html_to_pdf"].includes(tool.name)) return t.groupPdf;
|
|
57
|
-
if (["load_skill", "load_tools", "search_tools", "list_apps"].includes(tool.name)) return t.groupLoad;
|
|
58
|
-
if (tool.name.startsWith("web_") || tool.name === "fetch_file") return t.groupWeb;
|
|
59
|
-
if (tool.name === "memory") return t.groupMemory;
|
|
60
|
-
return t.groupTools;
|
|
61
|
-
});
|
|
62
|
-
return [...new Set(categories)].join(", ");
|
|
88
|
+
flush();
|
|
89
|
+
return items;
|
|
63
90
|
}
|
|
64
91
|
|
|
65
92
|
/** "9 steps · 1 failed · 1 rejected" for a group of steps. */
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
import { i18n } from "@k2b/stdlib";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* People-facing names of Cloud's built-in Assistant tools, keyed by tool name. Step rows, receipts, and approvals show
|
|
5
|
+
* these; a capability tool brings its own translated title instead. A tool test fails when a built-in tool has no entry.
|
|
6
|
+
*/
|
|
7
|
+
const toolNames = i18n.define({
|
|
8
|
+
baseLocale: "en",
|
|
9
|
+
messages: {
|
|
10
|
+
en: {
|
|
11
|
+
calculate: "Calculate",
|
|
12
|
+
card: "Card",
|
|
13
|
+
survey: "Survey",
|
|
14
|
+
text_editor: "Text editor",
|
|
15
|
+
local_bash: "Local Bash",
|
|
16
|
+
todo_write: "Working plan",
|
|
17
|
+
memory: "Memory",
|
|
18
|
+
list_files: "List files",
|
|
19
|
+
read_file: "Read file",
|
|
20
|
+
write_file: "Write file",
|
|
21
|
+
fetch_file: "Download file",
|
|
22
|
+
present: "Deliver file",
|
|
23
|
+
markdown_to_pdf: "Markdown to PDF",
|
|
24
|
+
html_to_pdf: "HTML to PDF",
|
|
25
|
+
view_image: "View image",
|
|
26
|
+
transcribe_audio: "Transcribe audio",
|
|
27
|
+
web_search: "Web search",
|
|
28
|
+
web_extract: "Read web page",
|
|
29
|
+
search_tools: "Search tools",
|
|
30
|
+
load_tools: "Load tools",
|
|
31
|
+
list_apps: "List apps",
|
|
32
|
+
search_help: "Search help",
|
|
33
|
+
read_help: "Read help",
|
|
34
|
+
search_project: "Search project",
|
|
35
|
+
read_project_knowledge: "Read project knowledge",
|
|
36
|
+
read_cloud_resource: "Read Cloud item",
|
|
37
|
+
load_skill: "Load skill",
|
|
38
|
+
search_skills: "Search skills",
|
|
39
|
+
code_run: "Run code",
|
|
40
|
+
code_action: "Run app action",
|
|
41
|
+
code_inspect: "Inspect run",
|
|
42
|
+
code_stop: "Stop run",
|
|
43
|
+
code_open: "Open app",
|
|
44
|
+
code_export: "Save run output",
|
|
45
|
+
code_present: "Show app in chat",
|
|
46
|
+
code_check: "App check",
|
|
47
|
+
code_secret: "Enter secret",
|
|
48
|
+
code_database: "App tables",
|
|
49
|
+
code_files: "List stored files",
|
|
50
|
+
code_file_stat: "File details",
|
|
51
|
+
code_file_copy: "Copy file",
|
|
52
|
+
code_database_export: "Export app database",
|
|
53
|
+
code_manage_read: "Read app details",
|
|
54
|
+
code_delete: "Delete app",
|
|
55
|
+
code_database_read: "Read app database",
|
|
56
|
+
code_database_clear: "Clear app data",
|
|
57
|
+
code_database_reset: "Reset app database",
|
|
58
|
+
code_storage_list: "List app storage",
|
|
59
|
+
code_storage_delete: "Delete from app storage",
|
|
60
|
+
code_access_read: "Read app access",
|
|
61
|
+
code_access_change: "Change app access",
|
|
62
|
+
code_actions: "List app actions",
|
|
63
|
+
code_sql: "Query app database",
|
|
64
|
+
code_versions: "List app versions",
|
|
65
|
+
code_list: "Find apps",
|
|
66
|
+
code_read: "Read app source",
|
|
67
|
+
code_history: "App history",
|
|
68
|
+
code_fork: "Copy app",
|
|
69
|
+
code_publish: "Publish app",
|
|
70
|
+
code_unpublish: "Withdraw app",
|
|
71
|
+
code_restore: "Restore app version",
|
|
72
|
+
code_update: "Update app details",
|
|
73
|
+
code_create: "Create app",
|
|
74
|
+
code_write: "Save app files",
|
|
75
|
+
code_remove: "Remove app file",
|
|
76
|
+
},
|
|
77
|
+
de: {
|
|
78
|
+
calculate: "Rechnen",
|
|
79
|
+
card: "Karte",
|
|
80
|
+
survey: "Umfrage",
|
|
81
|
+
text_editor: "Texteditor",
|
|
82
|
+
local_bash: "Lokale Bash",
|
|
83
|
+
todo_write: "Arbeitsplan",
|
|
84
|
+
memory: "Gedächtnis",
|
|
85
|
+
list_files: "Dateien auflisten",
|
|
86
|
+
read_file: "Datei lesen",
|
|
87
|
+
write_file: "Datei schreiben",
|
|
88
|
+
fetch_file: "Datei herunterladen",
|
|
89
|
+
present: "Datei bereitstellen",
|
|
90
|
+
markdown_to_pdf: "Markdown als PDF",
|
|
91
|
+
html_to_pdf: "HTML als PDF",
|
|
92
|
+
view_image: "Bild ansehen",
|
|
93
|
+
transcribe_audio: "Audio transkribieren",
|
|
94
|
+
web_search: "Websuche",
|
|
95
|
+
web_extract: "Webseite lesen",
|
|
96
|
+
search_tools: "Werkzeuge suchen",
|
|
97
|
+
load_tools: "Werkzeuge laden",
|
|
98
|
+
list_apps: "Apps auflisten",
|
|
99
|
+
search_help: "Hilfe durchsuchen",
|
|
100
|
+
read_help: "Hilfe lesen",
|
|
101
|
+
search_project: "Projekt durchsuchen",
|
|
102
|
+
read_project_knowledge: "Projektwissen lesen",
|
|
103
|
+
read_cloud_resource: "Cloud-Eintrag lesen",
|
|
104
|
+
load_skill: "Skill laden",
|
|
105
|
+
search_skills: "Skills suchen",
|
|
106
|
+
code_run: "Code ausführen",
|
|
107
|
+
code_action: "App-Aktion ausführen",
|
|
108
|
+
code_inspect: "Lauf prüfen",
|
|
109
|
+
code_stop: "Lauf beenden",
|
|
110
|
+
code_open: "App öffnen",
|
|
111
|
+
code_export: "Laufergebnis speichern",
|
|
112
|
+
code_present: "App im Chat zeigen",
|
|
113
|
+
code_check: "App-Prüfung",
|
|
114
|
+
code_secret: "Geheimnis eingeben",
|
|
115
|
+
code_database: "App-Tabellen",
|
|
116
|
+
code_files: "Gespeicherte Dateien auflisten",
|
|
117
|
+
code_file_stat: "Dateidetails",
|
|
118
|
+
code_file_copy: "Datei kopieren",
|
|
119
|
+
code_database_export: "App-Datenbank exportieren",
|
|
120
|
+
code_manage_read: "App-Details lesen",
|
|
121
|
+
code_delete: "App löschen",
|
|
122
|
+
code_database_read: "App-Datenbank lesen",
|
|
123
|
+
code_database_clear: "App-Daten leeren",
|
|
124
|
+
code_database_reset: "App-Datenbank zurücksetzen",
|
|
125
|
+
code_storage_list: "App-Speicher auflisten",
|
|
126
|
+
code_storage_delete: "Aus App-Speicher löschen",
|
|
127
|
+
code_access_read: "App-Zugriff lesen",
|
|
128
|
+
code_access_change: "App-Zugriff ändern",
|
|
129
|
+
code_actions: "App-Aktionen auflisten",
|
|
130
|
+
code_sql: "App-Datenbank abfragen",
|
|
131
|
+
code_versions: "App-Versionen auflisten",
|
|
132
|
+
code_list: "Apps suchen",
|
|
133
|
+
code_read: "App-Quelltext lesen",
|
|
134
|
+
code_history: "App-Verlauf",
|
|
135
|
+
code_fork: "App kopieren",
|
|
136
|
+
code_publish: "App veröffentlichen",
|
|
137
|
+
code_unpublish: "App zurückziehen",
|
|
138
|
+
code_restore: "App-Version wiederherstellen",
|
|
139
|
+
code_update: "App-Details ändern",
|
|
140
|
+
code_create: "App erstellen",
|
|
141
|
+
code_write: "App-Dateien speichern",
|
|
142
|
+
code_remove: "App-Datei entfernen",
|
|
143
|
+
},
|
|
144
|
+
},
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
/** Older chats call the visual tools by their `cloud_` names. */
|
|
148
|
+
const ALIASES: Record<string, string> = { cloud_card: "card", cloud_survey: "survey", cloud_text_editor: "text_editor" };
|
|
149
|
+
|
|
150
|
+
/** The localized name of a built-in tool, or undefined for a tool Cloud does not ship. */
|
|
151
|
+
export const aiBuiltinToolName = (name: string, locale: string): string | undefined => {
|
|
152
|
+
const t: Record<string, string> = toolNames.resolve([locale]).t;
|
|
153
|
+
const key = ALIASES[name] ?? name;
|
|
154
|
+
return Object.hasOwn(t, key) ? t[key] : undefined;
|
|
155
|
+
};
|
|
156
|
+
|
|
157
|
+
export const checkAiToolNames = () => toolNames.check();
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { type AiTurnBlock, isRenderableTurnBlock } from "../protocol";
|
|
2
|
+
import type { AiStoredMessage } from "../types";
|
|
2
3
|
import { hasCapabilityTable } from "./capability-result";
|
|
3
4
|
import { isCardToolName, isRecord, isSurveyToolName, isTextEditorToolName } from "./message-utils";
|
|
4
5
|
import { isFailedTool } from "./tool-groups";
|
|
@@ -78,9 +79,9 @@ const actionState = (block: ToolBlock, phase: AiTurnPhase): AiTurnActionState |
|
|
|
78
79
|
if (block.status === "rejected") return "rejected";
|
|
79
80
|
if (isSurveyToolName(block.name) || isTextEditorToolName(block.name)) return isFailedTool(block) ? null : "interaction";
|
|
80
81
|
if (block.name === "code_secret") return block.status === "awaiting_client" ? "interaction" : null;
|
|
81
|
-
// Effects in Cloud and decided
|
|
82
|
+
// Effects in Cloud and approvals, decided or expired, stay visible as receipts, whether or not the model mentions them.
|
|
82
83
|
const capabilityAction = block.presentation?.kind === "capability" && block.presentation.capabilityKind === "action";
|
|
83
|
-
if (!capabilityAction &&
|
|
84
|
+
if (!capabilityAction && block.approved === undefined) return null;
|
|
84
85
|
if (block.status === "running" || block.status === "awaiting_client") return live(phase) ? "running" : "not_run";
|
|
85
86
|
return isFailedTool(block) ? "failed" : "done";
|
|
86
87
|
};
|
|
@@ -139,3 +140,14 @@ export function layoutAiTurn(input: readonly AiTurnBlock[], options: { phase: Ai
|
|
|
139
140
|
waitingFor,
|
|
140
141
|
};
|
|
141
142
|
}
|
|
143
|
+
|
|
144
|
+
/** The loop ending is authoritative; partial messages also carry their own failed or interrupted ending. */
|
|
145
|
+
export const storedPhase = (entries: readonly Pick<AiStoredMessage, "loopDoneReason" | "stopReason">[]): AiTurnPhase => {
|
|
146
|
+
const reason = entries.findLast((entry) => entry.loopDoneReason)?.loopDoneReason;
|
|
147
|
+
if (reason === "aborted") return "stopped";
|
|
148
|
+
if (reason && reason !== "stop") return "failed";
|
|
149
|
+
const stop = entries.findLast((entry) => entry.stopReason)?.stopReason;
|
|
150
|
+
if (stop === "error") return "failed";
|
|
151
|
+
if (stop === "interrupted" || stop === "aborted") return "stopped";
|
|
152
|
+
return "completed";
|
|
153
|
+
};
|