@k2b/cloud 0.27.0 → 0.28.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 +2 -2
- package/src/ai/browser-code-contracts.ts +33 -63
- package/src/ai/browser.ts +1 -0
- package/src/ai/chat/blocks.tsx +16 -7
- package/src/ai/chat/live-turn.browser-harness.tsx +6 -3
- package/src/ai/chat/message-actions.tsx +123 -101
- package/src/ai/chat/message-utils.ts +30 -1
- package/src/ai/chat/messages.ts +198 -0
- package/src/ai/chat/presentation.tsx +66 -20
- package/src/ai/chat/tool-groups.ts +1 -1
- package/src/ai/chat/turn-error.ts +21 -0
- package/src/ai/chat/turn-view.tsx +55 -4
- package/src/ai/chat/user-message.tsx +19 -14
- package/src/ai/chat/visual-tools.tsx +1 -1
- package/src/ai/client/controller.ts +23 -6
- package/src/ai/code-mode-skill.ts +21 -25
- package/src/ai/code-runtime-tools.ts +5 -1
- package/src/ai/code-source-contracts.ts +2 -2
- package/src/ai/data-analysis-skill.ts +3 -3
- package/src/ai/default-tools.ts +15 -16
- package/src/ai/executor.ts +135 -59
- package/src/ai/index.ts +2 -0
- package/src/ai/open-tool-calls.ts +87 -0
- package/src/ai/routes.ts +6 -0
- package/src/ai/run-timeout.ts +4 -5
- package/src/ai/runtime.ts +8 -0
- package/src/ai/skill-seeds.ts +4 -4
- package/src/ai/store.ts +46 -14
- package/src/ai/system-prompt.ts +4 -21
- package/src/ai/turn-failure.ts +100 -0
- package/src/ai/turn-policy.ts +2 -1
- package/src/ai/types.ts +28 -1
- package/src/api/admin-outgoing-mail.ts +185 -5
- package/src/browser/FileChooser.tsx +11 -0
- package/src/browser/file-chooser-messages.ts +2 -0
- package/src/cli/admin/index.ts +3 -1
- package/src/cli/admin/notifications.ts +6 -0
- package/src/cli/admin/outgoing-mail.ts +104 -7
- package/src/contracts/outgoing-mail.ts +189 -1
- package/src/services/help/store.ts +2 -1
- package/src/services/index.ts +11 -1
- package/src/services/notifications/batches.ts +139 -79
- package/src/services/notifications/channels.ts +33 -13
- package/src/services/notifications/dispatcher.ts +57 -5
- package/src/services/notifications/email-frame.fixture.html +51 -0
- package/src/services/notifications/{email.ts → email-frame.ts} +12 -35
- package/src/services/notifications/email-mail.ts +101 -0
- package/src/services/notifications/index.ts +49 -36
- package/src/services/notifications/observability.ts +9 -1
- package/src/services/notifications/platform.ts +1 -1
- package/src/services/notifications/runtime.ts +9 -3
- package/src/services/outgoing-mail/admin.ts +84 -0
- package/src/services/outgoing-mail/attachments.ts +135 -0
- package/src/services/outgoing-mail/bulk.ts +11 -0
- package/src/services/outgoing-mail/dispatcher.ts +231 -0
- package/src/services/outgoing-mail/drain.ts +60 -0
- package/src/services/outgoing-mail/enqueue.ts +180 -0
- package/src/services/outgoing-mail/index.ts +103 -10
- package/src/services/outgoing-mail/message.ts +14 -0
- package/src/services/outgoing-mail/messages.ts +431 -0
- package/src/services/outgoing-mail/retention.ts +48 -0
- package/src/services/outgoing-mail/runtime.ts +72 -0
- package/src/services/outgoing-mail/send.ts +130 -0
- package/src/services/outgoing-mail/store.ts +115 -4
- package/src/services/outgoing-mail/sync.ts +39 -0
- package/src/services/outgoing-mail/transport.ts +5 -1
- package/src/services/pdf/markdown.ts +22 -4
- package/src/services/postgres.ts +15 -0
- package/src/services/settings/core-settings.ts +18 -0
- package/src/shared/ai-platform-prompt.ts +48 -4
- package/src/shared/markdown/extensions/links.ts +28 -20
- package/src/shared/markdown/index.ts +14 -5
- package/src/shared/markdown/shared.ts +0 -7
- package/src/ssr/GlobalAnnouncements.island.tsx +1 -1
- package/src/ssr/platform-messages.ts +1 -1
- package/src/styles/effects.css +15 -17
- package/src/styles/tokens.css +2 -0
- package/src/styles/utilities-markdown-editor.css +4 -39
- package/src/styles/utilities-markdown-table.css +14 -17
package/src/ai/chat/messages.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { i18n } from "@k2b/stdlib";
|
|
2
|
+
import type { AiTurnError } from "../types";
|
|
2
3
|
|
|
3
4
|
const messages = i18n.define({
|
|
4
5
|
baseLocale: "en",
|
|
@@ -21,6 +22,24 @@ const messages = i18n.define({
|
|
|
21
22
|
thinking: "Thinking",
|
|
22
23
|
showReasoning: "Show reasoning",
|
|
23
24
|
reconnecting: "Reconnecting",
|
|
25
|
+
turnErrorTitle: "The answer was interrupted.",
|
|
26
|
+
turnErrorReason: ({ code, limitMinutes }: AiTurnError) => {
|
|
27
|
+
if (code === "model_unavailable") return "The model service did not answer.";
|
|
28
|
+
if (code === "quota_exhausted") return "Your AI usage limit for this period is reached. You can continue once it resets.";
|
|
29
|
+
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
|
+
if (code === "time_limit")
|
|
31
|
+
return limitMinutes ? `The run time limit of ${limitMinutes} minutes was reached.` : "The run time limit was reached.";
|
|
32
|
+
if (code === "step_limit") return "The model reached its step limit for one answer.";
|
|
33
|
+
if (code === "wait_expired") return "The approval or answer did not arrive in time.";
|
|
34
|
+
if (code === "interrupted") return "The work was interrupted several times and could not be resumed.";
|
|
35
|
+
if (code === "not_allowed")
|
|
36
|
+
return "This chat may no longer use its model, project, or apps. Choose another model or ask your administrator.";
|
|
37
|
+
return "Something went wrong.";
|
|
38
|
+
},
|
|
39
|
+
turnErrorKept: "The results so far are kept.",
|
|
40
|
+
turnErrorContinueHint: "Send a new message to continue.",
|
|
41
|
+
continueTurn: "Continue",
|
|
42
|
+
continueTurnMessage: "Continue where you left off.",
|
|
24
43
|
byteRange: ({ start, end }: { start: string; end: string }) => `Bytes ${start}–${end}`,
|
|
25
44
|
worked: "Worked",
|
|
26
45
|
workedFor: ({ duration }: { duration: string }) => `Worked ${duration}`,
|
|
@@ -187,6 +206,86 @@ const messages = i18n.define({
|
|
|
187
206
|
calculate: "Calculate",
|
|
188
207
|
inspectImage: ({ name }: { name: string }) => `Inspect image: ${name}`,
|
|
189
208
|
readResource: ({ ref }: { ref: string }) => `Read resource: ${ref}`,
|
|
209
|
+
actionHelpful: "Helpful",
|
|
210
|
+
actionHelpfulRemove: "Remove positive feedback",
|
|
211
|
+
actionImprove: "Needs improvement",
|
|
212
|
+
actionImproveRemove: "Remove negative feedback",
|
|
213
|
+
actionMessageInfo: "Response details",
|
|
214
|
+
actionCopy: "Copy",
|
|
215
|
+
actionFork: "Continue in a new chat",
|
|
216
|
+
actionTryAgain: "Try again",
|
|
217
|
+
actionMoreDetailed: "More detailed",
|
|
218
|
+
actionMoreConcise: "More concise",
|
|
219
|
+
actionEditPrompt: "Edit and try again",
|
|
220
|
+
actionRetryGuidance: "Send guidance again",
|
|
221
|
+
cancel: "Cancel",
|
|
222
|
+
forkTitle: "Continue in a new chat",
|
|
223
|
+
forkConfirm: "Create chat",
|
|
224
|
+
forkName: "Chat name",
|
|
225
|
+
forkDefaultName: "Continued chat",
|
|
226
|
+
retryTitle: "Edit and try again",
|
|
227
|
+
retryPrompt: "Message",
|
|
228
|
+
retryPromptHint: "Attachments from the original message stay attached.",
|
|
229
|
+
retryFailed: "Could not try again. Your changes are still here.",
|
|
230
|
+
retrying: "Trying again",
|
|
231
|
+
feedbackTitle: "Help improve this response",
|
|
232
|
+
feedbackIntro: "What should be better? Choose all that apply or leave a short note.",
|
|
233
|
+
feedbackDetails: "Anything else",
|
|
234
|
+
feedbackDetailsHint: "Optional. Please leave out confidential information.",
|
|
235
|
+
feedbackSend: "Send feedback",
|
|
236
|
+
feedbackIncorrect: "Incorrect",
|
|
237
|
+
feedbackIncorrectHint: "Facts or conclusions were wrong.",
|
|
238
|
+
feedbackIgnoredRequest: "Did not do what I asked",
|
|
239
|
+
feedbackIgnoredRequestHint: "Important instructions were missed.",
|
|
240
|
+
feedbackIncomplete: "Incomplete",
|
|
241
|
+
feedbackIncompleteHint: "Important information or work was missing.",
|
|
242
|
+
feedbackWrongApp: "Used the wrong app or tool",
|
|
243
|
+
feedbackWrongAppHint: "Something was missing, unnecessary, or used badly.",
|
|
244
|
+
feedbackSlow: "Too slow",
|
|
245
|
+
feedbackSlowHint: "The answer took too long for the result.",
|
|
246
|
+
feedbackOther: "Something else",
|
|
247
|
+
feedbackOtherHint: "Something else should be better.",
|
|
248
|
+
infoSubtitle: "Technical details of this answer",
|
|
249
|
+
infoDetails: "Details",
|
|
250
|
+
infoProviderModel: "Provider model",
|
|
251
|
+
infoModelProfile: "Model",
|
|
252
|
+
infoLoopId: "Run ID",
|
|
253
|
+
infoFinished: "Ended",
|
|
254
|
+
infoEndCompleted: "Completed",
|
|
255
|
+
infoEndStopped: "Stopped",
|
|
256
|
+
infoEndFailed: "Failed",
|
|
257
|
+
infoEndInterrupted: "Interrupted",
|
|
258
|
+
infoEndStepLimit: "Step limit reached",
|
|
259
|
+
infoEndUsageLimit: "Usage limit reached",
|
|
260
|
+
infoEndContextFull: "Context window full",
|
|
261
|
+
infoEndLengthLimit: "Answer length limit reached",
|
|
262
|
+
infoCreated: "Created",
|
|
263
|
+
infoUnknown: "Unknown",
|
|
264
|
+
infoLegacy: "Older message",
|
|
265
|
+
infoTiming: "Time",
|
|
266
|
+
infoWorked: "Worked",
|
|
267
|
+
infoWorkedHint: "writing and tools",
|
|
268
|
+
infoGeneration: "Writing",
|
|
269
|
+
infoToolTime: "Tools",
|
|
270
|
+
infoWaiting: "Waiting for you",
|
|
271
|
+
infoWaitingHint: "approvals and answers",
|
|
272
|
+
infoWallClock: "Total time",
|
|
273
|
+
infoSpeed: "Writing speed",
|
|
274
|
+
infoTokensPerSecond: ({ value }: { value: string }) => `${value} tokens/s`,
|
|
275
|
+
infoUsage: "Usage",
|
|
276
|
+
infoInputTokens: "Input tokens",
|
|
277
|
+
infoOutputTokens: "Output tokens",
|
|
278
|
+
infoTotalTokens: "Total tokens",
|
|
279
|
+
infoTools: "Tools",
|
|
280
|
+
infoToolsHint: "Tools this answer used.",
|
|
281
|
+
infoIssueErrors: "errors",
|
|
282
|
+
infoIssueStream: "connection problems",
|
|
283
|
+
infoIssueMalformed: "malformed",
|
|
284
|
+
infoIssueCancelled: "cancelled",
|
|
285
|
+
compactedCount: ({ count, date }: { count: number; date: string }) =>
|
|
286
|
+
`${count === 1 ? "1 message" : `${count} messages`} summarized · ${date}`,
|
|
287
|
+
compactedHint: "From here on, the model sees this summary instead of the messages above.",
|
|
288
|
+
compactedEmpty: "No visible content",
|
|
190
289
|
},
|
|
191
290
|
de: {
|
|
192
291
|
backgroundRun: "Hintergrundlauf",
|
|
@@ -207,6 +306,26 @@ const messages = i18n.define({
|
|
|
207
306
|
thinking: "Denkt nach",
|
|
208
307
|
showReasoning: "Denkprozess anzeigen",
|
|
209
308
|
reconnecting: "Verbindung wird wiederhergestellt",
|
|
309
|
+
turnErrorTitle: "Die Antwort wurde abgebrochen.",
|
|
310
|
+
turnErrorReason: ({ code, limitMinutes }) => {
|
|
311
|
+
if (code === "model_unavailable") return "Der KI-Dienst hat nicht geantwortet.";
|
|
312
|
+
if (code === "quota_exhausted")
|
|
313
|
+
return "Dein KI-Kontingent für diesen Zeitraum ist aufgebraucht. Du kannst weiterarbeiten, sobald es sich erneuert.";
|
|
314
|
+
if (code === "context_full")
|
|
315
|
+
return "Dieser Chat ist zu lang für das Modell. Starte einen neuen Chat, um weiterzuarbeiten; dieser bleibt erhalten.";
|
|
316
|
+
if (code === "time_limit")
|
|
317
|
+
return limitMinutes ? `Das Laufzeitlimit von ${limitMinutes} Minuten ist erreicht.` : "Das Laufzeitlimit ist erreicht.";
|
|
318
|
+
if (code === "step_limit") return "Das Modell hat sein Schrittlimit für eine Antwort erreicht.";
|
|
319
|
+
if (code === "wait_expired") return "Die Freigabe oder Antwort kam nicht rechtzeitig.";
|
|
320
|
+
if (code === "interrupted") return "Die Arbeit wurde mehrmals unterbrochen und konnte nicht fortgesetzt werden.";
|
|
321
|
+
if (code === "not_allowed")
|
|
322
|
+
return "Dieser Chat darf sein Modell, sein Projekt oder seine Apps nicht mehr nutzen. Wähle ein anderes Modell oder wende dich an deine Administration.";
|
|
323
|
+
return "Etwas ist schiefgelaufen.";
|
|
324
|
+
},
|
|
325
|
+
turnErrorKept: "Die bisherigen Ergebnisse bleiben erhalten.",
|
|
326
|
+
turnErrorContinueHint: "Mit einer neuen Nachricht geht es weiter.",
|
|
327
|
+
continueTurn: "Weiterarbeiten",
|
|
328
|
+
continueTurnMessage: "Mach an der Stelle weiter, an der du aufgehört hast.",
|
|
210
329
|
byteRange: ({ start, end }) => `Bytes ${start}–${end}`,
|
|
211
330
|
worked: "Gearbeitet",
|
|
212
331
|
workedFor: ({ duration }) => `${duration} gearbeitet`,
|
|
@@ -377,6 +496,85 @@ const messages = i18n.define({
|
|
|
377
496
|
calculate: "Berechnen",
|
|
378
497
|
inspectImage: ({ name }) => `Bild prüfen: ${name}`,
|
|
379
498
|
readResource: ({ ref }) => `Ressource lesen: ${ref}`,
|
|
499
|
+
actionHelpful: "Hilfreich",
|
|
500
|
+
actionHelpfulRemove: "Positive Bewertung entfernen",
|
|
501
|
+
actionImprove: "Verbesserungswürdig",
|
|
502
|
+
actionImproveRemove: "Negative Bewertung entfernen",
|
|
503
|
+
actionMessageInfo: "Details zur Antwort",
|
|
504
|
+
actionCopy: "Kopieren",
|
|
505
|
+
actionFork: "In neuem Chat fortsetzen",
|
|
506
|
+
actionTryAgain: "Noch einmal versuchen",
|
|
507
|
+
actionMoreDetailed: "Ausführlicher",
|
|
508
|
+
actionMoreConcise: "Kürzer",
|
|
509
|
+
actionEditPrompt: "Bearbeiten und neu senden",
|
|
510
|
+
actionRetryGuidance: "Hinweis erneut senden",
|
|
511
|
+
cancel: "Abbrechen",
|
|
512
|
+
forkTitle: "In neuem Chat fortsetzen",
|
|
513
|
+
forkConfirm: "Chat erstellen",
|
|
514
|
+
forkName: "Name des Chats",
|
|
515
|
+
forkDefaultName: "Fortgesetzter Chat",
|
|
516
|
+
retryTitle: "Bearbeiten und neu senden",
|
|
517
|
+
retryPrompt: "Nachricht",
|
|
518
|
+
retryPromptHint: "Die Anhänge der ursprünglichen Nachricht bleiben erhalten.",
|
|
519
|
+
retryFailed: "Das hat nicht geklappt. Deine Änderungen sind noch da.",
|
|
520
|
+
retrying: "Wird gesendet",
|
|
521
|
+
feedbackTitle: "Hilf, diese Antwort zu verbessern",
|
|
522
|
+
feedbackIntro: "Was sollte besser sein? Wähle alles Passende aus oder schreib eine kurze Notiz.",
|
|
523
|
+
feedbackDetails: "Sonst noch etwas",
|
|
524
|
+
feedbackDetailsHint: "Optional. Bitte keine vertraulichen Daten angeben.",
|
|
525
|
+
feedbackSend: "Feedback senden",
|
|
526
|
+
feedbackIncorrect: "Falsch",
|
|
527
|
+
feedbackIncorrectHint: "Fakten oder Schlussfolgerungen stimmten nicht.",
|
|
528
|
+
feedbackIgnoredRequest: "Hat nicht getan, worum ich gebeten habe",
|
|
529
|
+
feedbackIgnoredRequestHint: "Wichtige Anweisungen wurden übersehen.",
|
|
530
|
+
feedbackIncomplete: "Unvollständig",
|
|
531
|
+
feedbackIncompleteHint: "Wichtige Informationen oder Arbeitsschritte fehlten.",
|
|
532
|
+
feedbackWrongApp: "Falsche App oder falsches Werkzeug",
|
|
533
|
+
feedbackWrongAppHint: "Etwas fehlte, war unnötig oder wurde falsch genutzt.",
|
|
534
|
+
feedbackSlow: "Zu langsam",
|
|
535
|
+
feedbackSlowHint: "Die Antwort hat für das Ergebnis zu lange gedauert.",
|
|
536
|
+
feedbackOther: "Etwas anderes",
|
|
537
|
+
feedbackOtherHint: "Etwas anderes sollte besser werden.",
|
|
538
|
+
infoSubtitle: "Technische Angaben zu dieser Antwort",
|
|
539
|
+
infoDetails: "Details",
|
|
540
|
+
infoProviderModel: "Modell beim Anbieter",
|
|
541
|
+
infoModelProfile: "Modell",
|
|
542
|
+
infoLoopId: "Durchlauf-ID",
|
|
543
|
+
infoFinished: "Beendet",
|
|
544
|
+
infoEndCompleted: "Abgeschlossen",
|
|
545
|
+
infoEndStopped: "Gestoppt",
|
|
546
|
+
infoEndFailed: "Fehlgeschlagen",
|
|
547
|
+
infoEndInterrupted: "Unterbrochen",
|
|
548
|
+
infoEndStepLimit: "Schrittlimit erreicht",
|
|
549
|
+
infoEndUsageLimit: "Nutzungslimit erreicht",
|
|
550
|
+
infoEndContextFull: "Kontextfenster voll",
|
|
551
|
+
infoEndLengthLimit: "Maximale Antwortlänge erreicht",
|
|
552
|
+
infoCreated: "Erstellt",
|
|
553
|
+
infoUnknown: "Unbekannt",
|
|
554
|
+
infoLegacy: "Ältere Nachricht",
|
|
555
|
+
infoTiming: "Zeit",
|
|
556
|
+
infoWorked: "Gearbeitet",
|
|
557
|
+
infoWorkedHint: "Schreiben und Werkzeuge",
|
|
558
|
+
infoGeneration: "Schreiben",
|
|
559
|
+
infoToolTime: "Werkzeuge",
|
|
560
|
+
infoWaiting: "Wartezeit auf dich",
|
|
561
|
+
infoWaitingHint: "Freigaben und Antworten",
|
|
562
|
+
infoWallClock: "Gesamtdauer",
|
|
563
|
+
infoSpeed: "Schreibtempo",
|
|
564
|
+
infoTokensPerSecond: ({ value }) => `${value} Token/s`,
|
|
565
|
+
infoUsage: "Verbrauch",
|
|
566
|
+
infoInputTokens: "Eingabe-Token",
|
|
567
|
+
infoOutputTokens: "Ausgabe-Token",
|
|
568
|
+
infoTotalTokens: "Token gesamt",
|
|
569
|
+
infoTools: "Werkzeuge",
|
|
570
|
+
infoToolsHint: "Werkzeuge, die diese Antwort genutzt hat.",
|
|
571
|
+
infoIssueErrors: "Fehler",
|
|
572
|
+
infoIssueStream: "Verbindungsprobleme",
|
|
573
|
+
infoIssueMalformed: "fehlerhaft",
|
|
574
|
+
infoIssueCancelled: "abgebrochen",
|
|
575
|
+
compactedCount: ({ count, date }) => `${count === 1 ? "1 Nachricht" : `${count} Nachrichten`} zusammengefasst · ${date}`,
|
|
576
|
+
compactedHint: "Ab hier sieht das Modell diese Zusammenfassung statt der Nachrichten darüber.",
|
|
577
|
+
compactedEmpty: "Kein sichtbarer Inhalt",
|
|
380
578
|
},
|
|
381
579
|
},
|
|
382
580
|
});
|
|
@@ -15,9 +15,10 @@ import {
|
|
|
15
15
|
import type { AiActiveTurn } from "../client/projection";
|
|
16
16
|
import { type AiActiveTurnSegment, type AiTurnBlock, splitActiveTurnBlocks } from "../protocol";
|
|
17
17
|
import { type AiAssistantTimelineItem, buildAiMessageTimeline } from "../timeline";
|
|
18
|
-
import type { AiConversationTimelineEntry, AiStoredMessage } from "../types";
|
|
18
|
+
import type { AiConversationTimelineEntry, AiStoredMessage, AiTurnError } from "../types";
|
|
19
19
|
import { type AiChatActions, AiChatActionsProvider, createAssistantMessageActions, useAiChatActions } from "./message-actions";
|
|
20
20
|
import { isRecord, isSurveyToolName, textFromMessage } from "./message-utils";
|
|
21
|
+
import { aiChatMessages } from "./messages";
|
|
21
22
|
import { type AiToolDisclosureState, createAiToolDisclosureState } from "./tool-disclosure";
|
|
22
23
|
import { type AiTurnLayout, type AiTurnPhase, layoutAiTurn, waitsForUser } from "./turn-layout";
|
|
23
24
|
import { TurnNavigator } from "./turn-navigator";
|
|
@@ -108,8 +109,9 @@ const storedPhase = (entries: readonly AiStoredMessage[]): AiTurnPhase => {
|
|
|
108
109
|
return reason === "aborted" ? "stopped" : "failed";
|
|
109
110
|
};
|
|
110
111
|
|
|
111
|
-
/** Responses with a work line
|
|
112
|
-
const isWideLayout = (layout: AiTurnLayout) =>
|
|
112
|
+
/** Responses with a work line, rich places, or a failure notice span the message column so their right edges align. */
|
|
113
|
+
const isWideLayout = (layout: AiTurnLayout, error?: AiTurnError) =>
|
|
114
|
+
layout.showWork || layout.results.length > 0 || layout.actions.length > 0 || Boolean(error);
|
|
113
115
|
|
|
114
116
|
const isEmptyLayout = (layout: AiTurnLayout) => !isWideLayout(layout) && layout.text.length === 0;
|
|
115
117
|
|
|
@@ -162,13 +164,49 @@ const storedItems = (
|
|
|
162
164
|
actions: AiChatActions,
|
|
163
165
|
views: TurnViews,
|
|
164
166
|
locale: string,
|
|
167
|
+
idle: boolean,
|
|
165
168
|
): ChatTimelineItem[] => {
|
|
166
169
|
const timeline = buildAiMessageTimeline([...messages]);
|
|
167
170
|
const lastItemOfLoop = new Map<string, number>();
|
|
171
|
+
// A failed turn's notice ends its last item, which is its user message when the model never answered.
|
|
172
|
+
const lastEntryOfLoop = new Map<string, number>();
|
|
168
173
|
timeline.forEach((item, index) => {
|
|
169
|
-
|
|
174
|
+
const loopId = item.type === "summary" ? null : item.type === "user" ? item.entry.loopId : item.loopId;
|
|
175
|
+
if (!loopId) return;
|
|
176
|
+
lastEntryOfLoop.set(loopId, index);
|
|
177
|
+
if (item.type === "assistant") lastItemOfLoop.set(loopId, index);
|
|
170
178
|
});
|
|
179
|
+
const turnErrors = new Map<string, AiTurnError>();
|
|
180
|
+
for (const message of messages) if (message.loopId && message.meta?.turnError) turnErrors.set(message.loopId, message.meta.turnError);
|
|
181
|
+
// Only the chat's newest turn can be continued, and only while no other turn runs.
|
|
182
|
+
const newestLoop = idle ? (messages.at(-1)?.loopId ?? null) : null;
|
|
171
183
|
const segmentId = createSegmentIds();
|
|
184
|
+
// The model never answered: the notice stands where the live turn showed its progress, under the same id.
|
|
185
|
+
const unansweredNotice = (entry: AiStoredMessage, itemIndex: number): ChatTimelineItem[] => {
|
|
186
|
+
const loopId = entry.loopId;
|
|
187
|
+
const error = loopId && lastEntryOfLoop.get(loopId) === itemIndex ? turnErrors.get(loopId) : undefined;
|
|
188
|
+
if (!loopId || !error) return [];
|
|
189
|
+
const id = segmentId(loopId, "start");
|
|
190
|
+
return [
|
|
191
|
+
{
|
|
192
|
+
kind: "message",
|
|
193
|
+
id,
|
|
194
|
+
role: "assistant",
|
|
195
|
+
createdAt: entry.createdAt,
|
|
196
|
+
class: "ai-chat-message-wide",
|
|
197
|
+
content: views.content({
|
|
198
|
+
id,
|
|
199
|
+
turnId: loopId,
|
|
200
|
+
phase: "failed",
|
|
201
|
+
layout: layoutAiTurn([], { phase: "failed" }),
|
|
202
|
+
earlier: false,
|
|
203
|
+
duration: () => null,
|
|
204
|
+
error,
|
|
205
|
+
continuable: newestLoop === loopId,
|
|
206
|
+
}),
|
|
207
|
+
},
|
|
208
|
+
];
|
|
209
|
+
};
|
|
172
210
|
return timeline.flatMap((item, itemIndex): ChatTimelineItem | ChatTimelineItem[] => {
|
|
173
211
|
if (item.type === "user") {
|
|
174
212
|
const text = aiUserMessageText(item.entry);
|
|
@@ -176,7 +214,7 @@ const storedItems = (
|
|
|
176
214
|
if (agentMessage) {
|
|
177
215
|
const separator = text.indexOf("\n\n");
|
|
178
216
|
const forwardedText = separator >= 0 ? text.slice(separator + 2) : text;
|
|
179
|
-
|
|
217
|
+
const systemItem: ChatTimelineItem = {
|
|
180
218
|
kind: "message",
|
|
181
219
|
id: item.id,
|
|
182
220
|
role: "system",
|
|
@@ -208,18 +246,20 @@ const storedItems = (
|
|
|
208
246
|
),
|
|
209
247
|
anchorId: item.entry.seq,
|
|
210
248
|
};
|
|
249
|
+
return [systemItem, ...unansweredNotice(item.entry, itemIndex)];
|
|
211
250
|
}
|
|
212
|
-
|
|
251
|
+
const userItem: ChatTimelineItem = {
|
|
213
252
|
kind: "message",
|
|
214
253
|
id: item.id,
|
|
215
254
|
role: "user",
|
|
216
255
|
createdAt: item.entry.createdAt,
|
|
217
256
|
content: text ? <p class="whitespace-pre-wrap">{text}</p> : undefined,
|
|
218
257
|
attachments: aiUserMessageAttachments(item.entry, actions),
|
|
219
|
-
actions: createAiUserMessageActions(item.entry, actions),
|
|
258
|
+
actions: createAiUserMessageActions(item.entry, actions, locale),
|
|
220
259
|
actionDisplay: "menu",
|
|
221
260
|
anchorId: item.entry.seq,
|
|
222
261
|
};
|
|
262
|
+
return [userItem, ...unansweredNotice(item.entry, itemIndex)];
|
|
223
263
|
}
|
|
224
264
|
|
|
225
265
|
if (item.type === "summary") {
|
|
@@ -228,16 +268,14 @@ const storedItems = (
|
|
|
228
268
|
return {
|
|
229
269
|
kind: "activity",
|
|
230
270
|
id: item.id,
|
|
231
|
-
label:
|
|
232
|
-
description: count ?
|
|
271
|
+
label: aiChatMessages(locale).compacted,
|
|
272
|
+
description: count ? aiChatMessages(locale).compactedCount({ count, date }) : date,
|
|
233
273
|
icon: "ti ti-brain",
|
|
234
274
|
tone: "ai",
|
|
235
275
|
content: (
|
|
236
276
|
<div>
|
|
237
|
-
<p class="mb-1 text-
|
|
238
|
-
|
|
239
|
-
</p>
|
|
240
|
-
<p class="whitespace-pre-wrap">{textFromMessage(item.entry.message) || "No visible content"}</p>
|
|
277
|
+
<p class="mb-1 text-dimmed">{aiChatMessages(locale).compactedHint}</p>
|
|
278
|
+
<p class="whitespace-pre-wrap">{textFromMessage(item.entry.message) || aiChatMessages(locale).compactedEmpty}</p>
|
|
241
279
|
</div>
|
|
242
280
|
),
|
|
243
281
|
};
|
|
@@ -245,9 +283,12 @@ const storedItems = (
|
|
|
245
283
|
|
|
246
284
|
const actionEntry = item.actionEntry?.compactedAt ? null : item.actionEntry;
|
|
247
285
|
const lastOfLoop = !item.loopId || lastItemOfLoop.get(item.loopId) === itemIndex;
|
|
248
|
-
const
|
|
286
|
+
const turnError = item.loopId && lastOfLoop ? turnErrors.get(item.loopId) : undefined;
|
|
287
|
+
const phase = turnError ? "failed" : lastOfLoop ? storedPhase(item.entries) : "completed";
|
|
249
288
|
const segments = splitSurveyResults(item.blocks);
|
|
250
|
-
|
|
289
|
+
// A turn that failed after an accepted answer ends with its notice where the live turn showed its progress, below
|
|
290
|
+
// the answer and under the same id, as activeItems() places it.
|
|
291
|
+
if (segments.length === 0 || (turnError && segments.at(-1)?.type === "survey")) segments.push({ type: "assistant", blocks: [] });
|
|
251
292
|
const lastAssistant = segments.findLastIndex((segment) => segment.type === "assistant");
|
|
252
293
|
const scheduledTask = item.entries.find((entry) => entry.meta?.scheduledTask)?.meta?.scheduledTask ?? null;
|
|
253
294
|
let firstSegment = true;
|
|
@@ -268,7 +309,8 @@ const storedItems = (
|
|
|
268
309
|
: undefined;
|
|
269
310
|
const task = firstSegment ? scheduledTask : null;
|
|
270
311
|
firstSegment = false;
|
|
271
|
-
|
|
312
|
+
const error = last ? turnError : undefined;
|
|
313
|
+
if (isEmptyLayout(layout) && !messageActions && !task && !error) return [];
|
|
272
314
|
const id = item.loopId ? segmentId(item.loopId, segmentOpener) : `${item.id}:${index}`;
|
|
273
315
|
return [
|
|
274
316
|
{
|
|
@@ -276,7 +318,7 @@ const storedItems = (
|
|
|
276
318
|
id,
|
|
277
319
|
role: "assistant",
|
|
278
320
|
createdAt: actionEntry?.createdAt ?? item.entries.at(-1)?.createdAt,
|
|
279
|
-
class: isWideLayout(layout) ? "ai-chat-message-wide" : undefined,
|
|
321
|
+
class: isWideLayout(layout, error) ? "ai-chat-message-wide" : undefined,
|
|
280
322
|
content: views.content({
|
|
281
323
|
id,
|
|
282
324
|
turnId,
|
|
@@ -285,6 +327,8 @@ const storedItems = (
|
|
|
285
327
|
earlier: !last,
|
|
286
328
|
duration: last ? () => ({ workedMs: item.workedMs, waitingMs: null }) : () => null,
|
|
287
329
|
scheduledTask: task,
|
|
330
|
+
error,
|
|
331
|
+
continuable: Boolean(error) && newestLoop === item.loopId,
|
|
288
332
|
}),
|
|
289
333
|
actions: messageActions,
|
|
290
334
|
actionDisplay: "inline",
|
|
@@ -300,6 +344,7 @@ const activeItems = (
|
|
|
300
344
|
views: TurnViews,
|
|
301
345
|
duration: Accessor<AiTurnDuration | null>,
|
|
302
346
|
reconnecting: boolean,
|
|
347
|
+
locale: string,
|
|
303
348
|
): ChatTimelineItem[] => {
|
|
304
349
|
if (!turn) return [];
|
|
305
350
|
const segments = splitActiveTurnBlocks(turn.blocks).flatMap((segment): (AiActiveTurnSegment | SurveySegment)[] =>
|
|
@@ -340,7 +385,7 @@ const activeItems = (
|
|
|
340
385
|
role: "user",
|
|
341
386
|
status: block.status === "pending" ? "pending" : block.status === "failed" ? "error" : "complete",
|
|
342
387
|
content: <p class="whitespace-pre-wrap">{aiSteerMessageText(block)}</p>,
|
|
343
|
-
actions: createAiSteerMessageActions(block, actions),
|
|
388
|
+
actions: createAiSteerMessageActions(block, actions, locale),
|
|
344
389
|
actionDisplay: "menu",
|
|
345
390
|
anchorId: turn.seq,
|
|
346
391
|
};
|
|
@@ -449,8 +494,9 @@ export function createAiChatTimeline(source: AiChatTimelineSource): Accessor<rea
|
|
|
449
494
|
// While the stream reconnects or a model call waits for its retry, the live turn says so and its clock stands.
|
|
450
495
|
const reconnecting = createMemo(() => source.activeTurn()?.providerRetry === true || source.reconnecting?.() === true);
|
|
451
496
|
const clock = createActiveTurnClock(source, reconnecting);
|
|
452
|
-
const
|
|
453
|
-
const
|
|
497
|
+
const idle = createMemo(() => source.activeTurn() === null);
|
|
498
|
+
const stored = createMemo(() => storedItems(source.messages(), actions, views, locale(), idle()));
|
|
499
|
+
const active = createMemo(() => activeItems(source.activeTurn(), actions, views, clock, reconnecting(), locale()));
|
|
454
500
|
return createMemo(() => {
|
|
455
501
|
const items = [...stored(), ...active()];
|
|
456
502
|
views.retain(new Set(items.map((item) => item.id)));
|
|
@@ -12,7 +12,7 @@ export function isFailedTool(tool: Tool): boolean {
|
|
|
12
12
|
if (tool.isError || tool.status === "failed") return true;
|
|
13
13
|
const result = tool.result;
|
|
14
14
|
return (
|
|
15
|
-
["code_run", "code_inspect"
|
|
15
|
+
["code_run", "code_inspect"].includes(tool.name) &&
|
|
16
16
|
result !== null &&
|
|
17
17
|
typeof result === "object" &&
|
|
18
18
|
"status" in result &&
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import type { AiTurnError } from "../types";
|
|
2
|
+
import { aiChatMessages } from "./messages";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Whether a new message can pick up the work. A full context, a used-up quota, or lost access would fail the next
|
|
6
|
+
* turn the same way; the reason then names the next step itself.
|
|
7
|
+
*/
|
|
8
|
+
export const aiTurnErrorCanContinue = (error: AiTurnError): boolean =>
|
|
9
|
+
error.code !== "context_full" && error.code !== "quota_exhausted" && error.code !== "not_allowed";
|
|
10
|
+
|
|
11
|
+
/** What the chat shows under the title: the reason, and that the results so far are kept when the work can go on. */
|
|
12
|
+
export const aiTurnErrorDescription = (error: AiTurnError, locale: string): string => {
|
|
13
|
+
const t = aiChatMessages(locale);
|
|
14
|
+
return aiTurnErrorCanContinue(error) ? `${t.turnErrorReason(error)} ${t.turnErrorKept}` : t.turnErrorReason(error);
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
/** The turn's stored error for readers without the chat view, such as `cld`: the description and how to go on. */
|
|
18
|
+
export const aiTurnErrorText = (error: AiTurnError, locale: string): string =>
|
|
19
|
+
aiTurnErrorCanContinue(error)
|
|
20
|
+
? `${aiTurnErrorDescription(error, locale)} ${aiChatMessages(locale).turnErrorContinueHint}`
|
|
21
|
+
: aiTurnErrorDescription(error, locale);
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { announce, Button, ButtonLink, Chat, useLocale } from "@k2b/ui";
|
|
1
|
+
import { announce, Button, ButtonLink, Chat, NoticeCard, useLocale } from "@k2b/ui";
|
|
2
2
|
import {
|
|
3
3
|
type Accessor,
|
|
4
4
|
createEffect,
|
|
@@ -16,6 +16,7 @@ import {
|
|
|
16
16
|
} from "solid-js";
|
|
17
17
|
import { markdown } from "../../shared";
|
|
18
18
|
import { AI_TURN_LEASE_MS, type AiTurnBlock } from "../protocol";
|
|
19
|
+
import type { AiTurnError } from "../types";
|
|
19
20
|
import { ApprovalBlockView, CompactionBlockView, CompactToolRow, SurveyToolView, TextEditorToolView } from "./blocks";
|
|
20
21
|
import { CapabilityTablePreview } from "./capability-table";
|
|
21
22
|
import { PresentToolBlock } from "./file-tools";
|
|
@@ -25,6 +26,7 @@ import { aiChatMessages } from "./messages";
|
|
|
25
26
|
import { AssistantMarkdownBlock } from "./primitives";
|
|
26
27
|
import { AiToolActivity, AiToolDisclosureProvider, type AiToolDisclosureState } from "./tool-disclosure";
|
|
27
28
|
import { type AiWorkEntry, countWorkSteps, groupWorkBlocks, summarizeToolGroup } from "./tool-groups";
|
|
29
|
+
import { aiTurnErrorCanContinue, aiTurnErrorDescription } from "./turn-error";
|
|
28
30
|
import type { AiTurnAction, AiTurnLayout, AiTurnPhase, AiTurnResult } from "./turn-layout";
|
|
29
31
|
import { CloudCardBlock } from "./visual-tools";
|
|
30
32
|
|
|
@@ -53,6 +55,10 @@ export type AiTurnSegment = {
|
|
|
53
55
|
* it in a row at the end of its last segment.
|
|
54
56
|
*/
|
|
55
57
|
reconnect?: "line" | "row";
|
|
58
|
+
/** Why the turn failed, shown in a notice at its end. Only the last segment of a failed turn carries it. */
|
|
59
|
+
error?: AiTurnError | null;
|
|
60
|
+
/** The failed turn is the chat's newest and nothing runs, so a new message can pick up its work. */
|
|
61
|
+
continuable?: boolean;
|
|
56
62
|
};
|
|
57
63
|
|
|
58
64
|
const isLive = (phase: AiTurnPhase) => phase === "running" || phase === "waiting";
|
|
@@ -121,7 +127,7 @@ const stepPhrase = (block: AiTurnBlock | null, t: Messages, withTargets: boolean
|
|
|
121
127
|
if (["code_write", "code_create", "code_update", "code_fork", "code_remove", "code_restore"].includes(name))
|
|
122
128
|
return withTarget(t.stepWritingCode);
|
|
123
129
|
if (["code_run", "code_action", "code_sql"].includes(name)) return withTarget(t.stepRunningCode);
|
|
124
|
-
if (
|
|
130
|
+
if (name === "code_inspect") return t.stepCheckingApp;
|
|
125
131
|
if (name.startsWith("code_")) return t.stepCode;
|
|
126
132
|
if (["markdown_to_pdf", "html_to_pdf"].includes(name)) return withTarget(t.stepPdf);
|
|
127
133
|
if (["load_skill", "load_tools", "search_tools", "list_apps"].includes(name)) return t.stepLoading;
|
|
@@ -165,7 +171,8 @@ function AiWorkLine(props: { segment: Accessor<AiTurnSegment> }) {
|
|
|
165
171
|
const duration = worked();
|
|
166
172
|
const short = duration === null ? "" : formatWorkDuration(duration, t());
|
|
167
173
|
if (segment().phase === "stopped") return t().workedStopped({ duration: short });
|
|
168
|
-
|
|
174
|
+
// A failed turn with a stored reason says it in its notice; the line then reads like any finished turn.
|
|
175
|
+
if (segment().phase === "failed" && !segment().error) return t().workedInterrupted({ duration: short });
|
|
169
176
|
return short ? t().workedFor({ duration: short }) : t().worked;
|
|
170
177
|
};
|
|
171
178
|
const ariaLabel = () => {
|
|
@@ -175,7 +182,7 @@ function AiWorkLine(props: { segment: Accessor<AiTurnSegment> }) {
|
|
|
175
182
|
return t().workedLabel({
|
|
176
183
|
duration: duration === null ? "" : formatWorkDuration(duration, t(), true),
|
|
177
184
|
steps: layout().steps ? steps() : "",
|
|
178
|
-
ending: phase === "stopped" || phase === "failed" ? phase : null,
|
|
185
|
+
ending: phase === "stopped" || (phase === "failed" && !segment().error) ? phase : null,
|
|
179
186
|
});
|
|
180
187
|
};
|
|
181
188
|
const trailing = () => {
|
|
@@ -522,6 +529,37 @@ function AiTurnActionView(props: { action: Accessor<AiTurnAction | undefined>; t
|
|
|
522
529
|
);
|
|
523
530
|
}
|
|
524
531
|
|
|
532
|
+
/**
|
|
533
|
+
* Why a turn failed and what comes next, at its end. "Continue" sends a visible message through the host's normal
|
|
534
|
+
* send path, so the person sees what was asked; it is offered only for the chat's newest turn and only when a new
|
|
535
|
+
* turn can finish the work.
|
|
536
|
+
*/
|
|
537
|
+
function AiTurnErrorNotice(props: { error: AiTurnError; continuable: boolean }) {
|
|
538
|
+
const locale = useLocale();
|
|
539
|
+
const t = () => aiChatMessages(locale());
|
|
540
|
+
const actions = useAiChatActions();
|
|
541
|
+
const [continuing, setContinuing] = createSignal(false);
|
|
542
|
+
const canContinue = () => props.continuable && aiTurnErrorCanContinue(props.error) && Boolean(actions.onContinueTurn);
|
|
543
|
+
const continueTurn = async () => {
|
|
544
|
+
if (continuing()) return;
|
|
545
|
+
setContinuing(true);
|
|
546
|
+
try {
|
|
547
|
+
await actions.onContinueTurn?.(t().continueTurnMessage);
|
|
548
|
+
} finally {
|
|
549
|
+
setContinuing(false);
|
|
550
|
+
}
|
|
551
|
+
};
|
|
552
|
+
return (
|
|
553
|
+
<NoticeCard tone="danger" class="ai-turn__notice" title={t().turnErrorTitle} detail={aiTurnErrorDescription(props.error, locale())}>
|
|
554
|
+
<Show when={canContinue()}>
|
|
555
|
+
<Button variant="text" size="sm" loading={continuing()} disabled={actions.actionDisabled?.()} onClick={() => void continueTurn()}>
|
|
556
|
+
<i class="ti ti-player-play" aria-hidden="true" /> {t().continueTurn}
|
|
557
|
+
</Button>
|
|
558
|
+
</Show>
|
|
559
|
+
</NoticeCard>
|
|
560
|
+
);
|
|
561
|
+
}
|
|
562
|
+
|
|
525
563
|
/**
|
|
526
564
|
* Place 3. Live it is a status that switches in this same element; it keeps its height until the turn ends. A status
|
|
527
565
|
* is not read token by token: screen readers hear each status once it is complete, and that the answer is ready.
|
|
@@ -605,6 +643,16 @@ export function AiTurnView(props: { segment: Accessor<AiTurnSegment>; disclosure
|
|
|
605
643
|
const actions = createMemo(() => new Map(layout().actions.map((action) => [action.id, action])));
|
|
606
644
|
const actionIds = createMemo(() => [...actions().keys()]);
|
|
607
645
|
const live = () => isLive(props.segment().phase);
|
|
646
|
+
// Screen readers hear once that the turn they followed failed and why; history read later is not announced.
|
|
647
|
+
createEffect(
|
|
648
|
+
on(live, (now, before) => {
|
|
649
|
+
const error = props.segment().error;
|
|
650
|
+
if (before && !now && error) {
|
|
651
|
+
const t = aiChatMessages(locale());
|
|
652
|
+
announce(`${t.turnErrorTitle} ${aiTurnErrorDescription(error, locale())}`);
|
|
653
|
+
}
|
|
654
|
+
}),
|
|
655
|
+
);
|
|
608
656
|
return (
|
|
609
657
|
<AiToolDisclosureProvider state={props.disclosureState}>
|
|
610
658
|
<div class="ai-turn">
|
|
@@ -638,6 +686,9 @@ export function AiTurnView(props: { segment: Accessor<AiTurnSegment>; disclosure
|
|
|
638
686
|
{/* Calm by design: no busy sweep while the turn reconnects. */}
|
|
639
687
|
<Chat.Activity label={aiChatMessages(locale()).reconnecting} icon="ti ti-refresh" />
|
|
640
688
|
</Show>
|
|
689
|
+
<Show when={props.segment().error}>
|
|
690
|
+
{(error) => <AiTurnErrorNotice error={error()} continuable={props.segment().continuable === true} />}
|
|
691
|
+
</Show>
|
|
641
692
|
</div>
|
|
642
693
|
</AiToolDisclosureProvider>
|
|
643
694
|
);
|