@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,261 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What an agent's run is doing, as data: the events it emits, and the state they add up to.
|
|
3
|
+
*
|
|
4
|
+
* Pure. `use-agent-activity.ts` subscribes to the run and feeds `applyActivityEvent` with what
|
|
5
|
+
* arrives; `hydrateActivity` seeds the state from what the run had already done when the screen
|
|
6
|
+
* opened. Both the live card in the thread and a task's timeline read the result.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
export interface PlanItem {
|
|
10
|
+
id: number;
|
|
11
|
+
text: string;
|
|
12
|
+
status: string;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export interface PlanProgress {
|
|
16
|
+
items: PlanItem[];
|
|
17
|
+
completed: number;
|
|
18
|
+
total: number;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export type AgentActivityEventType =
|
|
22
|
+
| 'system'
|
|
23
|
+
| 'thinking'
|
|
24
|
+
| 'response'
|
|
25
|
+
| 'tool_call'
|
|
26
|
+
| 'tool_result'
|
|
27
|
+
| 'error'
|
|
28
|
+
| 'complete'
|
|
29
|
+
| 'screenshot'
|
|
30
|
+
| 'plan_progress'
|
|
31
|
+
| 'file_change'
|
|
32
|
+
| 'source_found'
|
|
33
|
+
| 'threat';
|
|
34
|
+
|
|
35
|
+
export interface AgentActivityEvent {
|
|
36
|
+
type: AgentActivityEventType;
|
|
37
|
+
content: string;
|
|
38
|
+
timestamp: number;
|
|
39
|
+
sessionId: string;
|
|
40
|
+
agentId?: string;
|
|
41
|
+
metadata?: {
|
|
42
|
+
toolName?: string;
|
|
43
|
+
/** What the tool was called with; the browser tool's `action`, `url` and `query`, a plan's `action`, a delegate's `agent`. */
|
|
44
|
+
args?: {
|
|
45
|
+
action?: string;
|
|
46
|
+
url?: string;
|
|
47
|
+
query?: string;
|
|
48
|
+
agent?: string;
|
|
49
|
+
[key: string]: unknown;
|
|
50
|
+
};
|
|
51
|
+
duration?: number;
|
|
52
|
+
url?: string;
|
|
53
|
+
title?: string;
|
|
54
|
+
domain?: string;
|
|
55
|
+
};
|
|
56
|
+
data?: {
|
|
57
|
+
url?: string;
|
|
58
|
+
plan?: PlanProgress;
|
|
59
|
+
currentStep?: number;
|
|
60
|
+
maxSteps?: number;
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export interface AgentSource {
|
|
65
|
+
url: string;
|
|
66
|
+
title: string;
|
|
67
|
+
domain: string;
|
|
68
|
+
snippet: string;
|
|
69
|
+
timestamp: number;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export interface AgentActivityState {
|
|
73
|
+
/** Current plan with checklist items */
|
|
74
|
+
plan: PlanProgress | null;
|
|
75
|
+
/** Current action being executed */
|
|
76
|
+
currentAction: { toolName: string; content: string } | null;
|
|
77
|
+
/** Whether the agent has completed */
|
|
78
|
+
isComplete: boolean;
|
|
79
|
+
/** Whether there's an error */
|
|
80
|
+
hasError: boolean;
|
|
81
|
+
/** Last error message */
|
|
82
|
+
lastError: string | null;
|
|
83
|
+
/** Total events received */
|
|
84
|
+
eventCount: number;
|
|
85
|
+
/** All activity events (last 50) */
|
|
86
|
+
events: AgentActivityEvent[];
|
|
87
|
+
/** Start time of first event */
|
|
88
|
+
startedAt: number | null;
|
|
89
|
+
/** Sources found during browsing */
|
|
90
|
+
sources: AgentSource[];
|
|
91
|
+
/** Latest text response from agent */
|
|
92
|
+
latestResponse: string | null;
|
|
93
|
+
/** Total credits charged for this session (present once the run settles) */
|
|
94
|
+
creditsCharged?: number;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
export const INITIAL_ACTIVITY: AgentActivityState = {
|
|
98
|
+
plan: null,
|
|
99
|
+
currentAction: null,
|
|
100
|
+
isComplete: false,
|
|
101
|
+
hasError: false,
|
|
102
|
+
lastError: null,
|
|
103
|
+
eventCount: 0,
|
|
104
|
+
events: [],
|
|
105
|
+
startedAt: null,
|
|
106
|
+
sources: [],
|
|
107
|
+
latestResponse: null,
|
|
108
|
+
};
|
|
109
|
+
|
|
110
|
+
/** How many events the state keeps. */
|
|
111
|
+
export const MAX_ACTIVITY_EVENTS = 50;
|
|
112
|
+
|
|
113
|
+
/** Add one event to the run's state. */
|
|
114
|
+
export function applyActivityEvent(
|
|
115
|
+
prev: AgentActivityState,
|
|
116
|
+
event: AgentActivityEvent,
|
|
117
|
+
): AgentActivityState {
|
|
118
|
+
const next: AgentActivityState = {
|
|
119
|
+
...prev,
|
|
120
|
+
eventCount: prev.eventCount + 1,
|
|
121
|
+
events: [...prev.events.slice(-(MAX_ACTIVITY_EVENTS - 1)), event],
|
|
122
|
+
startedAt: prev.startedAt ?? event.timestamp,
|
|
123
|
+
};
|
|
124
|
+
|
|
125
|
+
switch (event.type) {
|
|
126
|
+
case 'plan_progress':
|
|
127
|
+
if (event.data?.plan) next.plan = event.data.plan;
|
|
128
|
+
break;
|
|
129
|
+
|
|
130
|
+
case 'tool_call':
|
|
131
|
+
next.currentAction = {
|
|
132
|
+
toolName: event.metadata?.toolName || 'action',
|
|
133
|
+
content: event.content,
|
|
134
|
+
};
|
|
135
|
+
break;
|
|
136
|
+
|
|
137
|
+
case 'tool_result':
|
|
138
|
+
next.currentAction = null;
|
|
139
|
+
break;
|
|
140
|
+
|
|
141
|
+
case 'error':
|
|
142
|
+
next.hasError = true;
|
|
143
|
+
next.lastError = event.content;
|
|
144
|
+
next.currentAction = null;
|
|
145
|
+
break;
|
|
146
|
+
|
|
147
|
+
case 'complete':
|
|
148
|
+
next.isComplete = true;
|
|
149
|
+
next.currentAction = null;
|
|
150
|
+
next.latestResponse = null;
|
|
151
|
+
break;
|
|
152
|
+
|
|
153
|
+
case 'source_found': {
|
|
154
|
+
const url = event.metadata?.url;
|
|
155
|
+
if (url && !prev.sources.some((source) => source.url === url)) {
|
|
156
|
+
next.sources = [
|
|
157
|
+
...prev.sources,
|
|
158
|
+
{
|
|
159
|
+
url,
|
|
160
|
+
title: event.metadata?.title || '',
|
|
161
|
+
domain: event.metadata?.domain || new URL(url).hostname,
|
|
162
|
+
snippet: event.content?.slice(0, 200) || '',
|
|
163
|
+
timestamp: event.timestamp,
|
|
164
|
+
},
|
|
165
|
+
];
|
|
166
|
+
}
|
|
167
|
+
break;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
case 'response':
|
|
171
|
+
next.latestResponse = event.content;
|
|
172
|
+
break;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
return next;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/** An event as the session-status route stores it. */
|
|
179
|
+
interface PersistedAgentEvent {
|
|
180
|
+
type: string;
|
|
181
|
+
content: string;
|
|
182
|
+
timestamp: number;
|
|
183
|
+
metadata?: AgentActivityEvent['metadata'];
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/** What `GET` on a session's status answers. */
|
|
187
|
+
export interface AgentSessionStatus {
|
|
188
|
+
recentEvents?: PersistedAgentEvent[];
|
|
189
|
+
session?: {
|
|
190
|
+
plan?: { items?: PlanItem[] };
|
|
191
|
+
status?: string;
|
|
192
|
+
result?: string;
|
|
193
|
+
stats?: { creditsCharged?: number };
|
|
194
|
+
};
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
function eventTypeOf(type: string): AgentActivityEventType {
|
|
198
|
+
switch (type) {
|
|
199
|
+
case 'action':
|
|
200
|
+
return 'tool_call';
|
|
201
|
+
case 'observation':
|
|
202
|
+
return 'tool_result';
|
|
203
|
+
case 'threat_detected':
|
|
204
|
+
return 'threat';
|
|
205
|
+
case 'thinking':
|
|
206
|
+
case 'response':
|
|
207
|
+
case 'error':
|
|
208
|
+
case 'complete':
|
|
209
|
+
case 'screenshot':
|
|
210
|
+
case 'plan_progress':
|
|
211
|
+
case 'file_change':
|
|
212
|
+
case 'source_found':
|
|
213
|
+
return type;
|
|
214
|
+
// `user_message`, `system_message`, `plan_update` and anything newer.
|
|
215
|
+
default:
|
|
216
|
+
return 'system';
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
/** Seed the state with what the run had done before this screen subscribed to it. */
|
|
221
|
+
export function hydrateActivity(
|
|
222
|
+
prev: AgentActivityState,
|
|
223
|
+
status: AgentSessionStatus,
|
|
224
|
+
sessionId: string,
|
|
225
|
+
): AgentActivityState {
|
|
226
|
+
const events = (Array.isArray(status.recentEvents) ? status.recentEvents : [])
|
|
227
|
+
.map(
|
|
228
|
+
(entry): AgentActivityEvent => ({
|
|
229
|
+
type: eventTypeOf(entry.type),
|
|
230
|
+
content: entry.content,
|
|
231
|
+
timestamp: entry.timestamp,
|
|
232
|
+
sessionId,
|
|
233
|
+
metadata: entry.metadata,
|
|
234
|
+
}),
|
|
235
|
+
)
|
|
236
|
+
.slice(-MAX_ACTIVITY_EVENTS);
|
|
237
|
+
const session = status.session;
|
|
238
|
+
const planItems = Array.isArray(session?.plan?.items) ? session.plan.items : [];
|
|
239
|
+
const failed = session?.status === 'failed';
|
|
240
|
+
const credits = session?.stats?.creditsCharged;
|
|
241
|
+
|
|
242
|
+
return {
|
|
243
|
+
...prev,
|
|
244
|
+
events,
|
|
245
|
+
eventCount: events.length,
|
|
246
|
+
startedAt: events[0]?.timestamp || prev.startedAt,
|
|
247
|
+
plan:
|
|
248
|
+
planItems.length > 0
|
|
249
|
+
? {
|
|
250
|
+
items: planItems,
|
|
251
|
+
completed: planItems.filter((item) => item.status === 'completed').length,
|
|
252
|
+
total: planItems.length,
|
|
253
|
+
}
|
|
254
|
+
: prev.plan,
|
|
255
|
+
isComplete: session?.status === 'completed' || session?.status === 'cancelled',
|
|
256
|
+
hasError: failed,
|
|
257
|
+
lastError: failed ? session?.result || prev.lastError : prev.lastError,
|
|
258
|
+
latestResponse: session?.result || prev.latestResponse,
|
|
259
|
+
creditsCharged: typeof credits === 'number' ? credits : prev.creditsCharged,
|
|
260
|
+
};
|
|
261
|
+
}
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import type { Translate } from '../../lib/copy';
|
|
2
|
+
import type { AutomationTrigger } from '../types';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* A scheduled task's trigger as words.
|
|
6
|
+
*
|
|
7
|
+
* Drawn by the thread's scheduled-task card, and by every host that lists the person's tasks, so
|
|
8
|
+
* the words live beside the type rather than in either. Pure: the translator is a parameter.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
function cronDayList(field: string): number[] | null {
|
|
12
|
+
const days: number[] = [];
|
|
13
|
+
for (const part of field.split(',')) {
|
|
14
|
+
const range = part.match(/^(\d)(?:-(\d))?$/);
|
|
15
|
+
if (!range) return null;
|
|
16
|
+
const from = Number(range[1]);
|
|
17
|
+
const to = range[2] === undefined ? from : Number(range[2]);
|
|
18
|
+
if (from > 6 || to > 6 || to < from) return null;
|
|
19
|
+
for (let day = from; day <= to; day += 1) days.push(day);
|
|
20
|
+
}
|
|
21
|
+
// Cron accepts 7 for Sunday in some dialects; the scheduler here writes 0.
|
|
22
|
+
const unique = [...new Set(days)].sort((a, b) => a - b);
|
|
23
|
+
return unique.length > 0 ? unique : null;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* A cron expression as a sentence, for the shapes the app itself writes.
|
|
28
|
+
*
|
|
29
|
+
* `scheduleToCron` on the API produces exactly three forms from the create
|
|
30
|
+
* dialog — `*\/N * * * *` for an interval, `M H * * *` for every day and
|
|
31
|
+
* `M H * * 1,3,5` for chosen days — and those are what a person expects to
|
|
32
|
+
* read back as "Every hour" or "Mondays at 09:00", not as five fields (#537).
|
|
33
|
+
* Anything else (a hand-written structured schedule) is returned verbatim
|
|
34
|
+
* rather than guessed at: a wrong sentence is worse than a cron string.
|
|
35
|
+
*/
|
|
36
|
+
export function cronLabel(cron: string, t: Translate): string {
|
|
37
|
+
const fields = cron.trim().split(/\s+/);
|
|
38
|
+
if (fields.length !== 5) return cron;
|
|
39
|
+
const [minute, hour, dayOfMonth, month, dayOfWeek] = fields as [
|
|
40
|
+
string,
|
|
41
|
+
string,
|
|
42
|
+
string,
|
|
43
|
+
string,
|
|
44
|
+
string,
|
|
45
|
+
];
|
|
46
|
+
if (dayOfMonth !== '*' || month !== '*') return cron;
|
|
47
|
+
|
|
48
|
+
const interval = minute.match(/^\*\/(\d+)$/);
|
|
49
|
+
if (interval && hour === '*' && dayOfWeek === '*') {
|
|
50
|
+
const minutes = Number(interval[1]);
|
|
51
|
+
if (minutes === 60) return t('automations.cron.everyHour');
|
|
52
|
+
if (minutes > 60 && minutes % 60 === 0)
|
|
53
|
+
return t('automations.cron.everyHours', { count: minutes / 60 });
|
|
54
|
+
return t('automations.cron.everyMinutes', { count: minutes });
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
if (!/^\d{1,2}$/.test(minute) || !/^\d{1,2}$/.test(hour)) return cron;
|
|
58
|
+
if (Number(minute) > 59 || Number(hour) > 23) return cron;
|
|
59
|
+
const time = `${hour.padStart(2, '0')}:${minute.padStart(2, '0')}`;
|
|
60
|
+
if (dayOfWeek === '*') return t('automations.cron.dailyAt', { time });
|
|
61
|
+
const days = cronDayList(dayOfWeek);
|
|
62
|
+
if (!days) return cron;
|
|
63
|
+
if (days.length === 7) return t('automations.cron.dailyAt', { time });
|
|
64
|
+
if (days.length === 5 && days.every((day, index) => day === index + 1)) {
|
|
65
|
+
return t('automations.cron.weekdaysAt', { time });
|
|
66
|
+
}
|
|
67
|
+
if (days.length === 2 && days[0] === 0 && days[1] === 6)
|
|
68
|
+
return t('automations.cron.weekendsAt', { time });
|
|
69
|
+
const names = days.map((day) => t(`automations.cron.dayPlural.${day}`));
|
|
70
|
+
return t('automations.cron.daysAt', { days: names.join(', '), time });
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export function triggerLabel(trigger: AutomationTrigger, t: Translate): string {
|
|
74
|
+
if (trigger.type === 'manual') return t('automations.trigger.manual');
|
|
75
|
+
if (trigger.type === 'event') {
|
|
76
|
+
return `${trigger.appId ?? t('automations.trigger.anyApp')} · ${trigger.eventType ?? t('automations.trigger.anyEvent')}`;
|
|
77
|
+
}
|
|
78
|
+
return `${trigger.cron ? cronLabel(trigger.cron, t) : t('automations.trigger.unscheduled')} · ${trigger.timezone ?? 'UTC'}`;
|
|
79
|
+
}
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
import type { Message } from '../types';
|
|
2
|
+
import { extractSources } from './thought-utils';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* A conversation as a Markdown document.
|
|
6
|
+
*
|
|
7
|
+
* Pure, and deliberately so: the header's menu item is the only thing that
|
|
8
|
+
* asks for this, and what it hands over is the same `messages` array the
|
|
9
|
+
* screen is streaming into. Everything about DELIVERY — a download on web, the
|
|
10
|
+
* share sheet on a phone — lives in `conversation-share.ts`, so this module can
|
|
11
|
+
* be tested against a string and nothing else.
|
|
12
|
+
*
|
|
13
|
+
* ## What is kept and what is left out
|
|
14
|
+
*
|
|
15
|
+
* - Roles, as headings. The person is `## You` (or the translation handed in)
|
|
16
|
+
* and the answer is `## Alia`, or the agent's own name when the thread is
|
|
17
|
+
* with one, or the delegated agent's name on a turn that came from one.
|
|
18
|
+
* - The text, verbatim. An answer is already Markdown — fenced code blocks,
|
|
19
|
+
* lists, tables — so it is pasted as it is rather than re-rendered, which is
|
|
20
|
+
* what makes a code block survive the round trip.
|
|
21
|
+
* - Images, as image links. A multi-part user turn carries them as
|
|
22
|
+
* `image_url` parts, and Markdown has a spelling for exactly that.
|
|
23
|
+
* - Sources, as a list of links under the answer that used them. They are
|
|
24
|
+
* read out of `toolInvocations` — the same jsonb the message is stored with
|
|
25
|
+
* — by the same `extractSources` the thread shows them from, so the export
|
|
26
|
+
* cites what the screen cites.
|
|
27
|
+
* - Thinking is OMITTED. It is the model's scratchpad, shown in a side panel
|
|
28
|
+
* on request; a document of the conversation is a document of what was
|
|
29
|
+
* said.
|
|
30
|
+
* - `system` turns are omitted too: they are not part of the exchange.
|
|
31
|
+
*/
|
|
32
|
+
export interface ConversationExportOptions {
|
|
33
|
+
/** The conversation's title, as the document's heading. */
|
|
34
|
+
title: string;
|
|
35
|
+
messages: readonly Message[];
|
|
36
|
+
/** When the document was made — the header states it. */
|
|
37
|
+
exportedAt: Date;
|
|
38
|
+
/** The name Alia answers under here; the heading for assistant turns. */
|
|
39
|
+
assistantName?: string;
|
|
40
|
+
/** The heading for the person's own turns, in their language. */
|
|
41
|
+
userLabel?: string;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const DEFAULT_ASSISTANT = 'Alia';
|
|
45
|
+
const DEFAULT_USER = 'You';
|
|
46
|
+
|
|
47
|
+
/** `YYYY-MM-DD` in the reader's own timezone, not the UTC one `toISOString` gives. */
|
|
48
|
+
export function localDate(date: Date): string {
|
|
49
|
+
const pad = (n: number) => String(n).padStart(2, '0');
|
|
50
|
+
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}`;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* The body of one message as Markdown: its text, then its images.
|
|
55
|
+
*
|
|
56
|
+
* A part the exporter does not know is dropped rather than stringified — an
|
|
57
|
+
* unknown part in a saved message is a shape from a later version of the app,
|
|
58
|
+
* and `[object Object]` in the middle of a document helps nobody.
|
|
59
|
+
*/
|
|
60
|
+
function messageBody(content: Message['content']): string {
|
|
61
|
+
if (typeof content === 'string') return content.trim();
|
|
62
|
+
if (!Array.isArray(content)) return '';
|
|
63
|
+
|
|
64
|
+
const text: string[] = [];
|
|
65
|
+
const images: string[] = [];
|
|
66
|
+
for (const part of content) {
|
|
67
|
+
if (part.type === 'text' && typeof part.text === 'string') {
|
|
68
|
+
text.push(part.text);
|
|
69
|
+
} else if (part.type === 'image_url') {
|
|
70
|
+
const url = (part.image_url as { url?: unknown } | undefined)?.url;
|
|
71
|
+
if (typeof url === 'string' && url !== '') images.push(url);
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
const lines = [text.join('').trim()];
|
|
76
|
+
images.forEach((url, i) => lines.push(``));
|
|
77
|
+
return lines.filter((line) => line !== '').join('\n\n');
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** The heading a turn is written under. */
|
|
81
|
+
function heading(message: Message, assistantName: string, userLabel: string): string {
|
|
82
|
+
if (message.role === 'user') return userLabel;
|
|
83
|
+
return message.agentInfo?.name || assistantName;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export function buildConversationMarkdown({
|
|
87
|
+
title,
|
|
88
|
+
messages,
|
|
89
|
+
exportedAt,
|
|
90
|
+
assistantName = DEFAULT_ASSISTANT,
|
|
91
|
+
userLabel = DEFAULT_USER,
|
|
92
|
+
}: ConversationExportOptions): string {
|
|
93
|
+
const sections: string[] = [
|
|
94
|
+
`# ${title.trim() || DEFAULT_ASSISTANT}`,
|
|
95
|
+
`_Exported ${localDate(exportedAt)}_`,
|
|
96
|
+
];
|
|
97
|
+
|
|
98
|
+
for (const message of messages) {
|
|
99
|
+
if (message.role !== 'user' && message.role !== 'assistant') continue;
|
|
100
|
+
|
|
101
|
+
const body = messageBody(message.content);
|
|
102
|
+
const sources = message.role === 'assistant' ? extractSources(message.toolInvocations) : [];
|
|
103
|
+
// An assistant placeholder that never got its answer, or a turn whose
|
|
104
|
+
// only content was a part the exporter dropped: nothing to write under
|
|
105
|
+
// the heading, so no heading.
|
|
106
|
+
if (body === '' && sources.length === 0) continue;
|
|
107
|
+
|
|
108
|
+
const parts = [`## ${heading(message, assistantName, userLabel)}`];
|
|
109
|
+
if (body !== '') parts.push(body);
|
|
110
|
+
if (sources.length > 0) {
|
|
111
|
+
parts.push(['### Sources', ...sources.map((s) => `- [${s.title}](${s.url})`)].join('\n'));
|
|
112
|
+
}
|
|
113
|
+
sections.push(parts.join('\n\n'));
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
return `${sections.join('\n\n')}\n`;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* A file name for the document: the title, made safe for a file system, with
|
|
121
|
+
* the date so two exports of one thread do not overwrite each other.
|
|
122
|
+
*/
|
|
123
|
+
export function exportFilename(title: string, exportedAt: Date): string {
|
|
124
|
+
// NFD splits an accented letter into letter + combining mark, and the range
|
|
125
|
+
// is the combining marks block, spelled as a range and not as `\p{M}`: a
|
|
126
|
+
// property escape throws at RUNTIME on Hermes, where this code runs.
|
|
127
|
+
const slug = title
|
|
128
|
+
.toLowerCase()
|
|
129
|
+
.normalize('NFD')
|
|
130
|
+
.replace(/[\u0300-\u036f]/g, '')
|
|
131
|
+
.replace(/[^a-z0-9]+/g, '-')
|
|
132
|
+
.replace(/^-+|-+$/g, '')
|
|
133
|
+
.slice(0, 60);
|
|
134
|
+
return `${slug || 'conversation'}-${localDate(exportedAt)}.md`;
|
|
135
|
+
}
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Where a thread changes day, and which day it changed to.
|
|
3
|
+
*
|
|
4
|
+
* This is the DERIVED boundary, and the only one drawn from a timestamp: the
|
|
5
|
+
* clock passed midnight, and nobody decided it. A thread's other boundary — "a
|
|
6
|
+
* new conversation starts here" — is a seam between two conversations, read
|
|
7
|
+
* from which one each message belongs to, so it is not computed here and never
|
|
8
|
+
* from elapsed time. "N hours passed, draw a line" lies the day somebody comes
|
|
9
|
+
* back a week later to continue the same idea.
|
|
10
|
+
*
|
|
11
|
+
* ## The timezone is the client's, and that is the whole difficulty
|
|
12
|
+
*
|
|
13
|
+
* `createdAt` arrives as an ISO instant, which names no day: 23:40 on the 4th
|
|
14
|
+
* in Madrid and 22:40 on the 4th in London are the same instant, and 12:00 UTC
|
|
15
|
+
* is the 1st in Niue and the 2nd in Kiritimati. A separator computed from the
|
|
16
|
+
* UTC calendar is therefore wrong for most of the planet for part of every day,
|
|
17
|
+
* and wrong in the way nobody reports: the line lands an hour off, or a
|
|
18
|
+
* conversation that ran through local midnight shows none at all.
|
|
19
|
+
*
|
|
20
|
+
* So the day of an instant is read through `Date`'s LOCAL accessors, which is
|
|
21
|
+
* the device's own timezone — the one the person reading the thread is in.
|
|
22
|
+
*
|
|
23
|
+
* ## A message with no timestamp draws no line
|
|
24
|
+
*
|
|
25
|
+
* Everything the server sends carries `createdAt`; a message the client has
|
|
26
|
+
* only just appended may not, if it has not made the round trip. Those are
|
|
27
|
+
* skipped rather than assumed to be today, because assuming would draw a line
|
|
28
|
+
* that a reload then removes — and a separator that appears and disappears is
|
|
29
|
+
* worse than one that arrives a moment late.
|
|
30
|
+
*/
|
|
31
|
+
|
|
32
|
+
/** What a separator says. `today` and `yesterday` are named so the caller can translate them. */
|
|
33
|
+
export type DaySeparatorLabel =
|
|
34
|
+
| { readonly kind: 'today' }
|
|
35
|
+
| { readonly kind: 'yesterday' }
|
|
36
|
+
| { readonly kind: 'date'; readonly text: string };
|
|
37
|
+
|
|
38
|
+
export interface DaySeparator {
|
|
39
|
+
/** The id of the message this separator sits directly above. */
|
|
40
|
+
readonly messageId: string;
|
|
41
|
+
readonly label: DaySeparatorLabel;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** The two fields a separator is derived from. */
|
|
45
|
+
interface DatedMessage {
|
|
46
|
+
readonly id: string;
|
|
47
|
+
readonly createdAt?: string;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** The local calendar day of an instant, as a key that compares equal only within one day. */
|
|
51
|
+
function localDayKey(date: Date): string {
|
|
52
|
+
return `${date.getFullYear()}-${date.getMonth()}-${date.getDate()}`;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** The instant a value names, or `null` if it names none. */
|
|
56
|
+
function instant(value: string | undefined): Date | null {
|
|
57
|
+
if (value === undefined) return null;
|
|
58
|
+
const date = new Date(value);
|
|
59
|
+
return Number.isNaN(date.getTime()) ? null : date;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* How many local days back `date` is from `now`.
|
|
64
|
+
*
|
|
65
|
+
* Counted between local CALENDAR days rather than by subtracting milliseconds:
|
|
66
|
+
* a day is not 24 hours on the two days a year a timezone shifts, and an hour
|
|
67
|
+
* of error on those days would relabel "yesterday" as a date.
|
|
68
|
+
*/
|
|
69
|
+
function daysBefore(date: Date, now: Date): number {
|
|
70
|
+
const startOfDate = new Date(date.getFullYear(), date.getMonth(), date.getDate());
|
|
71
|
+
const startOfNow = new Date(now.getFullYear(), now.getMonth(), now.getDate());
|
|
72
|
+
return Math.round((startOfNow.getTime() - startOfDate.getTime()) / 86_400_000);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function labelFor(date: Date, now: Date, locale: string): DaySeparatorLabel {
|
|
76
|
+
const back = daysBefore(date, now);
|
|
77
|
+
if (back === 0) return { kind: 'today' };
|
|
78
|
+
if (back === 1) return { kind: 'yesterday' };
|
|
79
|
+
return {
|
|
80
|
+
kind: 'date',
|
|
81
|
+
text: new Intl.DateTimeFormat(locale, {
|
|
82
|
+
weekday: 'short',
|
|
83
|
+
day: 'numeric',
|
|
84
|
+
month: 'short',
|
|
85
|
+
// Only when it is not this year. A year on every line is noise; a year on
|
|
86
|
+
// none of them is a thread that silently spans two.
|
|
87
|
+
...(date.getFullYear() === now.getFullYear() ? {} : { year: 'numeric' }),
|
|
88
|
+
}).format(date),
|
|
89
|
+
};
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* The separators a message list needs, in order.
|
|
94
|
+
*
|
|
95
|
+
* One per CHANGE of local day, so two messages on the same day produce none and
|
|
96
|
+
* a pair that straddles local midnight produces one. There is no separator above
|
|
97
|
+
* the first message: it would say "today" on top of every conversation anyone is
|
|
98
|
+
* currently having, which is a label for a boundary that is not there.
|
|
99
|
+
*/
|
|
100
|
+
export function daySeparators(
|
|
101
|
+
messages: readonly DatedMessage[],
|
|
102
|
+
now: Date,
|
|
103
|
+
locale: string,
|
|
104
|
+
): DaySeparator[] {
|
|
105
|
+
const separators: DaySeparator[] = [];
|
|
106
|
+
let previousKey: string | null = null;
|
|
107
|
+
|
|
108
|
+
for (const message of messages) {
|
|
109
|
+
const date = instant(message.createdAt);
|
|
110
|
+
if (date === null) continue;
|
|
111
|
+
|
|
112
|
+
const key = localDayKey(date);
|
|
113
|
+
if (previousKey !== null && key !== previousKey) {
|
|
114
|
+
separators.push({ messageId: message.id, label: labelFor(date, now, locale) });
|
|
115
|
+
}
|
|
116
|
+
previousKey = key;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
return separators;
|
|
120
|
+
}
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Message Processor - Platform-specific message processing
|
|
3
|
+
*
|
|
4
|
+
* This module handles platform-specific processing of AI responses.
|
|
5
|
+
* Different platforms (Web/Mobile App vs Telegram) have different capabilities,
|
|
6
|
+
* so we process messages differently for each.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { TITLE_STRIP_RE } from './title-tags';
|
|
10
|
+
|
|
11
|
+
export type Platform = 'app' | 'telegram';
|
|
12
|
+
|
|
13
|
+
export interface ProcessedMessage {
|
|
14
|
+
text: string;
|
|
15
|
+
components?: any[]; // Visual components for the app (COMPACTLIST, BANNER, etc.)
|
|
16
|
+
metadata?: Record<string, any>;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Process a message for the Web/Mobile App
|
|
21
|
+
* - Removes Telegram-specific tags ([REACT], [TGIMAGE], [TGLINKS], [TGDOC])
|
|
22
|
+
* - Preserves app-specific components ([COMPACTLIST], [BANNER], [COMPARISON], etc.)
|
|
23
|
+
*/
|
|
24
|
+
function processForApp(content: string): ProcessedMessage {
|
|
25
|
+
// Remove Telegram-specific tags and title tags
|
|
26
|
+
const cleanedText = content
|
|
27
|
+
.replace(/\[(?:ALIA_)?REACT:[^\]]+\]\s*/g, '')
|
|
28
|
+
.replace(/\[(?:ALIA_)?TGIMAGE[^\]]*\]\s*/g, '')
|
|
29
|
+
.replace(/\[(?:ALIA_)?TGLINKS[^\]]*\][\s\S]*?\[\/(?:ALIA_)?TGLINKS\]\s*/g, '')
|
|
30
|
+
.replace(/\[(?:ALIA_)?TGDOC[^\]]*\]\s*/g, '')
|
|
31
|
+
.replace(TITLE_STRIP_RE, '')
|
|
32
|
+
.trim();
|
|
33
|
+
|
|
34
|
+
// App-specific components like [COMPACTLIST], [BANNER], etc. are kept
|
|
35
|
+
// They will be rendered by the Markdown component
|
|
36
|
+
return {
|
|
37
|
+
text: cleanedText,
|
|
38
|
+
components: [], // Could extract components here if needed
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Process a message for Telegram
|
|
44
|
+
* - Removes app-specific components ([COMPACTLIST], [BANNER], etc.)
|
|
45
|
+
* - Preserves Telegram-specific tags for bot processing
|
|
46
|
+
*/
|
|
47
|
+
function processForTelegram(content: string): ProcessedMessage {
|
|
48
|
+
// Remove app-specific visual components (not supported in Telegram)
|
|
49
|
+
const cleanedText = content
|
|
50
|
+
.replace(/\[(?:ALIA_)?COMPACTLIST[^\]]*\][\s\S]*?\[\/(?:ALIA_)?COMPACTLIST\]\s*/g, '')
|
|
51
|
+
.replace(/\[(?:ALIA_)?BANNER[^\]]*\][\s\S]*?\[\/(?:ALIA_)?BANNER\]\s*/g, '')
|
|
52
|
+
.replace(/\[(?:ALIA_)?COMPARISON[^\]]*\][\s\S]*?\[\/(?:ALIA_)?COMPARISON\]\s*/g, '')
|
|
53
|
+
.replace(/\[(?:ALIA_)?TIMELINE[^\]]*\][\s\S]*?\[\/(?:ALIA_)?TIMELINE\]\s*/g, '')
|
|
54
|
+
.replace(/\[(?:ALIA_)?IMAGE[^\]]*\]/g, '')
|
|
55
|
+
.replace(/\[(?:ALIA_)?CREDIBILITY[^\]]*\]/g, '')
|
|
56
|
+
.replace(TITLE_STRIP_RE, '')
|
|
57
|
+
.trim();
|
|
58
|
+
|
|
59
|
+
// Telegram tags like [REACT], [TGIMAGE], etc. are kept
|
|
60
|
+
// They will be processed by the Telegram bot
|
|
61
|
+
return {
|
|
62
|
+
text: cleanedText,
|
|
63
|
+
};
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Main message processor - Routes to platform-specific processor
|
|
68
|
+
*/
|
|
69
|
+
export function processMessage(content: string, platform: Platform): ProcessedMessage {
|
|
70
|
+
switch (platform) {
|
|
71
|
+
case 'app':
|
|
72
|
+
return processForApp(content);
|
|
73
|
+
case 'telegram':
|
|
74
|
+
return processForTelegram(content);
|
|
75
|
+
default:
|
|
76
|
+
// Fallback: remove all special tags
|
|
77
|
+
return {
|
|
78
|
+
text: content
|
|
79
|
+
.replace(/\[(?:ALIA_)?REACT:[^\]]+\]\s*/g, '')
|
|
80
|
+
.replace(/\[(?:ALIA_)?TGIMAGE[^\]]*\]\s*/g, '')
|
|
81
|
+
.replace(/\[(?:ALIA_)?TGLINKS[^\]]*\][\s\S]*?\[\/(?:ALIA_)?TGLINKS\]\s*/g, '')
|
|
82
|
+
.replace(/\[(?:ALIA_)?TGDOC[^\]]*\]\s*/g, '')
|
|
83
|
+
.replace(/\[(?:ALIA_)?COMPACTLIST[^\]]*\][\s\S]*?\[\/(?:ALIA_)?COMPACTLIST\]\s*/g, '')
|
|
84
|
+
.replace(/\[(?:ALIA_)?BANNER[^\]]*\][\s\S]*?\[\/(?:ALIA_)?BANNER\]\s*/g, '')
|
|
85
|
+
.replace(/\[(?:ALIA_)?COMPARISON[^\]]*\][\s\S]*?\[\/(?:ALIA_)?COMPARISON\]\s*/g, '')
|
|
86
|
+
.replace(/\[(?:ALIA_)?TIMELINE[^\]]*\][\s\S]*?\[\/(?:ALIA_)?TIMELINE\]\s*/g, '')
|
|
87
|
+
.replace(/\[(?:ALIA_)?IMAGE[^\]]*\]/g, '')
|
|
88
|
+
.replace(/\[(?:ALIA_)?CREDIBILITY[^\]]*\]/g, '')
|
|
89
|
+
.replace(TITLE_STRIP_RE, '')
|
|
90
|
+
.trim(),
|
|
91
|
+
};
|
|
92
|
+
}
|
|
93
|
+
}
|