@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
package/dist/theme.css
ADDED
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
/* fallow-ignore-file code-duplication -- deliberately restates styles/_theme.scss: the whole point of this file is the full token set in one place */
|
|
2
|
+
/*
|
|
3
|
+
* Theming surface.
|
|
4
|
+
*
|
|
5
|
+
* Every value below is the widget's own default, restated here so the full set is
|
|
6
|
+
* discoverable in one place — the same declarations already ship inside cortex.css,
|
|
7
|
+
* so importing this file changes nothing until you edit it. Override any token on
|
|
8
|
+
* :root, or on any ancestor of the widget, to retheme it.
|
|
9
|
+
*
|
|
10
|
+
* Each color is a light-dark() pair. Which side applies follows the widget root's
|
|
11
|
+
* color-scheme, set by the `theme` config option ('light' | 'dark' | 'system').
|
|
12
|
+
* The radius and font tokens below the colors are plain values — neither depends
|
|
13
|
+
* on the scheme.
|
|
14
|
+
*/
|
|
15
|
+
:root {
|
|
16
|
+
--cortex-color-surface: light-dark(#ffffff, #0a0a0a);
|
|
17
|
+
--cortex-color-surface-subtle: light-dark(#f8fafc, #141414);
|
|
18
|
+
--cortex-color-surface-muted: light-dark(#f1f5f9, #1f1f1f);
|
|
19
|
+
|
|
20
|
+
--cortex-color-border: light-dark(#e2e8f0, #2e2e2e);
|
|
21
|
+
--cortex-color-border-strong: light-dark(#cbd5e1, #444444);
|
|
22
|
+
|
|
23
|
+
--cortex-color-text-heading: light-dark(#1e293b, #fafafa);
|
|
24
|
+
--cortex-color-text-strong: light-dark(#334155, #e5e5e5);
|
|
25
|
+
--cortex-color-text: light-dark(#475569, #d4d4d4);
|
|
26
|
+
--cortex-color-text-secondary: light-dark(#64748b, #a3a3a3);
|
|
27
|
+
--cortex-color-text-muted: light-dark(#94a3b8, #737373);
|
|
28
|
+
|
|
29
|
+
--cortex-color-primary: light-dark(#1e293b, #fafafa);
|
|
30
|
+
--cortex-color-primary-foreground: light-dark(#ffffff, #0a0a0a);
|
|
31
|
+
--cortex-color-accent: light-dark(#4f46e5, #818cf8);
|
|
32
|
+
--cortex-color-accent-surface: light-dark(#e0e7ff, #312e81);
|
|
33
|
+
--cortex-color-link: light-dark(#2563eb, #60a5fa);
|
|
34
|
+
--cortex-color-hover: light-dark(rgba(0, 0, 0, 0.05), rgba(255, 255, 255, 0.06));
|
|
35
|
+
|
|
36
|
+
/* User messages default to the same subtle surface every other quiet panel
|
|
37
|
+
* uses, split out so a consumer can give their own side of the chat a color
|
|
38
|
+
* without touching anything else. */
|
|
39
|
+
--cortex-color-user-surface: var(--cortex-color-surface-subtle);
|
|
40
|
+
--cortex-color-user-border: var(--cortex-color-border);
|
|
41
|
+
|
|
42
|
+
--cortex-color-success: light-dark(#059669, #34d399);
|
|
43
|
+
--cortex-color-success-surface: light-dark(#ecfdf5, #064e3b);
|
|
44
|
+
--cortex-color-success-border: light-dark(#d1fae5, #065f46);
|
|
45
|
+
--cortex-color-danger: light-dark(#dc2626, #f87171);
|
|
46
|
+
--cortex-color-danger-surface: light-dark(#fef2f2, #450a0a);
|
|
47
|
+
--cortex-color-danger-border: light-dark(#fecaca, #7f1d1d);
|
|
48
|
+
|
|
49
|
+
/* The corner scale every rounded surface derives from: the widget multiplies
|
|
50
|
+
* this by 0.5/0.75/1.5/2, so overriding it rescales all of them together. */
|
|
51
|
+
--cortex-radius: 0.5rem;
|
|
52
|
+
|
|
53
|
+
--cortex-font-mono: var(
|
|
54
|
+
--default-mono-font-family,
|
|
55
|
+
ui-monospace,
|
|
56
|
+
SFMono-Regular,
|
|
57
|
+
Menlo,
|
|
58
|
+
Monaco,
|
|
59
|
+
Consolas,
|
|
60
|
+
monospace
|
|
61
|
+
);
|
|
62
|
+
--cortex-font-size: 0.8125rem;
|
|
63
|
+
|
|
64
|
+
/* The widget's headings read --cortex-font-display and fall back to `inherit`,
|
|
65
|
+
* so it ships unset on purpose — declaring it here is how you opt in:
|
|
66
|
+
*
|
|
67
|
+
* --cortex-font-display: 'Your Display Face', sans-serif;
|
|
68
|
+
*/
|
|
69
|
+
}
|
package/index.ts
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* Public API Surface of cortex-react — mirrors cortex-angular's public-api.ts.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
// Config
|
|
6
|
+
export type { CortexReactConfig, CortexToolComponentProps } from './src/config';
|
|
7
|
+
export type {
|
|
8
|
+
CortexHooks,
|
|
9
|
+
CortexTheme,
|
|
10
|
+
CortexToolCall,
|
|
11
|
+
CortexToolCallContext,
|
|
12
|
+
CortexTransport,
|
|
13
|
+
CortexViewMode,
|
|
14
|
+
} from '@cortex/client';
|
|
15
|
+
|
|
16
|
+
// Components
|
|
17
|
+
export { CortexChatWidget } from './src/components/CortexChatWidget';
|
package/package.json
ADDED
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@m6d/cortex-react",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "React chat widget SDK for cortex servers",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"sideEffects": [
|
|
7
|
+
"dist/*.css"
|
|
8
|
+
],
|
|
9
|
+
"type": "module",
|
|
10
|
+
"exports": {
|
|
11
|
+
".": {
|
|
12
|
+
"types": "./dist/index.d.ts",
|
|
13
|
+
"bun": "./index.ts",
|
|
14
|
+
"import": "./dist/index.js",
|
|
15
|
+
"default": "./dist/index.js"
|
|
16
|
+
},
|
|
17
|
+
"./styles.css": "./dist/styles.css",
|
|
18
|
+
"./theme.css": "./dist/theme.css"
|
|
19
|
+
},
|
|
20
|
+
"main": "./dist/index.js",
|
|
21
|
+
"types": "./dist/index.d.ts",
|
|
22
|
+
"files": [
|
|
23
|
+
"dist",
|
|
24
|
+
"src",
|
|
25
|
+
"!src/**/*.test.ts",
|
|
26
|
+
"!src/**/*.test.tsx",
|
|
27
|
+
"theme.css",
|
|
28
|
+
"index.ts",
|
|
29
|
+
"README.md"
|
|
30
|
+
],
|
|
31
|
+
"scripts": {
|
|
32
|
+
"build": "bash scripts/build.sh",
|
|
33
|
+
"check": "tsc --noEmit"
|
|
34
|
+
},
|
|
35
|
+
"devDependencies": {
|
|
36
|
+
"@cortex/client": "workspace:*",
|
|
37
|
+
"@cortex/contracts": "workspace:*",
|
|
38
|
+
"@m6d/prettier-config": "workspace:*",
|
|
39
|
+
"@m6d/tsconfig": "workspace:*",
|
|
40
|
+
"@tanstack/ai": "^0.43.1",
|
|
41
|
+
"@tanstack/ai-client": "^0.23.1",
|
|
42
|
+
"@tanstack/ai-react": "^0.19.1",
|
|
43
|
+
"@types/bun": "latest",
|
|
44
|
+
"@types/react": "^19",
|
|
45
|
+
"@types/react-dom": "^19",
|
|
46
|
+
"dompurify": "^3.3.3",
|
|
47
|
+
"dts-bundle-generator": "^9.5.1",
|
|
48
|
+
"highlight.js": "^11.11.1",
|
|
49
|
+
"marked": "^17.0.4",
|
|
50
|
+
"react": "^19.2.0",
|
|
51
|
+
"sass": "^1.83.0",
|
|
52
|
+
"typescript": "~5.9.3"
|
|
53
|
+
},
|
|
54
|
+
"peerDependencies": {
|
|
55
|
+
"@tanstack/ai": "^0.43.1",
|
|
56
|
+
"@tanstack/ai-client": "^0.23.1",
|
|
57
|
+
"@tanstack/ai-react": "^0.19.1",
|
|
58
|
+
"dompurify": "^3.0.0",
|
|
59
|
+
"highlight.js": "^11.0.0",
|
|
60
|
+
"marked": "^17.0.0",
|
|
61
|
+
"react": ">=18",
|
|
62
|
+
"react-dom": ">=18"
|
|
63
|
+
},
|
|
64
|
+
"publishConfig": {
|
|
65
|
+
"access": "public"
|
|
66
|
+
}
|
|
67
|
+
}
|
|
@@ -0,0 +1,379 @@
|
|
|
1
|
+
import { useEffect, useLayoutEffect, useMemo, useRef } from 'react';
|
|
2
|
+
import type { ToolCallPart, UIMessage } from '@tanstack/ai';
|
|
3
|
+
import { useChat } from '@tanstack/ai-react';
|
|
4
|
+
import {
|
|
5
|
+
createCortexConnection,
|
|
6
|
+
metadataByMessageId,
|
|
7
|
+
settleTurn,
|
|
8
|
+
unansweredToolCalls,
|
|
9
|
+
type CortexApiClient,
|
|
10
|
+
type MountMode,
|
|
11
|
+
} from '@cortex/client';
|
|
12
|
+
import type { AttachmentSummary, MessageMetadata, ThreadSummary } from '@cortex/contracts/wire';
|
|
13
|
+
import type { CortexHooks } from '@cortex/client';
|
|
14
|
+
import type { CortexReactConfig } from './config';
|
|
15
|
+
|
|
16
|
+
export type SessionUi = {
|
|
17
|
+
messages: UIMessage[];
|
|
18
|
+
isAgentWorking: boolean;
|
|
19
|
+
isLoadingMessages: boolean;
|
|
20
|
+
hasPendingToolCalls: boolean;
|
|
21
|
+
/**
|
|
22
|
+
* The per-message facts the SDK's `UIMessage` has no room for — usage, model
|
|
23
|
+
* id, the aborted flag, attachments. They are cortex's own envelope around the
|
|
24
|
+
* stored parts, so they arrive with the persisted transcript rather than over
|
|
25
|
+
* the stream, and are merged back in by message id at render time.
|
|
26
|
+
*/
|
|
27
|
+
messageMetadata: ReadonlyMap<string, MessageMetadata>;
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
export type SessionPatch = Partial<SessionUi> | ((previous: SessionUi) => Partial<SessionUi>);
|
|
31
|
+
|
|
32
|
+
export type PendingSend = { prompt: string; attachments: AttachmentSummary[] };
|
|
33
|
+
|
|
34
|
+
/** The imperative face the widget keeps of the mounted session. */
|
|
35
|
+
export type SessionHandle = {
|
|
36
|
+
threadId: string;
|
|
37
|
+
send: (prompt: string, attachments: AttachmentSummary[]) => Promise<void>;
|
|
38
|
+
abort: () => Promise<void>;
|
|
39
|
+
addToolResult: (toolCallId: string, toolName: string, output: unknown) => void;
|
|
40
|
+
reattach: (thread: ThreadSummary) => void;
|
|
41
|
+
refreshMessages: (threadId: string) => void;
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
type ChatSessionProps = {
|
|
45
|
+
thread: ThreadSummary;
|
|
46
|
+
mode: MountMode;
|
|
47
|
+
api: CortexApiClient;
|
|
48
|
+
configRef: { current: CortexReactConfig };
|
|
49
|
+
sessionRef: { current: SessionHandle | undefined };
|
|
50
|
+
/** Sends that arrived while no session was mounted; consumed in order on mount. */
|
|
51
|
+
pendingSendRef: { current: PendingSend[] };
|
|
52
|
+
patchUi: (patch: SessionPatch) => void;
|
|
53
|
+
setRunning: (threadId: string, isRunning: boolean) => void;
|
|
54
|
+
/** Remounts this session in `reload` mode so hydration re-reads the transcript
|
|
55
|
+
* and re-attaches to whatever run is live now. */
|
|
56
|
+
remount: (thread: ThreadSummary) => void;
|
|
57
|
+
onTurnFinished: () => void;
|
|
58
|
+
onSendFailed: () => void;
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* The headless per-thread chat driver — CortexChatService's turn lifecycle
|
|
63
|
+
* around the SDK's `useChat`. It renders nothing: its state is lifted into the
|
|
64
|
+
* widget's context so the composer (which must keep its draft across thread
|
|
65
|
+
* switches) and the message list live outside the remount boundary. The
|
|
66
|
+
* `key={threadId}:{epoch}` the widget renders it under IS the per-thread
|
|
67
|
+
* lifetime — remounting replaces Angular's environment-injector dance.
|
|
68
|
+
*/
|
|
69
|
+
// fallow-ignore-next-line complexity -- 1:1 transcription of the Angular source; restructuring would risk parity drift
|
|
70
|
+
export function ChatSession(props: ChatSessionProps) {
|
|
71
|
+
const { thread, api, patchUi } = props;
|
|
72
|
+
|
|
73
|
+
const alive = useRef(true);
|
|
74
|
+
/**
|
|
75
|
+
* Whether this widget is driving the run it is showing or watching one another
|
|
76
|
+
* tab started. A watcher owes nothing: the tool calls it sees belong to the tab
|
|
77
|
+
* that sent the message, and how the turn ended is the server's to tell it.
|
|
78
|
+
*/
|
|
79
|
+
const ownsRun = useRef(false);
|
|
80
|
+
/**
|
|
81
|
+
* Whether the server has answered the turn in flight. Until it does, a failure
|
|
82
|
+
* means the message never got out and the composer takes back the files it was
|
|
83
|
+
* carrying; after it, those files are on the message the server stored.
|
|
84
|
+
*/
|
|
85
|
+
const serverAnswered = useRef(false);
|
|
86
|
+
const wasAborted = useRef(false);
|
|
87
|
+
const wasWorking = useRef(false);
|
|
88
|
+
const dispatchedToolCalls = useRef(new Set<string>());
|
|
89
|
+
const refreshing = useRef(Promise.resolve());
|
|
90
|
+
|
|
91
|
+
// Created once per mount: `useChat` captures the connection at client
|
|
92
|
+
// creation, and a new mount is exactly what a re-hydration needs.
|
|
93
|
+
const connection = useMemo(
|
|
94
|
+
() =>
|
|
95
|
+
createCortexConnection({
|
|
96
|
+
api,
|
|
97
|
+
thread: { id: thread.id, isRunning: thread.isRunning },
|
|
98
|
+
mode: props.mode,
|
|
99
|
+
onHydrated: (rows, messages) => {
|
|
100
|
+
if (!alive.current) return;
|
|
101
|
+
|
|
102
|
+
patchUi({
|
|
103
|
+
messageMetadata: metadataByMessageId(rows),
|
|
104
|
+
hasPendingToolCalls: unansweredToolCalls(messages).length > 0,
|
|
105
|
+
});
|
|
106
|
+
},
|
|
107
|
+
setLoadingMessages: (loading) => {
|
|
108
|
+
if (alive.current) patchUi({ isLoadingMessages: loading });
|
|
109
|
+
},
|
|
110
|
+
}),
|
|
111
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
112
|
+
[],
|
|
113
|
+
);
|
|
114
|
+
|
|
115
|
+
const chat = useChat({
|
|
116
|
+
threadId: thread.id,
|
|
117
|
+
persistence: true,
|
|
118
|
+
connection,
|
|
119
|
+
// Both guarded: the SDK disposes a replaced client a macrotask after
|
|
120
|
+
// unmount, so a dead session's callbacks can still fire into the new one.
|
|
121
|
+
onChunk: () => {
|
|
122
|
+
if (!alive.current) return;
|
|
123
|
+
serverAnswered.current = true;
|
|
124
|
+
},
|
|
125
|
+
onError: () => {
|
|
126
|
+
if (!alive.current) return;
|
|
127
|
+
patchUi({ hasPendingToolCalls: false });
|
|
128
|
+
// A failure only means the message never went while the server has said
|
|
129
|
+
// nothing at all. Once it has answered it holds the message, and the
|
|
130
|
+
// files it claimed are on it — reporting that as a failed send would
|
|
131
|
+
// offer the composer files it can no longer send.
|
|
132
|
+
if (serverAnswered.current) props.onTurnFinished();
|
|
133
|
+
else props.onSendFailed();
|
|
134
|
+
},
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
const chatRef = useRef(chat);
|
|
138
|
+
chatRef.current = chat;
|
|
139
|
+
const isLoadingRef = useRef(chat.isLoading);
|
|
140
|
+
isLoadingRef.current = chat.isLoading;
|
|
141
|
+
|
|
142
|
+
function syncPendingToolCalls() {
|
|
143
|
+
const pending = unansweredToolCalls(chatRef.current.messages);
|
|
144
|
+
patchUi({ hasPendingToolCalls: pending.length > 0 });
|
|
145
|
+
return pending;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
async function resolveToolCall(
|
|
149
|
+
call: ToolCallPart,
|
|
150
|
+
onToolCall: NonNullable<CortexHooks['onToolCall']>,
|
|
151
|
+
) {
|
|
152
|
+
const result = onToolCall(
|
|
153
|
+
{ toolCallId: call.id, toolName: call.name, input: call.input },
|
|
154
|
+
{ threadId: thread.id },
|
|
155
|
+
);
|
|
156
|
+
if (result === null || result === undefined) return;
|
|
157
|
+
|
|
158
|
+
const output = await Promise.resolve(result);
|
|
159
|
+
if (output === null || output === undefined) return;
|
|
160
|
+
// The hook is free to take its time, and the thread may have moved on since.
|
|
161
|
+
if (!alive.current) return;
|
|
162
|
+
|
|
163
|
+
await chatRef.current.addToolResult({ toolCallId: call.id, tool: call.name, output });
|
|
164
|
+
syncPendingToolCalls();
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* Hands the calls the run left open to the consumer's hook — cortex's tools all
|
|
169
|
+
* run on the server, so anything still unanswered when the stream ends is one
|
|
170
|
+
* the client was asked to resolve. Reports whether any are still waiting.
|
|
171
|
+
*/
|
|
172
|
+
function dispatchPendingToolCalls() {
|
|
173
|
+
const pending = syncPendingToolCalls();
|
|
174
|
+
const onToolCall = props.configRef.current.hooks?.onToolCall;
|
|
175
|
+
if (!onToolCall) return pending.length > 0;
|
|
176
|
+
|
|
177
|
+
for (const call of pending) {
|
|
178
|
+
// Anything short of complete arguments is a call the model never finished
|
|
179
|
+
// asking for — `input` is not parsed until then, so there is nothing to
|
|
180
|
+
// hand over. It still counts as pending; it is just not answerable.
|
|
181
|
+
if (call.state !== 'input-complete') continue;
|
|
182
|
+
if (dispatchedToolCalls.current.has(call.id)) continue;
|
|
183
|
+
|
|
184
|
+
dispatchedToolCalls.current.add(call.id);
|
|
185
|
+
void resolveToolCall(call, onToolCall);
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
return pending.length > 0;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
async function readBackMessages(threadId: string) {
|
|
192
|
+
if (!alive.current || threadId !== thread.id) return;
|
|
193
|
+
|
|
194
|
+
await settleTurn({
|
|
195
|
+
api,
|
|
196
|
+
threadId,
|
|
197
|
+
isStale: () => !alive.current,
|
|
198
|
+
isStreaming: () => isLoadingRef.current,
|
|
199
|
+
absorbMetadata: (metadata) => patchUi({ messageMetadata: metadata }),
|
|
200
|
+
setMessages: (messages) => {
|
|
201
|
+
chatRef.current.setMessages(messages);
|
|
202
|
+
patchUi({ hasPendingToolCalls: unansweredToolCalls(messages).length > 0 });
|
|
203
|
+
},
|
|
204
|
+
});
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
function refreshMessages(threadId: string) {
|
|
208
|
+
// Queued rather than deduplicated: a refresh asked for while one is in
|
|
209
|
+
// flight is asking about a newer state — the commit that has just landed —
|
|
210
|
+
// so it has to follow that one rather than be dropped in favour of it.
|
|
211
|
+
refreshing.current = refreshing.current.then(() => readBackMessages(threadId));
|
|
212
|
+
return refreshing.current;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
/**
|
|
216
|
+
* A finished stream leaves the SDK holding only what came over the wire, while
|
|
217
|
+
* usage, model id and attachments live in cortex's envelope — so a settled turn
|
|
218
|
+
* is read back from the database. The one turn that is not read back is one
|
|
219
|
+
* this widget is still in the middle of: a tool call it owes an answer to.
|
|
220
|
+
*/
|
|
221
|
+
async function settleSession() {
|
|
222
|
+
// Cancelling is the one exit that does not wait for the server: the turn
|
|
223
|
+
// settles on the thread:messages-updated event the commit sends, not here.
|
|
224
|
+
if (wasAborted.current) {
|
|
225
|
+
wasAborted.current = false;
|
|
226
|
+
return;
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
if (ownsRun.current && dispatchPendingToolCalls()) return;
|
|
230
|
+
|
|
231
|
+
await refreshMessages(thread.id);
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
async function performSend(prompt: string, attachments: AttachmentSummary[]) {
|
|
235
|
+
props.setRunning(thread.id, true);
|
|
236
|
+
ownsRun.current = true;
|
|
237
|
+
serverAnswered.current = false;
|
|
238
|
+
|
|
239
|
+
const message = {
|
|
240
|
+
id: crypto.randomUUID(),
|
|
241
|
+
role: 'user',
|
|
242
|
+
parts: [{ type: 'text', content: prompt }],
|
|
243
|
+
} satisfies UIMessage;
|
|
244
|
+
|
|
245
|
+
// The server claims the thread's pending attachments for this message and
|
|
246
|
+
// serves them back on it; recording them now only saves the round trip.
|
|
247
|
+
if (attachments.length) {
|
|
248
|
+
patchUi((previous) => ({
|
|
249
|
+
messageMetadata: new Map(previous.messageMetadata).set(message.id, { attachments }),
|
|
250
|
+
}));
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
await chatRef.current.append(message);
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
async function abort() {
|
|
257
|
+
if (!isLoadingRef.current) return;
|
|
258
|
+
|
|
259
|
+
// Ask the server first: the stop button can outlive the run, and an abort
|
|
260
|
+
// that found nothing to abort must not be presented as one. Only a confirmed
|
|
261
|
+
// abort suppresses the settle-time read-back — the unconfirmed path falls
|
|
262
|
+
// through to it and pulls the truthful committed turn.
|
|
263
|
+
const { aborted } = await api.abortStream(thread.id).catch(() => ({ aborted: false }));
|
|
264
|
+
// The selection can move while the request is out; a late answer about
|
|
265
|
+
// another thread must not poison the one selected now.
|
|
266
|
+
if (!alive.current) return;
|
|
267
|
+
|
|
268
|
+
if (aborted) {
|
|
269
|
+
wasAborted.current = true;
|
|
270
|
+
props.setRunning(thread.id, false);
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
chatRef.current.stop();
|
|
274
|
+
patchUi({ hasPendingToolCalls: false });
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
/** Answers a tool call on behalf of a consumer-supplied tool component. */
|
|
278
|
+
function addToolResult(toolCallId: string, toolName: string, output: unknown) {
|
|
279
|
+
void chatRef.current
|
|
280
|
+
.addToolResult({ toolCallId, tool: toolName, output })
|
|
281
|
+
.then(() => syncPendingToolCalls());
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
function reattach(freshThread: ThreadSummary) {
|
|
285
|
+
if (isLoadingRef.current) return;
|
|
286
|
+
|
|
287
|
+
// Only a live run needs a new client: joining one is something only a mount
|
|
288
|
+
// can do. With nothing to join, re-reading the transcript in place leaves
|
|
289
|
+
// the conversation on screen instead of tearing it down for a round trip.
|
|
290
|
+
if (freshThread.isRunning) {
|
|
291
|
+
props.remount(freshThread);
|
|
292
|
+
return;
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
void refreshMessages(freshThread.id);
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
useEffect(() => {
|
|
299
|
+
alive.current = true;
|
|
300
|
+
return () => {
|
|
301
|
+
alive.current = false;
|
|
302
|
+
};
|
|
303
|
+
}, []);
|
|
304
|
+
|
|
305
|
+
// The widget's imperative face. One stable object per mount whose methods are
|
|
306
|
+
// refreshed every render, registered in a layout effect: on a key remount the
|
|
307
|
+
// replaced instance's cleanup runs before the new instance registers, and the
|
|
308
|
+
// identity check keeps it from wiping a registration that is not its own.
|
|
309
|
+
const handleRef = useRef<SessionHandle | undefined>(undefined);
|
|
310
|
+
handleRef.current = {
|
|
311
|
+
threadId: thread.id,
|
|
312
|
+
send: performSend,
|
|
313
|
+
abort,
|
|
314
|
+
addToolResult,
|
|
315
|
+
reattach,
|
|
316
|
+
refreshMessages: (threadId) => {
|
|
317
|
+
void refreshMessages(threadId);
|
|
318
|
+
},
|
|
319
|
+
};
|
|
320
|
+
useLayoutEffect(() => {
|
|
321
|
+
props.sessionRef.current = handleRef.current;
|
|
322
|
+
});
|
|
323
|
+
useLayoutEffect(() => {
|
|
324
|
+
return () => {
|
|
325
|
+
if (props.sessionRef.current === handleRef.current) {
|
|
326
|
+
props.sessionRef.current = undefined;
|
|
327
|
+
}
|
|
328
|
+
};
|
|
329
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
330
|
+
}, []);
|
|
331
|
+
|
|
332
|
+
// Sends that were waiting for a thread to exist go out with this mount.
|
|
333
|
+
// Deferred a tick and cancelled on cleanup: under StrictMode the first,
|
|
334
|
+
// throwaway mount's client is disposed right after the effect runs, so a
|
|
335
|
+
// synchronous drain would tie the send to a client whose stream is already
|
|
336
|
+
// aborted. Leaving the queue untouched until the timer fires also hands it
|
|
337
|
+
// to the next mount if this one is torn down first.
|
|
338
|
+
useEffect(() => {
|
|
339
|
+
if (!props.pendingSendRef.current.length) return;
|
|
340
|
+
|
|
341
|
+
const timer = setTimeout(() => {
|
|
342
|
+
const pending = props.pendingSendRef.current;
|
|
343
|
+
props.pendingSendRef.current = [];
|
|
344
|
+
void (async () => {
|
|
345
|
+
for (const { prompt, attachments } of pending) {
|
|
346
|
+
await performSend(prompt, attachments);
|
|
347
|
+
}
|
|
348
|
+
})();
|
|
349
|
+
});
|
|
350
|
+
return () => clearTimeout(timer);
|
|
351
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
352
|
+
}, []);
|
|
353
|
+
|
|
354
|
+
useEffect(() => {
|
|
355
|
+
patchUi({ messages: chat.messages });
|
|
356
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
357
|
+
}, [chat.messages]);
|
|
358
|
+
|
|
359
|
+
// The end of a turn is the client falling idle, not the SDK's onFinish: the
|
|
360
|
+
// agent loop ends a run per iteration, so onFinish fires while the turn is
|
|
361
|
+
// still going. Both the tool hand-off and the refetch wait for the whole
|
|
362
|
+
// exchange.
|
|
363
|
+
useEffect(() => {
|
|
364
|
+
patchUi({ isAgentWorking: chat.isLoading });
|
|
365
|
+
|
|
366
|
+
if (chat.isLoading) {
|
|
367
|
+
wasWorking.current = true;
|
|
368
|
+
return;
|
|
369
|
+
}
|
|
370
|
+
if (!wasWorking.current) return;
|
|
371
|
+
|
|
372
|
+
wasWorking.current = false;
|
|
373
|
+
props.onTurnFinished();
|
|
374
|
+
void settleSession();
|
|
375
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
376
|
+
}, [chat.isLoading]);
|
|
377
|
+
|
|
378
|
+
return null;
|
|
379
|
+
}
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import { useCortex } from '../context';
|
|
2
|
+
import { cx } from '../cx';
|
|
3
|
+
|
|
4
|
+
export function AttachmentQueue() {
|
|
5
|
+
const { queue, t } = useCortex();
|
|
6
|
+
|
|
7
|
+
if (!queue.items.length) return null;
|
|
8
|
+
|
|
9
|
+
return (
|
|
10
|
+
<div className="cortex-attachment-queue">
|
|
11
|
+
{queue.items.map((item) => (
|
|
12
|
+
<div
|
|
13
|
+
key={item.localId}
|
|
14
|
+
className={cx(
|
|
15
|
+
'cortex-attachment-chip',
|
|
16
|
+
item.status === 'error' && 'cortex-attachment-chip--error',
|
|
17
|
+
)}
|
|
18
|
+
title={item.status === 'error' ? t('translate_attachment_rejected') : ''}
|
|
19
|
+
>
|
|
20
|
+
{item.status === 'uploading' || item.status === 'deleting' ? (
|
|
21
|
+
<svg className="cortex-attachment-chip__spinner" viewBox="0 0 16 16" fill="none">
|
|
22
|
+
<circle
|
|
23
|
+
cx="8"
|
|
24
|
+
cy="8"
|
|
25
|
+
r="6"
|
|
26
|
+
stroke="currentColor"
|
|
27
|
+
strokeWidth="2"
|
|
28
|
+
className="cortex-attachment-chip__spinner-track"
|
|
29
|
+
/>
|
|
30
|
+
<path
|
|
31
|
+
d="M14 8a6 6 0 0 0-6-6"
|
|
32
|
+
stroke="currentColor"
|
|
33
|
+
strokeWidth="2"
|
|
34
|
+
strokeLinecap="round"
|
|
35
|
+
/>
|
|
36
|
+
</svg>
|
|
37
|
+
) : (
|
|
38
|
+
<svg viewBox="0 0 16 16" className="cortex-attachment-chip__icon" fill="none">
|
|
39
|
+
<path
|
|
40
|
+
d="M4 1.5h5.172a2 2 0 0 1 1.414.586l2.328 2.328a2 2 0 0 1 .586 1.414V12.5a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2v-9a2 2 0 0 1 2-2Z"
|
|
41
|
+
stroke="currentColor"
|
|
42
|
+
strokeWidth="1.25"
|
|
43
|
+
/>
|
|
44
|
+
<path
|
|
45
|
+
d="M9.5 1.5v2a2 2 0 0 0 2 2h2"
|
|
46
|
+
stroke="currentColor"
|
|
47
|
+
strokeWidth="1.25"
|
|
48
|
+
strokeLinecap="round"
|
|
49
|
+
/>
|
|
50
|
+
</svg>
|
|
51
|
+
)}
|
|
52
|
+
|
|
53
|
+
<span className="cortex-attachment-chip__name">{item.filename}</span>
|
|
54
|
+
|
|
55
|
+
<button
|
|
56
|
+
type="button"
|
|
57
|
+
onClick={() => queue.remove(item.localId)}
|
|
58
|
+
className="cortex-attachment-chip__remove"
|
|
59
|
+
disabled={item.status === 'deleting'}
|
|
60
|
+
aria-label={t('translate_remove_attachment')}
|
|
61
|
+
>
|
|
62
|
+
<svg viewBox="0 0 16 16" fill="none">
|
|
63
|
+
<path
|
|
64
|
+
d="M4.5 4.5l7 7m0-7l-7 7"
|
|
65
|
+
stroke="currentColor"
|
|
66
|
+
strokeWidth="1.5"
|
|
67
|
+
strokeLinecap="round"
|
|
68
|
+
/>
|
|
69
|
+
</svg>
|
|
70
|
+
</button>
|
|
71
|
+
</div>
|
|
72
|
+
))}
|
|
73
|
+
</div>
|
|
74
|
+
);
|
|
75
|
+
}
|