@alia.onl/sdk 15.8.0 → 19.0.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/CHANGELOG.md +242 -13
- package/README.md +223 -151
- package/package.json +15 -5
- package/src/animations.ts +0 -1
- package/src/components/AliaAskProvider.tsx +127 -0
- package/src/components/AliaChatContent.tsx +604 -392
- package/src/components/AliaChatHeader.tsx +14 -38
- package/src/components/AliaChatScreen.tsx +56 -31
- package/src/components/AliaChatSheet.tsx +110 -36
- package/src/components/AliaComposer.tsx +1 -1
- package/src/components/AliaParticipants.tsx +202 -0
- package/src/components/AliaParticipantsHeader.tsx +60 -0
- package/src/components/AliaSharedConversation.tsx +86 -0
- package/src/components/AliaWelcomeMessage.tsx +18 -5
- package/src/components/Image.tsx +12 -0
- package/src/components/Markdown.tsx +498 -14
- package/src/components/PlanPreviewCard.tsx +7 -49
- package/src/components/ReplyMarkdown.tsx +359 -0
- package/src/components/RichBlocks.tsx +195 -0
- package/src/components/canvas/CanvasComponent.tsx +59 -0
- package/src/components/canvas/ChartRenderer.tsx +73 -0
- package/src/components/canvas/CodeRenderer.tsx +24 -0
- package/src/components/canvas/FormRenderer.tsx +101 -0
- package/src/components/canvas/MarkdownRenderer.tsx +14 -0
- package/src/components/canvas/TableRenderer.tsx +47 -0
- package/src/components/thought/FilesAndSources.tsx +120 -0
- package/src/components/thought/ThoughtPanel.tsx +417 -0
- package/src/components/thought/ToolStep.tsx +141 -0
- package/src/components/thread/AgentResultCard.tsx +125 -0
- package/src/components/thread/AgentTaskCard.tsx +127 -0
- package/src/components/thread/AliaThread.tsx +401 -0
- package/src/components/thread/MessageBlockBoundary.tsx +78 -0
- package/src/components/thread/NewConversationOffer.tsx +54 -0
- package/src/components/thread/ThreadRow.tsx +458 -0
- package/src/components/thread/ThreadSearch.tsx +129 -0
- package/src/components/thread/ToolResultCard.tsx +254 -0
- package/src/components/thread/TurnMenu.tsx +54 -0
- package/src/components/thread/TurnStatusLine.tsx +34 -0
- package/src/components/voice/AliaVoiceCall.tsx +21 -0
- package/src/components/voice/VoiceControls.tsx +2 -28
- package/src/components/voice/VoiceSession.tsx +39 -11
- package/src/hooks/useAliaActivity.ts +2 -2
- package/src/hooks/useAtBottom.ts +51 -0
- package/src/hooks/useAttachMenu.ts +94 -0
- package/src/hooks/useChatComposer.ts +299 -0
- package/src/hooks/useChatVoice.ts +129 -0
- package/src/hooks/useDocumentPicker.ts +3 -29
- package/src/hooks/useFailedTurnToast.ts +43 -0
- package/src/hooks/useMessageVote.ts +50 -0
- package/src/hooks/useOpenThought.ts +79 -0
- package/src/hooks/usePendingQuestions.ts +2 -2
- package/src/hooks/useReadAloud.ts +92 -0
- package/src/hooks/useShareHandler.ts +24 -0
- package/src/hooks/useSoundEffects.ts +1 -1
- package/src/hooks/useThoughtScope.ts +59 -0
- package/src/hooks/useThreadRows.ts +145 -0
- package/src/hooks/useThreadView.ts +68 -0
- package/src/hooks/useTimelineWindow.ts +256 -0
- package/src/hooks/useVoiceLauncher.ts +180 -0
- package/src/hooks/useVoiceRoom.ts +0 -3
- package/src/index.ts +43 -29
- package/src/lib/activity-store.ts +1 -1
- package/src/lib/add-menu.ts +7 -0
- package/src/lib/call-thread.ts +33 -0
- package/src/lib/citations.ts +79 -2
- package/src/lib/clipboard.ts +23 -0
- package/src/lib/content-utils.ts +10 -0
- package/src/lib/copy-en.ts +284 -0
- package/src/lib/copy.ts +32 -0
- package/src/lib/document-picker.ts +31 -0
- package/src/lib/flights.ts +22 -13
- package/src/lib/focus-return.ts +66 -0
- package/src/lib/keyboard.native.tsx +19 -0
- package/src/lib/keyboard.tsx +9 -0
- package/src/lib/labels.tsx +69 -17
- package/src/lib/participants.ts +86 -0
- package/src/lib/power-level-options.ts +66 -0
- package/src/lib/questions.ts +2 -2
- package/src/lib/task-labels.ts +4 -4
- package/src/lib/tool-call-status.ts +1 -1
- package/src/lib/tool-icons.ts +36 -0
- package/src/lib/tool-renderers.tsx +48 -0
- package/src/lib/turn-state.ts +2 -2
- package/src/lib/voice-call-request.ts +49 -0
- package/src/lib/voice-call.ts +76 -34
- package/src/lib/voice-controls-labels.ts +31 -0
- package/src/lib/voice-protocol.ts +32 -14
- package/src/lib/work-log.ts +6 -6
- package/src/runtime/agents.ts +151 -0
- package/src/runtime/api.ts +123 -0
- package/src/runtime/attachment-utils.ts +96 -0
- package/src/runtime/composer-draft-store.ts +270 -0
- package/src/runtime/config.tsx +186 -0
- package/src/runtime/conversation-store.ts +49 -0
- package/src/runtime/conversations.ts +557 -0
- package/src/runtime/error-utils.ts +104 -0
- package/src/runtime/host-tools.ts +140 -0
- package/src/runtime/index.ts +113 -0
- package/src/runtime/message-history.ts +124 -0
- package/src/runtime/model.ts +39 -0
- package/src/runtime/oxy-files.ts +37 -0
- package/src/runtime/power-levels.ts +66 -0
- package/src/runtime/query-keys.ts +34 -0
- package/src/runtime/random-uuid.ts +25 -0
- package/src/runtime/routes.ts +51 -0
- package/src/runtime/sharing.ts +165 -0
- package/src/runtime/sse-frame-reader.ts +88 -0
- package/src/runtime/stream-events.ts +146 -0
- package/src/runtime/stream-outcome.ts +21 -0
- package/src/runtime/thought-store.ts +103 -0
- package/src/runtime/thread/agent-activity.ts +261 -0
- package/src/runtime/thread/automation-labels.ts +79 -0
- package/src/runtime/thread/conversation-export.ts +135 -0
- package/src/runtime/thread/message-days.ts +120 -0
- package/src/runtime/thread/message-processor.ts +93 -0
- package/src/runtime/thread/task-utils.ts +11 -0
- package/src/runtime/thread/thought-utils.ts +752 -0
- package/src/runtime/thread/thread-history.ts +139 -0
- package/src/runtime/thread/timeline.ts +202 -0
- package/src/runtime/thread/title-tags.ts +8 -0
- package/src/runtime/thread/tool-cards.ts +383 -0
- package/src/runtime/turn-failure.ts +108 -0
- package/src/runtime/types.ts +148 -0
- package/src/runtime/usage-limit-error.ts +44 -0
- package/src/runtime/use-agent-activity.ts +80 -0
- package/src/runtime/use-conversation-stream.ts +1427 -0
- package/src/runtime/use-conversation-thread.ts +556 -0
- package/src/runtime/use-start-conversation.ts +107 -0
- package/src/runtime/use-thread-history.ts +64 -0
- package/src/runtime/use-thread-search.ts +95 -0
- package/src/runtime/use-turn-edit.ts +99 -0
- package/src/tools.ts +2 -2
- package/src/types.ts +18 -47
- package/src/components/AliaChatMessageList.tsx +0 -532
- package/src/components/ThinkingBlock.tsx +0 -43
- package/src/hooks/useAliaChat.ts +0 -573
- package/src/lib/catalogue.ts +0 -236
- package/src/lib/chat-stream.ts +0 -655
- package/src/lib/chat-transport.ts +0 -98
- package/src/lib/delegation.ts +0 -34
- package/src/lib/work-log-copy.ts +0 -61
|
@@ -0,0 +1,752 @@
|
|
|
1
|
+
import { getToolLabel } from '../../lib/tool-registry';
|
|
2
|
+
import type { FailedTurn } from '../turn-failure';
|
|
3
|
+
import type { Message, ToolInvocation } from '../types';
|
|
4
|
+
|
|
5
|
+
export interface Source {
|
|
6
|
+
title: string;
|
|
7
|
+
url: string;
|
|
8
|
+
snippet: string;
|
|
9
|
+
domain: string;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export interface ThoughtStep {
|
|
13
|
+
/**
|
|
14
|
+
* `thinking`, `tool` and `writing` are phases of the work; the other three are
|
|
15
|
+
* how it ENDED, and at most one of them closes a list. The panel translates
|
|
16
|
+
* every non-tool type itself — `label` is the English default, kept so a
|
|
17
|
+
* caller without a translator still has something to print.
|
|
18
|
+
*/
|
|
19
|
+
type: 'thinking' | 'tool' | 'writing' | 'done' | 'failed' | 'cancelled';
|
|
20
|
+
label: string;
|
|
21
|
+
toolName?: string;
|
|
22
|
+
sources?: Source[];
|
|
23
|
+
state?: 'partial-call' | 'call' | 'result';
|
|
24
|
+
/**
|
|
25
|
+
* The invocation a tool step was built from, so a row can open its input
|
|
26
|
+
* and output in place without a second lookup by position. Only tool steps
|
|
27
|
+
* carry one.
|
|
28
|
+
*/
|
|
29
|
+
invocation?: ToolInvocation;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Where a turn is in its life, as the runtime knows it — never as guessed
|
|
34
|
+
* from what the message happens to contain.
|
|
35
|
+
*
|
|
36
|
+
* - `queued`: the turn is in flight and nothing has arrived for it yet.
|
|
37
|
+
* - `running`: tokens, reasoning or tool calls are arriving.
|
|
38
|
+
* - `completed`: the run ended on its own terms.
|
|
39
|
+
* - `failed`: the run ended with an error — the failed-turn card is its twin.
|
|
40
|
+
* - `cancelled`: the person stopped it, or a persisted turn shows a tool that
|
|
41
|
+
* never returned, which is a run that was interrupted before it finished.
|
|
42
|
+
*/
|
|
43
|
+
export type TurnLifecycle = 'queued' | 'running' | 'completed' | 'failed' | 'cancelled';
|
|
44
|
+
|
|
45
|
+
/** The three ways a settled turn can have ended, as stamped on its message. */
|
|
46
|
+
export type TurnOutcome = NonNullable<Message['turnOutcome']>;
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* What the streaming runtime knows about the conversation a message sits in.
|
|
50
|
+
*
|
|
51
|
+
* All of it is optional because a message read back from the server after a
|
|
52
|
+
* reload has none of it — and a message with no live context is, by
|
|
53
|
+
* definition, one whose turn is over.
|
|
54
|
+
*/
|
|
55
|
+
export interface TurnContext {
|
|
56
|
+
/** The hook's `isLoading`: a turn is in flight in THIS conversation. */
|
|
57
|
+
isLoading?: boolean;
|
|
58
|
+
/**
|
|
59
|
+
* The message is the last assistant message of the conversation, which is
|
|
60
|
+
* the one a running turn writes into. Only meaningful with `isLoading`.
|
|
61
|
+
*/
|
|
62
|
+
isLastAssistant?: boolean;
|
|
63
|
+
/** The turn that got no answer, if any — its anchor names the failed message. */
|
|
64
|
+
failedTurn?: FailedTurn | null;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** The fields of a message the lifecycle is read from; a `Message` satisfies it. */
|
|
68
|
+
export type LifecycleMessage = Pick<
|
|
69
|
+
Message,
|
|
70
|
+
| 'id'
|
|
71
|
+
| 'content'
|
|
72
|
+
| 'thinking'
|
|
73
|
+
| 'toolInvocations'
|
|
74
|
+
| 'isStreaming'
|
|
75
|
+
| 'turnOutcome'
|
|
76
|
+
| 'researchProgress'
|
|
77
|
+
>;
|
|
78
|
+
|
|
79
|
+
/** A tool that was called and never came back — `call` or `partial-call`. */
|
|
80
|
+
function hasUnresolvedTool(message: Pick<LifecycleMessage, 'toolInvocations'>): boolean {
|
|
81
|
+
return message.toolInvocations?.some((inv) => inv.state !== 'result') ?? false;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function hasContent(content: LifecycleMessage['content'] | undefined): boolean {
|
|
85
|
+
return typeof content === 'string'
|
|
86
|
+
? content.length > 0
|
|
87
|
+
: Array.isArray(content) && content.length > 0;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** Anything at all has arrived for the turn: text, reasoning, a tool call or a research phase. */
|
|
91
|
+
function hasAnyOutput(message: LifecycleMessage): boolean {
|
|
92
|
+
return (
|
|
93
|
+
hasContent(message.content) ||
|
|
94
|
+
(message.thinking !== undefined && message.thinking.length > 0) ||
|
|
95
|
+
(message.toolInvocations?.length ?? 0) > 0 ||
|
|
96
|
+
message.researchProgress !== undefined
|
|
97
|
+
);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* The lifecycle of a turn, from the signals the runtime actually tracks.
|
|
102
|
+
*
|
|
103
|
+
* The panel used to decide a turn had ended by looking at whether the message
|
|
104
|
+
* had content — so the first streamed token flipped a running answer to
|
|
105
|
+
* "Done", a finished tool-only turn looked like it was still running, and
|
|
106
|
+
* array content counted as finished the moment it existed. None of those
|
|
107
|
+
* are the turn's state; they are what the message happened to hold.
|
|
108
|
+
*
|
|
109
|
+
* The order here is the order of certainty:
|
|
110
|
+
*
|
|
111
|
+
* 1. A settled outcome on the message, or the failed-turn card anchored on
|
|
112
|
+
* it, is final — a stream cannot un-fail.
|
|
113
|
+
* 2. Otherwise the turn is live while its message is being streamed into.
|
|
114
|
+
* `isStreaming` is the hook's own stamp and is trusted on its own, EXCEPT
|
|
115
|
+
* against an explicit `isLoading: false`: a stamp that outlived the turn
|
|
116
|
+
* (persisted mid-stream, or left on a voice row) must not run forever.
|
|
117
|
+
* The `isLoading && isLastAssistant` pair is the fallback for a message
|
|
118
|
+
* that carries no stamp at all.
|
|
119
|
+
* 3. With no live signal the turn is over. A tool still in `call` then is
|
|
120
|
+
* one that never returned — an interrupted run, not a finished one.
|
|
121
|
+
*/
|
|
122
|
+
export function turnLifecycle(message: LifecycleMessage, ctx: TurnContext = {}): TurnLifecycle {
|
|
123
|
+
if (message.turnOutcome === 'failed' || ctx.failedTurn?.anchorMessageId === message.id)
|
|
124
|
+
return 'failed';
|
|
125
|
+
if (message.turnOutcome === 'cancelled') return 'cancelled';
|
|
126
|
+
if (message.turnOutcome === 'completed') return 'completed';
|
|
127
|
+
|
|
128
|
+
const stamped = message.isStreaming === true && ctx.isLoading !== false;
|
|
129
|
+
const inferred = ctx.isLoading === true && ctx.isLastAssistant === true;
|
|
130
|
+
if (stamped || inferred) return hasAnyOutput(message) ? 'running' : 'queued';
|
|
131
|
+
|
|
132
|
+
return hasUnresolvedTool(message) ? 'cancelled' : 'completed';
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/** The lifecycles during which the panel still animates. */
|
|
136
|
+
export function isLiveLifecycle(lifecycle: TurnLifecycle): boolean {
|
|
137
|
+
return lifecycle === 'queued' || lifecycle === 'running';
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
function getDomain(url: string): string {
|
|
141
|
+
try {
|
|
142
|
+
return new URL(url).hostname.replace('www.', '');
|
|
143
|
+
} catch {
|
|
144
|
+
return url;
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* The sources of a finished `deepResearch` invocation, as the research
|
|
150
|
+
* handler persists them: `{ id, url, title }[]` under `result.sources`, the
|
|
151
|
+
* same shape the final `alia.research_progress` event carries live. No
|
|
152
|
+
* snippet — research keeps excerpts server-side — so the card shows the
|
|
153
|
+
* title alone.
|
|
154
|
+
*/
|
|
155
|
+
function researchInvocationSources(result: { sources?: unknown }): Source[] {
|
|
156
|
+
if (!Array.isArray(result.sources)) return [];
|
|
157
|
+
return researchSourcesToSources(
|
|
158
|
+
result.sources as Array<{ id?: unknown; url?: unknown; title?: unknown }>,
|
|
159
|
+
);
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/**
|
|
163
|
+
* Research sources — from a persisted invocation or from the live progress
|
|
164
|
+
* event — in the shape the Sources row and panel render. One per URL: the
|
|
165
|
+
* tracker already de-duplicates server-side, and this keeps that true of
|
|
166
|
+
* whatever a row holds.
|
|
167
|
+
*/
|
|
168
|
+
export function researchSourcesToSources(
|
|
169
|
+
sources?: Array<{ id?: unknown; url?: unknown; title?: unknown }> | null,
|
|
170
|
+
): Source[] {
|
|
171
|
+
if (!sources) return [];
|
|
172
|
+
const seen = new Set<string>();
|
|
173
|
+
const out: Source[] = [];
|
|
174
|
+
for (const s of sources) {
|
|
175
|
+
if (!s || typeof s.url !== 'string' || s.url.length === 0 || seen.has(s.url)) continue;
|
|
176
|
+
seen.add(s.url);
|
|
177
|
+
const title =
|
|
178
|
+
typeof s.title === 'string' && s.title.trim().length > 0 ? s.title.trim() : getDomain(s.url);
|
|
179
|
+
out.push({ title, url: s.url, snippet: '', domain: getDomain(s.url) });
|
|
180
|
+
}
|
|
181
|
+
return out;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/** The union of two source lists, first occurrence of a URL winning. */
|
|
185
|
+
export function mergeSources(...lists: Source[][]): Source[] {
|
|
186
|
+
const seen = new Set<string>();
|
|
187
|
+
const out: Source[] = [];
|
|
188
|
+
for (const list of lists) {
|
|
189
|
+
for (const source of list) {
|
|
190
|
+
if (seen.has(source.url)) continue;
|
|
191
|
+
seen.add(source.url);
|
|
192
|
+
out.push(source);
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
return out;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/**
|
|
199
|
+
* Extract unique sources from tool invocations (webSearch, webScraper,
|
|
200
|
+
* browse, and the `deepResearch` record a research answer is saved with).
|
|
201
|
+
*/
|
|
202
|
+
export function extractSources(toolInvocations?: ToolInvocation[]): Source[] {
|
|
203
|
+
if (!toolInvocations) return [];
|
|
204
|
+
|
|
205
|
+
const seen = new Set<string>();
|
|
206
|
+
const sources: Source[] = [];
|
|
207
|
+
|
|
208
|
+
for (const inv of toolInvocations) {
|
|
209
|
+
if (inv.state !== 'result' || !inv.result) continue;
|
|
210
|
+
|
|
211
|
+
if (inv.toolName === 'deepResearch') {
|
|
212
|
+
for (const source of researchInvocationSources(inv.result)) {
|
|
213
|
+
if (seen.has(source.url)) continue;
|
|
214
|
+
seen.add(source.url);
|
|
215
|
+
sources.push(source);
|
|
216
|
+
}
|
|
217
|
+
continue;
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
if (
|
|
221
|
+
(inv.toolName === 'webSearch' ||
|
|
222
|
+
(inv.toolName === 'browse' && inv.result.action === 'search')) &&
|
|
223
|
+
Array.isArray(inv.result.results)
|
|
224
|
+
) {
|
|
225
|
+
for (const r of inv.result.results) {
|
|
226
|
+
if (r.url && !seen.has(r.url)) {
|
|
227
|
+
seen.add(r.url);
|
|
228
|
+
sources.push({
|
|
229
|
+
title: r.title || getDomain(r.url),
|
|
230
|
+
url: r.url,
|
|
231
|
+
snippet: r.snippet || '',
|
|
232
|
+
domain: getDomain(r.url),
|
|
233
|
+
});
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
if (inv.toolName === 'browse' && inv.result.action === 'read' && inv.result.url) {
|
|
239
|
+
const url = inv.result.url;
|
|
240
|
+
if (!seen.has(url)) {
|
|
241
|
+
seen.add(url);
|
|
242
|
+
sources.push({
|
|
243
|
+
title: inv.result.title || getDomain(url),
|
|
244
|
+
url,
|
|
245
|
+
snippet: inv.result.content ? inv.result.content.slice(0, 200) : '',
|
|
246
|
+
domain: getDomain(url),
|
|
247
|
+
});
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
if (inv.toolName === 'webScraper' && inv.result.url) {
|
|
252
|
+
const url = inv.result.url;
|
|
253
|
+
if (!seen.has(url)) {
|
|
254
|
+
seen.add(url);
|
|
255
|
+
sources.push({
|
|
256
|
+
title: inv.result.title || getDomain(url),
|
|
257
|
+
url,
|
|
258
|
+
snippet: inv.result.content ? inv.result.content.slice(0, 200) : '',
|
|
259
|
+
domain: getDomain(url),
|
|
260
|
+
});
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
return sources;
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
/**
|
|
269
|
+
* Build an ordered timeline of steps from a message's thinking + tool
|
|
270
|
+
* invocations, closed by the step its lifecycle earns.
|
|
271
|
+
*
|
|
272
|
+
* The closing step is decided by the LIFECYCLE and nothing else: text in the
|
|
273
|
+
* message does not mean the turn is over (it is "Writing" while the turn
|
|
274
|
+
* runs), and a turn that ended in an error or a stop never reads "Done" — it
|
|
275
|
+
* gets its own terminal step. A tool still running is left in its own state
|
|
276
|
+
* so a finished tool before it does not hide it.
|
|
277
|
+
*/
|
|
278
|
+
export function buildSteps(
|
|
279
|
+
message: Partial<Pick<LifecycleMessage, 'thinking' | 'content' | 'toolInvocations'>>,
|
|
280
|
+
lifecycle: TurnLifecycle,
|
|
281
|
+
): ThoughtStep[] {
|
|
282
|
+
const steps: ThoughtStep[] = [];
|
|
283
|
+
|
|
284
|
+
// 1. Thinking step — also what a queued turn shows, since a turn with nothing
|
|
285
|
+
// to show yet is nonetheless being thought about.
|
|
286
|
+
if (message.thinking || lifecycle === 'queued') {
|
|
287
|
+
steps.push({ type: 'thinking', label: 'Thinking' });
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
// 2. Tool invocation steps
|
|
291
|
+
if (message.toolInvocations) {
|
|
292
|
+
for (const inv of message.toolInvocations) {
|
|
293
|
+
const step: ThoughtStep = {
|
|
294
|
+
type: 'tool',
|
|
295
|
+
label: getToolLabel(inv.toolName),
|
|
296
|
+
toolName: inv.toolName,
|
|
297
|
+
state: inv.state,
|
|
298
|
+
invocation: inv,
|
|
299
|
+
};
|
|
300
|
+
|
|
301
|
+
// A research step carries every source the answer was written from.
|
|
302
|
+
if (inv.toolName === 'deepResearch' && inv.state === 'result' && inv.result) {
|
|
303
|
+
const researchSources = researchInvocationSources(inv.result);
|
|
304
|
+
if (researchSources.length > 0) step.sources = researchSources;
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
// Attach sources for search tools that have results
|
|
308
|
+
if (
|
|
309
|
+
(inv.toolName === 'webSearch' ||
|
|
310
|
+
(inv.toolName === 'browse' && inv.result?.action === 'search')) &&
|
|
311
|
+
inv.state === 'result' &&
|
|
312
|
+
inv.result?.results
|
|
313
|
+
) {
|
|
314
|
+
step.sources = inv.result.results
|
|
315
|
+
.filter((r: any) => r.url)
|
|
316
|
+
.map((r: any) => ({
|
|
317
|
+
title: r.title || getDomain(r.url),
|
|
318
|
+
url: r.url,
|
|
319
|
+
snippet: r.snippet || '',
|
|
320
|
+
domain: getDomain(r.url),
|
|
321
|
+
}));
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
steps.push(step);
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
// 3. The step the lifecycle closes the list with.
|
|
329
|
+
switch (lifecycle) {
|
|
330
|
+
case 'queued':
|
|
331
|
+
break;
|
|
332
|
+
case 'running':
|
|
333
|
+
// A tool that has not returned is the live step; otherwise the model is
|
|
334
|
+
// writing, whether or not its first token has been flushed yet.
|
|
335
|
+
if (!hasUnresolvedTool(message)) steps.push({ type: 'writing', label: 'Writing' });
|
|
336
|
+
break;
|
|
337
|
+
case 'completed':
|
|
338
|
+
// "Done" under a message that holds nothing at all would be a step
|
|
339
|
+
// about nothing; a finished tool-only turn, though, is done.
|
|
340
|
+
if (steps.length > 0 || hasContent(message.content))
|
|
341
|
+
steps.push({ type: 'done', label: 'Done' });
|
|
342
|
+
break;
|
|
343
|
+
case 'failed':
|
|
344
|
+
steps.push({ type: 'failed', label: 'Failed' });
|
|
345
|
+
break;
|
|
346
|
+
case 'cancelled':
|
|
347
|
+
steps.push({ type: 'cancelled', label: 'Stopped' });
|
|
348
|
+
break;
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
return steps;
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
/**
|
|
355
|
+
* Text an audit row shows, before it is translated.
|
|
356
|
+
*
|
|
357
|
+
* The timeline is built outside React, with no translator, and it used to
|
|
358
|
+
* build its rows' words in English right here ("Plan", "3 steps",
|
|
359
|
+
* "Research: synthesizing") — so the Activity tab was English in every
|
|
360
|
+
* locale. It now says WHAT to show and the panel says it:
|
|
361
|
+
*
|
|
362
|
+
* - `key`: an i18n key and its params.
|
|
363
|
+
* - `tool`: a tool's own label, by tool name — see `toolLabel`.
|
|
364
|
+
* - `text`: words that are the conversation's own (a query, a file name, a
|
|
365
|
+
* server's progress message), shown as they are.
|
|
366
|
+
*/
|
|
367
|
+
export type AuditText =
|
|
368
|
+
| { key: string; params?: Record<string, string | number> }
|
|
369
|
+
| { tool: string }
|
|
370
|
+
| { text: string };
|
|
371
|
+
|
|
372
|
+
/**
|
|
373
|
+
* The tools this app has its own words for, in every locale.
|
|
374
|
+
*
|
|
375
|
+
* A set rather than a lookup into the catalog: `toolName` is an open string
|
|
376
|
+
* off the wire, and `thought.tool.constructor` would resolve to
|
|
377
|
+
* `Object.prototype.constructor` inside i18n-js. Anything else falls back to
|
|
378
|
+
* the SDK registry's English label, then to the raw name.
|
|
379
|
+
*/
|
|
380
|
+
const TRANSLATED_TOOLS: ReadonlySet<string> = new Set([
|
|
381
|
+
'webSearch',
|
|
382
|
+
'scrapeURL',
|
|
383
|
+
'getTimeline',
|
|
384
|
+
'searchKnowledgeBase',
|
|
385
|
+
'webScraper',
|
|
386
|
+
'browse',
|
|
387
|
+
'sendWhatsAppMessage',
|
|
388
|
+
'getWhatsAppChats',
|
|
389
|
+
'getWhatsAppMessages',
|
|
390
|
+
'sendTelegramMessage',
|
|
391
|
+
'getCurrentDate',
|
|
392
|
+
'generateFile',
|
|
393
|
+
'saveUserMemory',
|
|
394
|
+
'updateUserPreferences',
|
|
395
|
+
'updateUserContext',
|
|
396
|
+
]);
|
|
397
|
+
|
|
398
|
+
type Translate = (key: string, params?: Record<string, unknown>) => string;
|
|
399
|
+
|
|
400
|
+
/** A tool's label in the reader's language, where the app has one. */
|
|
401
|
+
export function toolLabel(toolName: string, t: Translate): string {
|
|
402
|
+
return TRANSLATED_TOOLS.has(toolName) ? t(`thought.tool.${toolName}`) : getToolLabel(toolName);
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
/** An `AuditText`, as the words a row shows. */
|
|
406
|
+
export function auditText(text: AuditText, t: Translate): string {
|
|
407
|
+
if ('key' in text) return t(text.key, text.params);
|
|
408
|
+
if ('tool' in text) return toolLabel(text.tool, t);
|
|
409
|
+
return text.text;
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
/** Research phases the Activity tab names; any other reads as "Researching". */
|
|
413
|
+
const RESEARCH_PHASES: ReadonlySet<string> = new Set([
|
|
414
|
+
'decomposing',
|
|
415
|
+
'searching',
|
|
416
|
+
'reading',
|
|
417
|
+
'synthesizing',
|
|
418
|
+
'follow_up',
|
|
419
|
+
'finalizing',
|
|
420
|
+
]);
|
|
421
|
+
|
|
422
|
+
/**
|
|
423
|
+
* Entry in the action audit timeline.
|
|
424
|
+
*/
|
|
425
|
+
export interface AuditEntry {
|
|
426
|
+
id: string;
|
|
427
|
+
type: 'tool_call' | 'research_phase' | 'agent_delegation' | 'plan' | 'artifact_generated';
|
|
428
|
+
label: AuditText;
|
|
429
|
+
description: AuditText;
|
|
430
|
+
/** `interrupted` is a tool call that never returned in a turn that is over. */
|
|
431
|
+
status: 'in_progress' | 'complete' | 'interrupted';
|
|
432
|
+
toolName?: string;
|
|
433
|
+
messageId: string;
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
/**
|
|
437
|
+
* Build a chronological audit timeline from all conversation messages.
|
|
438
|
+
*
|
|
439
|
+
* A tool call without a result is "in progress" only while its turn is
|
|
440
|
+
* actually running; in a turn that ended — stopped, failed, or read back from
|
|
441
|
+
* the server that way — it is a call that never returned, and it must not
|
|
442
|
+
* pulse forever.
|
|
443
|
+
*/
|
|
444
|
+
export function buildAuditTimeline(
|
|
445
|
+
messages: Message[],
|
|
446
|
+
ctx: Omit<TurnContext, 'isLastAssistant'> = {},
|
|
447
|
+
): AuditEntry[] {
|
|
448
|
+
const entries: AuditEntry[] = [];
|
|
449
|
+
const lastAssistant = [...messages].reverse().find((m) => m.role === 'assistant');
|
|
450
|
+
|
|
451
|
+
for (const msg of messages) {
|
|
452
|
+
if (msg.role !== 'assistant') continue;
|
|
453
|
+
const live = isLiveLifecycle(
|
|
454
|
+
turnLifecycle(msg, { ...ctx, isLastAssistant: msg === lastAssistant }),
|
|
455
|
+
);
|
|
456
|
+
|
|
457
|
+
// Agent delegation
|
|
458
|
+
if (msg.agentInfo) {
|
|
459
|
+
entries.push({
|
|
460
|
+
id: `agent-${msg.id}`,
|
|
461
|
+
type: 'agent_delegation',
|
|
462
|
+
label: { key: 'thought.audit.agent', params: { name: msg.agentInfo.name } },
|
|
463
|
+
description: { text: typeof msg.content === 'string' ? msg.content.slice(0, 80) : '' },
|
|
464
|
+
status: 'complete',
|
|
465
|
+
messageId: msg.id,
|
|
466
|
+
});
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
// The plan it showed
|
|
470
|
+
if (msg.plan) {
|
|
471
|
+
entries.push({
|
|
472
|
+
id: `plan-${msg.id}`,
|
|
473
|
+
type: 'plan',
|
|
474
|
+
label: { key: 'thought.audit.plan' },
|
|
475
|
+
description: {
|
|
476
|
+
key: 'thought.audit.planSteps',
|
|
477
|
+
params: { count: msg.plan.steps?.length || 0 },
|
|
478
|
+
},
|
|
479
|
+
status: 'complete',
|
|
480
|
+
messageId: msg.id,
|
|
481
|
+
});
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
// Tool invocations
|
|
485
|
+
if (msg.toolInvocations) {
|
|
486
|
+
for (const inv of msg.toolInvocations) {
|
|
487
|
+
const isDone = inv.state === 'result';
|
|
488
|
+
let description = '';
|
|
489
|
+
if (inv.args?.query) {
|
|
490
|
+
const q = String(inv.args.query);
|
|
491
|
+
description = q.length > 50 ? q.slice(0, 50) + '...' : q;
|
|
492
|
+
} else if (inv.args?.url) {
|
|
493
|
+
const u = String(inv.args.url);
|
|
494
|
+
description = u.length > 50 ? u.slice(0, 50) + '...' : u;
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
entries.push({
|
|
498
|
+
id: inv.toolCallId || `tool-${msg.id}-${inv.toolName}`,
|
|
499
|
+
type: 'tool_call',
|
|
500
|
+
label: { tool: inv.toolName },
|
|
501
|
+
description: { text: description },
|
|
502
|
+
status: isDone ? 'complete' : live ? 'in_progress' : 'interrupted',
|
|
503
|
+
toolName: inv.toolName,
|
|
504
|
+
messageId: msg.id,
|
|
505
|
+
});
|
|
506
|
+
|
|
507
|
+
// Artifact generated from generateFile
|
|
508
|
+
if (inv.toolName === 'generateFile' && isDone && inv.result) {
|
|
509
|
+
entries.push({
|
|
510
|
+
id: `artifact-${inv.toolCallId}`,
|
|
511
|
+
type: 'artifact_generated',
|
|
512
|
+
label: { key: 'thought.audit.fileGenerated' },
|
|
513
|
+
description: { text: inv.result.filename || inv.result.title || '' },
|
|
514
|
+
status: 'complete',
|
|
515
|
+
messageId: msg.id,
|
|
516
|
+
});
|
|
517
|
+
}
|
|
518
|
+
}
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
// Research phases. `failed` is the engine saying the write-up did not
|
|
522
|
+
// happen: finished, not in progress, and not "complete" either.
|
|
523
|
+
if (msg.researchProgress) {
|
|
524
|
+
const rp = msg.researchProgress;
|
|
525
|
+
const failed = rp.phase === 'failed';
|
|
526
|
+
entries.push({
|
|
527
|
+
id: `research-${msg.id}`,
|
|
528
|
+
type: 'research_phase',
|
|
529
|
+
label: failed
|
|
530
|
+
? { key: 'thought.audit.researchIncomplete' }
|
|
531
|
+
: rp.isComplete
|
|
532
|
+
? { key: 'thought.audit.researchComplete' }
|
|
533
|
+
: rp.phase !== undefined && RESEARCH_PHASES.has(rp.phase)
|
|
534
|
+
? { key: `thought.audit.researchPhase.${rp.phase}` }
|
|
535
|
+
: { key: 'thought.audit.researching' },
|
|
536
|
+
description: { text: rp.message || '' },
|
|
537
|
+
status: rp.isComplete || failed ? 'complete' : 'in_progress',
|
|
538
|
+
messageId: msg.id,
|
|
539
|
+
});
|
|
540
|
+
}
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
return entries;
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
// ---------------------------------------------------------------------------
|
|
547
|
+
// Execution rows (#544): what one tool call reads as, what a turn produced,
|
|
548
|
+
// and how long it worked.
|
|
549
|
+
// ---------------------------------------------------------------------------
|
|
550
|
+
|
|
551
|
+
/**
|
|
552
|
+
* How a single tool call reads in an execution row (`running`, `done`, `error`,
|
|
553
|
+
* `interrupted`). One definition, the SDK's, so its chat and this app agree.
|
|
554
|
+
*/
|
|
555
|
+
export { type ToolCallStatus, toolCallStatus } from '../../tools';
|
|
556
|
+
|
|
557
|
+
/** Upper bound on the text an expanded row prints for one side of a call. */
|
|
558
|
+
const TOOL_TEXT_LIMIT = 4000;
|
|
559
|
+
|
|
560
|
+
/**
|
|
561
|
+
* One side of a tool call — its arguments or its result — as text a reader
|
|
562
|
+
* can scan. Strings are printed as they are; anything else is pretty JSON.
|
|
563
|
+
* Long output is cut with a marker rather than hidden, so a row never shows
|
|
564
|
+
* a fragment as if it were the whole.
|
|
565
|
+
*/
|
|
566
|
+
export function toolCallText(value: unknown, limit = TOOL_TEXT_LIMIT): string {
|
|
567
|
+
if (value === undefined || value === null) return '';
|
|
568
|
+
let text: string;
|
|
569
|
+
if (typeof value === 'string') {
|
|
570
|
+
text = value;
|
|
571
|
+
} else {
|
|
572
|
+
try {
|
|
573
|
+
text = JSON.stringify(value, null, 2) ?? '';
|
|
574
|
+
} catch {
|
|
575
|
+
text = String(value);
|
|
576
|
+
}
|
|
577
|
+
}
|
|
578
|
+
return text.length > limit ? `${text.slice(0, limit)}\n…` : text;
|
|
579
|
+
}
|
|
580
|
+
|
|
581
|
+
/** A file a turn produced, as the Outputs section lists it. */
|
|
582
|
+
export interface OutputFile {
|
|
583
|
+
/** The invocation's id, which is also the canvas artifact's id when one was made. */
|
|
584
|
+
id: string;
|
|
585
|
+
name: string;
|
|
586
|
+
toolName: string;
|
|
587
|
+
}
|
|
588
|
+
|
|
589
|
+
/**
|
|
590
|
+
* The files a message's tools generated, read off the same persisted
|
|
591
|
+
* `toolInvocations` the sources come from: a finished `generateFile` names
|
|
592
|
+
* its file, and any tool that returned an `artifact` names that. Nothing is
|
|
593
|
+
* invented for a call that did not return — a running or interrupted
|
|
594
|
+
* generation is a step, not an output.
|
|
595
|
+
*/
|
|
596
|
+
export function extractOutputs(toolInvocations?: ToolInvocation[]): OutputFile[] {
|
|
597
|
+
if (!toolInvocations) return [];
|
|
598
|
+
const outputs: OutputFile[] = [];
|
|
599
|
+
for (const inv of toolInvocations) {
|
|
600
|
+
if (inv.state !== 'result' || !inv.result || typeof inv.result !== 'object') continue;
|
|
601
|
+
const result = inv.result as {
|
|
602
|
+
filename?: unknown;
|
|
603
|
+
title?: unknown;
|
|
604
|
+
artifact?: { title?: unknown } | null;
|
|
605
|
+
};
|
|
606
|
+
let name: string | null = null;
|
|
607
|
+
if (inv.toolName === 'generateFile') {
|
|
608
|
+
name =
|
|
609
|
+
typeof result.filename === 'string' && result.filename.length > 0
|
|
610
|
+
? result.filename
|
|
611
|
+
: typeof result.title === 'string' && result.title.length > 0
|
|
612
|
+
? result.title
|
|
613
|
+
: getToolLabel(inv.toolName);
|
|
614
|
+
} else if (result.artifact && typeof result.artifact === 'object') {
|
|
615
|
+
name =
|
|
616
|
+
typeof result.artifact.title === 'string' && result.artifact.title.length > 0
|
|
617
|
+
? result.artifact.title
|
|
618
|
+
: getToolLabel(inv.toolName);
|
|
619
|
+
}
|
|
620
|
+
if (name === null) continue;
|
|
621
|
+
outputs.push({
|
|
622
|
+
id: inv.toolCallId || `output-${outputs.length}`,
|
|
623
|
+
name,
|
|
624
|
+
toolName: inv.toolName,
|
|
625
|
+
});
|
|
626
|
+
}
|
|
627
|
+
return outputs;
|
|
628
|
+
}
|
|
629
|
+
|
|
630
|
+
/**
|
|
631
|
+
* When a turn started and, if the conversation says, when it ended — both as
|
|
632
|
+
* epoch milliseconds, `null` where the conversation does not say.
|
|
633
|
+
*
|
|
634
|
+
* The start is the send: the user message before this one carries the
|
|
635
|
+
* client's own stamp (the hook writes it and `POST /conversations` keeps it).
|
|
636
|
+
* The end is the assistant row's stamp, which the server writes when the
|
|
637
|
+
* turn is SAVED — after the model finished — so on a reloaded thread the two
|
|
638
|
+
* bracket the work. A message the client has only just appended carries the
|
|
639
|
+
* send time on both rows, and an older thread saved before clients stamped
|
|
640
|
+
* their sends carries the save time on both; either way the gap is nothing,
|
|
641
|
+
* which is not an elapsed time. Under a second is therefore read as "the
|
|
642
|
+
* conversation does not say".
|
|
643
|
+
*/
|
|
644
|
+
export interface TurnTiming {
|
|
645
|
+
startedAt: number | null;
|
|
646
|
+
endedAt: number | null;
|
|
647
|
+
}
|
|
648
|
+
|
|
649
|
+
const MIN_PERSISTED_ELAPSED_MS = 1000;
|
|
650
|
+
|
|
651
|
+
function stampToMs(stamp: string | undefined): number | null {
|
|
652
|
+
if (stamp === undefined) return null;
|
|
653
|
+
const ms = Date.parse(stamp);
|
|
654
|
+
return Number.isNaN(ms) ? null : ms;
|
|
655
|
+
}
|
|
656
|
+
|
|
657
|
+
/**
|
|
658
|
+
* Every turn's timing, in one pass over the thread.
|
|
659
|
+
*
|
|
660
|
+
* ## Why this exists beside `turnTiming`
|
|
661
|
+
*
|
|
662
|
+
* `turnTiming` answers for ONE message and has to find it first, so it opens
|
|
663
|
+
* with `messages.findIndex(...)` and then walks backwards for the send that
|
|
664
|
+
* started the turn. Both are scans. That is the right shape for the execution
|
|
665
|
+
* panel, which asks about a single message a person has opened.
|
|
666
|
+
*
|
|
667
|
+
* The thread asks about all of them. The thread once called it once per
|
|
668
|
+
* row, inside the render, with the WHOLE list as the second argument — so
|
|
669
|
+
* drawing n messages cost O(n²) comparisons, and it paid that on every render.
|
|
670
|
+
* The list re-renders roughly twenty times a second while an answer streams,
|
|
671
|
+
* which is where this stops being a curiosity: at a thousand messages the
|
|
672
|
+
* lookup alone is on the order of half a million comparisons per frame, for an
|
|
673
|
+
* answer that does not change as tokens arrive.
|
|
674
|
+
*
|
|
675
|
+
* One pass, carrying the last user stamp forward, gives the same answers in
|
|
676
|
+
* O(n). The per-message function stays for its own callers and for the cases
|
|
677
|
+
* this one cannot serve — a message that is not in the list it was handed.
|
|
678
|
+
*
|
|
679
|
+
* Keyed by message id. A message with no id is skipped rather than colliding
|
|
680
|
+
* with another one under `undefined`.
|
|
681
|
+
*/
|
|
682
|
+
export function turnTimings(
|
|
683
|
+
messages: ReadonlyArray<{ id: string; role: string; createdAt?: string }>,
|
|
684
|
+
): Map<string, TurnTiming> {
|
|
685
|
+
const timings = new Map<string, TurnTiming>();
|
|
686
|
+
/** The stamp of the last user message SEEN, which is the send that opened the turn each row below belongs to. */
|
|
687
|
+
let sendStamp: number | null = null;
|
|
688
|
+
|
|
689
|
+
for (const message of messages) {
|
|
690
|
+
const ownStamp = stampToMs(message.createdAt);
|
|
691
|
+
|
|
692
|
+
// Read before writing: a user message's own turn is bracketed by the send
|
|
693
|
+
// BEFORE it, never by itself. The original walked backwards from the row
|
|
694
|
+
// and broke on the first user message, which is the same message this
|
|
695
|
+
// carries forward.
|
|
696
|
+
if (message.id !== undefined) {
|
|
697
|
+
timings.set(message.id, {
|
|
698
|
+
startedAt: sendStamp ?? ownStamp,
|
|
699
|
+
endedAt:
|
|
700
|
+
sendStamp !== null &&
|
|
701
|
+
ownStamp !== null &&
|
|
702
|
+
ownStamp - sendStamp >= MIN_PERSISTED_ELAPSED_MS
|
|
703
|
+
? ownStamp
|
|
704
|
+
: null,
|
|
705
|
+
});
|
|
706
|
+
}
|
|
707
|
+
|
|
708
|
+
if (message.role === 'user') sendStamp = ownStamp;
|
|
709
|
+
}
|
|
710
|
+
|
|
711
|
+
return timings;
|
|
712
|
+
}
|
|
713
|
+
|
|
714
|
+
export function turnTiming(
|
|
715
|
+
message: { id: string; createdAt?: string },
|
|
716
|
+
messages: ReadonlyArray<{ id: string; role: string; createdAt?: string }>,
|
|
717
|
+
): TurnTiming {
|
|
718
|
+
const index = messages.findIndex((m) => m.id === message.id);
|
|
719
|
+
let sendStamp: number | null = null;
|
|
720
|
+
for (let i = index - 1; i >= 0; i -= 1) {
|
|
721
|
+
if (messages[i].role === 'user') {
|
|
722
|
+
sendStamp = stampToMs(messages[i].createdAt);
|
|
723
|
+
break;
|
|
724
|
+
}
|
|
725
|
+
}
|
|
726
|
+
const ownStamp = stampToMs(message.createdAt);
|
|
727
|
+
const startedAt = sendStamp ?? ownStamp;
|
|
728
|
+
const endedAt =
|
|
729
|
+
sendStamp !== null && ownStamp !== null && ownStamp - sendStamp >= MIN_PERSISTED_ELAPSED_MS
|
|
730
|
+
? ownStamp
|
|
731
|
+
: null;
|
|
732
|
+
return { startedAt, endedAt };
|
|
733
|
+
}
|
|
734
|
+
|
|
735
|
+
/**
|
|
736
|
+
* An elapsed time the way the summary row prints it: `10s`, `1m 5s`, `1h 2m`.
|
|
737
|
+
* `long` spells the units out for an accessible name, where "10s" is read as
|
|
738
|
+
* a letter. Rounded to the second; anything under one reads as `0s`.
|
|
739
|
+
*/
|
|
740
|
+
export function formatElapsed(ms: number, long = false): string {
|
|
741
|
+
const total = Math.max(0, Math.round(ms / 1000));
|
|
742
|
+
const hours = Math.floor(total / 3600);
|
|
743
|
+
const minutes = Math.floor((total % 3600) / 60);
|
|
744
|
+
const seconds = total % 60;
|
|
745
|
+
const unit = (n: number, short: string, one: string, many: string): string =>
|
|
746
|
+
long ? `${n} ${n === 1 ? one : many}` : `${n}${short}`;
|
|
747
|
+
if (hours > 0)
|
|
748
|
+
return `${unit(hours, 'h', 'hour', 'hours')} ${unit(minutes, 'm', 'minute', 'minutes')}`;
|
|
749
|
+
if (minutes > 0)
|
|
750
|
+
return `${unit(minutes, 'm', 'minute', 'minutes')} ${unit(seconds, 's', 'second', 'seconds')}`;
|
|
751
|
+
return unit(seconds, 's', 'second', 'seconds');
|
|
752
|
+
}
|