@ateam-ai-mobile/core 0.1.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/dist/index.d.mts +389 -0
- package/dist/index.d.ts +389 -0
- package/dist/index.js +1081 -0
- package/dist/index.js.map +1 -0
- package/dist/index.mjs +1027 -0
- package/dist/index.mjs.map +1 -0
- package/package.json +27 -0
- package/src/api/client.ts +105 -0
- package/src/api/mcp.ts +104 -0
- package/src/chat/store.ts +938 -0
- package/src/chat/types.ts +76 -0
- package/src/config.ts +118 -0
- package/src/index.ts +72 -0
- package/src/storage/types.ts +29 -0
- package/src/theme/tokens.ts +105 -0
- package/src/utils/emitter.ts +74 -0
|
@@ -0,0 +1,938 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Chat store — talks to the ADAS Core API.
|
|
3
|
+
*
|
|
4
|
+
* Flow:
|
|
5
|
+
* 1. loadConversation() → GET /api/conversation → display history
|
|
6
|
+
* 2. sendMessage(text) → POST /api/chat { goal } → returns jobId
|
|
7
|
+
* 3. WebSocket (poll fallback) → real-time progress
|
|
8
|
+
* 4. When done → GET /api/conversation → formatted response
|
|
9
|
+
*
|
|
10
|
+
* Platform notes: no Expo/AsyncStorage imports. Attachment persistence is
|
|
11
|
+
* delegated to the host via config.attachmentCache; identity and tokens come
|
|
12
|
+
* from the host via initAdas().
|
|
13
|
+
*/
|
|
14
|
+
import { create } from 'zustand';
|
|
15
|
+
import { getCoreApiUrl, getCoreWsUrl, getToken, getTenant, getUser, getConfig } from '../config';
|
|
16
|
+
import { pluginCommandEmitter } from '../utils/emitter';
|
|
17
|
+
import type { OutgoingAttachment } from '../storage/types';
|
|
18
|
+
import type { Message, MessageAttachment, JobProgress, UIAction } from './types';
|
|
19
|
+
|
|
20
|
+
// ---------------------------------------------------------------------------
|
|
21
|
+
// Types
|
|
22
|
+
// ---------------------------------------------------------------------------
|
|
23
|
+
|
|
24
|
+
interface ChatState {
|
|
25
|
+
messages: Message[];
|
|
26
|
+
loading: boolean;
|
|
27
|
+
error: string | null;
|
|
28
|
+
activeJobId: string | null;
|
|
29
|
+
conversationLoaded: boolean;
|
|
30
|
+
/** Load conversation history from Core */
|
|
31
|
+
loadConversation: () => Promise<void>;
|
|
32
|
+
/** Send a message via POST /api/chat — creates a job, streams progress */
|
|
33
|
+
sendMessage: (text: string, skillSlug?: string, attachments?: OutgoingAttachment[]) => Promise<void>;
|
|
34
|
+
/** Respond to a paused sys.askUser question */
|
|
35
|
+
respondToQuestion: (jobId: string, inputId: string, answer: string) => Promise<void>;
|
|
36
|
+
/** Update in-flight job progress (called by the stream handler) */
|
|
37
|
+
updateJobProgress: (jobId: string, progress: Partial<JobProgress>) => void;
|
|
38
|
+
/** Reset chat (also re-fetches history on next mount — used on auth switch) */
|
|
39
|
+
clearChat: () => void;
|
|
40
|
+
/** Start a fresh empty chat without re-fetching history */
|
|
41
|
+
newChat: () => void;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// ---------------------------------------------------------------------------
|
|
45
|
+
// Helpers
|
|
46
|
+
// ---------------------------------------------------------------------------
|
|
47
|
+
|
|
48
|
+
function coreHeaders(): Record<string, string> {
|
|
49
|
+
const token = getToken();
|
|
50
|
+
const headers: Record<string, string> = {
|
|
51
|
+
'Content-Type': 'application/json',
|
|
52
|
+
};
|
|
53
|
+
if (token) headers['Authorization'] = `Bearer ${token}`;
|
|
54
|
+
return headers;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function isTerminalStatus(status?: unknown): boolean {
|
|
58
|
+
const s = typeof status === 'string' ? status.toLowerCase() : '';
|
|
59
|
+
return s === 'done' || s === 'error' || s === 'failed' || s === 'completed';
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function normalizeJobStatus(snap: any): JobProgress['status'] {
|
|
63
|
+
const status = typeof snap?.status === 'string' ? snap.status.toLowerCase() : '';
|
|
64
|
+
if (status === 'done' || status === 'completed') return 'done';
|
|
65
|
+
if (status === 'error' || status === 'failed') return 'error';
|
|
66
|
+
if (status === 'paused' || status === 'waiting') return 'paused';
|
|
67
|
+
if (status === 'unknown') return 'unknown';
|
|
68
|
+
return 'running';
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function extractPendingQuestion(snap: any): any {
|
|
72
|
+
return snap?.pendingQuestion || snap?.state?.pendingQuestion || snap?.state?.waiting || null;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function extractActorId(snap: any): string | undefined {
|
|
76
|
+
const actorId =
|
|
77
|
+
snap?.actorId ||
|
|
78
|
+
snap?.actor?.actorId ||
|
|
79
|
+
snap?.actor?.id ||
|
|
80
|
+
snap?.state?.actorId ||
|
|
81
|
+
snap?.state?.actor?.actorId ||
|
|
82
|
+
snap?.state?.actor?.id;
|
|
83
|
+
return typeof actorId === 'string' && actorId.length > 0 ? actorId : undefined;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function extractLiveProgress(snap: any): JobProgress['liveProgress'] | undefined {
|
|
87
|
+
const lp = snap?.liveProgress || snap?.state?.__live_progress;
|
|
88
|
+
const latest = Array.isArray(lp) ? lp[lp.length - 1] : lp;
|
|
89
|
+
if (!latest?.message) return undefined;
|
|
90
|
+
return {
|
|
91
|
+
message: String(latest.message || ''),
|
|
92
|
+
...(latest.step != null ? { step: latest.step } : {}),
|
|
93
|
+
...(latest.total != null ? { total: latest.total } : {}),
|
|
94
|
+
};
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// ---------------------------------------------------------------------------
|
|
98
|
+
// Extract the most-recently-called tool from job exec steps.
|
|
99
|
+
// snap.state.steps.__exec = { iter_0: [{tool, ok, reason}], iter_1: [...] }
|
|
100
|
+
// ---------------------------------------------------------------------------
|
|
101
|
+
function extractCurrentTool(state: any): string | undefined {
|
|
102
|
+
const exec = state?.steps?.__exec;
|
|
103
|
+
if (!exec || typeof exec !== 'object') return undefined;
|
|
104
|
+
const latest = Object.keys(exec)
|
|
105
|
+
.map(k => ({ k, n: parseInt(k.replace('iter_', ''), 10) }))
|
|
106
|
+
.filter(({ n }) => !isNaN(n))
|
|
107
|
+
.sort((a, b) => b.n - a.n)[0];
|
|
108
|
+
if (!latest) return undefined;
|
|
109
|
+
const steps = exec[latest.k];
|
|
110
|
+
if (!Array.isArray(steps) || !steps.length) return undefined;
|
|
111
|
+
const last = steps[steps.length - 1];
|
|
112
|
+
return (last?.tool || last?.phase) as string | undefined;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function snapshotToProgress(snap: any, rootJobId: string): Partial<JobProgress> {
|
|
116
|
+
const pq = extractPendingQuestion(snap);
|
|
117
|
+
const progress: Partial<JobProgress> = {
|
|
118
|
+
jobId: rootJobId,
|
|
119
|
+
actorId: extractActorId(snap),
|
|
120
|
+
status: pq ? 'paused' : normalizeJobStatus(snap),
|
|
121
|
+
subStatus: snap?.subStatus || snap?.phase || snap?.state?.visual?.phase,
|
|
122
|
+
phase: snap?.phase || snap?.state?.visual?.phase,
|
|
123
|
+
iteration: snap?.iteration || snap?.state?.plannerIter,
|
|
124
|
+
skillSlug: snap?.skillSlug || snap?.activeSkill,
|
|
125
|
+
currentTool: snap?.currentTool || snap?.lastTool || extractCurrentTool(snap?.state),
|
|
126
|
+
};
|
|
127
|
+
|
|
128
|
+
const liveProgress = extractLiveProgress(snap);
|
|
129
|
+
if (liveProgress) progress.liveProgress = liveProgress;
|
|
130
|
+
|
|
131
|
+
if (pq) {
|
|
132
|
+
progress.pendingQuestion = {
|
|
133
|
+
inputId: pq.correlation_id || pq.correlationId || pq.inputId || '',
|
|
134
|
+
question: pq.prompt_message || pq.question || 'The assistant needs your input',
|
|
135
|
+
inputType: pq.inputType || 'text',
|
|
136
|
+
options: pq.options || pq.requested_fields,
|
|
137
|
+
placeholder: pq.placeholder,
|
|
138
|
+
};
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
return progress;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
function isTerminalSnapshot(snap: any): boolean {
|
|
145
|
+
if (extractPendingQuestion(snap)) return false;
|
|
146
|
+
return snap?.done === true || isTerminalStatus(snap?.status);
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* Sentinel delimiters used to embed structured table data inside a flat
|
|
151
|
+
* message string. UI layers split on these and render the contents with a
|
|
152
|
+
* native table component instead of markdown tables — markdown tables look
|
|
153
|
+
* terrible on narrow phone screens.
|
|
154
|
+
*
|
|
155
|
+
* Unicode Private Use Area chars (U+E100/U+E101) so they never collide with
|
|
156
|
+
* anything the model could legitimately emit.
|
|
157
|
+
*/
|
|
158
|
+
export const TABLE_BLOCK_OPEN = '\uE100';
|
|
159
|
+
export const TABLE_BLOCK_CLOSE = '\uE101';
|
|
160
|
+
|
|
161
|
+
/**
|
|
162
|
+
* Render a `{ columns: [...], rows: [...] }` payload as a sentinel-wrapped
|
|
163
|
+
* JSON marker. Returns null if the shape doesn't match.
|
|
164
|
+
*/
|
|
165
|
+
function renderTableShape(content: any): string | null {
|
|
166
|
+
if (!content || typeof content !== 'object') return null;
|
|
167
|
+
const cols = Array.isArray(content.columns) ? content.columns : null;
|
|
168
|
+
const rows = Array.isArray(content.rows) ? content.rows : null;
|
|
169
|
+
if (!cols || !rows) return null;
|
|
170
|
+
// encodeURIComponent so the JSON can never contain newlines or sentinel chars.
|
|
171
|
+
const payload = encodeURIComponent(JSON.stringify({ columns: cols, rows }));
|
|
172
|
+
return `${TABLE_BLOCK_OPEN}${payload}${TABLE_BLOCK_CLOSE}`;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/**
|
|
176
|
+
* Extract display text from a conversation turn's assistant blocks.
|
|
177
|
+
* Handles multiple block kinds (text, code, json, markdown, table, html).
|
|
178
|
+
*/
|
|
179
|
+
export function blocksToText(blocks?: Array<{ kind: string; content: any }>): string {
|
|
180
|
+
if (!blocks || blocks.length === 0) return '';
|
|
181
|
+
return blocks.map(b => {
|
|
182
|
+
if (b.kind === 'text' || b.kind === 'markdown' || b.kind === 'description') {
|
|
183
|
+
return typeof b.content === 'string' ? b.content : '';
|
|
184
|
+
}
|
|
185
|
+
if (b.kind === 'code') {
|
|
186
|
+
const code = typeof b.content === 'object' ? b.content.content : b.content;
|
|
187
|
+
return `\`\`\`\n${code}\n\`\`\``;
|
|
188
|
+
}
|
|
189
|
+
if (b.kind === 'table' && b.content && typeof b.content === 'object') {
|
|
190
|
+
const md = renderTableShape(b.content);
|
|
191
|
+
if (md) return md;
|
|
192
|
+
// Fall through to generic JSON rendering if shape is unexpected.
|
|
193
|
+
}
|
|
194
|
+
if (b.kind === 'json') {
|
|
195
|
+
// Skip pendingQuestion state objects — internal sys.askUser data
|
|
196
|
+
if (b.content && typeof b.content === 'object' && 'pendingQuestion' in b.content) return '';
|
|
197
|
+
// Polisher sometimes wraps tables as kind:'json' when the content has
|
|
198
|
+
// columns/rows — render those as table blocks too.
|
|
199
|
+
const md = renderTableShape(b.content);
|
|
200
|
+
if (md) return md;
|
|
201
|
+
return '```json\n' + JSON.stringify(b.content, null, 2) + '\n```';
|
|
202
|
+
}
|
|
203
|
+
if (b.kind === 'html') {
|
|
204
|
+
return typeof b.content === 'string' ? b.content : '';
|
|
205
|
+
}
|
|
206
|
+
return typeof b.content === 'string' ? b.content : JSON.stringify(b.content);
|
|
207
|
+
}).filter(Boolean).join('\n\n');
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
/** Extract image/file attachments from conversation blocks */
|
|
211
|
+
function blocksToAttachments(blocks?: Array<{ kind: string; hash?: string; url?: string; mimeType?: string; name?: string }>): MessageAttachment[] | undefined {
|
|
212
|
+
if (!blocks || blocks.length === 0) return undefined;
|
|
213
|
+
const atts: MessageAttachment[] = [];
|
|
214
|
+
for (const b of blocks) {
|
|
215
|
+
if ((b.kind === 'image' || b.kind === 'file') && (b.url || b.hash)) {
|
|
216
|
+
atts.push({
|
|
217
|
+
mimeType: b.mimeType || (b.kind === 'image' ? 'image/jpeg' : 'application/octet-stream'),
|
|
218
|
+
name: b.name || 'attachment',
|
|
219
|
+
previewUri: b.url ? `${getCoreApiUrl()}${b.url}` : undefined,
|
|
220
|
+
});
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
return atts.length > 0 ? atts : undefined;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
// ---------------------------------------------------------------------------
|
|
227
|
+
// Store
|
|
228
|
+
// ---------------------------------------------------------------------------
|
|
229
|
+
|
|
230
|
+
export const useChatStore = create<ChatState>((set, get) => ({
|
|
231
|
+
messages: [],
|
|
232
|
+
loading: false,
|
|
233
|
+
error: null,
|
|
234
|
+
activeJobId: null,
|
|
235
|
+
conversationLoaded: false,
|
|
236
|
+
|
|
237
|
+
// -----------------------------------------------------------------------
|
|
238
|
+
// Load conversation history from Core
|
|
239
|
+
// GET /api/conversation → array of turns [{user, assistant, jobId, ...}]
|
|
240
|
+
// -----------------------------------------------------------------------
|
|
241
|
+
loadConversation: async () => {
|
|
242
|
+
try {
|
|
243
|
+
const attachmentCache = getConfig().attachmentCache;
|
|
244
|
+
await attachmentCache?.ready();
|
|
245
|
+
|
|
246
|
+
const res = await fetch(`${getCoreApiUrl()}/api/conversation?limit=20`, {
|
|
247
|
+
headers: coreHeaders(),
|
|
248
|
+
});
|
|
249
|
+
if (!res.ok) {
|
|
250
|
+
console.warn('[adas-chat] Failed to load conversation:', res.status);
|
|
251
|
+
set({ conversationLoaded: true });
|
|
252
|
+
return;
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
const data = await res.json();
|
|
256
|
+
if (!data.ok || !data.items) {
|
|
257
|
+
set({ conversationLoaded: true });
|
|
258
|
+
return;
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
// Persisted turns from /api/conversation are the SOLE source of history.
|
|
262
|
+
// Live job state is mirrored only via WS into the placeholder bubble
|
|
263
|
+
// created by sendMessage; in-flight local state is preserved below.
|
|
264
|
+
const items: any[] = [...data.items].reverse();
|
|
265
|
+
const persistedMessages: Message[] = [];
|
|
266
|
+
const seenJobIds = new Set<string>();
|
|
267
|
+
for (const turn of items) {
|
|
268
|
+
// Deduplicate by jobId (chain jobs can produce duplicate turns)
|
|
269
|
+
if (turn.jobId && seenJobIds.has(turn.jobId)) continue;
|
|
270
|
+
if (turn.jobId) seenJobIds.add(turn.jobId);
|
|
271
|
+
|
|
272
|
+
// User message
|
|
273
|
+
const userText = blocksToText(turn.user?.blocks);
|
|
274
|
+
let userAtts = blocksToAttachments(turn.user?.blocks);
|
|
275
|
+
// Merge locally cached attachments if API doesn't have them
|
|
276
|
+
if (!userAtts && turn.jobId) {
|
|
277
|
+
userAtts = attachmentCache?.get(turn.jobId);
|
|
278
|
+
}
|
|
279
|
+
const turnChainId = typeof turn.chainId === 'string' ? turn.chainId : undefined;
|
|
280
|
+
if (userText || userAtts) {
|
|
281
|
+
persistedMessages.push({
|
|
282
|
+
id: `user-${turn.jobId}`,
|
|
283
|
+
role: 'user',
|
|
284
|
+
content: userText || '',
|
|
285
|
+
timestamp: new Date(turn.startedAt || Date.now()),
|
|
286
|
+
skillSlug: turn.skillSlug,
|
|
287
|
+
...(userAtts ? { attachments: userAtts } : {}),
|
|
288
|
+
...(turnChainId ? { chainId: turnChainId } : {}),
|
|
289
|
+
});
|
|
290
|
+
}
|
|
291
|
+
// Assistant response
|
|
292
|
+
const assistantText = blocksToText(turn.assistant?.blocks);
|
|
293
|
+
const assistantAtts = blocksToAttachments(turn.assistant?.blocks);
|
|
294
|
+
const assistantActions = Array.isArray(turn.assistant?.actions) ? turn.assistant.actions : undefined;
|
|
295
|
+
const turnKind = typeof turn.kind === 'string' ? turn.kind : undefined;
|
|
296
|
+
if (assistantText || assistantAtts || assistantActions?.length) {
|
|
297
|
+
persistedMessages.push({
|
|
298
|
+
id: `bot-${turn.jobId}`,
|
|
299
|
+
role: 'assistant',
|
|
300
|
+
content: assistantText || '',
|
|
301
|
+
timestamp: new Date(turn.finishedAt || turn.startedAt || Date.now()),
|
|
302
|
+
skillSlug: turn.skillSlug,
|
|
303
|
+
...(assistantAtts ? { attachments: assistantAtts } : {}),
|
|
304
|
+
...(assistantActions?.length ? { actions: assistantActions } : {}),
|
|
305
|
+
...(turnChainId ? { chainId: turnChainId } : {}),
|
|
306
|
+
...(turnKind ? { kind: turnKind } : {}),
|
|
307
|
+
});
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
// Preserve in-flight LOCAL state across pull-to-refresh. /api/conversation
|
|
312
|
+
// is the truth for HISTORY; anything mid-flight lives only as a local
|
|
313
|
+
// WS-driven placeholder created by sendMessage.
|
|
314
|
+
const current = get().messages;
|
|
315
|
+
const keptPlaceholders: Message[] = [];
|
|
316
|
+
for (const m of current) {
|
|
317
|
+
if (!m.id.startsWith('job-') || !m.jobProgress || !m.chainId) continue;
|
|
318
|
+
const matchIdx = persistedMessages.findIndex(pm =>
|
|
319
|
+
pm.role === 'assistant' && pm.chainId === m.chainId
|
|
320
|
+
);
|
|
321
|
+
if (matchIdx >= 0) {
|
|
322
|
+
// The persisted turn for this chain has arrived. Move the live
|
|
323
|
+
// progress overlay (pill) onto it — but only while still in flight.
|
|
324
|
+
const st = m.jobProgress.status;
|
|
325
|
+
if (st === 'running' || st === 'paused') {
|
|
326
|
+
persistedMessages[matchIdx] = { ...persistedMessages[matchIdx], jobProgress: m.jobProgress };
|
|
327
|
+
}
|
|
328
|
+
// Drop the placeholder either way.
|
|
329
|
+
} else {
|
|
330
|
+
// No persisted turn yet — chain still in flight. Keep it.
|
|
331
|
+
keptPlaceholders.push(m);
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
// Optimistic user bubbles (id=`user-<ms>` from sendMessage). Keep until
|
|
336
|
+
// a persisted user turn arrives. Dedup by chainId — content match has
|
|
337
|
+
// false positives ("Yes", "Continue", etc.).
|
|
338
|
+
const isLocalUserId = (id: string) => /^user-\d+$/.test(id);
|
|
339
|
+
const keptUserBubbles = current.filter(m => {
|
|
340
|
+
if (m.role !== 'user' || !isLocalUserId(m.id)) return false;
|
|
341
|
+
if (!m.chainId) return true; // POST in flight — can't dedup yet
|
|
342
|
+
return !persistedMessages.some(pm =>
|
|
343
|
+
pm.role === 'user' && pm.chainId === m.chainId
|
|
344
|
+
);
|
|
345
|
+
});
|
|
346
|
+
|
|
347
|
+
const finalMessages = [...persistedMessages, ...keptUserBubbles, ...keptPlaceholders];
|
|
348
|
+
finalMessages.sort((a, b) => a.timestamp.getTime() - b.timestamp.getTime());
|
|
349
|
+
|
|
350
|
+
let firstRunning: Message | undefined = keptPlaceholders.find(m => m.jobProgress?.status === 'running');
|
|
351
|
+
|
|
352
|
+
// Cold-start / post-refresh reattach: the WS subscription dies on app
|
|
353
|
+
// restart. /api/chain/current is the single hydration endpoint that
|
|
354
|
+
// says "is a chain still in flight for this actor?". If yes, re-open
|
|
355
|
+
// the live subscription on the same chainId so the pill comes back.
|
|
356
|
+
try {
|
|
357
|
+
const chainRes = await fetch(`${getCoreApiUrl()}/api/chain/current`, {
|
|
358
|
+
headers: coreHeaders(),
|
|
359
|
+
});
|
|
360
|
+
if (chainRes.ok) {
|
|
361
|
+
const chainBody = await chainRes.json();
|
|
362
|
+
const slim = chainBody?.data;
|
|
363
|
+
if (slim?.id) {
|
|
364
|
+
const chainId = String(slim.id);
|
|
365
|
+
const alreadyTracked = finalMessages.some(m =>
|
|
366
|
+
m.role === 'assistant' && m.jobProgress?.jobId === chainId
|
|
367
|
+
);
|
|
368
|
+
if (!alreadyTracked) {
|
|
369
|
+
const progress = snapshotToProgress(slim, chainId);
|
|
370
|
+
const chainCreatedAt = typeof slim.createdAt === 'number'
|
|
371
|
+
? slim.createdAt
|
|
372
|
+
: (typeof slim.lastUpdate === 'number' ? slim.lastUpdate : Date.now());
|
|
373
|
+
const chainLastUpdate = typeof slim.lastUpdate === 'number' ? slim.lastUpdate : chainCreatedAt;
|
|
374
|
+
const jobProgress = {
|
|
375
|
+
jobId: chainId,
|
|
376
|
+
actorId: extractActorId(slim),
|
|
377
|
+
status: 'running',
|
|
378
|
+
chainStartedAt: chainCreatedAt,
|
|
379
|
+
...progress,
|
|
380
|
+
} as JobProgress;
|
|
381
|
+
|
|
382
|
+
// Synthesize the user bubble if a hard refresh wiped the
|
|
383
|
+
// optimistic one and the chain hasn't persisted its turn yet.
|
|
384
|
+
const goal = typeof slim.goal === 'string' ? slim.goal : '';
|
|
385
|
+
const userExists = finalMessages.some(m =>
|
|
386
|
+
m.role === 'user' && (m.chainId === chainId || (goal && m.content === goal))
|
|
387
|
+
);
|
|
388
|
+
if (goal && !userExists) {
|
|
389
|
+
finalMessages.push({
|
|
390
|
+
id: `user-${chainId}`,
|
|
391
|
+
role: 'user',
|
|
392
|
+
content: goal,
|
|
393
|
+
timestamp: new Date(chainCreatedAt),
|
|
394
|
+
chainId,
|
|
395
|
+
});
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
// Assistant side: attach to matching persisted turn if one
|
|
399
|
+
// exists; otherwise push a placeholder bubble.
|
|
400
|
+
let matchIdx = -1;
|
|
401
|
+
for (let i = finalMessages.length - 1; i >= 0; i--) {
|
|
402
|
+
const m = finalMessages[i];
|
|
403
|
+
if (m.role === 'assistant' && m.chainId === chainId) {
|
|
404
|
+
matchIdx = i;
|
|
405
|
+
break;
|
|
406
|
+
}
|
|
407
|
+
}
|
|
408
|
+
if (matchIdx >= 0) {
|
|
409
|
+
finalMessages[matchIdx] = { ...finalMessages[matchIdx], jobProgress };
|
|
410
|
+
if (jobProgress.status === 'running') firstRunning = finalMessages[matchIdx];
|
|
411
|
+
} else {
|
|
412
|
+
const reattachBubble: Message = {
|
|
413
|
+
id: `job-${chainId}`,
|
|
414
|
+
role: 'assistant',
|
|
415
|
+
content: 'Working on it...',
|
|
416
|
+
timestamp: new Date(chainLastUpdate),
|
|
417
|
+
jobProgress,
|
|
418
|
+
chainId,
|
|
419
|
+
};
|
|
420
|
+
finalMessages.push(reattachBubble);
|
|
421
|
+
if (jobProgress.status === 'running') firstRunning = reattachBubble;
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
finalMessages.sort((a, b) => a.timestamp.getTime() - b.timestamp.getTime());
|
|
425
|
+
|
|
426
|
+
// Re-open the live subscription.
|
|
427
|
+
streamJob(chainId, getTenant(), getToken());
|
|
428
|
+
}
|
|
429
|
+
}
|
|
430
|
+
}
|
|
431
|
+
} catch {}
|
|
432
|
+
|
|
433
|
+
set({
|
|
434
|
+
messages: finalMessages,
|
|
435
|
+
conversationLoaded: true,
|
|
436
|
+
error: null,
|
|
437
|
+
activeJobId: firstRunning?.jobProgress?.jobId || null,
|
|
438
|
+
loading: !!firstRunning,
|
|
439
|
+
});
|
|
440
|
+
} catch (err: any) {
|
|
441
|
+
console.warn('[adas-chat] Error loading conversation:', err.message);
|
|
442
|
+
set({ conversationLoaded: true });
|
|
443
|
+
}
|
|
444
|
+
},
|
|
445
|
+
|
|
446
|
+
// -----------------------------------------------------------------------
|
|
447
|
+
// Send message via Core API
|
|
448
|
+
// POST /api/chat { goal } → { jobId } → stream → fetch turn result
|
|
449
|
+
// -----------------------------------------------------------------------
|
|
450
|
+
sendMessage: async (text: string, skillSlug?: string, attachments?: OutgoingAttachment[]) => {
|
|
451
|
+
// No `if (loading) return` guard: Chain Additions needs the user to send
|
|
452
|
+
// WHILE a chain is running. The backend router decides attach/cancel/new.
|
|
453
|
+
|
|
454
|
+
const userMsg: Message = {
|
|
455
|
+
id: `user-${Date.now()}`,
|
|
456
|
+
role: 'user',
|
|
457
|
+
content: text,
|
|
458
|
+
timestamp: new Date(),
|
|
459
|
+
...(attachments?.length ? {
|
|
460
|
+
attachments: attachments.map(a => ({
|
|
461
|
+
mimeType: a.mimeType,
|
|
462
|
+
name: a.name,
|
|
463
|
+
previewUri: a.mimeType.startsWith('image/') ? `data:${a.mimeType};base64,${a.data}` : undefined,
|
|
464
|
+
}))
|
|
465
|
+
} : {}),
|
|
466
|
+
};
|
|
467
|
+
|
|
468
|
+
set(s => ({ messages: [...s.messages, userMsg], loading: true, error: null }));
|
|
469
|
+
|
|
470
|
+
try {
|
|
471
|
+
// Backend auto-resolves the skill via conversation continuity → channel
|
|
472
|
+
// routing → default. triggerContext.channel + replyContext.chatId are
|
|
473
|
+
// required by handoff-controller-mcp's schema — without them handoffs
|
|
474
|
+
// fail silently.
|
|
475
|
+
const actor = getUser();
|
|
476
|
+
const body: Record<string, unknown> = {
|
|
477
|
+
goal: text,
|
|
478
|
+
// Opt in to the Chain Additions router (attach/cancel/new while a
|
|
479
|
+
// chain runs). Backend ignores it where not applicable — safe to send.
|
|
480
|
+
chainRouting: true,
|
|
481
|
+
triggerContext: {
|
|
482
|
+
channel: 'mobile',
|
|
483
|
+
replyContext: { chatId: actor?.actorId || 'unknown' },
|
|
484
|
+
},
|
|
485
|
+
};
|
|
486
|
+
if (skillSlug) body.skillSlug = skillSlug;
|
|
487
|
+
if (attachments?.length) body.attachments = attachments;
|
|
488
|
+
|
|
489
|
+
const res = await fetch(`${getCoreApiUrl()}/api/chat`, {
|
|
490
|
+
method: 'POST',
|
|
491
|
+
headers: coreHeaders(),
|
|
492
|
+
body: JSON.stringify(body),
|
|
493
|
+
});
|
|
494
|
+
|
|
495
|
+
if (!res.ok) {
|
|
496
|
+
let errMsg = `Server error (${res.status})`;
|
|
497
|
+
try {
|
|
498
|
+
const text = await res.text();
|
|
499
|
+
// Don't show raw HTML — extract a short message
|
|
500
|
+
if (text.startsWith('<') || text.startsWith('<!')) {
|
|
501
|
+
errMsg = res.status === 502 ? 'Server is temporarily unavailable. Please try again.' : `Server error (${res.status})`;
|
|
502
|
+
} else {
|
|
503
|
+
const parsed = JSON.parse(text);
|
|
504
|
+
errMsg = parsed.error || errMsg;
|
|
505
|
+
}
|
|
506
|
+
} catch {}
|
|
507
|
+
throw new Error(errMsg);
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
const data = await res.json();
|
|
511
|
+
|
|
512
|
+
if (!data.ok) {
|
|
513
|
+
throw new Error(data.error || 'Failed to send message');
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
// Chain Additions: the router may ATTACH this message to the running
|
|
517
|
+
// chain or CANCEL it. These replies carry NO jobId — handle them and
|
|
518
|
+
// don't start a second stream.
|
|
519
|
+
if (data.mode === 'chain_addition' || data.mode === 'chain_cancel') {
|
|
520
|
+
const ack = data.mode === 'chain_addition'
|
|
521
|
+
? (data.ack || 'Got it — added to the running task.')
|
|
522
|
+
: 'Stopping the task…';
|
|
523
|
+
set(s => ({
|
|
524
|
+
messages: s.messages
|
|
525
|
+
.map(m => (m.id === userMsg.id && data.chainId) ? { ...m, chainId: data.chainId } : m)
|
|
526
|
+
.concat({
|
|
527
|
+
id: `ack-${Date.now()}`,
|
|
528
|
+
role: 'assistant',
|
|
529
|
+
content: ack,
|
|
530
|
+
timestamp: new Date(),
|
|
531
|
+
...(data.chainId ? { chainId: data.chainId } : {}),
|
|
532
|
+
}),
|
|
533
|
+
}));
|
|
534
|
+
return;
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
const jobId = data.id || data.jobId;
|
|
538
|
+
if (!jobId) {
|
|
539
|
+
throw new Error('No job ID returned');
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
// Cache attachments so they survive loadConversation reload. Awaited
|
|
543
|
+
// intentionally — fire-and-forget can lose the write on quick reload.
|
|
544
|
+
if (attachments?.length) {
|
|
545
|
+
await getConfig().attachmentCache?.save(jobId, attachments);
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
// Placeholder assistant bubble = live mirror of the active chain.
|
|
549
|
+
// Carries chainId so a subsequent loadConversation refresh can adopt
|
|
550
|
+
// this placeholder's progress. For the root job, chainId == jobId.
|
|
551
|
+
const progressMsg: Message = {
|
|
552
|
+
id: `job-${jobId}`,
|
|
553
|
+
role: 'assistant',
|
|
554
|
+
content: 'Working on it...',
|
|
555
|
+
timestamp: new Date(),
|
|
556
|
+
jobProgress: {
|
|
557
|
+
jobId,
|
|
558
|
+
actorId: actor?.actorId,
|
|
559
|
+
status: 'running',
|
|
560
|
+
subStatus: 'Starting...',
|
|
561
|
+
chainStartedAt: Date.now(),
|
|
562
|
+
},
|
|
563
|
+
chainId: jobId,
|
|
564
|
+
};
|
|
565
|
+
|
|
566
|
+
// Attach chainId to the optimistic user bubble so loadConversation can
|
|
567
|
+
// dedup against persisted user turns by chainId.
|
|
568
|
+
set(s => ({
|
|
569
|
+
messages: s.messages.map(m =>
|
|
570
|
+
m.id === userMsg.id ? { ...m, chainId: jobId } : m
|
|
571
|
+
).concat(progressMsg),
|
|
572
|
+
activeJobId: jobId,
|
|
573
|
+
}));
|
|
574
|
+
|
|
575
|
+
// Start streaming for real-time progress
|
|
576
|
+
streamJob(jobId, getTenant(), getToken());
|
|
577
|
+
} catch (err: any) {
|
|
578
|
+
set(s => ({
|
|
579
|
+
messages: [...s.messages, {
|
|
580
|
+
id: `error-${Date.now()}`,
|
|
581
|
+
role: 'system' as const,
|
|
582
|
+
content: `Error: ${err.message}`,
|
|
583
|
+
timestamp: new Date(),
|
|
584
|
+
}],
|
|
585
|
+
loading: false,
|
|
586
|
+
}));
|
|
587
|
+
}
|
|
588
|
+
},
|
|
589
|
+
|
|
590
|
+
// -----------------------------------------------------------------------
|
|
591
|
+
// Respond to sys.askUser paused question
|
|
592
|
+
// -----------------------------------------------------------------------
|
|
593
|
+
respondToQuestion: async (jobId: string, inputId: string, answer: string) => {
|
|
594
|
+
try {
|
|
595
|
+
const res = await fetch(`${getCoreApiUrl()}/api/job/${jobId}/respond`, {
|
|
596
|
+
method: 'POST',
|
|
597
|
+
headers: coreHeaders(),
|
|
598
|
+
body: JSON.stringify({ inputId, answer }),
|
|
599
|
+
});
|
|
600
|
+
const data = await res.json();
|
|
601
|
+
if (!data.ok) {
|
|
602
|
+
console.warn('[adas-chat] Failed to respond to question:', data.error);
|
|
603
|
+
}
|
|
604
|
+
} catch (err: any) {
|
|
605
|
+
console.warn('[adas-chat] Error responding to question:', err.message);
|
|
606
|
+
}
|
|
607
|
+
},
|
|
608
|
+
|
|
609
|
+
// -----------------------------------------------------------------------
|
|
610
|
+
// Update job progress (called by the stream handler)
|
|
611
|
+
// -----------------------------------------------------------------------
|
|
612
|
+
updateJobProgress: (jobId: string, progress: Partial<JobProgress>) => {
|
|
613
|
+
set(s => ({
|
|
614
|
+
messages: s.messages.map(m => {
|
|
615
|
+
if (m.jobProgress?.jobId === jobId) {
|
|
616
|
+
const updated = { ...m.jobProgress, ...progress };
|
|
617
|
+
// Clear pendingQuestion when status is no longer paused
|
|
618
|
+
if (progress.status && progress.status !== 'paused') {
|
|
619
|
+
updated.pendingQuestion = undefined;
|
|
620
|
+
}
|
|
621
|
+
// Persisted turns (id `bot-…`) own their content/actions from
|
|
622
|
+
// /api/conversation — the live mirror must never overwrite them.
|
|
623
|
+
const isPersisted = m.id.startsWith('bot-');
|
|
624
|
+
const content = isPersisted
|
|
625
|
+
? m.content
|
|
626
|
+
: (progress.resultMessage
|
|
627
|
+
|| progress.pendingQuestion?.question
|
|
628
|
+
|| m.content);
|
|
629
|
+
const actions = isPersisted
|
|
630
|
+
? m.actions
|
|
631
|
+
: (progress.resultActions?.length ? progress.resultActions : m.actions);
|
|
632
|
+
return {
|
|
633
|
+
...m,
|
|
634
|
+
jobProgress: updated as JobProgress,
|
|
635
|
+
content,
|
|
636
|
+
...(actions ? { actions } : {}),
|
|
637
|
+
};
|
|
638
|
+
}
|
|
639
|
+
return m;
|
|
640
|
+
}),
|
|
641
|
+
loading: (progress.status === 'done' || progress.status === 'error' || progress.status === 'paused')
|
|
642
|
+
? false
|
|
643
|
+
: s.loading,
|
|
644
|
+
activeJobId: (progress.status === 'done' || progress.status === 'error' || progress.status === 'paused')
|
|
645
|
+
? null
|
|
646
|
+
: s.activeJobId,
|
|
647
|
+
}));
|
|
648
|
+
},
|
|
649
|
+
|
|
650
|
+
clearChat: () => {
|
|
651
|
+
set({
|
|
652
|
+
messages: [],
|
|
653
|
+
loading: false,
|
|
654
|
+
error: null,
|
|
655
|
+
activeJobId: null,
|
|
656
|
+
conversationLoaded: false,
|
|
657
|
+
});
|
|
658
|
+
},
|
|
659
|
+
newChat: () => {
|
|
660
|
+
set({
|
|
661
|
+
messages: [],
|
|
662
|
+
loading: false,
|
|
663
|
+
error: null,
|
|
664
|
+
activeJobId: null,
|
|
665
|
+
conversationLoaded: true,
|
|
666
|
+
});
|
|
667
|
+
},
|
|
668
|
+
}));
|
|
669
|
+
|
|
670
|
+
// ---------------------------------------------------------------------------
|
|
671
|
+
// WebSocket Streaming — persistent connection, reused across jobs
|
|
672
|
+
// ---------------------------------------------------------------------------
|
|
673
|
+
|
|
674
|
+
// Track processed plugin command correlationIds to avoid duplicates
|
|
675
|
+
const _processedPluginCmds = new Set<string>();
|
|
676
|
+
|
|
677
|
+
function dispatchPluginCommands(cmds: any[]) {
|
|
678
|
+
for (const cmd of cmds) {
|
|
679
|
+
const corrId = cmd?.correlationId || cmd?.correlation_id;
|
|
680
|
+
if (corrId && _processedPluginCmds.has(corrId)) continue;
|
|
681
|
+
if (corrId) _processedPluginCmds.add(corrId);
|
|
682
|
+
|
|
683
|
+
pluginCommandEmitter.emit('plugin-command', cmd);
|
|
684
|
+
const pluginId = cmd?.pluginId || cmd?.plugin_id || cmd?.args?.plugin_id;
|
|
685
|
+
if (pluginId) {
|
|
686
|
+
const props = cmd?.props ?? cmd?.args?.props ?? undefined;
|
|
687
|
+
pluginCommandEmitter.emit('focus-plugin', { pluginId, props });
|
|
688
|
+
}
|
|
689
|
+
}
|
|
690
|
+
}
|
|
691
|
+
|
|
692
|
+
// Persistent WS connection — stays open across jobs
|
|
693
|
+
let _ws: WebSocket | null = null;
|
|
694
|
+
let _wsReady = false;
|
|
695
|
+
let _currentJobId: string | null = null;
|
|
696
|
+
|
|
697
|
+
function ensureWs(tenant: string, token: string | null): WebSocket {
|
|
698
|
+
if (_ws && _wsReady) return _ws;
|
|
699
|
+
|
|
700
|
+
const wsUrl = `${getCoreWsUrl()}/ws?token=${encodeURIComponent(token || '')}&tenant=${encodeURIComponent(tenant)}`;
|
|
701
|
+
const ws = new WebSocket(wsUrl);
|
|
702
|
+
_ws = ws;
|
|
703
|
+
_wsReady = false;
|
|
704
|
+
|
|
705
|
+
ws.onopen = () => {
|
|
706
|
+
_wsReady = true;
|
|
707
|
+
// If a job is waiting for the connection, subscribe now
|
|
708
|
+
if (_currentJobId) {
|
|
709
|
+
ws.send(JSON.stringify({ type: 'subscribe', jobId: _currentJobId }));
|
|
710
|
+
}
|
|
711
|
+
};
|
|
712
|
+
|
|
713
|
+
ws.onmessage = (ev) => {
|
|
714
|
+
try {
|
|
715
|
+
const msg = JSON.parse(typeof ev.data === 'string' ? ev.data : '');
|
|
716
|
+
const { event, data } = msg;
|
|
717
|
+
const { updateJobProgress } = useChatStore.getState();
|
|
718
|
+
// Route by the event's OWN chain id — the server streams ALL of the
|
|
719
|
+
// user's active chains down one connection, so each event updates its
|
|
720
|
+
// own bubble. Fall back to the last-subscribed job for older backends.
|
|
721
|
+
const jobId = (data && (data.id || data.chainId)) || _currentJobId;
|
|
722
|
+
|
|
723
|
+
// Always process plugin_command events even when _currentJobId is null
|
|
724
|
+
// (e.g., during handoff relay)
|
|
725
|
+
if (event === 'plugin_command') {
|
|
726
|
+
dispatchPluginCommands([data]);
|
|
727
|
+
return;
|
|
728
|
+
}
|
|
729
|
+
if (event === 'plugin-command-ack' || event === 'pong') return;
|
|
730
|
+
|
|
731
|
+
if (!jobId) return;
|
|
732
|
+
|
|
733
|
+
switch (event) {
|
|
734
|
+
case 'init':
|
|
735
|
+
case 'job':
|
|
736
|
+
case 'update': {
|
|
737
|
+
const snap = data;
|
|
738
|
+
const progress = snapshotToProgress(snap, jobId);
|
|
739
|
+
|
|
740
|
+
if (isTerminalSnapshot(snap)) {
|
|
741
|
+
// Handoff chain: root job finished but relay job is still running.
|
|
742
|
+
// The backend broadcasts relay progress as synthetic updates on
|
|
743
|
+
// this same subscription — stay subscribed and show running.
|
|
744
|
+
if (snap.status === 'done' && (snap.isHandoff || snap.state?.handoffTransfer)) {
|
|
745
|
+
updateJobProgress(jobId, { status: 'running' });
|
|
746
|
+
return;
|
|
747
|
+
}
|
|
748
|
+
_currentJobId = null;
|
|
749
|
+
if (progress.status === 'paused' && progress.pendingQuestion) {
|
|
750
|
+
updateJobProgress(jobId, progress);
|
|
751
|
+
} else {
|
|
752
|
+
fetchTurnResult(jobId, snap);
|
|
753
|
+
}
|
|
754
|
+
return;
|
|
755
|
+
}
|
|
756
|
+
|
|
757
|
+
updateJobProgress(jobId, progress);
|
|
758
|
+
break;
|
|
759
|
+
}
|
|
760
|
+
case 'chain_final':
|
|
761
|
+
// Authoritative chain-termination event (fires once when the WHOLE
|
|
762
|
+
// chain is done — handoff-forgiven). Preferred over per-job `final`.
|
|
763
|
+
_currentJobId = null;
|
|
764
|
+
fetchTurnResult(jobId, data);
|
|
765
|
+
break;
|
|
766
|
+
case 'final':
|
|
767
|
+
_currentJobId = null;
|
|
768
|
+
fetchTurnResult(jobId, data);
|
|
769
|
+
break;
|
|
770
|
+
}
|
|
771
|
+
} catch (err) {
|
|
772
|
+
console.warn('[adas-chat] WS parse error:', err);
|
|
773
|
+
}
|
|
774
|
+
};
|
|
775
|
+
|
|
776
|
+
ws.onerror = () => {
|
|
777
|
+
_ws = null;
|
|
778
|
+
_wsReady = false;
|
|
779
|
+
// Fall back to polling for current job
|
|
780
|
+
if (_currentJobId) {
|
|
781
|
+
const jid = _currentJobId;
|
|
782
|
+
_currentJobId = null;
|
|
783
|
+
pollJob(jid, token);
|
|
784
|
+
}
|
|
785
|
+
};
|
|
786
|
+
|
|
787
|
+
ws.onclose = () => {
|
|
788
|
+
_ws = null;
|
|
789
|
+
_wsReady = false;
|
|
790
|
+
if (_currentJobId) {
|
|
791
|
+
const jid = _currentJobId;
|
|
792
|
+
_currentJobId = null;
|
|
793
|
+
pollJob(jid, token);
|
|
794
|
+
}
|
|
795
|
+
};
|
|
796
|
+
|
|
797
|
+
return ws;
|
|
798
|
+
}
|
|
799
|
+
|
|
800
|
+
function streamJob(jobId: string, tenant: string, token: string | null) {
|
|
801
|
+
_currentJobId = jobId;
|
|
802
|
+
const ws = ensureWs(tenant, token);
|
|
803
|
+
|
|
804
|
+
// If WS is already open, subscribe immediately; otherwise onopen handles it
|
|
805
|
+
if (_wsReady) {
|
|
806
|
+
ws.send(JSON.stringify({ type: 'subscribe', jobId }));
|
|
807
|
+
}
|
|
808
|
+
}
|
|
809
|
+
|
|
810
|
+
/** Tear down the live connection (env/auth switch, SDK unmount). */
|
|
811
|
+
export function closeChatStream(): void {
|
|
812
|
+
_currentJobId = null;
|
|
813
|
+
if (_ws) {
|
|
814
|
+
try { _ws.close(); } catch {}
|
|
815
|
+
}
|
|
816
|
+
_ws = null;
|
|
817
|
+
_wsReady = false;
|
|
818
|
+
}
|
|
819
|
+
|
|
820
|
+
/**
|
|
821
|
+
* When a job completes, fetch the conversation turn to get the formatted
|
|
822
|
+
* response. Falls back to extracting from the job snapshot.
|
|
823
|
+
*/
|
|
824
|
+
async function fetchTurnResult(jobId: string, jobSnap: any) {
|
|
825
|
+
// Clean up dedup set (cap to prevent unbounded growth)
|
|
826
|
+
if (_processedPluginCmds.size > 200) _processedPluginCmds.clear();
|
|
827
|
+
const { updateJobProgress } = useChatStore.getState();
|
|
828
|
+
const status = normalizeJobStatus(jobSnap) === 'error' ? 'error' : 'done';
|
|
829
|
+
|
|
830
|
+
// Handoff chain: if this job handed off, wait for the chain to settle by
|
|
831
|
+
// polling job status — done:true only when the relay job completes.
|
|
832
|
+
if (jobSnap.isHandoff || jobSnap.state?.handoffTransfer || jobSnap.result?.contentType === 'handoff') {
|
|
833
|
+
try {
|
|
834
|
+
for (let i = 0; i < 40; i++) { // up to ~60s
|
|
835
|
+
const statusRes = await fetch(`${getCoreApiUrl()}/api/job/${jobId}/status`, {
|
|
836
|
+
headers: coreHeaders(),
|
|
837
|
+
});
|
|
838
|
+
if (statusRes.ok) {
|
|
839
|
+
const statusData = await statusRes.json();
|
|
840
|
+
if (statusData.done && !statusData.isHandoff) {
|
|
841
|
+
break;
|
|
842
|
+
}
|
|
843
|
+
updateJobProgress(jobId, { status: 'running' });
|
|
844
|
+
}
|
|
845
|
+
await new Promise(r => setTimeout(r, 1500));
|
|
846
|
+
}
|
|
847
|
+
} catch {}
|
|
848
|
+
}
|
|
849
|
+
|
|
850
|
+
// Fetch the most recent conversation turn. By actor rather than by jobId,
|
|
851
|
+
// because handoff chains create child jobs — the real reply lives on the
|
|
852
|
+
// LAST child, not on the root job we submitted.
|
|
853
|
+
for (let attempt = 0; attempt < 5; attempt++) {
|
|
854
|
+
try {
|
|
855
|
+
const res = await fetch(`${getCoreApiUrl()}/api/conversation?limit=1`, {
|
|
856
|
+
headers: coreHeaders(),
|
|
857
|
+
});
|
|
858
|
+
|
|
859
|
+
if (res.ok) {
|
|
860
|
+
const data = await res.json();
|
|
861
|
+
const items = Array.isArray(data.items) ? data.items : [];
|
|
862
|
+
const turn = items[0];
|
|
863
|
+
if (turn?.assistant || turn?.turn?.assistant) {
|
|
864
|
+
const assistant = turn.assistant || turn.turn?.assistant;
|
|
865
|
+
const text = blocksToText(assistant.blocks);
|
|
866
|
+
const actions: UIAction[] | undefined =
|
|
867
|
+
Array.isArray(assistant.actions) && assistant.actions.length
|
|
868
|
+
? assistant.actions
|
|
869
|
+
: undefined;
|
|
870
|
+
if (text) {
|
|
871
|
+
updateJobProgress(jobId, {
|
|
872
|
+
status,
|
|
873
|
+
resultMessage: text,
|
|
874
|
+
...(actions ? { resultActions: actions } : {}),
|
|
875
|
+
});
|
|
876
|
+
return;
|
|
877
|
+
}
|
|
878
|
+
}
|
|
879
|
+
}
|
|
880
|
+
} catch {}
|
|
881
|
+
await new Promise(r => setTimeout(r, 800));
|
|
882
|
+
}
|
|
883
|
+
|
|
884
|
+
// Fallback: extract from job snapshot
|
|
885
|
+
const fallback =
|
|
886
|
+
jobSnap.result?.message ||
|
|
887
|
+
jobSnap.state?.finalReply ||
|
|
888
|
+
(status === 'error' ? 'Something went wrong. Please try again.' : 'Done.');
|
|
889
|
+
|
|
890
|
+
updateJobProgress(jobId, { status, resultMessage: fallback });
|
|
891
|
+
}
|
|
892
|
+
|
|
893
|
+
// ---------------------------------------------------------------------------
|
|
894
|
+
// Polling fallback (when WS is unavailable)
|
|
895
|
+
// ---------------------------------------------------------------------------
|
|
896
|
+
|
|
897
|
+
async function pollJob(jobId: string, token: string | null) {
|
|
898
|
+
const { updateJobProgress } = useChatStore.getState();
|
|
899
|
+
|
|
900
|
+
for (let i = 0; i < 120; i++) {
|
|
901
|
+
try {
|
|
902
|
+
const res = await fetch(`${getCoreApiUrl()}/api/job/${jobId}/status`, {
|
|
903
|
+
headers: {
|
|
904
|
+
...(token ? { Authorization: `Bearer ${token}` } : {}),
|
|
905
|
+
},
|
|
906
|
+
});
|
|
907
|
+
|
|
908
|
+
if (!res.ok) {
|
|
909
|
+
if (res.status === 404) return;
|
|
910
|
+
continue;
|
|
911
|
+
}
|
|
912
|
+
|
|
913
|
+
const snap = await res.json();
|
|
914
|
+
const progress = snapshotToProgress(snap, jobId);
|
|
915
|
+
|
|
916
|
+
// sys.askUser — same logic as the WS handler
|
|
917
|
+
if (progress.status === 'paused' && progress.pendingQuestion) {
|
|
918
|
+
updateJobProgress(jobId, progress);
|
|
919
|
+
return; // Stop polling — waiting for user input
|
|
920
|
+
}
|
|
921
|
+
|
|
922
|
+
updateJobProgress(jobId, progress);
|
|
923
|
+
|
|
924
|
+
if (isTerminalSnapshot(snap)) {
|
|
925
|
+
fetchTurnResult(jobId, snap);
|
|
926
|
+
return;
|
|
927
|
+
}
|
|
928
|
+
} catch {}
|
|
929
|
+
|
|
930
|
+
await new Promise(r => setTimeout(r, 1500));
|
|
931
|
+
}
|
|
932
|
+
|
|
933
|
+
// Timeout
|
|
934
|
+
updateJobProgress(jobId, {
|
|
935
|
+
status: 'error',
|
|
936
|
+
resultMessage: 'Job timed out. Please try again.',
|
|
937
|
+
});
|
|
938
|
+
}
|