@k2b/cloud 0.26.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 +3 -3
- package/src/_internal/define-app.ts +8 -1
- package/src/_internal/process-identity.ts +7 -1
- package/src/_internal/registry-validation.ts +3 -0
- package/src/_internal/runtime-context.ts +1 -0
- package/src/ai/admin.ts +1 -0
- package/src/ai/browser-code-contracts.ts +46 -64
- package/src/ai/browser.ts +9 -1
- package/src/ai/capabilities.ts +82 -22
- package/src/ai/chat/blocks.tsx +16 -7
- package/src/ai/chat/builtin-tools.tsx +31 -23
- package/src/ai/chat/file-tools.tsx +4 -1
- package/src/ai/chat/live-turn.browser-harness.tsx +15 -5
- package/src/ai/chat/message-actions.tsx +123 -101
- package/src/ai/chat/message-utils.ts +30 -1
- package/src/ai/chat/messages.ts +206 -2
- package/src/ai/chat/presentation.tsx +94 -28
- 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 +102 -16
- package/src/ai/chat/user-message.tsx +19 -14
- package/src/ai/chat/visual-tools.tsx +1 -1
- package/src/ai/client/controller.ts +83 -37
- package/src/ai/client/file-source.ts +20 -3
- package/src/ai/client/projection.ts +7 -2
- package/src/ai/code-mode-skill.ts +32 -36
- package/src/ai/code-runtime-tools.ts +14 -1
- package/src/ai/code-source-contracts.ts +56 -6
- package/src/ai/code-source-tools.ts +10 -3
- package/src/ai/credentials.ts +17 -3
- package/src/ai/data-analysis-skill.ts +3 -3
- package/src/ai/default-tools.ts +15 -16
- package/src/ai/executor.ts +281 -141
- package/src/ai/file-context.ts +14 -2
- package/src/ai/file-tools.ts +17 -3
- package/src/ai/files-store.ts +134 -11
- package/src/ai/grids-skill.ts +2 -2
- package/src/ai/index.ts +9 -0
- package/src/ai/memories.ts +14 -0
- package/src/ai/migrate.ts +125 -0
- package/src/ai/model-request-settings.ts +98 -0
- package/src/ai/open-tool-calls.ts +87 -0
- package/src/ai/protocol.ts +6 -0
- package/src/ai/provider.ts +7 -1
- package/src/ai/quota-provider.ts +2 -2
- package/src/ai/request-headers.ts +117 -0
- package/src/ai/routes.ts +40 -6
- package/src/ai/run-timeout.ts +4 -5
- package/src/ai/runtime.ts +9 -1
- package/src/ai/settings.ts +19 -2
- package/src/ai/skill-seeds.ts +35 -7
- package/src/ai/skills.ts +26 -0
- package/src/ai/solid.ts +1 -1
- package/src/ai/store.ts +155 -71
- package/src/ai/stream.ts +180 -37
- package/src/ai/structured.ts +20 -5
- package/src/ai/system-prompt.ts +8 -0
- package/src/ai/tool-call-names.ts +45 -0
- package/src/ai/turn-failure.ts +100 -0
- package/src/ai/turn-policy.ts +248 -0
- package/src/ai/types.ts +52 -4
- package/src/api/admin-ai-quotas.ts +36 -1
- package/src/api/admin-core-settings.ts +16 -23
- package/src/api/admin-outgoing-mail.ts +242 -0
- package/src/api/index.ts +2 -0
- package/src/browser/FileChooser.tsx +11 -0
- package/src/browser/file-chooser-messages.ts +2 -0
- package/src/cli/admin/ai-quotas.ts +70 -1
- package/src/cli/admin/index.ts +8 -0
- package/src/cli/admin/notifications.ts +6 -0
- package/src/cli/admin/outgoing-mail.ts +215 -0
- package/src/contracts/app.ts +2 -0
- package/src/contracts/index.ts +1 -0
- package/src/contracts/outgoing-mail.ts +265 -0
- package/src/contracts/registry.ts +2 -0
- package/src/services/help/store.ts +2 -1
- package/src/services/index.ts +13 -0
- 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 -45
- 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 +112 -0
- 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 +397 -0
- package/src/services/outgoing-mail/sync.ts +39 -0
- package/src/services/outgoing-mail/test-send.ts +40 -0
- package/src/services/outgoing-mail/transport.ts +17 -0
- package/src/services/pdf/markdown.ts +22 -4
- package/src/services/postgres.ts +15 -0
- package/src/services/settings/core-settings.ts +19 -38
- package/src/services/settings/store.ts +5 -1
- package/src/shared/ai-model-request-settings.ts +21 -0
- package/src/shared/ai-platform-prompt.ts +49 -5
- package/src/shared/ai-request-options.ts +185 -0
- 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/admin-navigation.ts +1 -1
- package/src/ssr/platform-messages.ts +3 -1
- package/src/ssr/workspace-navigation.ts +7 -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
|
@@ -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,
|
|
@@ -15,7 +15,8 @@ import {
|
|
|
15
15
|
untrack,
|
|
16
16
|
} from "solid-js";
|
|
17
17
|
import { markdown } from "../../shared";
|
|
18
|
-
import type
|
|
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
|
|
|
@@ -47,8 +49,16 @@ export type AiTurnSegment = {
|
|
|
47
49
|
duration: () => AiTurnDuration | null;
|
|
48
50
|
/** Shown above the work of a delivered scheduled result. */
|
|
49
51
|
scheduledTask?: { taskId: string; occurrenceId: string } | null;
|
|
50
|
-
/**
|
|
51
|
-
|
|
52
|
+
/**
|
|
53
|
+
* The live turn cannot reach the model or the server right now: a model call waits for its retry, or the stream
|
|
54
|
+
* reconnects. The working segment's work line says so while its clock stands; a turn without a work line yet says
|
|
55
|
+
* it in a row at the end of its last segment.
|
|
56
|
+
*/
|
|
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;
|
|
52
62
|
};
|
|
53
63
|
|
|
54
64
|
const isLive = (phase: AiTurnPhase) => phase === "running" || phase === "waiting";
|
|
@@ -63,6 +73,10 @@ export const formatWorkDuration = (ms: number, t: Messages, long = false): strin
|
|
|
63
73
|
return long ? t.durationHoursLong({ hours, minutes: minutes % 60 }) : t.durationHours({ hours, minutes: minutes % 60 });
|
|
64
74
|
};
|
|
65
75
|
|
|
76
|
+
/** Identity of the current step: it changes when another block becomes current or the current call changes state. */
|
|
77
|
+
const stepKey = (block: AiTurnBlock | null): string =>
|
|
78
|
+
!block ? "start" : `${block.id}:${block.kind === "tool" || block.kind === "compaction" ? block.status : block.kind}`;
|
|
79
|
+
|
|
66
80
|
/** A running clock: "2:41", "1:02:41". */
|
|
67
81
|
export const formatWorkClock = (ms: number): string => {
|
|
68
82
|
const total = Math.max(0, Math.floor(ms / 1000));
|
|
@@ -80,14 +94,25 @@ const toolTarget = (block: ToolBlock): string => {
|
|
|
80
94
|
return typeof value === "string" ? basename(value.trim()) : "";
|
|
81
95
|
};
|
|
82
96
|
|
|
83
|
-
/**
|
|
84
|
-
export const
|
|
97
|
+
/** A step that runs longer than this shows its duration, so a long step does not look like a stalled one. */
|
|
98
|
+
export const AI_LONG_STEP_MS = AI_TURN_LEASE_MS;
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* The current step of a live turn, as a short progressive phrase. A step that runs long shows its duration in place of
|
|
102
|
+
* its target: "Running code · 3 min".
|
|
103
|
+
*/
|
|
104
|
+
export const liveStepLabel = (block: AiTurnBlock | null, t: Messages, stepMs = 0): string => {
|
|
105
|
+
if (stepMs < AI_LONG_STEP_MS) return stepPhrase(block, t, true);
|
|
106
|
+
return `${stepPhrase(block, t, false)} · ${formatWorkDuration(stepMs, t)}`;
|
|
107
|
+
};
|
|
108
|
+
|
|
109
|
+
const stepPhrase = (block: AiTurnBlock | null, t: Messages, withTargets: boolean): string => {
|
|
85
110
|
if (!block || block.kind === "thinking" || block.kind === "steer_applied" || block.kind === "steer_message") return t.stepThinking;
|
|
86
111
|
if (block.kind === "text") return t.stepWriting;
|
|
87
112
|
if (block.kind === "compaction") return block.status === "running" ? t.stepCompacting : t.stepThinking;
|
|
88
113
|
// A finished call means the model is deciding what to do next.
|
|
89
114
|
if (block.status !== "running" && block.status !== "awaiting_client") return t.stepThinking;
|
|
90
|
-
const target = toolTarget(block);
|
|
115
|
+
const target = withTargets ? toolTarget(block) : "";
|
|
91
116
|
const withTarget = (label: string) => (target ? `${label} · ${target}` : label);
|
|
92
117
|
const name = block.name;
|
|
93
118
|
if (block.presentation?.kind === "capability") return withTarget(t.stepUsingApp({ app: block.presentation.appName }));
|
|
@@ -102,7 +127,7 @@ export const liveStepLabel = (block: AiTurnBlock | null, t: Messages): string =>
|
|
|
102
127
|
if (["code_write", "code_create", "code_update", "code_fork", "code_remove", "code_restore"].includes(name))
|
|
103
128
|
return withTarget(t.stepWritingCode);
|
|
104
129
|
if (["code_run", "code_action", "code_sql"].includes(name)) return withTarget(t.stepRunningCode);
|
|
105
|
-
if (
|
|
130
|
+
if (name === "code_inspect") return t.stepCheckingApp;
|
|
106
131
|
if (name.startsWith("code_")) return t.stepCode;
|
|
107
132
|
if (["markdown_to_pdf", "html_to_pdf"].includes(name)) return withTarget(t.stepPdf);
|
|
108
133
|
if (["load_skill", "load_tools", "search_tools", "list_apps"].includes(name)) return t.stepLoading;
|
|
@@ -117,20 +142,37 @@ function AiWorkLine(props: { segment: Accessor<AiTurnSegment> }) {
|
|
|
117
142
|
const t = () => aiChatMessages(locale());
|
|
118
143
|
const segment = () => props.segment();
|
|
119
144
|
const layout = () => segment().layout;
|
|
120
|
-
const
|
|
121
|
-
const
|
|
145
|
+
const reconnecting = () => isLive(segment().phase) && segment().reconnect === "line";
|
|
146
|
+
const waiting = () => !reconnecting() && segment().phase === "waiting" && layout().waitingFor !== null;
|
|
147
|
+
const working = () => isLive(segment().phase) && !waiting() && !reconnecting();
|
|
148
|
+
// The current step is timed on the work clock, so it stands while the turn reconnects.
|
|
149
|
+
const currentStep = createMemo(() => (isLive(segment().phase) ? stepKey(layout().current) : null));
|
|
150
|
+
const step = createMemo(on(currentStep, (key) => (key === null ? null : (untrack(segment().duration)?.workedMs ?? 0))));
|
|
151
|
+
const stepMs = () => {
|
|
152
|
+
const startedAt = step();
|
|
153
|
+
const workedMs = segment().duration()?.workedMs;
|
|
154
|
+
return startedAt === null || workedMs === undefined ? 0 : workedMs - startedAt;
|
|
155
|
+
};
|
|
156
|
+
// Screen readers hear once that the turn reconnects; the line itself is not read while it changes.
|
|
157
|
+
createEffect(
|
|
158
|
+
on(reconnecting, (now, before) => {
|
|
159
|
+
if (now && !before) announce(t().reconnecting);
|
|
160
|
+
}),
|
|
161
|
+
);
|
|
122
162
|
const worked = () => {
|
|
123
163
|
const duration = segment().duration();
|
|
124
164
|
return !segment().earlier && duration && duration.workedMs > 0 ? duration.workedMs : null;
|
|
125
165
|
};
|
|
126
166
|
const steps = () => t().steps({ count: layout().steps });
|
|
127
167
|
const label = () => {
|
|
128
|
-
if (
|
|
168
|
+
if (reconnecting()) return t().reconnecting;
|
|
169
|
+
if (working()) return liveStepLabel(layout().current, t(), stepMs());
|
|
129
170
|
if (waiting()) return layout().waitingFor === "approval" ? t().waitingForApproval : t().waitingForAnswer;
|
|
130
171
|
const duration = worked();
|
|
131
172
|
const short = duration === null ? "" : formatWorkDuration(duration, t());
|
|
132
173
|
if (segment().phase === "stopped") return t().workedStopped({ duration: short });
|
|
133
|
-
|
|
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 });
|
|
134
176
|
return short ? t().workedFor({ duration: short }) : t().worked;
|
|
135
177
|
};
|
|
136
178
|
const ariaLabel = () => {
|
|
@@ -140,12 +182,12 @@ function AiWorkLine(props: { segment: Accessor<AiTurnSegment> }) {
|
|
|
140
182
|
return t().workedLabel({
|
|
141
183
|
duration: duration === null ? "" : formatWorkDuration(duration, t(), true),
|
|
142
184
|
steps: layout().steps ? steps() : "",
|
|
143
|
-
ending: phase === "stopped" || phase === "failed" ? phase : null,
|
|
185
|
+
ending: phase === "stopped" || (phase === "failed" && !segment().error) ? phase : null,
|
|
144
186
|
});
|
|
145
187
|
};
|
|
146
188
|
const trailing = () => {
|
|
147
189
|
const duration = segment().duration();
|
|
148
|
-
if (working() && duration) {
|
|
190
|
+
if ((working() || reconnecting()) && duration) {
|
|
149
191
|
return (
|
|
150
192
|
<span class="ai-turn-work__meta">
|
|
151
193
|
{formatWorkClock(duration.workedMs)}
|
|
@@ -487,6 +529,37 @@ function AiTurnActionView(props: { action: Accessor<AiTurnAction | undefined>; t
|
|
|
487
529
|
);
|
|
488
530
|
}
|
|
489
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
|
+
|
|
490
563
|
/**
|
|
491
564
|
* Place 3. Live it is a status that switches in this same element; it keeps its height until the turn ends. A status
|
|
492
565
|
* is not read token by token: screen readers hear each status once it is complete, and that the answer is ready.
|
|
@@ -570,6 +643,16 @@ export function AiTurnView(props: { segment: Accessor<AiTurnSegment>; disclosure
|
|
|
570
643
|
const actions = createMemo(() => new Map(layout().actions.map((action) => [action.id, action])));
|
|
571
644
|
const actionIds = createMemo(() => [...actions().keys()]);
|
|
572
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
|
+
);
|
|
573
656
|
return (
|
|
574
657
|
<AiToolDisclosureProvider state={props.disclosureState}>
|
|
575
658
|
<div class="ai-turn">
|
|
@@ -599,10 +682,13 @@ export function AiTurnView(props: { segment: Accessor<AiTurnSegment>; disclosure
|
|
|
599
682
|
<For each={actionIds()}>
|
|
600
683
|
{(id) => <AiTurnActionView action={() => actions().get(id)} turnId={props.segment().turnId} phase={props.segment().phase} />}
|
|
601
684
|
</For>
|
|
602
|
-
<Show when={props.segment().
|
|
603
|
-
{/* Calm by design: no busy sweep while the
|
|
685
|
+
<Show when={props.segment().reconnect === "row"}>
|
|
686
|
+
{/* Calm by design: no busy sweep while the turn reconnects. */}
|
|
604
687
|
<Chat.Activity label={aiChatMessages(locale()).reconnecting} icon="ti ti-refresh" />
|
|
605
688
|
</Show>
|
|
689
|
+
<Show when={props.segment().error}>
|
|
690
|
+
{(error) => <AiTurnErrorNotice error={error()} continuable={props.segment().continuable === true} />}
|
|
691
|
+
</Show>
|
|
606
692
|
</div>
|
|
607
693
|
</AiToolDisclosureProvider>
|
|
608
694
|
);
|
|
@@ -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" />
|
|
@@ -2,7 +2,7 @@ import { type Accessor, createMemo, createSignal, onCleanup } from "solid-js";
|
|
|
2
2
|
import { createStore, reconcile } from "solid-js/store";
|
|
3
3
|
import { type AiAttachmentRef, aiAttachmentMarker } from "../attachments";
|
|
4
4
|
import { aiChatMessages } from "../chat/messages";
|
|
5
|
-
import { type AiStreamEvent, type AiTurnBlock, type AiTurnSnapshot, steerMessageBlockId } from "../protocol";
|
|
5
|
+
import { AI_TURN_LEASE_MS, type AiStreamEvent, type AiTurnBlock, type AiTurnSnapshot, steerMessageBlockId } from "../protocol";
|
|
6
6
|
import { type AiResourceMarker, aiResourceMarker } from "../resource-markers";
|
|
7
7
|
import type {
|
|
8
8
|
AiClientToolId,
|
|
@@ -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();
|
|
@@ -201,6 +214,7 @@ export const createAiChatController = (options: CreateAiChatControllerOptions) =
|
|
|
201
214
|
const abortRequests = new Map<string, Promise<boolean>>();
|
|
202
215
|
let stream: AiStreamHandle | null = null;
|
|
203
216
|
let streamSession: AiStreamSession | null = null;
|
|
217
|
+
let silenceTimer: ReturnType<typeof setTimeout> | undefined;
|
|
204
218
|
/** The chat error left by a stream that ended for good, until a stream opens again. */
|
|
205
219
|
let streamError: string | null = null;
|
|
206
220
|
let streamGeneration = 0;
|
|
@@ -249,7 +263,10 @@ export const createAiChatController = (options: CreateAiChatControllerOptions) =
|
|
|
249
263
|
const conversationId = activeConversationId();
|
|
250
264
|
if (conversationId) openStream(conversationId);
|
|
251
265
|
};
|
|
252
|
-
const error = () =>
|
|
266
|
+
const error = () => {
|
|
267
|
+
const run = runError();
|
|
268
|
+
return globalError() ?? (run && !turnShowsError(state.messages, run.turnId) ? run.message : null);
|
|
269
|
+
};
|
|
253
270
|
|
|
254
271
|
const activeTurn = () => state.activeTurn;
|
|
255
272
|
const messages = createMemo(() => messagesWithPendingSend(visibleMessages(state), pendingSends()[activeConversationId() ?? ""]));
|
|
@@ -294,8 +311,32 @@ export const createAiChatController = (options: CreateAiChatControllerOptions) =
|
|
|
294
311
|
void markConversationViewed(session.conversationId);
|
|
295
312
|
};
|
|
296
313
|
|
|
314
|
+
const clearSilenceTimer = () => {
|
|
315
|
+
if (silenceTimer) clearTimeout(silenceTimer);
|
|
316
|
+
silenceTimer = undefined;
|
|
317
|
+
};
|
|
318
|
+
|
|
319
|
+
/**
|
|
320
|
+
* A running turn whose open stream delivers nothing for one worker lease either works silently or lost an update,
|
|
321
|
+
* such as its end. The stream then starts again from the turn's saved state. The wait starts with the stream's
|
|
322
|
+
* first event, so a slow first state is never cut off. A turn that waits for an action in this browser is not
|
|
323
|
+
* silent, and a stream that reconnects brings a fresh state anyway.
|
|
324
|
+
*/
|
|
325
|
+
const watchSilence = (session: AiStreamSession) => {
|
|
326
|
+
clearSilenceTimer();
|
|
327
|
+
silenceTimer = setTimeout(() => {
|
|
328
|
+
silenceTimer = undefined;
|
|
329
|
+
if (!isCurrentStreamSession(streamSession, session)) return;
|
|
330
|
+
const status = streamStatus();
|
|
331
|
+
if (state.activeTurn?.status !== "running" || status === "connecting" || status === "reconnecting") return watchSilence(session);
|
|
332
|
+
closeStream();
|
|
333
|
+
openStream(session.conversationId);
|
|
334
|
+
}, AI_TURN_LEASE_MS);
|
|
335
|
+
};
|
|
336
|
+
|
|
297
337
|
const applyEvent = (session: AiStreamSession, event: AiStreamEvent) => {
|
|
298
338
|
if (!isCurrentStreamSession(streamSession, session)) return;
|
|
339
|
+
watchSilence(session);
|
|
299
340
|
const conversationId = session.conversationId;
|
|
300
341
|
if (event.type === "turn_finished" && pendingSends()[conversationId]) {
|
|
301
342
|
completedDuringSend.set(conversationId, event.turnId);
|
|
@@ -327,6 +368,7 @@ export const createAiChatController = (options: CreateAiChatControllerOptions) =
|
|
|
327
368
|
};
|
|
328
369
|
|
|
329
370
|
const closeStream = () => {
|
|
371
|
+
clearSilenceTimer();
|
|
330
372
|
stream?.close();
|
|
331
373
|
stream = null;
|
|
332
374
|
streamSession = null;
|
|
@@ -400,15 +442,15 @@ export const createAiChatController = (options: CreateAiChatControllerOptions) =
|
|
|
400
442
|
console.warn(`No frontend handler registered for AI tool "${name}" — leaving the action request pending.`);
|
|
401
443
|
return false;
|
|
402
444
|
}
|
|
445
|
+
let result: unknown;
|
|
403
446
|
try {
|
|
404
|
-
|
|
405
|
-
return submitTurnActionForConversation(conversationId, turnId, callId, { type: "tool_result", result });
|
|
447
|
+
result = await handler({ name, callId, args, turnId, conversationId });
|
|
406
448
|
} catch (toolError) {
|
|
407
|
-
|
|
408
|
-
type: "tool_result",
|
|
409
|
-
result: { error: toolError instanceof Error ? toolError.message : "Frontend tool failed" },
|
|
410
|
-
});
|
|
449
|
+
result = { error: toolError instanceof Error ? toolError.message : "Frontend tool failed" };
|
|
411
450
|
}
|
|
451
|
+
const submitted = await submitTurnActionForConversation(conversationId, turnId, callId, { type: "tool_result", result });
|
|
452
|
+
if (submitted) showActionResolved(conversationId, turnId, { type: "tool_result", callId, result });
|
|
453
|
+
return submitted;
|
|
412
454
|
};
|
|
413
455
|
|
|
414
456
|
// ---- conversation loading --------------------------------------------
|
|
@@ -1258,6 +1300,28 @@ export const createAiChatController = (options: CreateAiChatControllerOptions) =
|
|
|
1258
1300
|
}
|
|
1259
1301
|
};
|
|
1260
1302
|
|
|
1303
|
+
/**
|
|
1304
|
+
* Shows an action the server accepted as resolved, so the turn runs on before the stream confirms it. A turn that
|
|
1305
|
+
* runs is watched for silence again, which heals a confirmation that never arrives.
|
|
1306
|
+
*/
|
|
1307
|
+
const showActionResolved = (
|
|
1308
|
+
conversationId: string,
|
|
1309
|
+
turnId: string,
|
|
1310
|
+
action: { type: "approval_response"; callId: string; approved: boolean } | { type: "tool_result"; callId: string; result: unknown },
|
|
1311
|
+
) => {
|
|
1312
|
+
if (!isActiveConversation(conversationId) || state.activeTurn?.turnId !== turnId) return;
|
|
1313
|
+
const waitingStatus = action.type === "approval_response" ? "awaiting_approval" : "awaiting_client";
|
|
1314
|
+
setState("activeTurn", (current) => {
|
|
1315
|
+
if (!current || current.turnId !== turnId) return current;
|
|
1316
|
+
const pending = current.blocks.some(
|
|
1317
|
+
(block) => block.kind === "tool" && block.callId === action.callId && block.status === waitingStatus,
|
|
1318
|
+
);
|
|
1319
|
+
if (!pending) return current;
|
|
1320
|
+
return reconcileActiveTurnActions(current, [{ callId: action.callId, resolvedEvent: action }]);
|
|
1321
|
+
});
|
|
1322
|
+
cache.set(conversationId, { conversation: state.conversation, messages: state.messages, activeTurn: state.activeTurn });
|
|
1323
|
+
};
|
|
1324
|
+
|
|
1261
1325
|
const submitTurnAction = (
|
|
1262
1326
|
turnId: string,
|
|
1263
1327
|
callId: string,
|
|
@@ -1277,18 +1341,9 @@ export const createAiChatController = (options: CreateAiChatControllerOptions) =
|
|
|
1277
1341
|
approved: input.approved,
|
|
1278
1342
|
remember: input.remember,
|
|
1279
1343
|
});
|
|
1280
|
-
if (
|
|
1281
|
-
|
|
1282
|
-
|
|
1283
|
-
if (!current || current.turnId !== request.turnId) return current;
|
|
1284
|
-
const pending = current.blocks.some(
|
|
1285
|
-
(block) => block.kind === "tool" && block.callId === request.callId && block.status === "awaiting_approval",
|
|
1286
|
-
);
|
|
1287
|
-
if (!pending) return current;
|
|
1288
|
-
return reconcileActiveTurnActions(current, [{ callId: request.callId, resolvedEvent: action }]);
|
|
1289
|
-
});
|
|
1290
|
-
cache.set(conversationId, { conversation: state.conversation, messages: state.messages, activeTurn: state.activeTurn });
|
|
1291
|
-
return true;
|
|
1344
|
+
if (submitted)
|
|
1345
|
+
showActionResolved(conversationId, request.turnId, { type: "approval_response", callId: request.callId, approved: input.approved });
|
|
1346
|
+
return submitted;
|
|
1292
1347
|
};
|
|
1293
1348
|
|
|
1294
1349
|
const submitFrontendToolResult = async (request: { turnId: string; callId: string }, result: unknown) => {
|
|
@@ -1299,18 +1354,8 @@ export const createAiChatController = (options: CreateAiChatControllerOptions) =
|
|
|
1299
1354
|
type: "tool_result",
|
|
1300
1355
|
result,
|
|
1301
1356
|
});
|
|
1302
|
-
if (
|
|
1303
|
-
|
|
1304
|
-
setState("activeTurn", (current) => {
|
|
1305
|
-
if (!current || current.turnId !== request.turnId) return current;
|
|
1306
|
-
const pending = current.blocks.some(
|
|
1307
|
-
(block) => block.kind === "tool" && block.callId === request.callId && block.status === "awaiting_client",
|
|
1308
|
-
);
|
|
1309
|
-
if (!pending) return current;
|
|
1310
|
-
return reconcileActiveTurnActions(current, [{ callId: request.callId, resolvedEvent: action }]);
|
|
1311
|
-
});
|
|
1312
|
-
cache.set(conversationId, { conversation: state.conversation, messages: state.messages, activeTurn: state.activeTurn });
|
|
1313
|
-
return true;
|
|
1357
|
+
if (submitted) showActionResolved(conversationId, request.turnId, { type: "tool_result", callId: request.callId, result });
|
|
1358
|
+
return submitted;
|
|
1314
1359
|
};
|
|
1315
1360
|
|
|
1316
1361
|
if (options.initialConversationId) {
|
|
@@ -1386,6 +1431,7 @@ export const __aiControllerTest = {
|
|
|
1386
1431
|
completeFrontendToolBlock,
|
|
1387
1432
|
conversationRunError,
|
|
1388
1433
|
failSteerBlock,
|
|
1434
|
+
turnShowsError,
|
|
1389
1435
|
projectionForConversationOpen,
|
|
1390
1436
|
reconcileSteerBlocks,
|
|
1391
1437
|
runErrorFromEvent,
|
|
@@ -30,7 +30,14 @@ const bytesToBase64 = (bytes: Uint8Array): string => {
|
|
|
30
30
|
return btoa(binary);
|
|
31
31
|
};
|
|
32
32
|
|
|
33
|
-
export
|
|
33
|
+
export type ConversationFileSource = FileSource & {
|
|
34
|
+
/** Files below `prefix` (default `/`); with `page`, at most `page.limit` files by path after `page.after`. */
|
|
35
|
+
listFiles(prefix?: string, page?: { limit: number; after?: string }): Promise<AiFileStat[]>;
|
|
36
|
+
/** Delete a folder with everything below it. */
|
|
37
|
+
removeFolder(path: string): Promise<void>;
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
export const conversationFileSource = (baseUrl: string, conversationId: string): ConversationFileSource => {
|
|
34
41
|
const filesUrl = (suffix = "", params?: Record<string, string>) => {
|
|
35
42
|
const query = params ? `?${new URLSearchParams(params).toString()}` : "";
|
|
36
43
|
return `${baseUrl}/conversations/${conversationId}/files${suffix}${query}`;
|
|
@@ -45,8 +52,14 @@ export const conversationFileSource = (baseUrl: string, conversationId: string):
|
|
|
45
52
|
return (await response.json()) as T;
|
|
46
53
|
};
|
|
47
54
|
|
|
48
|
-
const listFiles = async (): Promise<AiFileStat[]> =>
|
|
49
|
-
|
|
55
|
+
const listFiles = async (prefix?: string, page?: { limit: number; after?: string }): Promise<AiFileStat[]> => {
|
|
56
|
+
const params: Record<string, string> = {
|
|
57
|
+
...(prefix ? { prefix } : {}),
|
|
58
|
+
...(page ? { limit: String(page.limit), ...(page.after ? { after: page.after } : {}) } : {}),
|
|
59
|
+
};
|
|
60
|
+
const url = Object.keys(params).length ? filesUrl("", params) : filesUrl();
|
|
61
|
+
return (await request<{ files: AiFileStat[] }>(url, { method: "GET" }, "Failed to load files")).files;
|
|
62
|
+
};
|
|
50
63
|
|
|
51
64
|
return {
|
|
52
65
|
previewPreferencesKey: "assistant.csv-preview",
|
|
@@ -80,6 +93,10 @@ export const conversationFileSource = (baseUrl: string, conversationId: string):
|
|
|
80
93
|
await request(filesUrl("", { path }), { method: "DELETE" }, "Failed to delete file");
|
|
81
94
|
},
|
|
82
95
|
|
|
96
|
+
async removeFolder(path) {
|
|
97
|
+
await request(filesUrl("", { path, recursive: "true" }), { method: "DELETE" }, "Failed to delete folder");
|
|
98
|
+
},
|
|
99
|
+
|
|
83
100
|
async rename(from, to) {
|
|
84
101
|
await request(filesUrl("/rename"), { method: "POST", body: JSON.stringify({ from, to }) }, "Failed to rename file");
|
|
85
102
|
},
|
|
@@ -9,7 +9,10 @@ export type AiActiveTurn = {
|
|
|
9
9
|
status: "running" | "waiting_for_action";
|
|
10
10
|
blocks: AiTurnBlock[];
|
|
11
11
|
modelProfileId: string | null;
|
|
12
|
-
/**
|
|
12
|
+
/**
|
|
13
|
+
* A model call failed transiently and waits for its retry. Live only: the next turn event ends it, and so does a
|
|
14
|
+
* snapshot that has moved past the retry.
|
|
15
|
+
*/
|
|
13
16
|
providerRetry?: boolean;
|
|
14
17
|
/** Server start of the turn, known from a state snapshot. */
|
|
15
18
|
createdAt?: string;
|
|
@@ -98,7 +101,9 @@ export const mergeActiveTurn = (previous: AiActiveTurn | null, incoming: AiActiv
|
|
|
98
101
|
const pendingSteers = previous.blocks.filter((block) => block.kind === "steer_message" && block.status !== "consumed");
|
|
99
102
|
const known = new Set(incoming.blocks.map((block) => block.id));
|
|
100
103
|
const blocks = [...incoming.blocks, ...pendingSteers.filter((block) => !known.has(block.id))];
|
|
101
|
-
|
|
104
|
+
// A snapshot never carries a provider retry. One at the retry's own position has seen nothing after it, so the wait goes on.
|
|
105
|
+
const retry = previous.providerRetry && incoming.seq === previous.seq ? { providerRetry: true } : {};
|
|
106
|
+
return { ...turnTiming(previous), ...incoming, ...retry, blocks, status: deriveStatus(blocks) };
|
|
102
107
|
};
|
|
103
108
|
|
|
104
109
|
export const reconcileActiveTurnActions = (
|