@townco/ui 0.1.82 → 0.1.93
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/core/hooks/use-chat-input.js +13 -6
- package/dist/core/hooks/use-chat-messages.d.ts +17 -0
- package/dist/core/hooks/use-chat-messages.js +294 -10
- package/dist/core/schemas/chat.d.ts +20 -0
- package/dist/core/schemas/chat.js +4 -0
- package/dist/core/schemas/index.d.ts +1 -0
- package/dist/core/schemas/index.js +1 -0
- package/dist/core/schemas/source.d.ts +22 -0
- package/dist/core/schemas/source.js +45 -0
- package/dist/core/store/chat-store.d.ts +4 -0
- package/dist/core/store/chat-store.js +54 -0
- package/dist/gui/components/Actions.d.ts +15 -0
- package/dist/gui/components/Actions.js +22 -0
- package/dist/gui/components/ChatInput.d.ts +9 -1
- package/dist/gui/components/ChatInput.js +24 -6
- package/dist/gui/components/ChatInputCommandMenu.d.ts +1 -0
- package/dist/gui/components/ChatInputCommandMenu.js +22 -5
- package/dist/gui/components/ChatInputParameters.d.ts +13 -0
- package/dist/gui/components/ChatInputParameters.js +67 -0
- package/dist/gui/components/ChatLayout.d.ts +2 -0
- package/dist/gui/components/ChatLayout.js +183 -61
- package/dist/gui/components/ChatPanelTabContent.d.ts +7 -0
- package/dist/gui/components/ChatPanelTabContent.js +17 -7
- package/dist/gui/components/ChatView.js +105 -15
- package/dist/gui/components/CitationChip.d.ts +15 -0
- package/dist/gui/components/CitationChip.js +72 -0
- package/dist/gui/components/EditableUserMessage.d.ts +18 -0
- package/dist/gui/components/EditableUserMessage.js +109 -0
- package/dist/gui/components/MessageActions.d.ts +16 -0
- package/dist/gui/components/MessageActions.js +97 -0
- package/dist/gui/components/MessageContent.js +22 -7
- package/dist/gui/components/Response.d.ts +3 -0
- package/dist/gui/components/Response.js +30 -3
- package/dist/gui/components/Sidebar.js +1 -1
- package/dist/gui/components/TodoSubline.js +1 -1
- package/dist/gui/components/WorkProgress.js +7 -0
- package/dist/gui/components/index.d.ts +6 -1
- package/dist/gui/components/index.js +6 -1
- package/dist/gui/hooks/index.d.ts +1 -0
- package/dist/gui/hooks/index.js +1 -0
- package/dist/gui/hooks/use-favicon.d.ts +6 -0
- package/dist/gui/hooks/use-favicon.js +47 -0
- package/dist/gui/hooks/use-scroll-to-bottom.d.ts +14 -0
- package/dist/gui/hooks/use-scroll-to-bottom.js +317 -1
- package/dist/gui/index.d.ts +1 -1
- package/dist/gui/index.js +1 -1
- package/dist/gui/lib/motion.js +6 -6
- package/dist/gui/lib/remark-citations.d.ts +28 -0
- package/dist/gui/lib/remark-citations.js +70 -0
- package/dist/sdk/client/acp-client.d.ts +38 -1
- package/dist/sdk/client/acp-client.js +67 -3
- package/dist/sdk/schemas/message.d.ts +40 -0
- package/dist/sdk/schemas/message.js +20 -0
- package/dist/sdk/transports/http.d.ts +24 -1
- package/dist/sdk/transports/http.js +189 -1
- package/dist/sdk/transports/stdio.d.ts +1 -0
- package/dist/sdk/transports/stdio.js +39 -0
- package/dist/sdk/transports/types.d.ts +46 -1
- package/dist/sdk/transports/websocket.d.ts +1 -0
- package/dist/sdk/transports/websocket.js +4 -0
- package/dist/tui/components/ChatView.js +3 -4
- package/package.json +5 -3
- package/src/styles/global.css +71 -0
|
@@ -23,22 +23,29 @@ export function useChatInput(client, startSession) {
|
|
|
23
23
|
* Handle input submission
|
|
24
24
|
*/
|
|
25
25
|
const handleSubmit = useCallback(async () => {
|
|
26
|
-
|
|
26
|
+
// Read fresh state directly from store to avoid stale closure issues
|
|
27
|
+
const currentInput = useChatStore.getState().input;
|
|
28
|
+
if (!currentInput.value.trim() || currentInput.isSubmitting) {
|
|
27
29
|
return;
|
|
28
30
|
}
|
|
29
|
-
const message =
|
|
30
|
-
const attachments =
|
|
31
|
+
const message = currentInput.value;
|
|
32
|
+
const attachments = currentInput.attachedFiles;
|
|
33
|
+
const promptParameters = currentInput.selectedPromptParameters;
|
|
31
34
|
logger.debug("Submitting message with attachments", {
|
|
32
35
|
messageLength: message.length,
|
|
33
36
|
attachmentCount: attachments.length,
|
|
34
37
|
hasAttachments: attachments.length > 0,
|
|
38
|
+
hasPromptParameters: !!promptParameters && Object.keys(promptParameters).length > 0,
|
|
39
|
+
promptParameters,
|
|
35
40
|
});
|
|
36
41
|
// Clear input immediately for better UX
|
|
37
42
|
setInputValue("");
|
|
38
43
|
setInputSubmitting(true);
|
|
39
44
|
try {
|
|
40
|
-
await sendMessage(message, attachments.length > 0 ? attachments : undefined)
|
|
41
|
-
|
|
45
|
+
await sendMessage(message, attachments.length > 0 ? attachments : undefined, promptParameters && Object.keys(promptParameters).length > 0
|
|
46
|
+
? promptParameters
|
|
47
|
+
: undefined);
|
|
48
|
+
// Clear attachments after successful send (keep promptParameters for next message)
|
|
42
49
|
useChatStore.setState((state) => ({
|
|
43
50
|
input: { ...state.input, attachedFiles: [] },
|
|
44
51
|
}));
|
|
@@ -52,7 +59,7 @@ export function useChatInput(client, startSession) {
|
|
|
52
59
|
finally {
|
|
53
60
|
setInputSubmitting(false);
|
|
54
61
|
}
|
|
55
|
-
}, [
|
|
62
|
+
}, [setInputValue, setInputSubmitting, sendMessage]);
|
|
56
63
|
/**
|
|
57
64
|
* Handle file attachment
|
|
58
65
|
*/
|
|
@@ -194,6 +194,15 @@ export declare function useChatMessages(client: AcpClient | null, startSession:
|
|
|
194
194
|
mimeType: string;
|
|
195
195
|
data: string;
|
|
196
196
|
}[] | undefined;
|
|
197
|
+
sources?: {
|
|
198
|
+
id: string;
|
|
199
|
+
url: string;
|
|
200
|
+
title: string;
|
|
201
|
+
snippet?: string | undefined;
|
|
202
|
+
favicon?: string | undefined;
|
|
203
|
+
toolCallId: string;
|
|
204
|
+
sourceName?: string | undefined;
|
|
205
|
+
}[] | undefined;
|
|
197
206
|
}[];
|
|
198
207
|
isStreaming: boolean;
|
|
199
208
|
sendMessage: (content: string, attachments?: {
|
|
@@ -202,5 +211,13 @@ export declare function useChatMessages(client: AcpClient | null, startSession:
|
|
|
202
211
|
size: number;
|
|
203
212
|
mimeType: string;
|
|
204
213
|
data: string;
|
|
214
|
+
}[] | undefined, promptParameters?: Record<string, string> | undefined) => Promise<void>;
|
|
215
|
+
editAndResend: (userMessageIndex: number, newContent: string, attachments?: {
|
|
216
|
+
name: string;
|
|
217
|
+
path: string;
|
|
218
|
+
size: number;
|
|
219
|
+
mimeType: string;
|
|
220
|
+
data: string;
|
|
205
221
|
}[] | undefined) => Promise<void>;
|
|
222
|
+
cancel: () => Promise<void>;
|
|
206
223
|
};
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { createLogger } from "@townco/core";
|
|
2
|
-
import { useCallback } from "react";
|
|
2
|
+
import { useCallback, useRef } from "react";
|
|
3
3
|
import { useChatStore } from "../store/chat-store.js";
|
|
4
4
|
const logger = createLogger("use-chat-messages", "debug");
|
|
5
5
|
/**
|
|
@@ -20,14 +20,25 @@ export function useChatMessages(client, startSession) {
|
|
|
20
20
|
const addToolCallToCurrentMessage = useChatStore((state) => state.addToolCallToCurrentMessage);
|
|
21
21
|
const updateToolCallInCurrentMessage = useChatStore((state) => state.updateToolCallInCurrentMessage);
|
|
22
22
|
const addHookNotificationToCurrentMessage = useChatStore((state) => state.addHookNotificationToCurrentMessage);
|
|
23
|
+
const addSourcesToCurrentMessage = useChatStore((state) => state.addSourcesToCurrentMessage);
|
|
24
|
+
const truncateMessagesFrom = useChatStore((state) => state.truncateMessagesFrom);
|
|
25
|
+
// Track the current assistant message ID for cancellation
|
|
26
|
+
const currentAssistantMessageIdRef = useRef(null);
|
|
27
|
+
// Track if current turn was cancelled
|
|
28
|
+
const wasCancelledRef = useRef(false);
|
|
23
29
|
/**
|
|
24
30
|
* Send a message to the agent
|
|
31
|
+
* @param content - The message text content
|
|
32
|
+
* @param attachments - Optional image attachments
|
|
33
|
+
* @param promptParameters - Optional per-message parameters to influence agent behavior
|
|
25
34
|
*/
|
|
26
|
-
const sendMessage = useCallback(async (content, attachments) => {
|
|
35
|
+
const sendMessage = useCallback(async (content, attachments, promptParameters) => {
|
|
27
36
|
logger.debug("[sendMessage] Called with", {
|
|
28
37
|
contentLength: content.length,
|
|
29
38
|
attachmentsCount: attachments?.length || 0,
|
|
30
39
|
hasAttachments: !!attachments && attachments.length > 0,
|
|
40
|
+
hasPromptParameters: !!promptParameters,
|
|
41
|
+
promptParameters: promptParameters,
|
|
31
42
|
});
|
|
32
43
|
if (!client) {
|
|
33
44
|
logger.error("No client available");
|
|
@@ -83,12 +94,16 @@ export function useChatMessages(client, startSession) {
|
|
|
83
94
|
streamingStartTime: startTime, // Use the same start time from when user sent message
|
|
84
95
|
};
|
|
85
96
|
addMessage(assistantMessage);
|
|
97
|
+
// Store the assistant message ID for potential cancellation
|
|
98
|
+
currentAssistantMessageIdRef.current = assistantMessageId;
|
|
99
|
+
// Reset cancellation flag for new message
|
|
100
|
+
wasCancelledRef.current = false;
|
|
86
101
|
// Start receiving chunks (async iterator)
|
|
87
102
|
const messageStream = client.receiveMessages();
|
|
88
103
|
// Send ONLY the new message (not full history)
|
|
89
104
|
// The agent backend now manages conversation context
|
|
90
105
|
client
|
|
91
|
-
.sendMessage(content, activeSessionId, attachments)
|
|
106
|
+
.sendMessage(content, activeSessionId, attachments, promptParameters)
|
|
92
107
|
.catch((error) => {
|
|
93
108
|
const message = error instanceof Error ? error.message : String(error);
|
|
94
109
|
setError(message);
|
|
@@ -99,6 +114,11 @@ export function useChatMessages(client, startSession) {
|
|
|
99
114
|
let accumulatedContent = "";
|
|
100
115
|
let streamCompleted = false;
|
|
101
116
|
for await (const chunk of messageStream) {
|
|
117
|
+
// Check if cancelled before processing each chunk
|
|
118
|
+
if (wasCancelledRef.current) {
|
|
119
|
+
logger.info("Stream cancelled, exiting loop");
|
|
120
|
+
break;
|
|
121
|
+
}
|
|
102
122
|
// Handle different chunk types using discriminated union
|
|
103
123
|
if (chunk.type === "content") {
|
|
104
124
|
// Content chunk - text streaming
|
|
@@ -139,6 +159,7 @@ export function useChatMessages(client, startSession) {
|
|
|
139
159
|
});
|
|
140
160
|
setIsStreaming(false);
|
|
141
161
|
setStreamingStartTime(null); // Clear global streaming start time
|
|
162
|
+
currentAssistantMessageIdRef.current = null; // Clear cancellation ref
|
|
142
163
|
streamCompleted = true;
|
|
143
164
|
break;
|
|
144
165
|
}
|
|
@@ -150,8 +171,11 @@ export function useChatMessages(client, startSession) {
|
|
|
150
171
|
content: accumulatedContent,
|
|
151
172
|
...(chunk.tokenUsage ? { tokenUsage: chunk.tokenUsage } : {}),
|
|
152
173
|
});
|
|
153
|
-
// Small delay to allow
|
|
154
|
-
|
|
174
|
+
// Small delay to allow rendering between chunks (~60fps)
|
|
175
|
+
// Skip delay when tab is hidden to prevent browser throttling from blocking the stream
|
|
176
|
+
if (!document.hidden) {
|
|
177
|
+
await new Promise((resolve) => setTimeout(resolve, 16));
|
|
178
|
+
}
|
|
155
179
|
}
|
|
156
180
|
}
|
|
157
181
|
}
|
|
@@ -177,16 +201,34 @@ export function useChatMessages(client, startSession) {
|
|
|
177
201
|
// Add/update hook notification in current assistant message
|
|
178
202
|
addHookNotificationToCurrentMessage(chunk.notification);
|
|
179
203
|
}
|
|
204
|
+
else if (chunk.type === "sources") {
|
|
205
|
+
// Sources chunk - citation sources from tool calls
|
|
206
|
+
logger.debug("Received sources chunk", { chunk });
|
|
207
|
+
// Add sources to current assistant message for citation rendering
|
|
208
|
+
addSourcesToCurrentMessage(chunk.sources);
|
|
209
|
+
}
|
|
180
210
|
}
|
|
181
211
|
// Ensure streaming state is cleared even if no explicit isComplete was received
|
|
182
212
|
if (!streamCompleted) {
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
213
|
+
if (wasCancelledRef.current) {
|
|
214
|
+
// User cancelled - append "[Cancelled]" indicator
|
|
215
|
+
logger.info("Stream cancelled by user");
|
|
216
|
+
updateMessage(assistantMessageId, {
|
|
217
|
+
content: `${accumulatedContent}\n\n[Cancelled]`,
|
|
218
|
+
isStreaming: false,
|
|
219
|
+
streamingStartTime: undefined,
|
|
220
|
+
});
|
|
221
|
+
}
|
|
222
|
+
else {
|
|
223
|
+
logger.warn("Stream ended without isComplete flag");
|
|
224
|
+
updateMessage(assistantMessageId, {
|
|
225
|
+
isStreaming: false,
|
|
226
|
+
streamingStartTime: undefined,
|
|
227
|
+
});
|
|
228
|
+
}
|
|
188
229
|
setIsStreaming(false);
|
|
189
230
|
setStreamingStartTime(null);
|
|
231
|
+
currentAssistantMessageIdRef.current = null;
|
|
190
232
|
}
|
|
191
233
|
}
|
|
192
234
|
catch (error) {
|
|
@@ -194,6 +236,7 @@ export function useChatMessages(client, startSession) {
|
|
|
194
236
|
setError(message);
|
|
195
237
|
setIsStreaming(false);
|
|
196
238
|
setStreamingStartTime(null); // Clear streaming start time on error
|
|
239
|
+
currentAssistantMessageIdRef.current = null;
|
|
197
240
|
// Ensure the assistant message isStreaming is set to false
|
|
198
241
|
updateMessage(assistantMessageId, {
|
|
199
242
|
isStreaming: false,
|
|
@@ -215,10 +258,251 @@ export function useChatMessages(client, startSession) {
|
|
|
215
258
|
addToolCallToCurrentMessage,
|
|
216
259
|
updateToolCallInCurrentMessage,
|
|
217
260
|
addHookNotificationToCurrentMessage,
|
|
261
|
+
addSourcesToCurrentMessage,
|
|
262
|
+
]);
|
|
263
|
+
/**
|
|
264
|
+
* Cancel the current agent turn
|
|
265
|
+
* Stops streaming and appends "[Cancelled]" to the message
|
|
266
|
+
*/
|
|
267
|
+
const cancel = useCallback(async () => {
|
|
268
|
+
if (!client || !isStreaming) {
|
|
269
|
+
logger.debug("Cannot cancel: not streaming or no client");
|
|
270
|
+
return;
|
|
271
|
+
}
|
|
272
|
+
logger.info("Cancelling current turn");
|
|
273
|
+
// Set cancelled flag FIRST - the stream loop will check this
|
|
274
|
+
wasCancelledRef.current = true;
|
|
275
|
+
try {
|
|
276
|
+
// Call client cancel to stop the agent
|
|
277
|
+
await client.cancel(sessionId || undefined);
|
|
278
|
+
}
|
|
279
|
+
catch (error) {
|
|
280
|
+
logger.error("Error cancelling turn", { error });
|
|
281
|
+
}
|
|
282
|
+
// Note: The stream loop will handle updating the message with "[Cancelled]"
|
|
283
|
+
// and clearing the streaming state when it exits
|
|
284
|
+
}, [client, isStreaming, sessionId]);
|
|
285
|
+
/**
|
|
286
|
+
* Edit and resend a message from a specific point in the conversation.
|
|
287
|
+
* Truncates the conversation to the specified message and sends the new content.
|
|
288
|
+
*
|
|
289
|
+
* @param messageIndex - The index of the user message to edit (in UI messages array)
|
|
290
|
+
* @param newContent - The new text content
|
|
291
|
+
* @param attachments - Optional image attachments
|
|
292
|
+
*/
|
|
293
|
+
const editAndResend = useCallback(async (userMessageIndex, newContent, attachments) => {
|
|
294
|
+
logger.debug("[editAndResend] Called with", {
|
|
295
|
+
userMessageIndex,
|
|
296
|
+
contentLength: newContent.length,
|
|
297
|
+
attachmentsCount: attachments?.length || 0,
|
|
298
|
+
});
|
|
299
|
+
if (!client) {
|
|
300
|
+
logger.error("No client available");
|
|
301
|
+
setError("No client available");
|
|
302
|
+
return;
|
|
303
|
+
}
|
|
304
|
+
if (!sessionId) {
|
|
305
|
+
logger.error("No session available");
|
|
306
|
+
setError("No session available");
|
|
307
|
+
return;
|
|
308
|
+
}
|
|
309
|
+
// Find the Nth user message (0-indexed) in the UI messages array
|
|
310
|
+
let userMessageCount = 0;
|
|
311
|
+
let targetArrayIndex = -1;
|
|
312
|
+
for (let i = 0; i < messages.length; i++) {
|
|
313
|
+
if (messages[i]?.role === "user") {
|
|
314
|
+
if (userMessageCount === userMessageIndex) {
|
|
315
|
+
targetArrayIndex = i;
|
|
316
|
+
break;
|
|
317
|
+
}
|
|
318
|
+
userMessageCount++;
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
if (targetArrayIndex === -1) {
|
|
322
|
+
logger.error("User message not found", {
|
|
323
|
+
userMessageIndex,
|
|
324
|
+
totalUserMessages: userMessageCount,
|
|
325
|
+
});
|
|
326
|
+
setError("User message not found");
|
|
327
|
+
return;
|
|
328
|
+
}
|
|
329
|
+
const targetMessage = messages[targetArrayIndex];
|
|
330
|
+
logger.debug("[editAndResend] Found target message", {
|
|
331
|
+
userMessageIndex,
|
|
332
|
+
targetArrayIndex,
|
|
333
|
+
messageRole: targetMessage?.role,
|
|
334
|
+
});
|
|
335
|
+
// Create assistant message ID outside try block so it's accessible in catch
|
|
336
|
+
const assistantMessageId = `msg_${Date.now()}_assistant`;
|
|
337
|
+
try {
|
|
338
|
+
// Start streaming and track time
|
|
339
|
+
const startTime = Date.now();
|
|
340
|
+
setIsStreaming(true);
|
|
341
|
+
setStreamingStartTime(startTime);
|
|
342
|
+
// Truncate UI messages - keep everything BEFORE the target message
|
|
343
|
+
truncateMessagesFrom(targetArrayIndex);
|
|
344
|
+
// Add the new user message to UI
|
|
345
|
+
const userMessage = {
|
|
346
|
+
id: `msg_${Date.now()}_user`,
|
|
347
|
+
role: "user",
|
|
348
|
+
content: newContent,
|
|
349
|
+
timestamp: new Date().toISOString(),
|
|
350
|
+
isStreaming: false,
|
|
351
|
+
...(attachments && attachments.length > 0
|
|
352
|
+
? {
|
|
353
|
+
images: attachments
|
|
354
|
+
.filter((a) => a.mimeType.startsWith("image/"))
|
|
355
|
+
.map((a) => ({ mimeType: a.mimeType, data: a.data })),
|
|
356
|
+
}
|
|
357
|
+
: {}),
|
|
358
|
+
};
|
|
359
|
+
addMessage(userMessage);
|
|
360
|
+
// Create placeholder for assistant message
|
|
361
|
+
const assistantMessage = {
|
|
362
|
+
id: assistantMessageId,
|
|
363
|
+
role: "assistant",
|
|
364
|
+
content: "",
|
|
365
|
+
timestamp: new Date().toISOString(),
|
|
366
|
+
isStreaming: true,
|
|
367
|
+
streamingStartTime: startTime,
|
|
368
|
+
};
|
|
369
|
+
addMessage(assistantMessage);
|
|
370
|
+
// Store the assistant message ID for potential cancellation
|
|
371
|
+
currentAssistantMessageIdRef.current = assistantMessageId;
|
|
372
|
+
wasCancelledRef.current = false;
|
|
373
|
+
// Start receiving chunks (async iterator)
|
|
374
|
+
const messageStream = client.receiveMessages();
|
|
375
|
+
// Call editAndResend on the backend
|
|
376
|
+
client
|
|
377
|
+
.editAndResend(userMessageIndex, newContent, sessionId, attachments)
|
|
378
|
+
.catch((error) => {
|
|
379
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
380
|
+
setError(message);
|
|
381
|
+
setIsStreaming(false);
|
|
382
|
+
setStreamingStartTime(null);
|
|
383
|
+
});
|
|
384
|
+
// Listen for streaming chunks (same as sendMessage)
|
|
385
|
+
let accumulatedContent = "";
|
|
386
|
+
let streamCompleted = false;
|
|
387
|
+
for await (const chunk of messageStream) {
|
|
388
|
+
if (wasCancelledRef.current) {
|
|
389
|
+
logger.info("Stream cancelled, exiting loop");
|
|
390
|
+
break;
|
|
391
|
+
}
|
|
392
|
+
if (chunk.type === "content") {
|
|
393
|
+
// biome-ignore lint/suspicious/noExplicitAny: Accessing dynamic properties from streaming chunks
|
|
394
|
+
const chunkMeta = chunk._meta;
|
|
395
|
+
const contextSizeData =
|
|
396
|
+
// biome-ignore lint/suspicious/noExplicitAny: Accessing dynamic properties from streaming chunks
|
|
397
|
+
chunkMeta?.context_size || chunk.context_size;
|
|
398
|
+
if (contextSizeData != null) {
|
|
399
|
+
const contextSize = contextSizeData;
|
|
400
|
+
logger.info("✅ Received context_size from backend", {
|
|
401
|
+
context_size: contextSize,
|
|
402
|
+
totalEstimated: contextSize.totalEstimated,
|
|
403
|
+
});
|
|
404
|
+
setLatestContextSize(contextSize);
|
|
405
|
+
}
|
|
406
|
+
if (chunk.isComplete) {
|
|
407
|
+
updateMessage(assistantMessageId, {
|
|
408
|
+
content: accumulatedContent,
|
|
409
|
+
isStreaming: false,
|
|
410
|
+
streamingStartTime: undefined,
|
|
411
|
+
...(chunk.tokenUsage ? { tokenUsage: chunk.tokenUsage } : {}),
|
|
412
|
+
});
|
|
413
|
+
setIsStreaming(false);
|
|
414
|
+
setStreamingStartTime(null);
|
|
415
|
+
currentAssistantMessageIdRef.current = null;
|
|
416
|
+
streamCompleted = true;
|
|
417
|
+
break;
|
|
418
|
+
}
|
|
419
|
+
else {
|
|
420
|
+
if (chunk.contentDelta.type === "text") {
|
|
421
|
+
accumulatedContent += chunk.contentDelta.text;
|
|
422
|
+
updateMessage(assistantMessageId, {
|
|
423
|
+
content: accumulatedContent,
|
|
424
|
+
...(chunk.tokenUsage ? { tokenUsage: chunk.tokenUsage } : {}),
|
|
425
|
+
});
|
|
426
|
+
if (!document.hidden) {
|
|
427
|
+
await new Promise((resolve) => setTimeout(resolve, 16));
|
|
428
|
+
}
|
|
429
|
+
}
|
|
430
|
+
}
|
|
431
|
+
}
|
|
432
|
+
else if (chunk.type === "tool_call") {
|
|
433
|
+
logger.debug("Received tool_call chunk", { chunk });
|
|
434
|
+
addToolCall(sessionId, chunk.toolCall);
|
|
435
|
+
addToolCallToCurrentMessage(chunk.toolCall);
|
|
436
|
+
}
|
|
437
|
+
else if (chunk.type === "tool_call_update") {
|
|
438
|
+
logger.debug("Received tool_call_update chunk", { chunk });
|
|
439
|
+
updateToolCall(sessionId, chunk.toolCallUpdate);
|
|
440
|
+
updateToolCallInCurrentMessage(chunk.toolCallUpdate);
|
|
441
|
+
}
|
|
442
|
+
else if (chunk.type === "hook_notification") {
|
|
443
|
+
logger.debug("Received hook_notification chunk", { chunk });
|
|
444
|
+
addHookNotificationToCurrentMessage(chunk.notification);
|
|
445
|
+
}
|
|
446
|
+
else if (chunk.type === "sources") {
|
|
447
|
+
logger.debug("Received sources chunk", { chunk });
|
|
448
|
+
addSourcesToCurrentMessage(chunk.sources);
|
|
449
|
+
}
|
|
450
|
+
}
|
|
451
|
+
if (!streamCompleted) {
|
|
452
|
+
if (wasCancelledRef.current) {
|
|
453
|
+
logger.info("Stream cancelled by user");
|
|
454
|
+
updateMessage(assistantMessageId, {
|
|
455
|
+
content: `${accumulatedContent}\n\n[Cancelled]`,
|
|
456
|
+
isStreaming: false,
|
|
457
|
+
streamingStartTime: undefined,
|
|
458
|
+
});
|
|
459
|
+
}
|
|
460
|
+
else {
|
|
461
|
+
logger.warn("Stream ended without isComplete flag");
|
|
462
|
+
updateMessage(assistantMessageId, {
|
|
463
|
+
isStreaming: false,
|
|
464
|
+
streamingStartTime: undefined,
|
|
465
|
+
});
|
|
466
|
+
}
|
|
467
|
+
setIsStreaming(false);
|
|
468
|
+
setStreamingStartTime(null);
|
|
469
|
+
currentAssistantMessageIdRef.current = null;
|
|
470
|
+
}
|
|
471
|
+
}
|
|
472
|
+
catch (error) {
|
|
473
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
474
|
+
setError(message);
|
|
475
|
+
setIsStreaming(false);
|
|
476
|
+
setStreamingStartTime(null);
|
|
477
|
+
currentAssistantMessageIdRef.current = null;
|
|
478
|
+
updateMessage(assistantMessageId, {
|
|
479
|
+
isStreaming: false,
|
|
480
|
+
streamingStartTime: undefined,
|
|
481
|
+
});
|
|
482
|
+
}
|
|
483
|
+
}, [
|
|
484
|
+
client,
|
|
485
|
+
sessionId,
|
|
486
|
+
messages,
|
|
487
|
+
truncateMessagesFrom,
|
|
488
|
+
addMessage,
|
|
489
|
+
updateMessage,
|
|
490
|
+
setIsStreaming,
|
|
491
|
+
setStreamingStartTime,
|
|
492
|
+
setError,
|
|
493
|
+
setLatestContextSize,
|
|
494
|
+
addToolCall,
|
|
495
|
+
updateToolCall,
|
|
496
|
+
addToolCallToCurrentMessage,
|
|
497
|
+
updateToolCallInCurrentMessage,
|
|
498
|
+
addHookNotificationToCurrentMessage,
|
|
499
|
+
addSourcesToCurrentMessage,
|
|
218
500
|
]);
|
|
219
501
|
return {
|
|
220
502
|
messages,
|
|
221
503
|
isStreaming,
|
|
222
504
|
sendMessage,
|
|
505
|
+
editAndResend,
|
|
506
|
+
cancel,
|
|
223
507
|
};
|
|
224
508
|
}
|
|
@@ -279,6 +279,15 @@ export declare const DisplayMessage: z.ZodObject<{
|
|
|
279
279
|
mimeType: z.ZodString;
|
|
280
280
|
data: z.ZodString;
|
|
281
281
|
}, z.core.$strip>>>;
|
|
282
|
+
sources: z.ZodOptional<z.ZodArray<z.ZodObject<{
|
|
283
|
+
id: z.ZodString;
|
|
284
|
+
url: z.ZodString;
|
|
285
|
+
title: z.ZodString;
|
|
286
|
+
snippet: z.ZodOptional<z.ZodString>;
|
|
287
|
+
favicon: z.ZodOptional<z.ZodString>;
|
|
288
|
+
toolCallId: z.ZodString;
|
|
289
|
+
sourceName: z.ZodOptional<z.ZodString>;
|
|
290
|
+
}, z.core.$strip>>>;
|
|
282
291
|
}, z.core.$strip>;
|
|
283
292
|
export type DisplayMessage = z.infer<typeof DisplayMessage>;
|
|
284
293
|
/**
|
|
@@ -294,6 +303,7 @@ export declare const InputState: z.ZodObject<{
|
|
|
294
303
|
mimeType: z.ZodString;
|
|
295
304
|
data: z.ZodString;
|
|
296
305
|
}, z.core.$strip>>;
|
|
306
|
+
selectedPromptParameters: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodString>>;
|
|
297
307
|
}, z.core.$strip>;
|
|
298
308
|
export type InputState = z.infer<typeof InputState>;
|
|
299
309
|
/**
|
|
@@ -533,6 +543,15 @@ export declare const ChatSessionState: z.ZodObject<{
|
|
|
533
543
|
mimeType: z.ZodString;
|
|
534
544
|
data: z.ZodString;
|
|
535
545
|
}, z.core.$strip>>>;
|
|
546
|
+
sources: z.ZodOptional<z.ZodArray<z.ZodObject<{
|
|
547
|
+
id: z.ZodString;
|
|
548
|
+
url: z.ZodString;
|
|
549
|
+
title: z.ZodString;
|
|
550
|
+
snippet: z.ZodOptional<z.ZodString>;
|
|
551
|
+
favicon: z.ZodOptional<z.ZodString>;
|
|
552
|
+
toolCallId: z.ZodString;
|
|
553
|
+
sourceName: z.ZodOptional<z.ZodString>;
|
|
554
|
+
}, z.core.$strip>>>;
|
|
536
555
|
}, z.core.$strip>>;
|
|
537
556
|
input: z.ZodObject<{
|
|
538
557
|
value: z.ZodString;
|
|
@@ -544,6 +563,7 @@ export declare const ChatSessionState: z.ZodObject<{
|
|
|
544
563
|
mimeType: z.ZodString;
|
|
545
564
|
data: z.ZodString;
|
|
546
565
|
}, z.core.$strip>>;
|
|
566
|
+
selectedPromptParameters: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodString>>;
|
|
547
567
|
}, z.core.$strip>;
|
|
548
568
|
error: z.ZodNullable<z.ZodString>;
|
|
549
569
|
}, z.core.$strip>;
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { z } from "zod";
|
|
2
2
|
import { HookNotification, HookType } from "../../sdk/schemas/message.js";
|
|
3
|
+
import { SourceSchema } from "./source.js";
|
|
3
4
|
import { TokenUsageSchema, ToolCallSchema } from "./tool-call.js";
|
|
4
5
|
/**
|
|
5
6
|
* Chat UI state schemas
|
|
@@ -64,6 +65,7 @@ export const DisplayMessage = z.object({
|
|
|
64
65
|
hookNotifications: z.array(HookNotificationDisplay).optional(), // Hook notifications for this message
|
|
65
66
|
tokenUsage: TokenUsageSchema.optional(), // Token usage for this message
|
|
66
67
|
images: z.array(DisplayImageAttachment).optional(), // Image attachments for user messages
|
|
68
|
+
sources: z.array(SourceSchema).optional(), // Citation sources from tool calls
|
|
67
69
|
});
|
|
68
70
|
/**
|
|
69
71
|
* Input state schema
|
|
@@ -78,6 +80,8 @@ export const InputState = z.object({
|
|
|
78
80
|
mimeType: z.string(),
|
|
79
81
|
data: z.string(), // base64 encoded file data
|
|
80
82
|
})),
|
|
83
|
+
/** Selected prompt parameters for per-message configuration. Maps parameter ID to selected option ID. */
|
|
84
|
+
selectedPromptParameters: z.record(z.string(), z.string()).optional(),
|
|
81
85
|
});
|
|
82
86
|
/**
|
|
83
87
|
* Chat session UI state
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
/**
|
|
3
|
+
* Citation source extracted from tool calls (WebSearch, WebFetch, MCP tools)
|
|
4
|
+
*/
|
|
5
|
+
export declare const SourceSchema: z.ZodObject<{
|
|
6
|
+
id: z.ZodString;
|
|
7
|
+
url: z.ZodString;
|
|
8
|
+
title: z.ZodString;
|
|
9
|
+
snippet: z.ZodOptional<z.ZodString>;
|
|
10
|
+
favicon: z.ZodOptional<z.ZodString>;
|
|
11
|
+
toolCallId: z.ZodString;
|
|
12
|
+
sourceName: z.ZodOptional<z.ZodString>;
|
|
13
|
+
}, z.core.$strip>;
|
|
14
|
+
export type Source = z.infer<typeof SourceSchema>;
|
|
15
|
+
/**
|
|
16
|
+
* Helper to derive favicon URL from a domain
|
|
17
|
+
*/
|
|
18
|
+
export declare function getFaviconUrl(url: string): string;
|
|
19
|
+
/**
|
|
20
|
+
* Helper to extract domain name from URL
|
|
21
|
+
*/
|
|
22
|
+
export declare function getSourceName(url: string): string;
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
/**
|
|
3
|
+
* Citation source extracted from tool calls (WebSearch, WebFetch, MCP tools)
|
|
4
|
+
*/
|
|
5
|
+
export const SourceSchema = z.object({
|
|
6
|
+
/** Unique identifier for LLM reference (e.g., "1", "2") */
|
|
7
|
+
id: z.string(),
|
|
8
|
+
/** The URL of the source */
|
|
9
|
+
url: z.string().url(),
|
|
10
|
+
/** Title of the source page/article */
|
|
11
|
+
title: z.string(),
|
|
12
|
+
/** Optional snippet/excerpt from the source */
|
|
13
|
+
snippet: z.string().optional(),
|
|
14
|
+
/** Favicon URL (derived from domain) */
|
|
15
|
+
favicon: z.string().optional(),
|
|
16
|
+
/** The tool call ID that produced this source */
|
|
17
|
+
toolCallId: z.string(),
|
|
18
|
+
/** Source name/domain (e.g., "Reuters", "GitHub") */
|
|
19
|
+
sourceName: z.string().optional(),
|
|
20
|
+
});
|
|
21
|
+
/**
|
|
22
|
+
* Helper to derive favicon URL from a domain
|
|
23
|
+
*/
|
|
24
|
+
export function getFaviconUrl(url) {
|
|
25
|
+
try {
|
|
26
|
+
const domain = new URL(url).hostname;
|
|
27
|
+
return `https://www.google.com/s2/favicons?domain=${domain}&sz=32`;
|
|
28
|
+
}
|
|
29
|
+
catch {
|
|
30
|
+
return "";
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
/**
|
|
34
|
+
* Helper to extract domain name from URL
|
|
35
|
+
*/
|
|
36
|
+
export function getSourceName(url) {
|
|
37
|
+
try {
|
|
38
|
+
const hostname = new URL(url).hostname;
|
|
39
|
+
// Remove www. prefix and get the main domain
|
|
40
|
+
return hostname.replace(/^www\./, "").split(".")[0] || hostname;
|
|
41
|
+
}
|
|
42
|
+
catch {
|
|
43
|
+
return "Unknown";
|
|
44
|
+
}
|
|
45
|
+
}
|
|
@@ -2,6 +2,7 @@ import { type LogEntry } from "@townco/core";
|
|
|
2
2
|
import type { TodoItem } from "../../gui/components/TodoListItem.js";
|
|
3
3
|
import type { HookNotification } from "../../sdk/schemas/message.js";
|
|
4
4
|
import type { ConnectionStatus, DisplayMessage, InputState } from "../schemas/index.js";
|
|
5
|
+
import type { Source } from "../schemas/source.js";
|
|
5
6
|
import type { ToolCall, ToolCallUpdate } from "../schemas/tool-call.js";
|
|
6
7
|
/**
|
|
7
8
|
* Selector to get todos for the current session (memoized to prevent infinite loops)
|
|
@@ -62,10 +63,12 @@ export interface ChatStore {
|
|
|
62
63
|
addToolCallToCurrentMessage: (toolCall: ToolCall) => void;
|
|
63
64
|
updateToolCallInCurrentMessage: (update: ToolCallUpdate) => void;
|
|
64
65
|
addHookNotificationToCurrentMessage: (notification: HookNotification) => void;
|
|
66
|
+
addSourcesToCurrentMessage: (sources: Source[]) => void;
|
|
65
67
|
setInputValue: (value: string) => void;
|
|
66
68
|
setInputSubmitting: (submitting: boolean) => void;
|
|
67
69
|
addFileAttachment: (file: InputState["attachedFiles"][number]) => void;
|
|
68
70
|
removeFileAttachment: (index: number) => void;
|
|
71
|
+
setSelectedPromptParameters: (params: Record<string, string>) => void;
|
|
69
72
|
clearInput: () => void;
|
|
70
73
|
addTokenUsage: (tokenUsage: {
|
|
71
74
|
inputTokens?: number;
|
|
@@ -79,6 +82,7 @@ export interface ChatStore {
|
|
|
79
82
|
addLog: (log: LogEntry) => void;
|
|
80
83
|
clearLogs: () => void;
|
|
81
84
|
setActiveTab: (tab: "chat" | "logs") => void;
|
|
85
|
+
truncateMessagesFrom: (messageIndex: number) => void;
|
|
82
86
|
}
|
|
83
87
|
/**
|
|
84
88
|
* Create chat store
|