@k2b/cloud 0.25.0 → 0.27.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/capabilities.ts +12 -0
- 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/registry.ts +1 -0
- package/src/_internal/runtime-context.ts +1 -0
- package/src/access/GroupCoverage.tsx +175 -0
- package/src/access/PermissionEditor.tsx +119 -99
- package/src/access/messages.ts +30 -0
- package/src/ai/admin.ts +1 -0
- package/src/ai/approval-routes.ts +5 -5
- package/src/ai/browser-code-contracts.ts +14 -2
- package/src/ai/browser.ts +8 -1
- package/src/ai/capabilities.ts +115 -33
- package/src/ai/chat/blocks.tsx +86 -179
- package/src/ai/chat/builtin-tools.tsx +83 -48
- package/src/ai/chat/file-tools.tsx +4 -1
- package/src/ai/chat/live-turn.browser-harness.tsx +44 -0
- package/src/ai/chat/message-actions.tsx +6 -2
- package/src/ai/chat/message-utils.ts +17 -14
- package/src/ai/chat/messages.ts +330 -2
- package/src/ai/chat/presentation.tsx +278 -104
- package/src/ai/chat/tool-groups.ts +55 -35
- package/src/ai/chat/turn-layout.ts +141 -0
- package/src/ai/chat/turn-view.tsx +644 -0
- package/src/ai/client/controller.ts +60 -31
- package/src/ai/client/file-source.ts +20 -3
- package/src/ai/client/projection.ts +42 -6
- package/src/ai/code-mode-skill.ts +27 -27
- package/src/ai/code-runtime-tools.ts +10 -1
- package/src/ai/code-source-contracts.ts +54 -4
- package/src/ai/code-source-tools.ts +10 -3
- package/src/ai/credentials.ts +17 -3
- package/src/ai/data-analysis-skill.ts +2 -2
- package/src/ai/default-tools.ts +2 -2
- package/src/ai/executor.ts +177 -80
- 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 +7 -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/protocol.ts +26 -4
- package/src/ai/provider-fetch.ts +67 -15
- package/src/ai/provider-retry.ts +105 -0
- package/src/ai/provider.ts +7 -1
- package/src/ai/quota-provider.ts +16 -7
- package/src/ai/request-headers.ts +117 -0
- package/src/ai/routes.ts +34 -6
- package/src/ai/runtime.ts +1 -1
- package/src/ai/settings.ts +19 -2
- package/src/ai/skill-seeds.ts +31 -3
- package/src/ai/skills.ts +26 -0
- package/src/ai/solid.ts +1 -1
- package/src/ai/store.ts +202 -59
- package/src/ai/stream.ts +182 -37
- package/src/ai/structured.ts +20 -5
- package/src/ai/system-prompt.ts +25 -0
- package/src/ai/timeline.ts +9 -11
- package/src/ai/tool-call-names.ts +45 -0
- package/src/ai/turn-policy.ts +247 -0
- package/src/ai/turn-timing.ts +31 -3
- package/src/ai/types.ts +36 -5
- 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 +62 -0
- package/src/api/index.ts +2 -0
- package/src/cli/admin/ai-quotas.ts +70 -1
- package/src/cli/admin/index.ts +6 -0
- package/src/cli/admin/outgoing-mail.ts +118 -0
- package/src/contracts/app.ts +2 -0
- package/src/contracts/index.ts +1 -0
- package/src/contracts/outgoing-mail.ts +77 -0
- package/src/contracts/registry.ts +4 -0
- package/src/services/index.ts +3 -0
- package/src/services/notifications/email.ts +16 -26
- package/src/services/outgoing-mail/index.ts +19 -0
- package/src/services/outgoing-mail/store.ts +286 -0
- package/src/services/outgoing-mail/test-send.ts +40 -0
- package/src/services/outgoing-mail/transport.ts +13 -0
- package/src/services/settings/core-settings.ts +1 -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 +1 -1
- package/src/shared/ai-request-options.ts +185 -0
- package/src/shared/app-presentation.ts +10 -2
- package/src/ssr/admin-navigation.ts +1 -1
- package/src/ssr/platform-messages.ts +2 -0
- package/src/ssr/workspace-navigation.ts +7 -1
- package/src/styles/effects.css +69 -0
|
@@ -1,16 +1,28 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import {
|
|
1
|
+
import type { ChatTimelineItem } from "@k2b/ui";
|
|
2
|
+
import { useLocale } from "@k2b/ui";
|
|
3
|
+
import {
|
|
4
|
+
type Accessor,
|
|
5
|
+
createEffect,
|
|
6
|
+
createMemo,
|
|
7
|
+
createRoot,
|
|
8
|
+
createSignal,
|
|
9
|
+
getOwner,
|
|
10
|
+
type JSX,
|
|
11
|
+
onCleanup,
|
|
12
|
+
type Setter,
|
|
13
|
+
Show,
|
|
14
|
+
} from "solid-js";
|
|
3
15
|
import type { AiActiveTurn } from "../client/projection";
|
|
4
|
-
import { type AiActiveTurnSegment, splitActiveTurnBlocks } from "../protocol";
|
|
5
|
-
import { type AiAssistantTimelineItem, buildAiMessageTimeline
|
|
16
|
+
import { type AiActiveTurnSegment, type AiTurnBlock, splitActiveTurnBlocks } from "../protocol";
|
|
17
|
+
import { type AiAssistantTimelineItem, buildAiMessageTimeline } from "../timeline";
|
|
6
18
|
import type { AiConversationTimelineEntry, AiStoredMessage } from "../types";
|
|
7
|
-
import { AiTurnBlockList } from "./blocks";
|
|
8
19
|
import { type AiChatActions, AiChatActionsProvider, createAssistantMessageActions, useAiChatActions } from "./message-actions";
|
|
9
|
-
import {
|
|
10
|
-
import { aiChatMessages } from "./messages";
|
|
20
|
+
import { isRecord, isSurveyToolName, textFromMessage } from "./message-utils";
|
|
11
21
|
import { type AiToolDisclosureState, createAiToolDisclosureState } from "./tool-disclosure";
|
|
22
|
+
import { type AiTurnLayout, type AiTurnPhase, layoutAiTurn, waitsForUser } from "./turn-layout";
|
|
12
23
|
import { TurnNavigator } from "./turn-navigator";
|
|
13
24
|
import { activeTimelineSeq } from "./turn-navigator-utils";
|
|
25
|
+
import { type AiTurnDuration, type AiTurnSegment, AiTurnView } from "./turn-view";
|
|
14
26
|
import {
|
|
15
27
|
aiSteerMessageText,
|
|
16
28
|
aiUserMessageAttachments,
|
|
@@ -28,14 +40,6 @@ export type AiChatTimelineSession = {
|
|
|
28
40
|
export { type AiChatActions, AiChatActionsProvider };
|
|
29
41
|
|
|
30
42
|
type AssistantBlock = AiAssistantTimelineItem["blocks"][number];
|
|
31
|
-
|
|
32
|
-
// Activity blocks span the full message column so disclosure chevrons share one
|
|
33
|
-
// right edge. Tool and compaction blocks count even while busy; reasoning counts
|
|
34
|
-
// only once it has text, because empty reasoning renders at most a busy row and
|
|
35
|
-
// must not widen a prose-only reply.
|
|
36
|
-
const isWideBlock = (block: AssistantBlock) =>
|
|
37
|
-
block.kind === "tool" || block.kind === "compaction" || (block.kind === "thinking" && block.text.trim().length > 0);
|
|
38
|
-
|
|
39
43
|
type SurveyResultBlock = Extract<AssistantBlock, { kind: "tool" }>;
|
|
40
44
|
type SurveySegment = { type: "assistant"; blocks: AssistantBlock[] } | { type: "survey"; block: SurveyResultBlock };
|
|
41
45
|
|
|
@@ -68,31 +72,104 @@ const surveyItem = (block: SurveyResultBlock, turnId: string): ChatTimelineItem
|
|
|
68
72
|
content: <CloudSurveyResultBlock args={block.args} result={block.result} />,
|
|
69
73
|
});
|
|
70
74
|
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
const
|
|
78
|
-
return (
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
}
|
|
75
|
+
/**
|
|
76
|
+
* Timeline ids of a turn's assistant segments. A segment is named by what opened it: `start`, the steering message, or
|
|
77
|
+
* the accepted survey answer before it. A live turn and its history produce the same ids, also for consecutive steering
|
|
78
|
+
* messages, and loading older history does not rename a segment, so the timeline keeps the same elements.
|
|
79
|
+
*/
|
|
80
|
+
const createSegmentIds = () => {
|
|
81
|
+
const used = new Map<string, number>();
|
|
82
|
+
return (turnId: string, opener: string) => {
|
|
83
|
+
const base = `ai-turn:${turnId}:${opener}`;
|
|
84
|
+
// A turn that was compacted while it ran is split in history; its later part continues the same name.
|
|
85
|
+
const count = used.get(base) ?? 0;
|
|
86
|
+
used.set(base, count + 1);
|
|
87
|
+
return count === 0 ? base : `${base}:${count}`;
|
|
88
|
+
};
|
|
89
|
+
};
|
|
90
|
+
|
|
91
|
+
const steerOpener = (steerId: string) => `steer:${steerId}`;
|
|
92
|
+
const surveyOpener = (callId: string) => `survey:${callId}`;
|
|
93
|
+
|
|
94
|
+
/** History stores consecutive steering messages before the blocks they steered; the last one opened the segment. */
|
|
95
|
+
const leadingSteerOpener = (blocks: readonly AssistantBlock[]): string | null => {
|
|
96
|
+
let opener: string | null = null;
|
|
97
|
+
for (const block of blocks) {
|
|
98
|
+
if (block.kind !== "steer_applied") break;
|
|
99
|
+
opener = steerOpener(block.steerId);
|
|
100
|
+
}
|
|
101
|
+
return opener;
|
|
102
|
+
};
|
|
103
|
+
|
|
104
|
+
/** History phase of a finished loop, from how its loop ended. */
|
|
105
|
+
const storedPhase = (entries: readonly AiStoredMessage[]): AiTurnPhase => {
|
|
106
|
+
const reason = entries.findLast((entry) => entry.loopDoneReason)?.loopDoneReason;
|
|
107
|
+
if (!reason || reason === "stop") return "completed";
|
|
108
|
+
return reason === "aborted" ? "stopped" : "failed";
|
|
109
|
+
};
|
|
110
|
+
|
|
111
|
+
/** Responses with a work line or rich places span the message column so their right edges align. */
|
|
112
|
+
const isWideLayout = (layout: AiTurnLayout) => layout.showWork || layout.results.length > 0 || layout.actions.length > 0;
|
|
113
|
+
|
|
114
|
+
const isEmptyLayout = (layout: AiTurnLayout) => !isWideLayout(layout) && layout.text.length === 0;
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* Turn views live in their own roots, keyed by timeline id. A changed segment updates its view instead of
|
|
118
|
+
* replacing it, so host state inside a turn, such as a running Studio session, survives every update.
|
|
119
|
+
*/
|
|
120
|
+
const createTurnViews = (disclosureState: AiToolDisclosureState) => {
|
|
121
|
+
const owner = getOwner();
|
|
122
|
+
const views = new Map<string, { set: Setter<AiTurnSegment>; content: JSX.Element; dispose: () => void }>();
|
|
123
|
+
onCleanup(() => {
|
|
124
|
+
for (const view of views.values()) view.dispose();
|
|
125
|
+
views.clear();
|
|
126
|
+
});
|
|
127
|
+
return {
|
|
128
|
+
content(segment: AiTurnSegment): JSX.Element {
|
|
129
|
+
const existing = views.get(segment.id);
|
|
130
|
+
if (existing) {
|
|
131
|
+
existing.set(() => segment);
|
|
132
|
+
return existing.content;
|
|
133
|
+
}
|
|
134
|
+
return createRoot((dispose) => {
|
|
135
|
+
const [current, set] = createSignal(segment);
|
|
136
|
+
const content = <AiTurnView segment={current} disclosureState={disclosureState} />;
|
|
137
|
+
views.set(segment.id, { set, content, dispose });
|
|
138
|
+
return content;
|
|
139
|
+
}, owner);
|
|
140
|
+
},
|
|
141
|
+
retain(ids: ReadonlySet<string>) {
|
|
142
|
+
for (const [id, view] of views) {
|
|
143
|
+
if (ids.has(id)) continue;
|
|
144
|
+
view.dispose();
|
|
145
|
+
views.delete(id);
|
|
146
|
+
}
|
|
147
|
+
},
|
|
148
|
+
};
|
|
149
|
+
};
|
|
150
|
+
|
|
151
|
+
type TurnViews = ReturnType<typeof createTurnViews>;
|
|
152
|
+
|
|
153
|
+
/** A segment's final text is what Copy copies; intermediate texts stay in the work line. */
|
|
154
|
+
const finalText = (layout: AiTurnLayout) =>
|
|
155
|
+
layout.text
|
|
156
|
+
.map((block) => block.text)
|
|
157
|
+
.join("\n\n")
|
|
158
|
+
.trim();
|
|
88
159
|
|
|
89
160
|
const storedItems = (
|
|
90
161
|
messages: readonly AiStoredMessage[],
|
|
91
162
|
actions: AiChatActions,
|
|
92
|
-
|
|
163
|
+
views: TurnViews,
|
|
93
164
|
locale: string,
|
|
94
|
-
): ChatTimelineItem[] =>
|
|
95
|
-
buildAiMessageTimeline([...messages])
|
|
165
|
+
): ChatTimelineItem[] => {
|
|
166
|
+
const timeline = buildAiMessageTimeline([...messages]);
|
|
167
|
+
const lastItemOfLoop = new Map<string, number>();
|
|
168
|
+
timeline.forEach((item, index) => {
|
|
169
|
+
if (item.type === "assistant" && item.loopId) lastItemOfLoop.set(item.loopId, index);
|
|
170
|
+
});
|
|
171
|
+
const segmentId = createSegmentIds();
|
|
172
|
+
return timeline.flatMap((item, itemIndex): ChatTimelineItem | ChatTimelineItem[] => {
|
|
96
173
|
if (item.type === "user") {
|
|
97
174
|
const text = aiUserMessageText(item.entry);
|
|
98
175
|
const agentMessage = item.entry.meta?.agentMessage;
|
|
@@ -167,72 +244,96 @@ const storedItems = (
|
|
|
167
244
|
}
|
|
168
245
|
|
|
169
246
|
const actionEntry = item.actionEntry?.compactedAt ? null : item.actionEntry;
|
|
170
|
-
const
|
|
247
|
+
const lastOfLoop = !item.loopId || lastItemOfLoop.get(item.loopId) === itemIndex;
|
|
248
|
+
const phase = lastOfLoop ? storedPhase(item.entries) : "completed";
|
|
171
249
|
const segments = splitSurveyResults(item.blocks);
|
|
172
250
|
if (segments.length === 0) segments.push({ type: "assistant", blocks: [] });
|
|
173
251
|
const lastAssistant = segments.findLastIndex((segment) => segment.type === "assistant");
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
:
|
|
208
|
-
|
|
209
|
-
|
|
252
|
+
const scheduledTask = item.entries.find((entry) => entry.meta?.scheduledTask)?.meta?.scheduledTask ?? null;
|
|
253
|
+
let firstSegment = true;
|
|
254
|
+
let opener = leadingSteerOpener(item.blocks) ?? "start";
|
|
255
|
+
return segments.flatMap((segment, index): ChatTimelineItem[] => {
|
|
256
|
+
const turnId = item.loopId ?? item.id;
|
|
257
|
+
if (segment.type === "survey") {
|
|
258
|
+
opener = surveyOpener(segment.block.callId);
|
|
259
|
+
return [surveyItem(segment.block, turnId)];
|
|
260
|
+
}
|
|
261
|
+
const segmentOpener = opener;
|
|
262
|
+
const last = lastOfLoop && index === lastAssistant;
|
|
263
|
+
const segmentPhase = last ? phase : "completed";
|
|
264
|
+
const layout = layoutAiTurn(segment.blocks, { phase: segmentPhase, codePresentations: Boolean(actions.renderCodePresentation) });
|
|
265
|
+
const messageActions =
|
|
266
|
+
actionEntry && index === lastAssistant
|
|
267
|
+
? createAssistantMessageActions({ entry: actionEntry, entries: item.entries, copyText: finalText(layout), actions })
|
|
268
|
+
: undefined;
|
|
269
|
+
const task = firstSegment ? scheduledTask : null;
|
|
270
|
+
firstSegment = false;
|
|
271
|
+
if (isEmptyLayout(layout) && !messageActions && !task) return [];
|
|
272
|
+
const id = item.loopId ? segmentId(item.loopId, segmentOpener) : `${item.id}:${index}`;
|
|
273
|
+
return [
|
|
274
|
+
{
|
|
275
|
+
kind: "message",
|
|
276
|
+
id,
|
|
277
|
+
role: "assistant",
|
|
278
|
+
createdAt: actionEntry?.createdAt ?? item.entries.at(-1)?.createdAt,
|
|
279
|
+
class: isWideLayout(layout) ? "ai-chat-message-wide" : undefined,
|
|
280
|
+
content: views.content({
|
|
281
|
+
id,
|
|
282
|
+
turnId,
|
|
283
|
+
phase: segmentPhase,
|
|
284
|
+
layout,
|
|
285
|
+
earlier: !last,
|
|
286
|
+
duration: last ? () => ({ workedMs: item.workedMs, waitingMs: null }) : () => null,
|
|
287
|
+
scheduledTask: task,
|
|
288
|
+
}),
|
|
289
|
+
actions: messageActions,
|
|
290
|
+
actionDisplay: "inline",
|
|
291
|
+
},
|
|
292
|
+
];
|
|
210
293
|
});
|
|
211
294
|
});
|
|
295
|
+
};
|
|
212
296
|
|
|
213
|
-
const activeItems = (
|
|
297
|
+
const activeItems = (
|
|
298
|
+
turn: AiActiveTurn | null,
|
|
299
|
+
actions: AiChatActions,
|
|
300
|
+
views: TurnViews,
|
|
301
|
+
duration: Accessor<AiTurnDuration | null>,
|
|
302
|
+
reconnecting: boolean,
|
|
303
|
+
): ChatTimelineItem[] => {
|
|
214
304
|
if (!turn) return [];
|
|
215
305
|
const segments = splitActiveTurnBlocks(turn.blocks).flatMap((segment): (AiActiveTurnSegment | SurveySegment)[] =>
|
|
216
306
|
segment.type === "steer" ? [segment] : splitSurveyResults(segment.blocks),
|
|
217
307
|
);
|
|
218
|
-
//
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
308
|
+
// A running turn ends with an assistant item, so its progress indicator and a retry wait stay at the end: before the
|
|
309
|
+
// first model block, after an accepted answer, and below a steering message that waits for the next model call.
|
|
310
|
+
const tail = segments.at(-1)?.type;
|
|
311
|
+
const steerTail = turn.status === "running" && tail === "steer";
|
|
312
|
+
if (segments.length === 0 || steerTail || (turn.status === "running" && tail === "survey"))
|
|
313
|
+
segments.push({ type: "assistant", blocks: [] });
|
|
314
|
+
const lastAssistant = segments.findLastIndex((segment) => segment.type === "assistant");
|
|
315
|
+
// Until the model takes up the steering message, the segment above it is still the one at work.
|
|
316
|
+
const above = steerTail ? segments.findLastIndex((segment, index) => segment.type === "assistant" && index < lastAssistant) : -1;
|
|
317
|
+
const working = above >= 0 ? above : lastAssistant;
|
|
318
|
+
const phaseOf = (index: number): AiTurnPhase =>
|
|
319
|
+
index !== working ? "completed" : turn.status === "waiting_for_action" ? "waiting" : "running";
|
|
320
|
+
const layoutOf = (blocks: AiTurnBlock[], index: number) =>
|
|
321
|
+
layoutAiTurn(blocks, { phase: phaseOf(index), codePresentations: Boolean(actions.renderCodePresentation) });
|
|
322
|
+
// The work line says in place that the turn reconnects. A turn without one yet says it in a row at its end, where a
|
|
323
|
+
// row that comes and goes moves nothing above it.
|
|
324
|
+
const workingSegment = segments[working];
|
|
325
|
+
const reconnectInLine = reconnecting && workingSegment?.type === "assistant" && layoutOf(workingSegment.blocks, working).showWork;
|
|
326
|
+
const segmentId = createSegmentIds();
|
|
327
|
+
let opener = "start";
|
|
231
328
|
|
|
232
|
-
return segments.
|
|
233
|
-
if (segment.type === "survey")
|
|
329
|
+
return segments.flatMap((segment, index): ChatTimelineItem | ChatTimelineItem[] => {
|
|
330
|
+
if (segment.type === "survey") {
|
|
331
|
+
opener = surveyOpener(segment.block.callId);
|
|
332
|
+
return surveyItem(segment.block, turn.turnId);
|
|
333
|
+
}
|
|
234
334
|
if (segment.type === "steer") {
|
|
235
335
|
const block = segment.block;
|
|
336
|
+
opener = steerOpener(block.steerId);
|
|
236
337
|
return {
|
|
237
338
|
kind: "message",
|
|
238
339
|
id: `${turn.turnId}-steer-${block.id}`,
|
|
@@ -245,29 +346,94 @@ const activeItems = (turn: AiActiveTurn | null, actions: AiChatActions, disclosu
|
|
|
245
346
|
};
|
|
246
347
|
}
|
|
247
348
|
|
|
248
|
-
const
|
|
349
|
+
const last = index === lastAssistant;
|
|
350
|
+
const live = index === working;
|
|
351
|
+
const phase = phaseOf(index);
|
|
352
|
+
const layout = layoutOf(segment.blocks, index);
|
|
353
|
+
// Consecutive steering messages leave empty segments between them; history has none. The last segment stays, since
|
|
354
|
+
// it carries the progress indicator.
|
|
355
|
+
if (!last && isEmptyLayout(layout)) return [];
|
|
356
|
+
const id = segmentId(turn.turnId, opener);
|
|
249
357
|
return {
|
|
250
358
|
kind: "message",
|
|
251
|
-
id
|
|
359
|
+
id,
|
|
252
360
|
role: "assistant",
|
|
253
|
-
status: turn.status === "running" &&
|
|
254
|
-
class:
|
|
255
|
-
content: (
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
),
|
|
361
|
+
status: turn.status === "running" && last ? "streaming" : "complete",
|
|
362
|
+
class: isWideLayout(layout) ? "ai-chat-message-wide" : undefined,
|
|
363
|
+
content: views.content({
|
|
364
|
+
id,
|
|
365
|
+
turnId: turn.turnId,
|
|
366
|
+
phase,
|
|
367
|
+
layout,
|
|
368
|
+
earlier: !live,
|
|
369
|
+
duration: live ? duration : () => null,
|
|
370
|
+
reconnect: live && reconnectInLine ? "line" : last && reconnecting && !reconnectInLine ? "row" : undefined,
|
|
371
|
+
}),
|
|
264
372
|
};
|
|
265
373
|
});
|
|
266
374
|
};
|
|
267
375
|
|
|
376
|
+
/**
|
|
377
|
+
* Work time of the active turn: wall time minus time spent waiting for the user. A state snapshot seeds it after a
|
|
378
|
+
* reconnect; the clock stands while the turn waits for an approval or an answer and continues from the same value
|
|
379
|
+
* afterwards. While `paused`, the shown time stands too and then jumps to the true work time; the clock itself keeps
|
|
380
|
+
* running, so a step that starts right after the pause is timed from the true work time.
|
|
381
|
+
*/
|
|
382
|
+
const createActiveTurnClock = (source: AiChatTimelineSource, paused: Accessor<boolean>): Accessor<AiTurnDuration | null> => {
|
|
383
|
+
const [now, setNow] = createSignal(Date.now());
|
|
384
|
+
const running = createMemo(() => source.activeTurn() !== null);
|
|
385
|
+
createEffect(() => {
|
|
386
|
+
if (!running() || typeof window === "undefined") return;
|
|
387
|
+
setNow(Date.now());
|
|
388
|
+
const timer = window.setInterval(() => setNow(Date.now()), 1000);
|
|
389
|
+
onCleanup(() => window.clearInterval(timer));
|
|
390
|
+
});
|
|
391
|
+
let state: { turnId: string; seed: string; startedAt: number; waitMs: number; waitingSince: number | null } | null = null;
|
|
392
|
+
const duration = createMemo(() => {
|
|
393
|
+
const turn = source.activeTurn();
|
|
394
|
+
const time = now();
|
|
395
|
+
if (!turn) {
|
|
396
|
+
state = null;
|
|
397
|
+
return null;
|
|
398
|
+
}
|
|
399
|
+
const seed = `${turn.createdAt}|${turn.actionWaitMs}|${turn.waitingSince}`;
|
|
400
|
+
if (!state || state.turnId !== turn.turnId || state.seed !== seed) {
|
|
401
|
+
const userMessage = source.messages().find((message) => message.loopId === turn.turnId && message.message.role === "user");
|
|
402
|
+
const started = Date.parse(turn.createdAt ?? userMessage?.createdAt ?? "");
|
|
403
|
+
const waitingSince = turn.waitingSince ? Date.parse(turn.waitingSince) : Number.NaN;
|
|
404
|
+
state = {
|
|
405
|
+
turnId: turn.turnId,
|
|
406
|
+
seed,
|
|
407
|
+
startedAt: Number.isFinite(started) ? Math.min(started, time) : state?.turnId === turn.turnId ? state.startedAt : time,
|
|
408
|
+
waitMs: turn.actionWaitMs ?? (state?.turnId === turn.turnId ? state.waitMs : 0),
|
|
409
|
+
waitingSince: Number.isFinite(waitingSince) ? Math.min(waitingSince, time) : null,
|
|
410
|
+
};
|
|
411
|
+
}
|
|
412
|
+
// A tool the browser runs by itself is work; only an approval or an answer the person owes stops the clock.
|
|
413
|
+
const waiting = turn.blocks.some(waitsForUser);
|
|
414
|
+
if (waiting && state.waitingSince === null) state.waitingSince = time;
|
|
415
|
+
if (!waiting && state.waitingSince !== null) {
|
|
416
|
+
state.waitMs += time - state.waitingSince;
|
|
417
|
+
state.waitingSince = null;
|
|
418
|
+
}
|
|
419
|
+
return {
|
|
420
|
+
workedMs: Math.max(0, (state.waitingSince ?? time) - state.startedAt - state.waitMs),
|
|
421
|
+
waitingMs: state.waitingSince === null ? null : Math.max(0, time - state.waitingSince),
|
|
422
|
+
};
|
|
423
|
+
});
|
|
424
|
+
let shown: AiTurnDuration | null = null;
|
|
425
|
+
return createMemo(() => {
|
|
426
|
+
const current = duration();
|
|
427
|
+
if (!paused() || !shown || !current) shown = current;
|
|
428
|
+
return shown;
|
|
429
|
+
});
|
|
430
|
+
};
|
|
431
|
+
|
|
268
432
|
export type AiChatTimelineSource = {
|
|
269
433
|
messages: Accessor<readonly AiStoredMessage[]>;
|
|
270
434
|
activeTurn: Accessor<AiActiveTurn | null>;
|
|
435
|
+
/** The conversation stream lost its connection and reconnects, such as `streamStatus() === "reconnecting"`. */
|
|
436
|
+
reconnecting?: Accessor<boolean>;
|
|
271
437
|
};
|
|
272
438
|
|
|
273
439
|
/**
|
|
@@ -279,9 +445,17 @@ export function createAiChatTimeline(source: AiChatTimelineSource): Accessor<rea
|
|
|
279
445
|
const actions = useAiChatActions();
|
|
280
446
|
const locale = useLocale();
|
|
281
447
|
const disclosureState = createAiToolDisclosureState();
|
|
282
|
-
const
|
|
283
|
-
|
|
284
|
-
|
|
448
|
+
const views = createTurnViews(disclosureState);
|
|
449
|
+
// While the stream reconnects or a model call waits for its retry, the live turn says so and its clock stands.
|
|
450
|
+
const reconnecting = createMemo(() => source.activeTurn()?.providerRetry === true || source.reconnecting?.() === true);
|
|
451
|
+
const clock = createActiveTurnClock(source, reconnecting);
|
|
452
|
+
const stored = createMemo(() => storedItems(source.messages(), actions, views, locale()));
|
|
453
|
+
const active = createMemo(() => activeItems(source.activeTurn(), actions, views, clock, reconnecting()));
|
|
454
|
+
return createMemo(() => {
|
|
455
|
+
const items = [...stored(), ...active()];
|
|
456
|
+
views.retain(new Set(items.map((item) => item.id)));
|
|
457
|
+
return items;
|
|
458
|
+
});
|
|
285
459
|
}
|
|
286
460
|
|
|
287
461
|
export type AiChatTurnNavigatorProps = {
|
|
@@ -1,12 +1,15 @@
|
|
|
1
1
|
import type { AiTurnBlock } from "../protocol";
|
|
2
|
-
import {
|
|
3
|
-
import { isCardToolName, isSurveyToolName, isTextEditorToolName } from "./message-utils";
|
|
2
|
+
import { aiChatMessages } from "./messages";
|
|
4
3
|
|
|
5
4
|
type Tool = Extract<AiTurnBlock, { kind: "tool" }>;
|
|
6
|
-
type
|
|
5
|
+
type Text = Extract<AiTurnBlock, { kind: "text" }>;
|
|
6
|
+
export type AiWorkEntry = Exclude<AiTurnBlock, { kind: "text" } | { kind: "steer_message" }>;
|
|
7
|
+
export type AiWorkGroup = { kind: "text"; block: Text } | { kind: "steps"; id: string; entries: AiWorkEntry[] };
|
|
7
8
|
|
|
9
|
+
/** A failed call, including a code run that reported an error. A rejected approval is a decision, not a failure. */
|
|
8
10
|
export function isFailedTool(tool: Tool): boolean {
|
|
9
|
-
if (tool.
|
|
11
|
+
if (tool.status === "rejected") return false;
|
|
12
|
+
if (tool.isError || tool.status === "failed") return true;
|
|
10
13
|
const result = tool.result;
|
|
11
14
|
return (
|
|
12
15
|
["code_run", "code_inspect", "code_interact"].includes(tool.name) &&
|
|
@@ -17,43 +20,60 @@ export function isFailedTool(tool: Tool): boolean {
|
|
|
17
20
|
);
|
|
18
21
|
}
|
|
19
22
|
|
|
20
|
-
/**
|
|
21
|
-
|
|
22
|
-
|
|
23
|
+
/**
|
|
24
|
+
* Split folded work into intermediate texts and the step groups between them. Reasoning stays inside its group so it
|
|
25
|
+
* does not break up the tools around it. A group is keyed by its first tool, whose id is the same live and in history.
|
|
26
|
+
*/
|
|
27
|
+
export function groupWorkBlocks(blocks: readonly AiTurnBlock[]): AiWorkGroup[] {
|
|
28
|
+
const groups: AiWorkGroup[] = [];
|
|
23
29
|
for (const block of blocks) {
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
if (last?.kind === "tools") last.blocks.push(block);
|
|
36
|
-
else groups.push({ kind: "tools", blocks: [block] });
|
|
37
|
-
} else groups.push({ kind: "block", block });
|
|
30
|
+
if (block.kind === "steer_message") continue;
|
|
31
|
+
if (block.kind === "text") {
|
|
32
|
+
groups.push({ kind: "text", block });
|
|
33
|
+
continue;
|
|
34
|
+
}
|
|
35
|
+
const last = groups.at(-1);
|
|
36
|
+
if (last?.kind === "steps") last.entries.push(block);
|
|
37
|
+
else groups.push({ kind: "steps", id: block.id, entries: [block] });
|
|
38
|
+
}
|
|
39
|
+
for (const group of groups) {
|
|
40
|
+
if (group.kind === "steps") group.id = (group.entries.find((entry) => entry.kind === "tool") ?? group.entries[0]!).id;
|
|
38
41
|
}
|
|
39
42
|
return groups;
|
|
40
43
|
}
|
|
41
44
|
|
|
45
|
+
/** What a group of tools did, in categories; failures and rejections are counted separately. */
|
|
42
46
|
export function summarizeToolGroup(tools: readonly Tool[], locale: string): string {
|
|
43
|
-
const
|
|
47
|
+
const t = aiChatMessages(locale);
|
|
44
48
|
const categories = tools.map((tool) => {
|
|
45
|
-
if (tool.name === "todo_write") return
|
|
46
|
-
if (tool.name
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
if (
|
|
50
|
-
if (
|
|
51
|
-
if (
|
|
52
|
-
if (["
|
|
53
|
-
|
|
54
|
-
if (tool.name.startsWith("web_") || tool.name === "fetch_file") return
|
|
55
|
-
|
|
49
|
+
if (tool.name === "todo_write") return t.groupPlan;
|
|
50
|
+
if (tool.name === "present" || tool.name === "code_present" || tool.name === "card" || tool.name === "cloud_card")
|
|
51
|
+
return t.groupDelivered;
|
|
52
|
+
if (tool.name.startsWith("code_")) return t.groupCode;
|
|
53
|
+
if (tool.presentation?.kind === "capability" || tool.name === "read_cloud_resource") return t.groupCloud;
|
|
54
|
+
if (["read_file", "list_files", "view_image"].includes(tool.name)) return t.groupReadFiles;
|
|
55
|
+
if (tool.name === "write_file") return t.groupWriteFiles;
|
|
56
|
+
if (["markdown_to_pdf", "html_to_pdf"].includes(tool.name)) return t.groupPdf;
|
|
57
|
+
if (["load_skill", "load_tools", "search_tools", "list_apps"].includes(tool.name)) return t.groupLoad;
|
|
58
|
+
if (tool.name.startsWith("web_") || tool.name === "fetch_file") return t.groupWeb;
|
|
59
|
+
if (tool.name === "memory") return t.groupMemory;
|
|
60
|
+
return t.groupTools;
|
|
56
61
|
});
|
|
57
|
-
|
|
58
|
-
|
|
62
|
+
return [...new Set(categories)].join(", ");
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** "9 steps · 1 failed · 1 rejected" for a group of steps. */
|
|
66
|
+
export function countWorkSteps(entries: readonly AiWorkEntry[], locale: string): string {
|
|
67
|
+
const t = aiChatMessages(locale);
|
|
68
|
+
const tools = entries.filter((entry): entry is Tool => entry.kind === "tool");
|
|
69
|
+
const steps = tools.length + entries.filter((entry) => entry.kind === "compaction").length;
|
|
70
|
+
const failed = tools.filter(isFailedTool).length;
|
|
71
|
+
const rejected = tools.filter((tool) => tool.status === "rejected").length;
|
|
72
|
+
return [
|
|
73
|
+
steps ? t.steps({ count: steps }) : "",
|
|
74
|
+
failed ? t.groupFailed({ count: failed }) : "",
|
|
75
|
+
rejected ? t.groupRejected({ count: rejected }) : "",
|
|
76
|
+
]
|
|
77
|
+
.filter(Boolean)
|
|
78
|
+
.join(" · ");
|
|
59
79
|
}
|