@k2b/cloud 0.27.0 → 0.29.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 +7 -5
- 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/browser-code-contracts.ts +33 -63
- package/src/ai/browser.ts +1 -0
- package/src/ai/capabilities.ts +35 -9
- package/src/ai/chat/blocks.tsx +113 -24
- 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 +15 -3
- package/src/ai/chat/message-actions.tsx +124 -102
- package/src/ai/chat/message-utils.ts +57 -6
- package/src/ai/chat/messages.ts +214 -0
- package/src/ai/chat/presentation.tsx +67 -28
- package/src/ai/chat/tool-groups.ts +1 -1
- package/src/ai/chat/tool-names.ts +157 -0
- package/src/ai/chat/turn-error.ts +21 -0
- package/src/ai/chat/turn-layout.ts +12 -0
- package/src/ai/chat/turn-view.tsx +69 -10
- package/src/ai/chat/user-message.tsx +19 -14
- package/src/ai/chat/visual-tools.tsx +1 -1
- package/src/ai/chat-tasks.ts +16 -3
- 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/compaction.ts +22 -3
- package/src/ai/data-analysis-skill.ts +3 -3
- package/src/ai/default-tools.ts +23 -17
- package/src/ai/executor.ts +262 -99
- 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 +3 -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/open-tool-calls.ts +87 -0
- package/src/ai/projects-routes.ts +5 -1
- package/src/ai/projects.ts +5 -1
- package/src/ai/protocol.ts +34 -1
- package/src/ai/provider.ts +16 -1
- package/src/ai/quota-provider.ts +4 -1
- package/src/ai/routes.ts +26 -10
- package/src/ai/run-timeout.ts +4 -5
- package/src/ai/runtime.ts +8 -0
- package/src/ai/settings.ts +54 -17
- package/src/ai/skill-seeds.ts +14 -6
- package/src/ai/store.ts +102 -34
- package/src/ai/structured.ts +2 -1
- package/src/ai/system-prompt.ts +9 -22
- package/src/ai/transcription.ts +58 -13
- package/src/ai/turn-failure.ts +104 -0
- package/src/ai/turn-guidance.ts +505 -0
- package/src/ai/turn-policy.ts +2 -1
- package/src/ai/types.ts +29 -1
- package/src/ai/ui.tsx +1 -0
- package/src/api/admin-core-settings.ts +16 -1
- package/src/api/admin-outgoing-mail.ts +185 -5
- package/src/api/me.ts +42 -1
- package/src/browser/FileChooser.tsx +82 -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 +66 -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/index.ts +3 -1
- package/src/cli/admin/notifications.ts +6 -0
- package/src/cli/admin/outgoing-mail.ts +104 -7
- 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 +189 -1
- 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/help/store.ts +2 -1
- package/src/services/image-metadata.ts +438 -0
- package/src/services/index.ts +12 -1
- 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/batches.ts +139 -79
- package/src/services/notifications/browser.ts +48 -15
- package/src/services/notifications/channels.ts +33 -13
- package/src/services/notifications/dispatcher.ts +88 -6
- 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 +33 -5
- package/src/services/notifications/preview.ts +23 -0
- package/src/services/notifications/quiet.ts +153 -0
- package/src/services/notifications/runtime.ts +9 -3
- package/src/services/notifications/user.ts +6 -0
- 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/pdf/template-preview.ts +2 -1
- package/src/services/postgres.ts +15 -0
- package/src/services/settings/app.ts +3 -0
- package/src/services/settings/core-settings.ts +18 -0
- package/src/services/settings/store.ts +4 -1
- package/src/services/weather/geo.ts +6 -2
- 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/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/GlobalAnnouncements.island.tsx +1 -1
- 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/platform-messages.ts +1 -1
- package/src/ssr/rail-messages.ts +4 -0
- package/src/ssr/rail-navigation.ts +2 -0
- package/src/styles/effects.css +38 -17
- package/src/styles/file-chooser.css +25 -0
- 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/styles/utilities-navigation.css +41 -0
|
@@ -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,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";
|
|
@@ -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
|
+
};
|
|
@@ -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,8 @@ 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_check") return t.stepCheckingApp;
|
|
131
|
+
if (name === "code_inspect") return t.stepCheckingRun;
|
|
125
132
|
if (name.startsWith("code_")) return t.stepCode;
|
|
126
133
|
if (["markdown_to_pdf", "html_to_pdf"].includes(name)) return withTarget(t.stepPdf);
|
|
127
134
|
if (["load_skill", "load_tools", "search_tools", "list_apps"].includes(name)) return t.stepLoading;
|
|
@@ -165,7 +172,8 @@ function AiWorkLine(props: { segment: Accessor<AiTurnSegment> }) {
|
|
|
165
172
|
const duration = worked();
|
|
166
173
|
const short = duration === null ? "" : formatWorkDuration(duration, t());
|
|
167
174
|
if (segment().phase === "stopped") return t().workedStopped({ duration: short });
|
|
168
|
-
|
|
175
|
+
// A failed turn with a stored reason says it in its notice; the line then reads like any finished turn.
|
|
176
|
+
if (segment().phase === "failed" && !segment().error) return t().workedInterrupted({ duration: short });
|
|
169
177
|
return short ? t().workedFor({ duration: short }) : t().worked;
|
|
170
178
|
};
|
|
171
179
|
const ariaLabel = () => {
|
|
@@ -175,7 +183,7 @@ function AiWorkLine(props: { segment: Accessor<AiTurnSegment> }) {
|
|
|
175
183
|
return t().workedLabel({
|
|
176
184
|
duration: duration === null ? "" : formatWorkDuration(duration, t(), true),
|
|
177
185
|
steps: layout().steps ? steps() : "",
|
|
178
|
-
ending: phase === "stopped" || phase === "failed" ? phase : null,
|
|
186
|
+
ending: phase === "stopped" || (phase === "failed" && !segment().error) ? phase : null,
|
|
179
187
|
});
|
|
180
188
|
};
|
|
181
189
|
const trailing = () => {
|
|
@@ -362,6 +370,7 @@ function ResultLinks(props: { links: Record<string, unknown>[] }) {
|
|
|
362
370
|
|
|
363
371
|
/** The summary and links of a table result, unless its receipt in place 4 already shows them. */
|
|
364
372
|
function CapabilityResultRow(props: { block: ToolBlock }) {
|
|
373
|
+
const locale = useLocale();
|
|
365
374
|
const block = () => props.block;
|
|
366
375
|
const summary = () => resultSummary(block());
|
|
367
376
|
const links = () => resultLinks(block());
|
|
@@ -370,7 +379,7 @@ function CapabilityResultRow(props: { block: ToolBlock }) {
|
|
|
370
379
|
<Chat.Activity
|
|
371
380
|
icon={aiToolIcon(block().name, block().presentation?.appIcon)}
|
|
372
381
|
accent={block().presentation?.appAccent}
|
|
373
|
-
label={summary() || (block().presentation?.title ?? displayToolName(block().name))}
|
|
382
|
+
label={summary() || (block().presentation?.title ?? displayToolName(block().name, locale()))}
|
|
374
383
|
trailing={links().length > 0 ? <ResultLinks links={links()} /> : undefined}
|
|
375
384
|
/>
|
|
376
385
|
</Show>
|
|
@@ -378,6 +387,7 @@ function CapabilityResultRow(props: { block: ToolBlock }) {
|
|
|
378
387
|
}
|
|
379
388
|
|
|
380
389
|
function AiTurnResultView(props: { result: Accessor<AiTurnResult | undefined>; receipt: Accessor<boolean> }) {
|
|
390
|
+
const locale = useLocale();
|
|
381
391
|
const actions = useAiChatActions();
|
|
382
392
|
const block = () => props.result()?.block;
|
|
383
393
|
return (
|
|
@@ -407,7 +417,10 @@ function AiTurnResultView(props: { result: Accessor<AiTurnResult | undefined>; r
|
|
|
407
417
|
<Show when={!props.receipt()}>
|
|
408
418
|
<CapabilityResultRow block={current()} />
|
|
409
419
|
</Show>
|
|
410
|
-
<CapabilityTablePreview
|
|
420
|
+
<CapabilityTablePreview
|
|
421
|
+
result={current().result}
|
|
422
|
+
label={current().presentation?.title ?? displayToolName(current().name, locale())}
|
|
423
|
+
/>
|
|
411
424
|
</Match>
|
|
412
425
|
</Switch>
|
|
413
426
|
)}
|
|
@@ -419,7 +432,7 @@ function AiReceipt(props: { action: AiTurnAction; stopped: boolean }) {
|
|
|
419
432
|
const locale = useLocale();
|
|
420
433
|
const t = () => aiChatMessages(locale());
|
|
421
434
|
const block = () => props.action.block;
|
|
422
|
-
const title = () => block().presentation?.title ?? displayToolName(block().name);
|
|
435
|
+
const title = () => block().presentation?.title ?? displayToolName(block().name, locale());
|
|
423
436
|
const links = () => (props.action.state === "done" ? resultLinks(block()) : []);
|
|
424
437
|
const label = () => {
|
|
425
438
|
const state = props.action.state;
|
|
@@ -458,7 +471,9 @@ function AiTurnActionView(props: { action: Accessor<AiTurnAction | undefined>; t
|
|
|
458
471
|
const current = action();
|
|
459
472
|
if (next !== "open" || previous === "open" || !isLive(props.phase) || current?.block.status !== "awaiting_approval") return;
|
|
460
473
|
announce(
|
|
461
|
-
aiChatMessages(locale()).approvalNeeded({
|
|
474
|
+
aiChatMessages(locale()).approvalNeeded({
|
|
475
|
+
title: current.block.presentation?.title ?? displayToolName(current.block.name, locale()),
|
|
476
|
+
}),
|
|
462
477
|
);
|
|
463
478
|
}),
|
|
464
479
|
);
|
|
@@ -493,7 +508,7 @@ function AiTurnActionView(props: { action: Accessor<AiTurnAction | undefined>; t
|
|
|
493
508
|
<Switch
|
|
494
509
|
fallback={
|
|
495
510
|
<Chat.Activity
|
|
496
|
-
label={current().block.presentation?.title ?? displayToolName(current().block.name)}
|
|
511
|
+
label={current().block.presentation?.title ?? displayToolName(current().block.name, locale())}
|
|
497
512
|
icon={aiToolIcon(current().block.name)}
|
|
498
513
|
busy={current().block.status === "awaiting_client"}
|
|
499
514
|
/>
|
|
@@ -509,7 +524,7 @@ function AiTurnActionView(props: { action: Accessor<AiTurnAction | undefined>; t
|
|
|
509
524
|
</Match>
|
|
510
525
|
<Match when={current().state === "running"}>
|
|
511
526
|
<Chat.Activity
|
|
512
|
-
label={current().block.presentation?.title ?? displayToolName(current().block.name)}
|
|
527
|
+
label={current().block.presentation?.title ?? displayToolName(current().block.name, locale())}
|
|
513
528
|
icon={aiToolIcon(current().block.name, current().block.presentation?.appIcon)}
|
|
514
529
|
accent={current().block.presentation?.appAccent}
|
|
515
530
|
busy
|
|
@@ -522,6 +537,37 @@ function AiTurnActionView(props: { action: Accessor<AiTurnAction | undefined>; t
|
|
|
522
537
|
);
|
|
523
538
|
}
|
|
524
539
|
|
|
540
|
+
/**
|
|
541
|
+
* Why a turn failed and what comes next, at its end. "Continue" sends a visible message through the host's normal
|
|
542
|
+
* send path, so the person sees what was asked; it is offered only for the chat's newest turn and only when a new
|
|
543
|
+
* turn can finish the work.
|
|
544
|
+
*/
|
|
545
|
+
function AiTurnErrorNotice(props: { error: AiTurnError; continuable: boolean }) {
|
|
546
|
+
const locale = useLocale();
|
|
547
|
+
const t = () => aiChatMessages(locale());
|
|
548
|
+
const actions = useAiChatActions();
|
|
549
|
+
const [continuing, setContinuing] = createSignal(false);
|
|
550
|
+
const canContinue = () => props.continuable && aiTurnErrorCanContinue(props.error) && Boolean(actions.onContinueTurn);
|
|
551
|
+
const continueTurn = async () => {
|
|
552
|
+
if (continuing()) return;
|
|
553
|
+
setContinuing(true);
|
|
554
|
+
try {
|
|
555
|
+
await actions.onContinueTurn?.(t().continueTurnMessage);
|
|
556
|
+
} finally {
|
|
557
|
+
setContinuing(false);
|
|
558
|
+
}
|
|
559
|
+
};
|
|
560
|
+
return (
|
|
561
|
+
<NoticeCard tone="danger" class="ai-turn__notice" title={t().turnErrorTitle} detail={aiTurnErrorDescription(props.error, locale())}>
|
|
562
|
+
<Show when={canContinue()}>
|
|
563
|
+
<Button variant="text" size="sm" loading={continuing()} disabled={actions.actionDisabled?.()} onClick={() => void continueTurn()}>
|
|
564
|
+
<i class="ti ti-player-play" aria-hidden="true" /> {t().continueTurn}
|
|
565
|
+
</Button>
|
|
566
|
+
</Show>
|
|
567
|
+
</NoticeCard>
|
|
568
|
+
);
|
|
569
|
+
}
|
|
570
|
+
|
|
525
571
|
/**
|
|
526
572
|
* Place 3. Live it is a status that switches in this same element; it keeps its height until the turn ends. A status
|
|
527
573
|
* is not read token by token: screen readers hear each status once it is complete, and that the answer is ready.
|
|
@@ -605,6 +651,16 @@ export function AiTurnView(props: { segment: Accessor<AiTurnSegment>; disclosure
|
|
|
605
651
|
const actions = createMemo(() => new Map(layout().actions.map((action) => [action.id, action])));
|
|
606
652
|
const actionIds = createMemo(() => [...actions().keys()]);
|
|
607
653
|
const live = () => isLive(props.segment().phase);
|
|
654
|
+
// Screen readers hear once that the turn they followed failed and why; history read later is not announced.
|
|
655
|
+
createEffect(
|
|
656
|
+
on(live, (now, before) => {
|
|
657
|
+
const error = props.segment().error;
|
|
658
|
+
if (before && !now && error) {
|
|
659
|
+
const t = aiChatMessages(locale());
|
|
660
|
+
announce(`${t.turnErrorTitle} ${aiTurnErrorDescription(error, locale())}`);
|
|
661
|
+
}
|
|
662
|
+
}),
|
|
663
|
+
);
|
|
608
664
|
return (
|
|
609
665
|
<AiToolDisclosureProvider state={props.disclosureState}>
|
|
610
666
|
<div class="ai-turn">
|
|
@@ -638,6 +694,9 @@ export function AiTurnView(props: { segment: Accessor<AiTurnSegment>; disclosure
|
|
|
638
694
|
{/* Calm by design: no busy sweep while the turn reconnects. */}
|
|
639
695
|
<Chat.Activity label={aiChatMessages(locale()).reconnecting} icon="ti ti-refresh" />
|
|
640
696
|
</Show>
|
|
697
|
+
<Show when={props.segment().error}>
|
|
698
|
+
{(error) => <AiTurnErrorNotice error={error()} continuable={props.segment().continuable === true} />}
|
|
699
|
+
</Show>
|
|
641
700
|
</div>
|
|
642
701
|
</AiToolDisclosureProvider>
|
|
643
702
|
);
|
|
@@ -13,11 +13,14 @@ import {
|
|
|
13
13
|
userVisibleTextFromMessage,
|
|
14
14
|
vfsAttachmentsFromMessage,
|
|
15
15
|
} from "./message-utils";
|
|
16
|
+
import { aiChatMessages } from "./messages";
|
|
16
17
|
|
|
17
18
|
const openModifyRetryDialog = (
|
|
18
19
|
entry: AiStoredMessage,
|
|
19
20
|
onRetryMessage: (entry: AiStoredMessage, input?: AiRetryMessageInput) => void | Promise<void>,
|
|
21
|
+
locale: string,
|
|
20
22
|
) => {
|
|
23
|
+
const t = aiChatMessages(locale);
|
|
21
24
|
void dialogCore.open<void>((close) => {
|
|
22
25
|
const [draft, setDraft] = createSignal(userVisibleTextFromMessage(entry.message));
|
|
23
26
|
const content = () => userContentWithEditedVisibleText(entry.message, draft());
|
|
@@ -34,11 +37,11 @@ const openModifyRetryDialog = (
|
|
|
34
37
|
|
|
35
38
|
return (
|
|
36
39
|
<PanelDialog>
|
|
37
|
-
<PanelDialog.Header title=
|
|
40
|
+
<PanelDialog.Header title={t.retryTitle} icon="ti ti-pencil" close={close} />
|
|
38
41
|
<PanelDialog.Body>
|
|
39
42
|
<TextInput
|
|
40
|
-
label=
|
|
41
|
-
description=
|
|
43
|
+
label={t.retryPrompt}
|
|
44
|
+
description={t.retryPromptHint}
|
|
42
45
|
multiline
|
|
43
46
|
lines={8}
|
|
44
47
|
value={draft}
|
|
@@ -47,18 +50,18 @@ const openModifyRetryDialog = (
|
|
|
47
50
|
/>
|
|
48
51
|
<Show when={retryMutation.error()}>
|
|
49
52
|
<p class="text-xs text-red-600 dark:text-red-400" role="alert">
|
|
50
|
-
|
|
53
|
+
{t.retryFailed}
|
|
51
54
|
</p>
|
|
52
55
|
</Show>
|
|
53
56
|
</PanelDialog.Body>
|
|
54
57
|
<PanelDialog.Footer>
|
|
55
58
|
<div class="ml-auto flex items-center gap-2">
|
|
56
59
|
<Button type="button" variant="secondary" size="sm" onClick={() => close()}>
|
|
57
|
-
|
|
60
|
+
{t.cancel}
|
|
58
61
|
</Button>
|
|
59
62
|
<Button type="button" variant="ai" size="sm" disabled={!canRetry()} onClick={retry}>
|
|
60
63
|
<i class={`ti ${retryMutation.loading() ? "ti-loader-2 animate-spin" : "ti-refresh"}`} aria-hidden="true" />
|
|
61
|
-
{retryMutation.loading() ?
|
|
64
|
+
{retryMutation.loading() ? t.retrying : t.actionTryAgain}
|
|
62
65
|
</Button>
|
|
63
66
|
</div>
|
|
64
67
|
</PanelDialog.Footer>
|
|
@@ -93,38 +96,39 @@ export const aiUserMessageAttachments = (entry: AiStoredMessage, actions: AiChat
|
|
|
93
96
|
})),
|
|
94
97
|
];
|
|
95
98
|
|
|
96
|
-
export const createAiUserMessageActions = (entry: AiStoredMessage, actions: AiChatActions): ChatAction[] => {
|
|
99
|
+
export const createAiUserMessageActions = (entry: AiStoredMessage, actions: AiChatActions, locale: string): ChatAction[] => {
|
|
100
|
+
const t = aiChatMessages(locale);
|
|
97
101
|
const result: ChatAction[] = [];
|
|
98
102
|
const copyText = copyTextFromMessage(entry.message);
|
|
99
103
|
|
|
100
104
|
if (copyText) {
|
|
101
|
-
result.push({ id: "copy", label:
|
|
105
|
+
result.push({ id: "copy", label: t.actionCopy, icon: "ti ti-copy", copyText });
|
|
102
106
|
}
|
|
103
107
|
if (!entry.compactedAt && actions.onRetryMessage) {
|
|
104
108
|
result.push(
|
|
105
109
|
{
|
|
106
110
|
id: "retry",
|
|
107
|
-
label:
|
|
111
|
+
label: t.actionTryAgain,
|
|
108
112
|
icon: "ti ti-refresh",
|
|
109
113
|
onSelect: () => actions.onRetryMessage?.(entry, { mode: "retry" }),
|
|
110
114
|
},
|
|
111
115
|
{
|
|
112
116
|
id: "details",
|
|
113
|
-
label:
|
|
117
|
+
label: t.actionMoreDetailed,
|
|
114
118
|
icon: "ti ti-list-details",
|
|
115
119
|
onSelect: () => actions.onRetryMessage?.(entry, { mode: "details" }),
|
|
116
120
|
},
|
|
117
121
|
{
|
|
118
122
|
id: "concise",
|
|
119
|
-
label:
|
|
123
|
+
label: t.actionMoreConcise,
|
|
120
124
|
icon: "ti ti-align-left",
|
|
121
125
|
onSelect: () => actions.onRetryMessage?.(entry, { mode: "concise" }),
|
|
122
126
|
},
|
|
123
127
|
{
|
|
124
128
|
id: "edit",
|
|
125
|
-
label:
|
|
129
|
+
label: t.actionEditPrompt,
|
|
126
130
|
icon: "ti ti-pencil",
|
|
127
|
-
onSelect: () => openModifyRetryDialog(entry, actions.onRetryMessage
|
|
131
|
+
onSelect: () => openModifyRetryDialog(entry, actions.onRetryMessage!, locale),
|
|
128
132
|
},
|
|
129
133
|
);
|
|
130
134
|
}
|
|
@@ -136,12 +140,13 @@ export const aiSteerMessageText = (block: Extract<AiTurnBlock, { kind: "steer_me
|
|
|
136
140
|
export const createAiSteerMessageActions = (
|
|
137
141
|
block: Extract<AiTurnBlock, { kind: "steer_message" }>,
|
|
138
142
|
actions: AiChatActions,
|
|
143
|
+
locale: string,
|
|
139
144
|
): ChatAction[] => {
|
|
140
145
|
if (block.status !== "failed" || !actions.onRetrySteer) return [];
|
|
141
146
|
return [
|
|
142
147
|
{
|
|
143
148
|
id: "retry-steer",
|
|
144
|
-
label:
|
|
149
|
+
label: aiChatMessages(locale).actionRetryGuidance,
|
|
145
150
|
icon: "ti ti-refresh",
|
|
146
151
|
onSelect: () => actions.onRetrySteer?.(block),
|
|
147
152
|
},
|
|
@@ -228,7 +228,7 @@ export function CloudSurveyBlock(props: {
|
|
|
228
228
|
</Show>
|
|
229
229
|
</div>
|
|
230
230
|
</div>
|
|
231
|
-
<footer class="flex min-h-12 items-center gap-3
|
|
231
|
+
<footer class="flex min-h-12 items-center gap-3 px-4 pb-3">
|
|
232
232
|
<Show when={submitted()}>
|
|
233
233
|
<span class="inline-flex items-center gap-1.5 text-xs font-medium text-secondary">
|
|
234
234
|
<i class="ti ti-check text-[var(--k2b-ai-accent)]" aria-hidden="true" />
|
package/src/ai/chat-tasks.ts
CHANGED
|
@@ -4,6 +4,7 @@ import { getCapability } from "../_internal/registry";
|
|
|
4
4
|
import { logger } from "../services/logging";
|
|
5
5
|
import {
|
|
6
6
|
createMandate,
|
|
7
|
+
MANDATE_POLICY_DENIED_MESSAGE,
|
|
7
8
|
type Mandate,
|
|
8
9
|
type MandatePolicyV1,
|
|
9
10
|
MandatePolicyV1Schema,
|
|
@@ -88,7 +89,14 @@ export class AiChatTaskIdempotencyConflictError extends Error {
|
|
|
88
89
|
}
|
|
89
90
|
|
|
90
91
|
class AiChatTaskCreateRace extends Error {}
|
|
91
|
-
class AiChatTaskAuthorityError extends Error {
|
|
92
|
+
export class AiChatTaskAuthorityError extends Error {
|
|
93
|
+
constructor(
|
|
94
|
+
message: string,
|
|
95
|
+
readonly code?: string,
|
|
96
|
+
) {
|
|
97
|
+
super(message);
|
|
98
|
+
}
|
|
99
|
+
}
|
|
92
100
|
const log = logger("ai:chat-tasks");
|
|
93
101
|
|
|
94
102
|
type OccurrenceRow = {
|
|
@@ -497,10 +505,15 @@ export const aiChatTasks = {
|
|
|
497
505
|
input: input.input,
|
|
498
506
|
capabilityApproval: input.approval,
|
|
499
507
|
});
|
|
500
|
-
if (!result.ok)
|
|
508
|
+
if (!result.ok) {
|
|
509
|
+
const policyDenied = result.error.code === "FORBIDDEN" && result.error.message === MANDATE_POLICY_DENIED_MESSAGE;
|
|
501
510
|
throw new AiChatTaskAuthorityError(
|
|
502
|
-
|
|
511
|
+
policyDenied
|
|
512
|
+
? `This task's grants do not allow ${input.appId}.${input.capabilityId} with these inputs; continue with the granted tools or explain what is missing.`
|
|
513
|
+
: `Background task cannot call ${input.appId}.${input.capabilityId}: ${result.error.message}. Check the task grants and fixed inputs in the normal chat; this run cannot expand them.`,
|
|
514
|
+
policyDenied ? "MANDATE_POLICY_DENIED" : result.error.code,
|
|
503
515
|
);
|
|
516
|
+
}
|
|
504
517
|
},
|
|
505
518
|
list: async (input: {
|
|
506
519
|
userId: string;
|
|
@@ -94,13 +94,26 @@ const isCurrentStreamSession = (current: AiStreamSession | null, candidate: AiSt
|
|
|
94
94
|
|
|
95
95
|
const DEFAULT_RUN_ERROR = "Assistant response failed.";
|
|
96
96
|
|
|
97
|
-
|
|
98
|
-
|
|
97
|
+
/** The failure of the chat's latest turn, and that turn when known. */
|
|
98
|
+
type AiRunError = { message: string; turnId: string | null };
|
|
99
99
|
|
|
100
|
-
const
|
|
100
|
+
const conversationRunError = (conversation: AiConversation | null | undefined): AiRunError | null =>
|
|
101
|
+
conversation?.runStatus === "failed"
|
|
102
|
+
? { message: conversation.runError?.trim() || DEFAULT_RUN_ERROR, turnId: conversation.runTurnId ?? null }
|
|
103
|
+
: null;
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Whether the failed turn shows its failure itself: its last message carries the reason, and the timeline words it in
|
|
107
|
+
* a notice at the turn's end. The run error then stays out of `error()`, so it does not appear twice. A turn without a
|
|
108
|
+
* recorded reason, such as a failed compaction or a turn from an earlier release, still reports it there.
|
|
109
|
+
*/
|
|
110
|
+
const turnShowsError = (messages: readonly AiStoredMessage[], turnId: string | null): boolean =>
|
|
111
|
+
turnId !== null && messages.some((message) => message.loopId === turnId && message.meta?.turnError);
|
|
112
|
+
|
|
113
|
+
const runErrorFromEvent = (event: AiStreamEvent, activeTurnId: string | null | undefined): AiRunError | null | undefined => {
|
|
101
114
|
if (event.type === "state") return conversationRunError(event.conversation);
|
|
102
115
|
if (event.type !== "turn_finished" || event.turnId !== activeTurnId) return undefined;
|
|
103
|
-
return event.status === "failed" ? event.error?.trim() || DEFAULT_RUN_ERROR : null;
|
|
116
|
+
return event.status === "failed" ? { message: event.error?.trim() || DEFAULT_RUN_ERROR, turnId: event.turnId } : null;
|
|
104
117
|
};
|
|
105
118
|
|
|
106
119
|
export type CreateAiChatControllerOptions = {
|
|
@@ -168,7 +181,7 @@ const errorText = (error: unknown, fallback: string): string =>
|
|
|
168
181
|
export const createAiChatController = (options: CreateAiChatControllerOptions) => {
|
|
169
182
|
const [activeConversationId, setActiveConversationIdSignal] = createSignal<string | null>(options.initialConversationId ?? null);
|
|
170
183
|
const [globalError, setGlobalError] = createSignal<string | null>(options.initialError ?? null);
|
|
171
|
-
const [runError, setRunError] = createSignal<
|
|
184
|
+
const [runError, setRunError] = createSignal<AiRunError | null>(conversationRunError(options.initialDetail?.conversation));
|
|
172
185
|
const [runStatusRaw, setRunStatusRaw] = createSignal<AiChatRunStatus | null>(null);
|
|
173
186
|
const [streamStatus, setStreamStatus] = createSignal<AiStreamStatus>("idle");
|
|
174
187
|
const initialProjection = options.initialDetail ? detailToProjection(options.initialDetail) : emptyProjection();
|
|
@@ -250,7 +263,10 @@ export const createAiChatController = (options: CreateAiChatControllerOptions) =
|
|
|
250
263
|
const conversationId = activeConversationId();
|
|
251
264
|
if (conversationId) openStream(conversationId);
|
|
252
265
|
};
|
|
253
|
-
const error = () =>
|
|
266
|
+
const error = () => {
|
|
267
|
+
const run = runError();
|
|
268
|
+
return globalError() ?? (run && !turnShowsError(state.messages, run.turnId) ? run.message : null);
|
|
269
|
+
};
|
|
254
270
|
|
|
255
271
|
const activeTurn = () => state.activeTurn;
|
|
256
272
|
const messages = createMemo(() => messagesWithPendingSend(visibleMessages(state), pendingSends()[activeConversationId() ?? ""]));
|
|
@@ -1415,6 +1431,7 @@ export const __aiControllerTest = {
|
|
|
1415
1431
|
completeFrontendToolBlock,
|
|
1416
1432
|
conversationRunError,
|
|
1417
1433
|
failSteerBlock,
|
|
1434
|
+
turnShowsError,
|
|
1418
1435
|
projectionForConversationOpen,
|
|
1419
1436
|
reconcileSteerBlocks,
|
|
1420
1437
|
runErrorFromEvent,
|