@m6d/cortex-react 1.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/dist/index.d.ts +70 -0
- package/dist/index.js +3 -0
- package/dist/styles.css +2652 -0
- package/dist/theme.css +69 -0
- package/index.ts +17 -0
- package/package.json +67 -0
- package/src/chat-session.tsx +379 -0
- package/src/components/AttachmentQueue.tsx +75 -0
- package/src/components/ChatComposer.tsx +211 -0
- package/src/components/CopyButton.tsx +73 -0
- package/src/components/CortexChatWidget.tsx +538 -0
- package/src/components/JsonTree.tsx +111 -0
- package/src/components/LlmUsageBreakdown.tsx +42 -0
- package/src/components/LlmUsageChips.tsx +37 -0
- package/src/components/Message.tsx +81 -0
- package/src/components/MessageAbortedFlag.tsx +29 -0
- package/src/components/MessageAttachments.tsx +55 -0
- package/src/components/MessageList.tsx +102 -0
- package/src/components/MessageLlmInspector.tsx +184 -0
- package/src/components/MessagePart.tsx +54 -0
- package/src/components/MessageReasoningAnimated.tsx +12 -0
- package/src/components/MessageReasoningPart.tsx +76 -0
- package/src/components/MessageTextPart.tsx +78 -0
- package/src/components/MessageTokenUsage.tsx +145 -0
- package/src/components/MessageToolCallAnimated.tsx +86 -0
- package/src/components/MessageToolCallOutcome.tsx +95 -0
- package/src/components/MessageToolCallPart.tsx +95 -0
- package/src/components/MessageToolCallStatus.tsx +35 -0
- package/src/components/SubtleActivity.tsx +74 -0
- package/src/components/ThreadList.tsx +219 -0
- package/src/components/ToolAnimation.tsx +22 -0
- package/src/components/ToolExecuteCodeAnimated.tsx +8 -0
- package/src/components/ToolQueryGraphAnimated.tsx +8 -0
- package/src/config.ts +14 -0
- package/src/context.ts +48 -0
- package/src/cx.ts +4 -0
- package/src/format.ts +4 -0
|
@@ -0,0 +1,538 @@
|
|
|
1
|
+
import { useCallback, useEffect, useMemo, useRef, useState, useSyncExternalStore } from 'react';
|
|
2
|
+
import {
|
|
3
|
+
applyWsEvent,
|
|
4
|
+
attachmentQueueFlags,
|
|
5
|
+
createAttachmentQueue,
|
|
6
|
+
createCortexApiClient,
|
|
7
|
+
createCortexSocket,
|
|
8
|
+
removeThread,
|
|
9
|
+
sortThreads,
|
|
10
|
+
translate,
|
|
11
|
+
upsertThread,
|
|
12
|
+
type MountMode,
|
|
13
|
+
} from '@cortex/client';
|
|
14
|
+
import type { AttachmentSummary, ThreadSummary, WsEvent } from '@cortex/contracts/wire';
|
|
15
|
+
import {
|
|
16
|
+
ChatSession,
|
|
17
|
+
type PendingSend,
|
|
18
|
+
type SessionHandle,
|
|
19
|
+
type SessionPatch,
|
|
20
|
+
type SessionUi,
|
|
21
|
+
} from '../chat-session';
|
|
22
|
+
import type { CortexReactConfig } from '../config';
|
|
23
|
+
import { CortexContext, type CortexContextValue } from '../context';
|
|
24
|
+
import { cx } from '../cx';
|
|
25
|
+
import { ChatComposer, type ChatComposerHandle } from './ChatComposer';
|
|
26
|
+
import { MessageList } from './MessageList';
|
|
27
|
+
import { ThreadList } from './ThreadList';
|
|
28
|
+
|
|
29
|
+
const initialSessionUi = {
|
|
30
|
+
messages: [],
|
|
31
|
+
isAgentWorking: false,
|
|
32
|
+
isLoadingMessages: false,
|
|
33
|
+
hasPendingToolCalls: false,
|
|
34
|
+
messageMetadata: new Map<string, never>(),
|
|
35
|
+
} satisfies SessionUi;
|
|
36
|
+
|
|
37
|
+
type MountedSession = { thread: ThreadSummary; mode: MountMode; epoch: number };
|
|
38
|
+
|
|
39
|
+
// fallow-ignore-next-line complexity -- 1:1 transcription of the Angular source; restructuring would risk parity drift
|
|
40
|
+
export function CortexChatWidget({
|
|
41
|
+
config,
|
|
42
|
+
className,
|
|
43
|
+
}: {
|
|
44
|
+
config: CortexReactConfig;
|
|
45
|
+
/** Sizing classes for the host, like the classes on Angular's widget element. */
|
|
46
|
+
className?: string;
|
|
47
|
+
}) {
|
|
48
|
+
const configRef = useRef(config);
|
|
49
|
+
configRef.current = config;
|
|
50
|
+
|
|
51
|
+
const [threads, setThreads] = useState<ThreadSummary[]>();
|
|
52
|
+
const [session, setSession] = useState<MountedSession>();
|
|
53
|
+
const [sessionUi, setSessionUi] = useState<SessionUi>(initialSessionUi);
|
|
54
|
+
const [debugMode, setDebugMode] = useState(false);
|
|
55
|
+
const [screen, setScreen] = useState<'threads' | 'chat'>('threads');
|
|
56
|
+
const [sidebarOpen, setSidebarOpen] = useState(false);
|
|
57
|
+
|
|
58
|
+
const sessionRef = useRef<SessionHandle | undefined>(undefined);
|
|
59
|
+
const pendingSendRef = useRef<PendingSend[]>([]);
|
|
60
|
+
const pendingThreadCreation = useRef<Promise<string> | undefined>(undefined);
|
|
61
|
+
const composerRef = useRef<ChatComposerHandle | null>(null);
|
|
62
|
+
|
|
63
|
+
const api = useMemo(() => createCortexApiClient(() => configRef.current.transport), []);
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Selection is the thread handed to `selectThread`, resolved against the live
|
|
67
|
+
* list: the row there receives the title and isRunning updates the server
|
|
68
|
+
* pushes, so the selection follows them without hand-syncing. The snapshot
|
|
69
|
+
* only answers for a thread the list has not caught up with yet.
|
|
70
|
+
*/
|
|
71
|
+
const selectedThread = useMemo(() => {
|
|
72
|
+
const snapshot = session?.thread;
|
|
73
|
+
if (!snapshot) return undefined;
|
|
74
|
+
|
|
75
|
+
return threads?.find((thread) => thread.id === snapshot.id) ?? snapshot;
|
|
76
|
+
}, [threads, session]);
|
|
77
|
+
|
|
78
|
+
const selectedThreadRef = useRef(selectedThread);
|
|
79
|
+
selectedThreadRef.current = selectedThread;
|
|
80
|
+
const sessionUiRef = useRef(sessionUi);
|
|
81
|
+
sessionUiRef.current = sessionUi;
|
|
82
|
+
|
|
83
|
+
const patchUi = useCallback((patch: SessionPatch) => {
|
|
84
|
+
setSessionUi((previous) => ({
|
|
85
|
+
...previous,
|
|
86
|
+
...(typeof patch === 'function' ? patch(previous) : patch),
|
|
87
|
+
}));
|
|
88
|
+
}, []);
|
|
89
|
+
|
|
90
|
+
const setRunning = useCallback((threadId: string, isRunning: boolean) => {
|
|
91
|
+
setThreads((current) => {
|
|
92
|
+
const thread = current?.find((candidate) => candidate.id === threadId);
|
|
93
|
+
return thread ? upsertThread(current ?? [], { ...thread, isRunning }) : current;
|
|
94
|
+
});
|
|
95
|
+
}, []);
|
|
96
|
+
|
|
97
|
+
const selectThread = useCallback(
|
|
98
|
+
(thread: ThreadSummary, options?: { skipLoadingMessages?: boolean }) => {
|
|
99
|
+
if (selectedThreadRef.current?.id === thread.id && sessionRef.current) return;
|
|
100
|
+
|
|
101
|
+
const previous = selectedThreadRef.current;
|
|
102
|
+
// Eager, not render-synced: a caller in the same tick (a second file in a
|
|
103
|
+
// multi-file drop, a queued send) must already see the selection.
|
|
104
|
+
selectedThreadRef.current = thread;
|
|
105
|
+
setSessionUi(initialSessionUi);
|
|
106
|
+
setSession({ thread, mode: options?.skipLoadingMessages ? 'skip' : 'load', epoch: 0 });
|
|
107
|
+
|
|
108
|
+
if (previous && previous.id !== thread.id) {
|
|
109
|
+
void configRef.current.hooks?.onThreadDeselected?.(previous);
|
|
110
|
+
}
|
|
111
|
+
void configRef.current.hooks?.onThreadSelected?.(thread);
|
|
112
|
+
},
|
|
113
|
+
[],
|
|
114
|
+
);
|
|
115
|
+
|
|
116
|
+
const deselectThread = useCallback(() => {
|
|
117
|
+
const previous = selectedThreadRef.current;
|
|
118
|
+
if (!previous && !sessionRef.current) return;
|
|
119
|
+
|
|
120
|
+
selectedThreadRef.current = undefined;
|
|
121
|
+
setSession(undefined);
|
|
122
|
+
setSessionUi(initialSessionUi);
|
|
123
|
+
|
|
124
|
+
if (previous) {
|
|
125
|
+
void configRef.current.hooks?.onThreadDeselected?.(previous);
|
|
126
|
+
}
|
|
127
|
+
}, []);
|
|
128
|
+
|
|
129
|
+
const remountSession = useCallback(
|
|
130
|
+
(thread: ThreadSummary) => {
|
|
131
|
+
// Like Angular's mountChat: a re-mount starts with a clean envelope and no
|
|
132
|
+
// owed tool calls; hydration brings the current ones back.
|
|
133
|
+
patchUi({ hasPendingToolCalls: false, messageMetadata: new Map() });
|
|
134
|
+
setSession((current) => ({ thread, mode: 'reload', epoch: (current?.epoch ?? 0) + 1 }));
|
|
135
|
+
},
|
|
136
|
+
[patchUi],
|
|
137
|
+
);
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* The id of the thread the next message belongs to, creating it when the
|
|
141
|
+
* widget is still on a blank chat. `prompt` only seeds the generated title.
|
|
142
|
+
* Resolved once for concurrent callers so a multi-file drop onto a blank chat
|
|
143
|
+
* does not race several thread creations against each other.
|
|
144
|
+
*/
|
|
145
|
+
const ensureThread = useCallback(
|
|
146
|
+
(prompt?: string) => {
|
|
147
|
+
const selected = selectedThreadRef.current;
|
|
148
|
+
if (selected) return Promise.resolve(selected.id);
|
|
149
|
+
|
|
150
|
+
pendingThreadCreation.current ??= api.createThread(prompt).then(
|
|
151
|
+
(thread) => {
|
|
152
|
+
pendingThreadCreation.current = undefined;
|
|
153
|
+
setThreads((current) => upsertThread(current ?? [], thread));
|
|
154
|
+
selectThread(thread, { skipLoadingMessages: true });
|
|
155
|
+
return thread.id;
|
|
156
|
+
},
|
|
157
|
+
(error: unknown) => {
|
|
158
|
+
pendingThreadCreation.current = undefined;
|
|
159
|
+
throw error;
|
|
160
|
+
},
|
|
161
|
+
);
|
|
162
|
+
return pendingThreadCreation.current;
|
|
163
|
+
},
|
|
164
|
+
[api, selectThread],
|
|
165
|
+
);
|
|
166
|
+
|
|
167
|
+
const queueStore = useMemo(
|
|
168
|
+
() => createAttachmentQueue({ api, ensureThread: () => ensureThread() }),
|
|
169
|
+
[api, ensureThread],
|
|
170
|
+
);
|
|
171
|
+
const queueItems = useSyncExternalStore(
|
|
172
|
+
queueStore.subscribe,
|
|
173
|
+
queueStore.getState,
|
|
174
|
+
queueStore.getState,
|
|
175
|
+
);
|
|
176
|
+
|
|
177
|
+
const send = useCallback(
|
|
178
|
+
async (prompt: string, attachments: AttachmentSummary[] = []) => {
|
|
179
|
+
if (sessionUiRef.current.isAgentWorking || sessionUiRef.current.hasPendingToolCalls) return;
|
|
180
|
+
|
|
181
|
+
const handle = sessionRef.current;
|
|
182
|
+
if (handle) {
|
|
183
|
+
await handle.send(prompt, attachments);
|
|
184
|
+
return;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
// Blank chat: the message goes out with the session the new thread
|
|
188
|
+
// mounts. A queue, not a slot — a second send racing the same creation
|
|
189
|
+
// must not overwrite the first.
|
|
190
|
+
pendingSendRef.current.push({ prompt, attachments });
|
|
191
|
+
try {
|
|
192
|
+
await ensureThread(prompt);
|
|
193
|
+
} catch (error) {
|
|
194
|
+
// A failed creation must not leave messages behind to replay into
|
|
195
|
+
// whatever thread mounts next.
|
|
196
|
+
pendingSendRef.current = [];
|
|
197
|
+
throw error;
|
|
198
|
+
}
|
|
199
|
+
},
|
|
200
|
+
[ensureThread],
|
|
201
|
+
);
|
|
202
|
+
|
|
203
|
+
const abort = useCallback(async () => {
|
|
204
|
+
await sessionRef.current?.abort();
|
|
205
|
+
}, []);
|
|
206
|
+
|
|
207
|
+
const addToolResult = useCallback((toolCallId: string, toolName: string, output: unknown) => {
|
|
208
|
+
sessionRef.current?.addToolResult(toolCallId, toolName, output);
|
|
209
|
+
}, []);
|
|
210
|
+
|
|
211
|
+
const deleteThread = useCallback(
|
|
212
|
+
async (threadId: string) => {
|
|
213
|
+
if (selectedThreadRef.current?.id === threadId) deselectThread();
|
|
214
|
+
|
|
215
|
+
await api.deleteThread(threadId);
|
|
216
|
+
setThreads((current) => (current ? removeThread(current, threadId) : current));
|
|
217
|
+
},
|
|
218
|
+
[api, deselectThread],
|
|
219
|
+
);
|
|
220
|
+
|
|
221
|
+
const onTurnFinished = useCallback(() => {
|
|
222
|
+
queueStore.discardConsumed();
|
|
223
|
+
setTimeout(() => composerRef.current?.focusInput());
|
|
224
|
+
}, [queueStore]);
|
|
225
|
+
|
|
226
|
+
const onSendFailed = useCallback(() => {
|
|
227
|
+
queueStore.restoreConsumed(selectedThreadRef.current?.id);
|
|
228
|
+
}, [queueStore]);
|
|
229
|
+
|
|
230
|
+
const reloadThreads = useCallback(async () => {
|
|
231
|
+
const listed = sortThreads(await api.listThreads());
|
|
232
|
+
setThreads(listed);
|
|
233
|
+
return listed;
|
|
234
|
+
}, [api]);
|
|
235
|
+
|
|
236
|
+
const handleWsEvent = useCallback(
|
|
237
|
+
(event: WsEvent) => {
|
|
238
|
+
setThreads((current) => {
|
|
239
|
+
const next = applyWsEvent(current ?? [], event);
|
|
240
|
+
// Before the first reload lands the list is still undefined (the loading
|
|
241
|
+
// skeleton); only an event that actually adds something may materialize it.
|
|
242
|
+
return current || next.length ? next : current;
|
|
243
|
+
});
|
|
244
|
+
|
|
245
|
+
const selectedId = selectedThreadRef.current?.id;
|
|
246
|
+
switch (event.type) {
|
|
247
|
+
case 'thread:deleted':
|
|
248
|
+
if (selectedId === event.payload.threadId) deselectThread();
|
|
249
|
+
break;
|
|
250
|
+
case 'thread:run-started':
|
|
251
|
+
// The payload rather than the list: the state update absorbing this
|
|
252
|
+
// event into the list has not been rendered yet.
|
|
253
|
+
if (selectedId === event.payload.thread.id) {
|
|
254
|
+
sessionRef.current?.reattach(event.payload.thread);
|
|
255
|
+
}
|
|
256
|
+
break;
|
|
257
|
+
case 'thread:messages-updated':
|
|
258
|
+
sessionRef.current?.refreshMessages(event.payload.threadId);
|
|
259
|
+
break;
|
|
260
|
+
}
|
|
261
|
+
},
|
|
262
|
+
[deselectThread],
|
|
263
|
+
);
|
|
264
|
+
|
|
265
|
+
useEffect(() => {
|
|
266
|
+
void reloadThreads();
|
|
267
|
+
|
|
268
|
+
// The first open is startup; only a socket that comes back marks a gap.
|
|
269
|
+
let openedOnce = false;
|
|
270
|
+
const socket = createCortexSocket({
|
|
271
|
+
// Thunks so every (re)connection attempt reads the config current then.
|
|
272
|
+
wsUrl: () => configRef.current.wsUrl,
|
|
273
|
+
transport: {
|
|
274
|
+
baseUrl: () => {
|
|
275
|
+
const baseUrl = configRef.current.transport.baseUrl;
|
|
276
|
+
return typeof baseUrl === 'string' ? baseUrl : baseUrl();
|
|
277
|
+
},
|
|
278
|
+
getHeaders: () => configRef.current.transport.getHeaders(),
|
|
279
|
+
},
|
|
280
|
+
onEvent: handleWsEvent,
|
|
281
|
+
onOpen: () => {
|
|
282
|
+
if (!openedOnce) {
|
|
283
|
+
openedOnce = true;
|
|
284
|
+
return;
|
|
285
|
+
}
|
|
286
|
+
// Anything could have happened while the socket was down, so the thread
|
|
287
|
+
// list is re-read from the server, and re-attaching sees the open
|
|
288
|
+
// thread's current `isRunning` to decide whether there is a run to tail.
|
|
289
|
+
void reloadThreads().then((listed) => {
|
|
290
|
+
const selected = selectedThreadRef.current;
|
|
291
|
+
if (!selected) return;
|
|
292
|
+
|
|
293
|
+
sessionRef.current?.reattach(
|
|
294
|
+
listed.find((thread) => thread.id === selected.id) ?? selected,
|
|
295
|
+
);
|
|
296
|
+
});
|
|
297
|
+
},
|
|
298
|
+
});
|
|
299
|
+
|
|
300
|
+
return () => {
|
|
301
|
+
socket.close();
|
|
302
|
+
};
|
|
303
|
+
}, [handleWsEvent, reloadThreads]);
|
|
304
|
+
|
|
305
|
+
// Leaving a thread abandons the files queued for it.
|
|
306
|
+
const previousThreadId = useRef<string | undefined>(undefined);
|
|
307
|
+
const selectedThreadId = selectedThread?.id;
|
|
308
|
+
useEffect(() => {
|
|
309
|
+
if (selectedThreadId === previousThreadId.current) return;
|
|
310
|
+
|
|
311
|
+
queueStore.clear(previousThreadId.current);
|
|
312
|
+
previousThreadId.current = selectedThreadId;
|
|
313
|
+
}, [selectedThreadId, queueStore]);
|
|
314
|
+
|
|
315
|
+
const locale = config.locale ?? 'en';
|
|
316
|
+
const t = useCallback(
|
|
317
|
+
(key: string, params?: Record<string, string | number>) => translate(locale, key, params),
|
|
318
|
+
[locale],
|
|
319
|
+
);
|
|
320
|
+
|
|
321
|
+
const viewMode = config.viewMode ?? 'helper';
|
|
322
|
+
|
|
323
|
+
const contextValue = {
|
|
324
|
+
config,
|
|
325
|
+
t,
|
|
326
|
+
api,
|
|
327
|
+
debugMode,
|
|
328
|
+
threads,
|
|
329
|
+
selectedThread,
|
|
330
|
+
deleteThread,
|
|
331
|
+
...sessionUi,
|
|
332
|
+
send,
|
|
333
|
+
abort,
|
|
334
|
+
addToolResult,
|
|
335
|
+
queue: {
|
|
336
|
+
items: queueItems,
|
|
337
|
+
...attachmentQueueFlags(queueItems),
|
|
338
|
+
accept: queueStore.accept,
|
|
339
|
+
remove: queueStore.remove,
|
|
340
|
+
consumeReady: queueStore.consumeReady,
|
|
341
|
+
},
|
|
342
|
+
} satisfies CortexContextValue;
|
|
343
|
+
|
|
344
|
+
function openThread(thread: ThreadSummary) {
|
|
345
|
+
selectThread(thread);
|
|
346
|
+
setScreen('chat');
|
|
347
|
+
setSidebarOpen(false);
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
function newChat() {
|
|
351
|
+
deselectThread();
|
|
352
|
+
setScreen('chat');
|
|
353
|
+
setSidebarOpen(false);
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
function goBack() {
|
|
357
|
+
deselectThread();
|
|
358
|
+
setScreen('threads');
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
return (
|
|
362
|
+
<CortexContext.Provider value={contextValue}>
|
|
363
|
+
<div className={cx('cortex-widget', className)} data-cortex-theme={config.theme}>
|
|
364
|
+
<div
|
|
365
|
+
className={cx(
|
|
366
|
+
'cortex-widget__container',
|
|
367
|
+
viewMode === 'full' && 'cortex-widget__container--full',
|
|
368
|
+
sidebarOpen && 'cortex-widget__container--sidebar-open',
|
|
369
|
+
)}
|
|
370
|
+
onDragOver={(event) => event.preventDefault()}
|
|
371
|
+
onDrop={(event) => event.preventDefault()}
|
|
372
|
+
>
|
|
373
|
+
{/* SCREEN 1: THREADS */}
|
|
374
|
+
<ThreadList
|
|
375
|
+
className={cx(
|
|
376
|
+
'cortex-widget__screen',
|
|
377
|
+
screen === 'threads' && 'cortex-widget__screen--active',
|
|
378
|
+
screen !== 'threads' && 'cortex-widget__screen--left',
|
|
379
|
+
)}
|
|
380
|
+
onThreadSelected={openThread}
|
|
381
|
+
onNewChatRequested={newChat}
|
|
382
|
+
/>
|
|
383
|
+
|
|
384
|
+
{/* SCREEN 2: CHAT */}
|
|
385
|
+
<div
|
|
386
|
+
className={cx(
|
|
387
|
+
'cortex-widget__screen',
|
|
388
|
+
screen === 'chat' && 'cortex-widget__screen--active',
|
|
389
|
+
screen !== 'chat' && 'cortex-widget__screen--right',
|
|
390
|
+
)}
|
|
391
|
+
>
|
|
392
|
+
<div className="cortex-widget__chat-header">
|
|
393
|
+
{/* Sidebar toggle (full mode, small screens) */}
|
|
394
|
+
<button
|
|
395
|
+
onClick={() => setSidebarOpen((open) => !open)}
|
|
396
|
+
className="cortex-widget__sidebar-toggle-btn"
|
|
397
|
+
>
|
|
398
|
+
<svg width="16" height="16" viewBox="0 0 16 16" fill="none">
|
|
399
|
+
<path
|
|
400
|
+
d="M2.5 4h11M2.5 8h11M2.5 12h11"
|
|
401
|
+
stroke="currentColor"
|
|
402
|
+
strokeWidth="1.4"
|
|
403
|
+
strokeLinecap="round"
|
|
404
|
+
/>
|
|
405
|
+
</svg>
|
|
406
|
+
</button>
|
|
407
|
+
|
|
408
|
+
{/* Back button */}
|
|
409
|
+
<button onClick={goBack} className="cortex-widget__back-btn">
|
|
410
|
+
<svg
|
|
411
|
+
width="14"
|
|
412
|
+
height="14"
|
|
413
|
+
viewBox="0 0 16 16"
|
|
414
|
+
fill="none"
|
|
415
|
+
className="cortex-widget__back-icon"
|
|
416
|
+
>
|
|
417
|
+
<path
|
|
418
|
+
d="M10 3L5 8l5 5"
|
|
419
|
+
stroke="currentColor"
|
|
420
|
+
strokeWidth="1.5"
|
|
421
|
+
strokeLinecap="round"
|
|
422
|
+
strokeLinejoin="round"
|
|
423
|
+
/>
|
|
424
|
+
</svg>
|
|
425
|
+
</button>
|
|
426
|
+
|
|
427
|
+
<div className="cortex-widget__chat-title-wrap">
|
|
428
|
+
<p className="cortex-widget__chat-title">
|
|
429
|
+
{selectedThread?.title ?? t('translate_new_chat')}
|
|
430
|
+
</p>
|
|
431
|
+
</div>
|
|
432
|
+
|
|
433
|
+
{config.showDebugButton && (
|
|
434
|
+
<button
|
|
435
|
+
onClick={() => setDebugMode((mode) => !mode)}
|
|
436
|
+
className={cx(
|
|
437
|
+
'cortex-widget__debug-btn',
|
|
438
|
+
debugMode ? 'cortex-widget__debug-btn--on' : 'cortex-widget__debug-btn--off',
|
|
439
|
+
)}
|
|
440
|
+
>
|
|
441
|
+
{debugMode ? t('translate_debug') : t('translate_normal')}
|
|
442
|
+
</button>
|
|
443
|
+
)}
|
|
444
|
+
</div>
|
|
445
|
+
|
|
446
|
+
{sessionUi.isLoadingMessages && !sessionUi.isAgentWorking ? (
|
|
447
|
+
<div className="cortex-widget__messages-skeleton">
|
|
448
|
+
<div className="cortex-widget__msg-skel cortex-widget__msg-skel--user">
|
|
449
|
+
<div className="cortex-widget__msg-skel-bubble cortex-widget__msg-skel-bubble--user">
|
|
450
|
+
<div
|
|
451
|
+
className="cortex-skeleton cortex-widget__msg-skel-line"
|
|
452
|
+
style={{ width: '13rem' }}
|
|
453
|
+
/>
|
|
454
|
+
<div
|
|
455
|
+
className="cortex-skeleton cortex-widget__msg-skel-line"
|
|
456
|
+
style={{ width: '9rem' }}
|
|
457
|
+
/>
|
|
458
|
+
</div>
|
|
459
|
+
</div>
|
|
460
|
+
<div className="cortex-widget__msg-skel cortex-widget__msg-skel--assistant">
|
|
461
|
+
<div className="cortex-widget__msg-skel-bubble cortex-widget__msg-skel-bubble--assistant">
|
|
462
|
+
<div
|
|
463
|
+
className="cortex-skeleton cortex-widget__msg-skel-line"
|
|
464
|
+
style={{ width: '16rem' }}
|
|
465
|
+
/>
|
|
466
|
+
<div
|
|
467
|
+
className="cortex-skeleton cortex-widget__msg-skel-line"
|
|
468
|
+
style={{ width: '18rem' }}
|
|
469
|
+
/>
|
|
470
|
+
<div
|
|
471
|
+
className="cortex-skeleton cortex-widget__msg-skel-line"
|
|
472
|
+
style={{ width: '12rem' }}
|
|
473
|
+
/>
|
|
474
|
+
</div>
|
|
475
|
+
</div>
|
|
476
|
+
<div className="cortex-widget__msg-skel cortex-widget__msg-skel--user">
|
|
477
|
+
<div className="cortex-widget__msg-skel-bubble cortex-widget__msg-skel-bubble--user">
|
|
478
|
+
<div
|
|
479
|
+
className="cortex-skeleton cortex-widget__msg-skel-line"
|
|
480
|
+
style={{ width: '11rem' }}
|
|
481
|
+
/>
|
|
482
|
+
</div>
|
|
483
|
+
</div>
|
|
484
|
+
<div className="cortex-widget__msg-skel cortex-widget__msg-skel--assistant">
|
|
485
|
+
<div className="cortex-widget__msg-skel-bubble cortex-widget__msg-skel-bubble--assistant">
|
|
486
|
+
<div
|
|
487
|
+
className="cortex-skeleton cortex-widget__msg-skel-line"
|
|
488
|
+
style={{ width: '14rem' }}
|
|
489
|
+
/>
|
|
490
|
+
<div
|
|
491
|
+
className="cortex-skeleton cortex-widget__msg-skel-line"
|
|
492
|
+
style={{ width: '15rem' }}
|
|
493
|
+
/>
|
|
494
|
+
</div>
|
|
495
|
+
</div>
|
|
496
|
+
</div>
|
|
497
|
+
) : (
|
|
498
|
+
<MessageList className="cortex-widget__messages" debugMode={debugMode} />
|
|
499
|
+
)}
|
|
500
|
+
|
|
501
|
+
{sessionUi.isAgentWorking && !sessionUi.hasPendingToolCalls && (
|
|
502
|
+
<div className="cortex-widget__working">
|
|
503
|
+
<div className="cortex-widget__working-dots">
|
|
504
|
+
<span className="cortex-working-dot" />
|
|
505
|
+
<span className="cortex-working-dot" />
|
|
506
|
+
<span className="cortex-working-dot" />
|
|
507
|
+
</div>
|
|
508
|
+
<span className="cortex-widget__working-text">{t('translate_thinking')}</span>
|
|
509
|
+
</div>
|
|
510
|
+
)}
|
|
511
|
+
|
|
512
|
+
{!sessionUi.hasPendingToolCalls && <ChatComposer ref={composerRef} />}
|
|
513
|
+
</div>
|
|
514
|
+
|
|
515
|
+
{/* Sidebar overlay backdrop (full mode, small screens) */}
|
|
516
|
+
<div className="cortex-widget__sidebar-backdrop" onClick={() => setSidebarOpen(false)} />
|
|
517
|
+
</div>
|
|
518
|
+
|
|
519
|
+
{session && (
|
|
520
|
+
<ChatSession
|
|
521
|
+
key={`${session.thread.id}:${session.epoch}`}
|
|
522
|
+
thread={session.thread}
|
|
523
|
+
mode={session.mode}
|
|
524
|
+
api={api}
|
|
525
|
+
configRef={configRef}
|
|
526
|
+
sessionRef={sessionRef}
|
|
527
|
+
pendingSendRef={pendingSendRef}
|
|
528
|
+
patchUi={patchUi}
|
|
529
|
+
setRunning={setRunning}
|
|
530
|
+
remount={remountSession}
|
|
531
|
+
onTurnFinished={onTurnFinished}
|
|
532
|
+
onSendFailed={onSendFailed}
|
|
533
|
+
/>
|
|
534
|
+
)}
|
|
535
|
+
</div>
|
|
536
|
+
</CortexContext.Provider>
|
|
537
|
+
);
|
|
538
|
+
}
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
import { useMemo, useState } from 'react';
|
|
2
|
+
import type { MouseEvent } from 'react';
|
|
3
|
+
import { deepParseJson, describeJsonValue } from '@cortex/client';
|
|
4
|
+
import { cx } from '../cx';
|
|
5
|
+
|
|
6
|
+
type JsonTreeProps = {
|
|
7
|
+
data: unknown;
|
|
8
|
+
expandDepth?: number;
|
|
9
|
+
className?: string;
|
|
10
|
+
};
|
|
11
|
+
|
|
12
|
+
export function JsonTree({ data, expandDepth = 1, className }: JsonTreeProps) {
|
|
13
|
+
/** Memoized: the tree renders recursively, so parsing per pass would redo the
|
|
14
|
+
* whole document on every render. */
|
|
15
|
+
const parsedData = useMemo(() => deepParseJson(data), [data]);
|
|
16
|
+
|
|
17
|
+
/** Until the reader touches a node, its state follows expandDepth. */
|
|
18
|
+
const [userToggled, setUserToggled] = useState<Record<string, boolean | undefined>>({});
|
|
19
|
+
|
|
20
|
+
function isCollapsed(path: string, depth: number) {
|
|
21
|
+
return userToggled[path] ?? depth >= expandDepth;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function toggle(path: string, depth: number) {
|
|
25
|
+
setUserToggled((current) => ({ ...current, [path]: !isCollapsed(path, depth) }));
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
return (
|
|
29
|
+
<div className={cx('cortex-json-tree', className)}>
|
|
30
|
+
<JsonValue value={parsedData} path="$" depth={0} isCollapsed={isCollapsed} toggle={toggle} />
|
|
31
|
+
</div>
|
|
32
|
+
);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
type JsonValueProps = {
|
|
36
|
+
value: unknown;
|
|
37
|
+
path: string;
|
|
38
|
+
depth: number;
|
|
39
|
+
isCollapsed: (path: string, depth: number) => boolean;
|
|
40
|
+
toggle: (path: string, depth: number) => void;
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Recursive renderer. Every value goes through describeJsonValue(), so objects,
|
|
45
|
+
* arrays and primitives all arrive in one shape and this needs a single set of
|
|
46
|
+
* branches rather than one per container kind.
|
|
47
|
+
*/
|
|
48
|
+
function JsonValue({ value, path, depth, isCollapsed, toggle }: JsonValueProps) {
|
|
49
|
+
const node = describeJsonValue(value, path);
|
|
50
|
+
|
|
51
|
+
if (node.kind === 'primitive') return <span className={node.className}>{node.text}</span>;
|
|
52
|
+
|
|
53
|
+
if (node.entries.length === 0) {
|
|
54
|
+
return (
|
|
55
|
+
<span className="jt-bracket">
|
|
56
|
+
{node.open}
|
|
57
|
+
{node.close}
|
|
58
|
+
</span>
|
|
59
|
+
);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const collapsed = isCollapsed(path, depth);
|
|
63
|
+
|
|
64
|
+
function onToggle(event: MouseEvent<HTMLSpanElement>) {
|
|
65
|
+
toggle(path, depth);
|
|
66
|
+
event.stopPropagation();
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
return (
|
|
70
|
+
<>
|
|
71
|
+
<span className="jt-toggle" onClick={onToggle} role="button">
|
|
72
|
+
<span className={cx('jt-arrow', collapsed && 'jt-arrow--collapsed')}>▾</span>
|
|
73
|
+
<span className="jt-bracket">{node.open}</span>
|
|
74
|
+
</span>
|
|
75
|
+
|
|
76
|
+
{collapsed ? (
|
|
77
|
+
<>
|
|
78
|
+
<span className="jt-collapsed-hint" onClick={onToggle} role="button">
|
|
79
|
+
{node.summary}
|
|
80
|
+
</span>
|
|
81
|
+
<span className="jt-bracket">{node.close}</span>
|
|
82
|
+
</>
|
|
83
|
+
) : (
|
|
84
|
+
<>
|
|
85
|
+
<div className="jt-indent">
|
|
86
|
+
{node.entries.map((entry, index) => (
|
|
87
|
+
// One line of a container: its key if it has one, its value, and a separating comma.
|
|
88
|
+
<div className="jt-line" key={entry.path}>
|
|
89
|
+
{entry.key !== null && (
|
|
90
|
+
<>
|
|
91
|
+
<span className="jt-key">{`"${entry.key}"`}</span>
|
|
92
|
+
<span className="jt-colon">{': '}</span>
|
|
93
|
+
</>
|
|
94
|
+
)}
|
|
95
|
+
<JsonValue
|
|
96
|
+
value={entry.value}
|
|
97
|
+
path={entry.path}
|
|
98
|
+
depth={depth + 1}
|
|
99
|
+
isCollapsed={isCollapsed}
|
|
100
|
+
toggle={toggle}
|
|
101
|
+
/>
|
|
102
|
+
{index < node.entries.length - 1 && <span className="jt-comma">,</span>}
|
|
103
|
+
</div>
|
|
104
|
+
))}
|
|
105
|
+
</div>
|
|
106
|
+
<span className="jt-bracket">{node.close}</span>
|
|
107
|
+
</>
|
|
108
|
+
)}
|
|
109
|
+
</>
|
|
110
|
+
);
|
|
111
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import type { TokenUsage } from '@cortex/contracts/wire';
|
|
2
|
+
import { useCortex } from '../context';
|
|
3
|
+
import { num } from '../format';
|
|
4
|
+
|
|
5
|
+
type LlmUsageBreakdownProps = {
|
|
6
|
+
usage: TokenUsage;
|
|
7
|
+
};
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* The expanded token accounting for one LLM request: where the input came from,
|
|
11
|
+
* what the output was spent on, and the total.
|
|
12
|
+
*/
|
|
13
|
+
export function LlmUsageBreakdown({ usage }: LlmUsageBreakdownProps) {
|
|
14
|
+
const { t } = useCortex();
|
|
15
|
+
|
|
16
|
+
return (
|
|
17
|
+
<div className="cortex-llm-inspector__usage-rows">
|
|
18
|
+
<div className="cortex-llm-inspector__usage-row">
|
|
19
|
+
<span className="cortex-llm-inspector__usage-lbl">{t('translate_input')}</span>
|
|
20
|
+
<span className="cortex-llm-inspector__usage-val">{num(usage.input.total)}</span>
|
|
21
|
+
<span className="cortex-llm-inspector__usage-detail">
|
|
22
|
+
{t('translate_fresh')} {num(usage.input.noCache)} · {t('translate_read')}{' '}
|
|
23
|
+
{num(usage.input.cacheRead)} · {t('translate_write')} {num(usage.input.cacheWrite)}
|
|
24
|
+
</span>
|
|
25
|
+
</div>
|
|
26
|
+
|
|
27
|
+
<div className="cortex-llm-inspector__usage-row">
|
|
28
|
+
<span className="cortex-llm-inspector__usage-lbl">{t('translate_output')}</span>
|
|
29
|
+
<span className="cortex-llm-inspector__usage-val">{num(usage.output.total)}</span>
|
|
30
|
+
<span className="cortex-llm-inspector__usage-detail">
|
|
31
|
+
{t('translate_text')} {num(usage.output.text)} · {t('translate_reasoning')}{' '}
|
|
32
|
+
{num(usage.output.reasoning)}
|
|
33
|
+
</span>
|
|
34
|
+
</div>
|
|
35
|
+
|
|
36
|
+
<div className="cortex-llm-inspector__usage-row cortex-llm-inspector__usage-row--total">
|
|
37
|
+
<span className="cortex-llm-inspector__usage-lbl">{t('translate_total')}</span>
|
|
38
|
+
<span className="cortex-llm-inspector__usage-val">{num(usage.total)}</span>
|
|
39
|
+
</div>
|
|
40
|
+
</div>
|
|
41
|
+
);
|
|
42
|
+
}
|