mirror of
https://github.com/MODSetter/SurfSense.git
synced 2026-05-19 18:45:15 +02:00
commit
05030f6664
4 changed files with 333 additions and 230 deletions
|
|
@ -56,6 +56,7 @@ import {
|
||||||
buildContentForPersistence,
|
buildContentForPersistence,
|
||||||
buildContentForUI,
|
buildContentForUI,
|
||||||
type ContentPartsState,
|
type ContentPartsState,
|
||||||
|
FrameBatchedUpdater,
|
||||||
readSSEStream,
|
readSSEStream,
|
||||||
type ThinkingStepData,
|
type ThinkingStepData,
|
||||||
updateThinkingSteps,
|
updateThinkingSteps,
|
||||||
|
|
@ -272,7 +273,6 @@ export default function NewChatPage() {
|
||||||
|
|
||||||
// Initialize thread and load messages
|
// Initialize thread and load messages
|
||||||
// For new chats (no urlChatId), we use lazy creation - thread is created on first message
|
// For new chats (no urlChatId), we use lazy creation - thread is created on first message
|
||||||
// biome-ignore lint/correctness/useExhaustiveDependencies: searchSpaceId triggers re-init when switching spaces with the same urlChatId
|
|
||||||
const initializeThread = useCallback(async () => {
|
const initializeThread = useCallback(async () => {
|
||||||
setIsInitializing(true);
|
setIsInitializing(true);
|
||||||
|
|
||||||
|
|
@ -333,7 +333,6 @@ export default function NewChatPage() {
|
||||||
}
|
}
|
||||||
}, [
|
}, [
|
||||||
urlChatId,
|
urlChatId,
|
||||||
searchSpaceId,
|
|
||||||
setMessageDocumentsMap,
|
setMessageDocumentsMap,
|
||||||
setMentionedDocuments,
|
setMentionedDocuments,
|
||||||
setSidebarDocuments,
|
setSidebarDocuments,
|
||||||
|
|
@ -341,10 +340,10 @@ export default function NewChatPage() {
|
||||||
closeEditorPanel,
|
closeEditorPanel,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
// Initialize on mount
|
// Initialize on mount, and re-init when switching search spaces (even if urlChatId is the same)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
initializeThread();
|
initializeThread();
|
||||||
}, [initializeThread]);
|
}, [initializeThread, searchSpaceId]);
|
||||||
|
|
||||||
// Prefetch document titles for @ mention picker
|
// Prefetch document titles for @ mention picker
|
||||||
// Runs when user lands on page so data is ready when they type @
|
// Runs when user lands on page so data is ready when they type @
|
||||||
|
|
@ -571,6 +570,7 @@ export default function NewChatPage() {
|
||||||
// Prepare assistant message
|
// Prepare assistant message
|
||||||
const assistantMsgId = `msg-assistant-${Date.now()}`;
|
const assistantMsgId = `msg-assistant-${Date.now()}`;
|
||||||
const currentThinkingSteps = new Map<string, ThinkingStepData>();
|
const currentThinkingSteps = new Map<string, ThinkingStepData>();
|
||||||
|
const batcher = new FrameBatchedUpdater();
|
||||||
|
|
||||||
const contentPartsState: ContentPartsState = {
|
const contentPartsState: ContentPartsState = {
|
||||||
contentParts: [],
|
contentParts: [],
|
||||||
|
|
@ -642,96 +642,74 @@ export default function NewChatPage() {
|
||||||
throw new Error(`Backend error: ${response.status}`);
|
throw new Error(`Backend error: ${response.status}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const flushMessages = () => {
|
||||||
|
setMessages((prev) =>
|
||||||
|
prev.map((m) =>
|
||||||
|
m.id === assistantMsgId
|
||||||
|
? { ...m, content: buildContentForUI(contentPartsState, TOOLS_WITH_UI) }
|
||||||
|
: m
|
||||||
|
)
|
||||||
|
);
|
||||||
|
};
|
||||||
|
const scheduleFlush = () => batcher.schedule(flushMessages);
|
||||||
|
|
||||||
for await (const parsed of readSSEStream(response)) {
|
for await (const parsed of readSSEStream(response)) {
|
||||||
switch (parsed.type) {
|
switch (parsed.type) {
|
||||||
case "text-delta":
|
case "text-delta":
|
||||||
appendText(contentPartsState, parsed.delta);
|
appendText(contentPartsState, parsed.delta);
|
||||||
setMessages((prev) =>
|
scheduleFlush();
|
||||||
prev.map((m) =>
|
break;
|
||||||
m.id === assistantMsgId
|
|
||||||
? { ...m, content: buildContentForUI(contentPartsState, TOOLS_WITH_UI) }
|
|
||||||
: m
|
|
||||||
)
|
|
||||||
);
|
|
||||||
break;
|
|
||||||
|
|
||||||
case "tool-input-start":
|
case "tool-input-start":
|
||||||
// Add tool call inline - this breaks the current text segment
|
addToolCall(contentPartsState, TOOLS_WITH_UI, parsed.toolCallId, parsed.toolName, {});
|
||||||
addToolCall(contentPartsState, TOOLS_WITH_UI, parsed.toolCallId, parsed.toolName, {});
|
batcher.flush();
|
||||||
setMessages((prev) =>
|
break;
|
||||||
prev.map((m) =>
|
|
||||||
m.id === assistantMsgId
|
|
||||||
? { ...m, content: buildContentForUI(contentPartsState, TOOLS_WITH_UI) }
|
|
||||||
: m
|
|
||||||
)
|
|
||||||
);
|
|
||||||
break;
|
|
||||||
|
|
||||||
case "tool-input-available": {
|
case "tool-input-available": {
|
||||||
// Update existing tool call's args, or add if not exists
|
if (toolCallIndices.has(parsed.toolCallId)) {
|
||||||
if (toolCallIndices.has(parsed.toolCallId)) {
|
updateToolCall(contentPartsState, parsed.toolCallId, { args: parsed.input || {} });
|
||||||
updateToolCall(contentPartsState, parsed.toolCallId, { args: parsed.input || {} });
|
} else {
|
||||||
} else {
|
addToolCall(
|
||||||
addToolCall(
|
contentPartsState,
|
||||||
contentPartsState,
|
TOOLS_WITH_UI,
|
||||||
TOOLS_WITH_UI,
|
parsed.toolCallId,
|
||||||
parsed.toolCallId,
|
parsed.toolName,
|
||||||
parsed.toolName,
|
parsed.input || {}
|
||||||
parsed.input || {}
|
|
||||||
);
|
|
||||||
}
|
|
||||||
setMessages((prev) =>
|
|
||||||
prev.map((m) =>
|
|
||||||
m.id === assistantMsgId
|
|
||||||
? { ...m, content: buildContentForUI(contentPartsState, TOOLS_WITH_UI) }
|
|
||||||
: m
|
|
||||||
)
|
|
||||||
);
|
);
|
||||||
break;
|
|
||||||
}
|
}
|
||||||
|
batcher.flush();
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
case "tool-output-available": {
|
case "tool-output-available": {
|
||||||
// Update the tool call with its result
|
updateToolCall(contentPartsState, parsed.toolCallId, { result: parsed.output });
|
||||||
updateToolCall(contentPartsState, parsed.toolCallId, { result: parsed.output });
|
markInterruptsCompleted(contentParts);
|
||||||
markInterruptsCompleted(contentParts);
|
if (parsed.output?.status === "pending" && parsed.output?.podcast_id) {
|
||||||
// Handle podcast-specific logic
|
const idx = toolCallIndices.get(parsed.toolCallId);
|
||||||
if (parsed.output?.status === "pending" && parsed.output?.podcast_id) {
|
if (idx !== undefined) {
|
||||||
// Check if this is a podcast tool by looking at the content part
|
const part = contentParts[idx];
|
||||||
const idx = toolCallIndices.get(parsed.toolCallId);
|
if (part?.type === "tool-call" && part.toolName === "generate_podcast") {
|
||||||
if (idx !== undefined) {
|
setActivePodcastTaskId(String(parsed.output.podcast_id));
|
||||||
const part = contentParts[idx];
|
|
||||||
if (part?.type === "tool-call" && part.toolName === "generate_podcast") {
|
|
||||||
setActivePodcastTaskId(String(parsed.output.podcast_id));
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
setMessages((prev) =>
|
|
||||||
prev.map((m) =>
|
|
||||||
m.id === assistantMsgId
|
|
||||||
? { ...m, content: buildContentForUI(contentPartsState, TOOLS_WITH_UI) }
|
|
||||||
: m
|
|
||||||
)
|
|
||||||
);
|
|
||||||
break;
|
|
||||||
}
|
}
|
||||||
|
batcher.flush();
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
case "data-thinking-step": {
|
case "data-thinking-step": {
|
||||||
const stepData = parsed.data as ThinkingStepData;
|
const stepData = parsed.data as ThinkingStepData;
|
||||||
if (stepData?.id) {
|
if (stepData?.id) {
|
||||||
currentThinkingSteps.set(stepData.id, stepData);
|
currentThinkingSteps.set(stepData.id, stepData);
|
||||||
updateThinkingSteps(contentPartsState, currentThinkingSteps);
|
const didUpdate = updateThinkingSteps(contentPartsState, currentThinkingSteps);
|
||||||
setMessages((prev) =>
|
if (didUpdate) {
|
||||||
prev.map((m) =>
|
scheduleFlush();
|
||||||
m.id === assistantMsgId
|
|
||||||
? { ...m, content: buildContentForUI(contentPartsState, TOOLS_WITH_UI) }
|
|
||||||
: m
|
|
||||||
)
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
break;
|
|
||||||
}
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
case "data-thread-title-update": {
|
case "data-thread-title-update": {
|
||||||
const titleData = parsed.data as { threadId: number; title: string };
|
const titleData = parsed.data as { threadId: number; title: string };
|
||||||
if (titleData?.title && titleData?.threadId === currentThreadId) {
|
if (titleData?.title && titleData?.threadId === currentThreadId) {
|
||||||
setCurrentThread((prev) => (prev ? { ...prev, title: titleData.title } : prev));
|
setCurrentThread((prev) => (prev ? { ...prev, title: titleData.title } : prev));
|
||||||
|
|
@ -803,6 +781,8 @@ export default function NewChatPage() {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
batcher.flush();
|
||||||
|
|
||||||
// Skip persistence for interrupted messages -- handleResume will persist the final version
|
// Skip persistence for interrupted messages -- handleResume will persist the final version
|
||||||
const finalContent = buildContentForPersistence(contentPartsState, TOOLS_WITH_UI);
|
const finalContent = buildContentForPersistence(contentPartsState, TOOLS_WITH_UI);
|
||||||
if (contentParts.length > 0 && !wasInterrupted) {
|
if (contentParts.length > 0 && !wasInterrupted) {
|
||||||
|
|
@ -832,6 +812,7 @@ export default function NewChatPage() {
|
||||||
trackChatResponseReceived(searchSpaceId, currentThreadId);
|
trackChatResponseReceived(searchSpaceId, currentThreadId);
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
batcher.dispose();
|
||||||
if (error instanceof Error && error.name === "AbortError") {
|
if (error instanceof Error && error.name === "AbortError") {
|
||||||
// Request was cancelled by user - persist partial response if any content was received
|
// Request was cancelled by user - persist partial response if any content was received
|
||||||
const hasContent = contentParts.some(
|
const hasContent = contentParts.some(
|
||||||
|
|
@ -899,6 +880,7 @@ export default function NewChatPage() {
|
||||||
setMentionedDocuments,
|
setMentionedDocuments,
|
||||||
setSidebarDocuments,
|
setSidebarDocuments,
|
||||||
setMessageDocumentsMap,
|
setMessageDocumentsMap,
|
||||||
|
setAgentCreatedDocuments,
|
||||||
queryClient,
|
queryClient,
|
||||||
currentThread,
|
currentThread,
|
||||||
currentUser,
|
currentUser,
|
||||||
|
|
@ -931,6 +913,7 @@ export default function NewChatPage() {
|
||||||
abortControllerRef.current = controller;
|
abortControllerRef.current = controller;
|
||||||
|
|
||||||
const currentThinkingSteps = new Map<string, ThinkingStepData>();
|
const currentThinkingSteps = new Map<string, ThinkingStepData>();
|
||||||
|
const batcher = new FrameBatchedUpdater();
|
||||||
|
|
||||||
const contentPartsState: ContentPartsState = {
|
const contentPartsState: ContentPartsState = {
|
||||||
contentParts: [],
|
contentParts: [],
|
||||||
|
|
@ -1018,84 +1001,67 @@ export default function NewChatPage() {
|
||||||
throw new Error(`Backend error: ${response.status}`);
|
throw new Error(`Backend error: ${response.status}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const flushMessages = () => {
|
||||||
|
setMessages((prev) =>
|
||||||
|
prev.map((m) =>
|
||||||
|
m.id === assistantMsgId
|
||||||
|
? { ...m, content: buildContentForUI(contentPartsState, TOOLS_WITH_UI) }
|
||||||
|
: m
|
||||||
|
)
|
||||||
|
);
|
||||||
|
};
|
||||||
|
const scheduleFlush = () => batcher.schedule(flushMessages);
|
||||||
|
|
||||||
for await (const parsed of readSSEStream(response)) {
|
for await (const parsed of readSSEStream(response)) {
|
||||||
switch (parsed.type) {
|
switch (parsed.type) {
|
||||||
case "text-delta":
|
case "text-delta":
|
||||||
appendText(contentPartsState, parsed.delta);
|
appendText(contentPartsState, parsed.delta);
|
||||||
setMessages((prev) =>
|
scheduleFlush();
|
||||||
prev.map((m) =>
|
break;
|
||||||
m.id === assistantMsgId
|
|
||||||
? { ...m, content: buildContentForUI(contentPartsState, TOOLS_WITH_UI) }
|
|
||||||
: m
|
|
||||||
)
|
|
||||||
);
|
|
||||||
break;
|
|
||||||
|
|
||||||
case "tool-input-start":
|
case "tool-input-start":
|
||||||
addToolCall(contentPartsState, TOOLS_WITH_UI, parsed.toolCallId, parsed.toolName, {});
|
addToolCall(contentPartsState, TOOLS_WITH_UI, parsed.toolCallId, parsed.toolName, {});
|
||||||
setMessages((prev) =>
|
batcher.flush();
|
||||||
prev.map((m) =>
|
break;
|
||||||
m.id === assistantMsgId
|
|
||||||
? { ...m, content: buildContentForUI(contentPartsState, TOOLS_WITH_UI) }
|
|
||||||
: m
|
|
||||||
)
|
|
||||||
);
|
|
||||||
break;
|
|
||||||
|
|
||||||
case "tool-input-available":
|
case "tool-input-available":
|
||||||
if (toolCallIndices.has(parsed.toolCallId)) {
|
if (toolCallIndices.has(parsed.toolCallId)) {
|
||||||
updateToolCall(contentPartsState, parsed.toolCallId, {
|
|
||||||
args: parsed.input || {},
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
addToolCall(
|
|
||||||
contentPartsState,
|
|
||||||
TOOLS_WITH_UI,
|
|
||||||
parsed.toolCallId,
|
|
||||||
parsed.toolName,
|
|
||||||
parsed.input || {}
|
|
||||||
);
|
|
||||||
}
|
|
||||||
setMessages((prev) =>
|
|
||||||
prev.map((m) =>
|
|
||||||
m.id === assistantMsgId
|
|
||||||
? { ...m, content: buildContentForUI(contentPartsState, TOOLS_WITH_UI) }
|
|
||||||
: m
|
|
||||||
)
|
|
||||||
);
|
|
||||||
break;
|
|
||||||
|
|
||||||
case "tool-output-available":
|
|
||||||
updateToolCall(contentPartsState, parsed.toolCallId, {
|
updateToolCall(contentPartsState, parsed.toolCallId, {
|
||||||
result: parsed.output,
|
args: parsed.input || {},
|
||||||
});
|
});
|
||||||
markInterruptsCompleted(contentParts);
|
} else {
|
||||||
setMessages((prev) =>
|
addToolCall(
|
||||||
prev.map((m) =>
|
contentPartsState,
|
||||||
m.id === assistantMsgId
|
TOOLS_WITH_UI,
|
||||||
? { ...m, content: buildContentForUI(contentPartsState, TOOLS_WITH_UI) }
|
parsed.toolCallId,
|
||||||
: m
|
parsed.toolName,
|
||||||
)
|
parsed.input || {}
|
||||||
);
|
);
|
||||||
break;
|
|
||||||
|
|
||||||
case "data-thinking-step": {
|
|
||||||
const stepData = parsed.data as ThinkingStepData;
|
|
||||||
if (stepData?.id) {
|
|
||||||
currentThinkingSteps.set(stepData.id, stepData);
|
|
||||||
updateThinkingSteps(contentPartsState, currentThinkingSteps);
|
|
||||||
setMessages((prev) =>
|
|
||||||
prev.map((m) =>
|
|
||||||
m.id === assistantMsgId
|
|
||||||
? { ...m, content: buildContentForUI(contentPartsState, TOOLS_WITH_UI) }
|
|
||||||
: m
|
|
||||||
)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
break;
|
|
||||||
}
|
}
|
||||||
|
batcher.flush();
|
||||||
|
break;
|
||||||
|
|
||||||
case "data-interrupt-request": {
|
case "tool-output-available":
|
||||||
|
updateToolCall(contentPartsState, parsed.toolCallId, {
|
||||||
|
result: parsed.output,
|
||||||
|
});
|
||||||
|
markInterruptsCompleted(contentParts);
|
||||||
|
batcher.flush();
|
||||||
|
break;
|
||||||
|
|
||||||
|
case "data-thinking-step": {
|
||||||
|
const stepData = parsed.data as ThinkingStepData;
|
||||||
|
if (stepData?.id) {
|
||||||
|
currentThinkingSteps.set(stepData.id, stepData);
|
||||||
|
const didUpdate = updateThinkingSteps(contentPartsState, currentThinkingSteps);
|
||||||
|
if (didUpdate) {
|
||||||
|
scheduleFlush();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
case "data-interrupt-request": {
|
||||||
const interruptData = parsed.data as Record<string, unknown>;
|
const interruptData = parsed.data as Record<string, unknown>;
|
||||||
const actionRequests = (interruptData.action_requests ?? []) as Array<{
|
const actionRequests = (interruptData.action_requests ?? []) as Array<{
|
||||||
name: string;
|
name: string;
|
||||||
|
|
@ -1144,6 +1110,8 @@ export default function NewChatPage() {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
batcher.flush();
|
||||||
|
|
||||||
const finalContent = buildContentForPersistence(contentPartsState, TOOLS_WITH_UI);
|
const finalContent = buildContentForPersistence(contentPartsState, TOOLS_WITH_UI);
|
||||||
if (contentParts.length > 0) {
|
if (contentParts.length > 0) {
|
||||||
try {
|
try {
|
||||||
|
|
@ -1160,6 +1128,7 @@ export default function NewChatPage() {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
batcher.dispose();
|
||||||
if (error instanceof Error && error.name === "AbortError") {
|
if (error instanceof Error && error.name === "AbortError") {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
@ -1305,6 +1274,7 @@ export default function NewChatPage() {
|
||||||
toolCallIndices: new Map(),
|
toolCallIndices: new Map(),
|
||||||
};
|
};
|
||||||
const { contentParts, toolCallIndices } = contentPartsState;
|
const { contentParts, toolCallIndices } = contentPartsState;
|
||||||
|
const batcher = new FrameBatchedUpdater();
|
||||||
|
|
||||||
// Add placeholder messages to UI
|
// Add placeholder messages to UI
|
||||||
// Always add back the user message (with new query for edit, or original content for reload)
|
// Always add back the user message (with new query for edit, or original content for reload)
|
||||||
|
|
@ -1349,92 +1319,77 @@ export default function NewChatPage() {
|
||||||
throw new Error(`Backend error: ${response.status}`);
|
throw new Error(`Backend error: ${response.status}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const flushMessages = () => {
|
||||||
|
setMessages((prev) =>
|
||||||
|
prev.map((m) =>
|
||||||
|
m.id === assistantMsgId
|
||||||
|
? { ...m, content: buildContentForUI(contentPartsState, TOOLS_WITH_UI) }
|
||||||
|
: m
|
||||||
|
)
|
||||||
|
);
|
||||||
|
};
|
||||||
|
const scheduleFlush = () => batcher.schedule(flushMessages);
|
||||||
|
|
||||||
for await (const parsed of readSSEStream(response)) {
|
for await (const parsed of readSSEStream(response)) {
|
||||||
switch (parsed.type) {
|
switch (parsed.type) {
|
||||||
case "text-delta":
|
case "text-delta":
|
||||||
appendText(contentPartsState, parsed.delta);
|
appendText(contentPartsState, parsed.delta);
|
||||||
setMessages((prev) =>
|
scheduleFlush();
|
||||||
prev.map((m) =>
|
break;
|
||||||
m.id === assistantMsgId
|
|
||||||
? { ...m, content: buildContentForUI(contentPartsState, TOOLS_WITH_UI) }
|
|
||||||
: m
|
|
||||||
)
|
|
||||||
);
|
|
||||||
break;
|
|
||||||
|
|
||||||
case "tool-input-start":
|
case "tool-input-start":
|
||||||
addToolCall(contentPartsState, TOOLS_WITH_UI, parsed.toolCallId, parsed.toolName, {});
|
addToolCall(contentPartsState, TOOLS_WITH_UI, parsed.toolCallId, parsed.toolName, {});
|
||||||
setMessages((prev) =>
|
batcher.flush();
|
||||||
prev.map((m) =>
|
break;
|
||||||
m.id === assistantMsgId
|
|
||||||
? { ...m, content: buildContentForUI(contentPartsState, TOOLS_WITH_UI) }
|
|
||||||
: m
|
|
||||||
)
|
|
||||||
);
|
|
||||||
break;
|
|
||||||
|
|
||||||
case "tool-input-available":
|
case "tool-input-available":
|
||||||
if (toolCallIndices.has(parsed.toolCallId)) {
|
if (toolCallIndices.has(parsed.toolCallId)) {
|
||||||
updateToolCall(contentPartsState, parsed.toolCallId, { args: parsed.input || {} });
|
updateToolCall(contentPartsState, parsed.toolCallId, { args: parsed.input || {} });
|
||||||
} else {
|
} else {
|
||||||
addToolCall(
|
addToolCall(
|
||||||
contentPartsState,
|
contentPartsState,
|
||||||
TOOLS_WITH_UI,
|
TOOLS_WITH_UI,
|
||||||
parsed.toolCallId,
|
parsed.toolCallId,
|
||||||
parsed.toolName,
|
parsed.toolName,
|
||||||
parsed.input || {}
|
parsed.input || {}
|
||||||
);
|
|
||||||
}
|
|
||||||
setMessages((prev) =>
|
|
||||||
prev.map((m) =>
|
|
||||||
m.id === assistantMsgId
|
|
||||||
? { ...m, content: buildContentForUI(contentPartsState, TOOLS_WITH_UI) }
|
|
||||||
: m
|
|
||||||
)
|
|
||||||
);
|
);
|
||||||
break;
|
}
|
||||||
|
batcher.flush();
|
||||||
|
break;
|
||||||
|
|
||||||
case "tool-output-available":
|
case "tool-output-available":
|
||||||
updateToolCall(contentPartsState, parsed.toolCallId, { result: parsed.output });
|
updateToolCall(contentPartsState, parsed.toolCallId, { result: parsed.output });
|
||||||
markInterruptsCompleted(contentParts);
|
markInterruptsCompleted(contentParts);
|
||||||
if (parsed.output?.status === "pending" && parsed.output?.podcast_id) {
|
if (parsed.output?.status === "pending" && parsed.output?.podcast_id) {
|
||||||
const idx = toolCallIndices.get(parsed.toolCallId);
|
const idx = toolCallIndices.get(parsed.toolCallId);
|
||||||
if (idx !== undefined) {
|
if (idx !== undefined) {
|
||||||
const part = contentParts[idx];
|
const part = contentParts[idx];
|
||||||
if (part?.type === "tool-call" && part.toolName === "generate_podcast") {
|
if (part?.type === "tool-call" && part.toolName === "generate_podcast") {
|
||||||
setActivePodcastTaskId(String(parsed.output.podcast_id));
|
setActivePodcastTaskId(String(parsed.output.podcast_id));
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
setMessages((prev) =>
|
|
||||||
prev.map((m) =>
|
|
||||||
m.id === assistantMsgId
|
|
||||||
? { ...m, content: buildContentForUI(contentPartsState, TOOLS_WITH_UI) }
|
|
||||||
: m
|
|
||||||
)
|
|
||||||
);
|
|
||||||
break;
|
|
||||||
|
|
||||||
case "data-thinking-step": {
|
|
||||||
const stepData = parsed.data as ThinkingStepData;
|
|
||||||
if (stepData?.id) {
|
|
||||||
currentThinkingSteps.set(stepData.id, stepData);
|
|
||||||
updateThinkingSteps(contentPartsState, currentThinkingSteps);
|
|
||||||
setMessages((prev) =>
|
|
||||||
prev.map((m) =>
|
|
||||||
m.id === assistantMsgId
|
|
||||||
? { ...m, content: buildContentForUI(contentPartsState, TOOLS_WITH_UI) }
|
|
||||||
: m
|
|
||||||
)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
break;
|
|
||||||
}
|
}
|
||||||
|
batcher.flush();
|
||||||
|
break;
|
||||||
|
|
||||||
case "error":
|
case "data-thinking-step": {
|
||||||
throw new Error(parsed.errorText || "Server error");
|
const stepData = parsed.data as ThinkingStepData;
|
||||||
|
if (stepData?.id) {
|
||||||
|
currentThinkingSteps.set(stepData.id, stepData);
|
||||||
|
const didUpdate = updateThinkingSteps(contentPartsState, currentThinkingSteps);
|
||||||
|
if (didUpdate) {
|
||||||
|
scheduleFlush();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
case "error":
|
||||||
|
throw new Error(parsed.errorText || "Server error");
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
batcher.flush();
|
||||||
|
|
||||||
// Persist messages after streaming completes
|
// Persist messages after streaming completes
|
||||||
const finalContent = buildContentForPersistence(contentPartsState, TOOLS_WITH_UI);
|
const finalContent = buildContentForPersistence(contentPartsState, TOOLS_WITH_UI);
|
||||||
|
|
@ -1477,6 +1432,7 @@ export default function NewChatPage() {
|
||||||
if (error instanceof Error && error.name === "AbortError") {
|
if (error instanceof Error && error.name === "AbortError") {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
batcher.dispose();
|
||||||
console.error("[NewChatPage] Regeneration error:", error);
|
console.error("[NewChatPage] Regeneration error:", error);
|
||||||
trackChatError(
|
trackChatError(
|
||||||
searchSpaceId,
|
searchSpaceId,
|
||||||
|
|
@ -1484,7 +1440,6 @@ export default function NewChatPage() {
|
||||||
error instanceof Error ? error.message : "Unknown error"
|
error instanceof Error ? error.message : "Unknown error"
|
||||||
);
|
);
|
||||||
toast.error("Failed to regenerate response. Please try again.");
|
toast.error("Failed to regenerate response. Please try again.");
|
||||||
// Update assistant message with error
|
|
||||||
setMessages((prev) =>
|
setMessages((prev) =>
|
||||||
prev.map((m) =>
|
prev.map((m) =>
|
||||||
m.id === assistantMsgId
|
m.id === assistantMsgId
|
||||||
|
|
|
||||||
|
|
@ -175,6 +175,7 @@ function parseTextWithCitations(text: string): ReactNode[] {
|
||||||
const MarkdownTextImpl = () => {
|
const MarkdownTextImpl = () => {
|
||||||
return (
|
return (
|
||||||
<MarkdownTextPrimitive
|
<MarkdownTextPrimitive
|
||||||
|
smooth={false}
|
||||||
remarkPlugins={[remarkGfm, remarkMath]}
|
remarkPlugins={[remarkGfm, remarkMath]}
|
||||||
rehypePlugins={[rehypeKatex]}
|
rehypePlugins={[rehypeKatex]}
|
||||||
className="aui-md"
|
className="aui-md"
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,7 @@ import {
|
||||||
ThreadPrimitive,
|
ThreadPrimitive,
|
||||||
useAui,
|
useAui,
|
||||||
useAuiState,
|
useAuiState,
|
||||||
|
useThreadViewportStore,
|
||||||
} from "@assistant-ui/react";
|
} from "@assistant-ui/react";
|
||||||
import { useAtom, useAtomValue, useSetAtom } from "jotai";
|
import { useAtom, useAtomValue, useSetAtom } from "jotai";
|
||||||
import {
|
import {
|
||||||
|
|
@ -113,7 +114,8 @@ const ThreadContent: FC = () => {
|
||||||
>
|
>
|
||||||
<ThreadPrimitive.Viewport
|
<ThreadPrimitive.Viewport
|
||||||
turnAnchor="top"
|
turnAnchor="top"
|
||||||
className="aui-thread-viewport relative flex flex-1 min-h-0 flex-col overflow-y-scroll px-4 pt-4"
|
className="aui-thread-viewport relative flex flex-1 min-h-0 flex-col overflow-y-auto px-4 pt-4"
|
||||||
|
style={{ scrollbarGutter: "stable" }}
|
||||||
>
|
>
|
||||||
<AuiIf condition={({ thread }) => thread.isEmpty}>
|
<AuiIf condition={({ thread }) => thread.isEmpty}>
|
||||||
<ThreadWelcome />
|
<ThreadWelcome />
|
||||||
|
|
@ -128,7 +130,7 @@ const ThreadContent: FC = () => {
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<ThreadPrimitive.ViewportFooter
|
<ThreadPrimitive.ViewportFooter
|
||||||
className="aui-thread-viewport-footer sticky bottom-0 z-10 mx-auto mt-auto flex w-full max-w-(--thread-max-width) flex-col gap-4 overflow-visible rounded-t-3xl bg-main-panel pb-4 md:pb-6"
|
className="aui-thread-viewport-footer sticky bottom-0 z-10 mx-auto flex w-full max-w-(--thread-max-width) flex-col gap-4 overflow-visible rounded-t-3xl bg-main-panel pb-4 md:pb-6"
|
||||||
style={{ paddingBottom: "max(1rem, env(safe-area-inset-bottom))" }}
|
style={{ paddingBottom: "max(1rem, env(safe-area-inset-bottom))" }}
|
||||||
>
|
>
|
||||||
<ThreadScrollToBottom />
|
<ThreadScrollToBottom />
|
||||||
|
|
@ -349,7 +351,13 @@ const Composer: FC = () => {
|
||||||
const promptPickerRef = useRef<PromptPickerRef>(null);
|
const promptPickerRef = useRef<PromptPickerRef>(null);
|
||||||
const { search_space_id, chat_id } = useParams();
|
const { search_space_id, chat_id } = useParams();
|
||||||
const aui = useAui();
|
const aui = useAui();
|
||||||
|
const threadViewportStore = useThreadViewportStore();
|
||||||
const hasAutoFocusedRef = useRef(false);
|
const hasAutoFocusedRef = useRef(false);
|
||||||
|
const submitCleanupRef = useRef<(() => void) | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
return () => { submitCleanupRef.current?.(); };
|
||||||
|
}, []);
|
||||||
|
|
||||||
const [clipboardInitialText, setClipboardInitialText] = useState<string | undefined>();
|
const [clipboardInitialText, setClipboardInitialText] = useState<string | undefined>();
|
||||||
const clipboardLoadedRef = useRef(false);
|
const clipboardLoadedRef = useRef(false);
|
||||||
|
|
@ -593,6 +601,63 @@ const Composer: FC = () => {
|
||||||
setMentionedDocuments([]);
|
setMentionedDocuments([]);
|
||||||
setSidebarDocs([]);
|
setSidebarDocs([]);
|
||||||
}
|
}
|
||||||
|
if (isThreadRunning || isBlockedByOtherUser) return;
|
||||||
|
if (showDocumentPopover) return;
|
||||||
|
|
||||||
|
const viewportEl = document.querySelector(".aui-thread-viewport");
|
||||||
|
const heightBefore = viewportEl?.scrollHeight ?? 0;
|
||||||
|
|
||||||
|
aui.composer().send();
|
||||||
|
editorRef.current?.clear();
|
||||||
|
setMentionedDocuments([]);
|
||||||
|
setSidebarDocs([]);
|
||||||
|
|
||||||
|
// With turnAnchor="top", ViewportSlack adds min-height to the last
|
||||||
|
// assistant message so that scrolling-to-bottom actually positions the
|
||||||
|
// user message at the TOP of the viewport. That slack height is
|
||||||
|
// calculated asynchronously (ResizeObserver → style → layout).
|
||||||
|
//
|
||||||
|
// We poll via rAF for ~2 s, re-scrolling whenever scrollHeight changes
|
||||||
|
// (user msg render → assistant placeholder → ViewportSlack min-height →
|
||||||
|
// first streamed content). Backup setTimeout calls cover cases where
|
||||||
|
// the batcher's 50 ms throttle delays the DOM update past the rAF.
|
||||||
|
const scrollToBottom = () =>
|
||||||
|
threadViewportStore.getState().scrollToBottom({ behavior: "instant" });
|
||||||
|
|
||||||
|
let lastHeight = heightBefore;
|
||||||
|
let frames = 0;
|
||||||
|
let cancelled = false;
|
||||||
|
const POLL_FRAMES = 120;
|
||||||
|
|
||||||
|
const pollAndScroll = () => {
|
||||||
|
if (cancelled) return;
|
||||||
|
const el = document.querySelector(".aui-thread-viewport");
|
||||||
|
if (el) {
|
||||||
|
const h = el.scrollHeight;
|
||||||
|
if (h !== lastHeight) {
|
||||||
|
lastHeight = h;
|
||||||
|
scrollToBottom();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (++frames < POLL_FRAMES) {
|
||||||
|
requestAnimationFrame(pollAndScroll);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
requestAnimationFrame(pollAndScroll);
|
||||||
|
|
||||||
|
const t1 = setTimeout(scrollToBottom, 100);
|
||||||
|
const t2 = setTimeout(scrollToBottom, 300);
|
||||||
|
const t3 = setTimeout(scrollToBottom, 600);
|
||||||
|
|
||||||
|
// Cleanup if component unmounts during the polling window. The ref is
|
||||||
|
// checked inside pollAndScroll; timeouts are cleared in the return below.
|
||||||
|
// Store cleanup fn so it can be called from a useEffect cleanup if needed.
|
||||||
|
submitCleanupRef.current = () => {
|
||||||
|
cancelled = true;
|
||||||
|
clearTimeout(t1);
|
||||||
|
clearTimeout(t2);
|
||||||
|
clearTimeout(t3);
|
||||||
|
};
|
||||||
}, [
|
}, [
|
||||||
showDocumentPopover,
|
showDocumentPopover,
|
||||||
showPromptPicker,
|
showPromptPicker,
|
||||||
|
|
@ -602,6 +667,7 @@ const Composer: FC = () => {
|
||||||
aui,
|
aui,
|
||||||
setMentionedDocuments,
|
setMentionedDocuments,
|
||||||
setSidebarDocs,
|
setSidebarDocs,
|
||||||
|
threadViewportStore,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const handleDocumentRemove = useCallback(
|
const handleDocumentRemove = useCallback(
|
||||||
|
|
|
||||||
|
|
@ -27,18 +27,48 @@ export interface ContentPartsState {
|
||||||
toolCallIndices: Map<string, number>;
|
toolCallIndices: Map<string, number>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function areThinkingStepsEqual(
|
||||||
|
current: ThinkingStepData[],
|
||||||
|
next: ThinkingStepData[]
|
||||||
|
): boolean {
|
||||||
|
if (current.length !== next.length) return false;
|
||||||
|
|
||||||
|
for (let i = 0; i < current.length; i += 1) {
|
||||||
|
const curr = current[i];
|
||||||
|
const nxt = next[i];
|
||||||
|
if (curr.id !== nxt.id || curr.title !== nxt.title || curr.status !== nxt.status) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (curr.items.length !== nxt.items.length) return false;
|
||||||
|
for (let j = 0; j < curr.items.length; j += 1) {
|
||||||
|
if (curr.items[j] !== nxt.items[j]) return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
export function updateThinkingSteps(
|
export function updateThinkingSteps(
|
||||||
state: ContentPartsState,
|
state: ContentPartsState,
|
||||||
steps: Map<string, ThinkingStepData>
|
steps: Map<string, ThinkingStepData>
|
||||||
): void {
|
): boolean {
|
||||||
const stepsArray = Array.from(steps.values());
|
const stepsArray = Array.from(steps.values());
|
||||||
const existingIdx = state.contentParts.findIndex((p) => p.type === "data-thinking-steps");
|
const existingIdx = state.contentParts.findIndex((p) => p.type === "data-thinking-steps");
|
||||||
|
|
||||||
if (existingIdx >= 0) {
|
if (existingIdx >= 0) {
|
||||||
|
const existing = state.contentParts[existingIdx];
|
||||||
|
if (
|
||||||
|
existing?.type === "data-thinking-steps" &&
|
||||||
|
areThinkingStepsEqual(existing.data.steps, stepsArray)
|
||||||
|
) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
state.contentParts[existingIdx] = {
|
state.contentParts[existingIdx] = {
|
||||||
type: "data-thinking-steps",
|
type: "data-thinking-steps",
|
||||||
data: { steps: stepsArray },
|
data: { steps: stepsArray },
|
||||||
};
|
};
|
||||||
|
return true;
|
||||||
} else {
|
} else {
|
||||||
state.contentParts.unshift({
|
state.contentParts.unshift({
|
||||||
type: "data-thinking-steps",
|
type: "data-thinking-steps",
|
||||||
|
|
@ -50,6 +80,56 @@ export function updateThinkingSteps(
|
||||||
for (const [id, idx] of state.toolCallIndices) {
|
for (const [id, idx] of state.toolCallIndices) {
|
||||||
state.toolCallIndices.set(id, idx + 1);
|
state.toolCallIndices.set(id, idx + 1);
|
||||||
}
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Coalesces rapid setMessages calls into at most one React state update per
|
||||||
|
* throttle interval. During streaming, SSE text-delta events arrive much
|
||||||
|
* faster than the user can perceive; throttling to ~50 ms lets React +
|
||||||
|
* ReactMarkdown do far fewer reconciliation passes, eliminating flicker.
|
||||||
|
*/
|
||||||
|
export class FrameBatchedUpdater {
|
||||||
|
private timerId: ReturnType<typeof setTimeout> | null = null;
|
||||||
|
private flusher: (() => void) | null = null;
|
||||||
|
private dirty = false;
|
||||||
|
private static readonly INTERVAL_MS = 50;
|
||||||
|
|
||||||
|
/** Mark state as dirty — will flush after the throttle interval. */
|
||||||
|
schedule(flush: () => void): void {
|
||||||
|
this.flusher = flush;
|
||||||
|
this.dirty = true;
|
||||||
|
if (this.timerId === null) {
|
||||||
|
this.timerId = setTimeout(() => {
|
||||||
|
this.timerId = null;
|
||||||
|
if (this.dirty) {
|
||||||
|
this.dirty = false;
|
||||||
|
this.flusher?.();
|
||||||
|
}
|
||||||
|
}, FrameBatchedUpdater.INTERVAL_MS);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Immediately flush any pending update (call on tool events or stream end). */
|
||||||
|
flush(): void {
|
||||||
|
if (this.timerId !== null) {
|
||||||
|
clearTimeout(this.timerId);
|
||||||
|
this.timerId = null;
|
||||||
|
}
|
||||||
|
if (this.dirty) {
|
||||||
|
this.dirty = false;
|
||||||
|
this.flusher?.();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
dispose(): void {
|
||||||
|
if (this.timerId !== null) {
|
||||||
|
clearTimeout(this.timerId);
|
||||||
|
this.timerId = null;
|
||||||
|
}
|
||||||
|
this.dirty = false;
|
||||||
|
this.flusher = null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -149,6 +229,7 @@ export type SSEEvent =
|
||||||
| { type: "data-thinking-step"; data: ThinkingStepData }
|
| { type: "data-thinking-step"; data: ThinkingStepData }
|
||||||
| { type: "data-thread-title-update"; data: { threadId: number; title: string } }
|
| { type: "data-thread-title-update"; data: { threadId: number; title: string } }
|
||||||
| { type: "data-interrupt-request"; data: Record<string, unknown> }
|
| { type: "data-interrupt-request"; data: Record<string, unknown> }
|
||||||
|
| { type: "data-documents-updated"; data: Record<string, unknown> }
|
||||||
| { type: "error"; errorText: string };
|
| { type: "error"; errorText: string };
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue