Spaces:
Running
Running
feat: implement debounced auto-save for conversations and enhance ResultBlock component with truncation support
Browse files- src/App.tsx +108 -36
- src/components/ResultBlock.tsx +63 -16
- src/services/mcpClient.ts +2 -8
- src/services/oauth.ts +11 -1
src/App.tsx
CHANGED
|
@@ -10,7 +10,6 @@ import {
|
|
| 10 |
Play,
|
| 11 |
Plus,
|
| 12 |
Zap,
|
| 13 |
-
RotateCcw,
|
| 14 |
Settings,
|
| 15 |
X,
|
| 16 |
PanelRightClose,
|
|
@@ -170,6 +169,7 @@ const App: React.FC = () => {
|
|
| 170 |
const [isConversationsPanelVisible, setIsConversationsPanelVisible] = useState<boolean>(false);
|
| 171 |
const chatContainerRef = useRef<HTMLDivElement>(null);
|
| 172 |
const debounceTimers = useRef<Record<number, NodeJS.Timeout>>({});
|
|
|
|
| 173 |
const toolsContainerRef = useRef<HTMLDivElement>(null);
|
| 174 |
const inputRef = useRef<HTMLInputElement>(null);
|
| 175 |
const {
|
|
@@ -278,32 +278,48 @@ const App: React.FC = () => {
|
|
| 278 |
}, []);
|
| 279 |
|
| 280 |
// Auto-save current conversation when messages change
|
|
|
|
| 281 |
useEffect(() => {
|
| 282 |
if (messages.length === 0) return;
|
| 283 |
|
| 284 |
-
|
| 285 |
-
|
| 286 |
-
|
| 287 |
-
|
| 288 |
-
|
| 289 |
-
|
| 290 |
-
|
| 291 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 292 |
};
|
| 293 |
|
| 294 |
-
|
| 295 |
-
|
| 296 |
-
|
| 297 |
-
|
| 298 |
|
| 299 |
-
|
| 300 |
-
|
| 301 |
-
|
|
|
|
|
|
|
| 302 |
};
|
| 303 |
-
|
| 304 |
-
saveCurrentConversation().catch((error) => {
|
| 305 |
-
console.error("Failed to save conversation:", error);
|
| 306 |
-
});
|
| 307 |
}, [messages, currentConversationId, conversations]);
|
| 308 |
|
| 309 |
const updateToolInDB = async (tool: Tool): Promise<void> => {
|
|
@@ -595,22 +611,50 @@ const App: React.FC = () => {
|
|
| 595 |
setMessages([...currentMessages, { role: "assistant", content: "" }]);
|
| 596 |
|
| 597 |
let accumulatedContent = "";
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 598 |
const response = await generateResponse(
|
| 599 |
messagesForGeneration,
|
| 600 |
toolSchemas,
|
| 601 |
(token: string) => {
|
| 602 |
accumulatedContent += token;
|
| 603 |
-
|
| 604 |
-
|
| 605 |
-
|
| 606 |
-
|
| 607 |
-
|
| 608 |
-
|
| 609 |
-
|
| 610 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 611 |
}
|
| 612 |
);
|
| 613 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 614 |
currentMessages.push({ role: "assistant", content: response });
|
| 615 |
const toolCallContent = extractToolCallContent(response);
|
| 616 |
|
|
@@ -760,22 +804,50 @@ const App: React.FC = () => {
|
|
| 760 |
setMessages([...currentMessages, { role: "assistant", content: "" }]);
|
| 761 |
|
| 762 |
let accumulatedContent = "";
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 763 |
const response = await generateResponse(
|
| 764 |
messagesForGeneration,
|
| 765 |
toolSchemas,
|
| 766 |
(token: string) => {
|
| 767 |
accumulatedContent += token;
|
| 768 |
-
|
| 769 |
-
|
| 770 |
-
|
| 771 |
-
|
| 772 |
-
|
| 773 |
-
|
| 774 |
-
|
| 775 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 776 |
}
|
| 777 |
);
|
| 778 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 779 |
currentMessages.push({ role: "assistant", content: response });
|
| 780 |
const toolCallContent = extractToolCallContent(response);
|
| 781 |
|
|
|
|
| 10 |
Play,
|
| 11 |
Plus,
|
| 12 |
Zap,
|
|
|
|
| 13 |
Settings,
|
| 14 |
X,
|
| 15 |
PanelRightClose,
|
|
|
|
| 169 |
const [isConversationsPanelVisible, setIsConversationsPanelVisible] = useState<boolean>(false);
|
| 170 |
const chatContainerRef = useRef<HTMLDivElement>(null);
|
| 171 |
const debounceTimers = useRef<Record<number, NodeJS.Timeout>>({});
|
| 172 |
+
const conversationSaveTimer = useRef<NodeJS.Timeout | null>(null);
|
| 173 |
const toolsContainerRef = useRef<HTMLDivElement>(null);
|
| 174 |
const inputRef = useRef<HTMLInputElement>(null);
|
| 175 |
const {
|
|
|
|
| 278 |
}, []);
|
| 279 |
|
| 280 |
// Auto-save current conversation when messages change
|
| 281 |
+
// Debounced conversation auto-save to prevent excessive IndexedDB writes
|
| 282 |
useEffect(() => {
|
| 283 |
if (messages.length === 0) return;
|
| 284 |
|
| 285 |
+
// Clear existing timer
|
| 286 |
+
if (conversationSaveTimer.current) {
|
| 287 |
+
clearTimeout(conversationSaveTimer.current);
|
| 288 |
+
}
|
| 289 |
+
|
| 290 |
+
// Set new timer to save after 1 second of inactivity
|
| 291 |
+
conversationSaveTimer.current = setTimeout(() => {
|
| 292 |
+
const saveCurrentConversation = async () => {
|
| 293 |
+
const title = generateConversationTitle(messages);
|
| 294 |
+
const conversation: Conversation = {
|
| 295 |
+
...(currentConversationId ? { id: currentConversationId } : {}),
|
| 296 |
+
title,
|
| 297 |
+
messages,
|
| 298 |
+
createdAt: currentConversationId ? conversations.find(c => c.id === currentConversationId)?.createdAt || Date.now() : Date.now(),
|
| 299 |
+
updatedAt: Date.now(),
|
| 300 |
+
};
|
| 301 |
+
|
| 302 |
+
const id = await saveConversation(conversation);
|
| 303 |
+
if (!currentConversationId) {
|
| 304 |
+
setCurrentConversationId(id);
|
| 305 |
+
}
|
| 306 |
+
|
| 307 |
+
// Reload conversations list
|
| 308 |
+
const updatedConversations = await loadAllConversations();
|
| 309 |
+
setConversations(updatedConversations);
|
| 310 |
};
|
| 311 |
|
| 312 |
+
saveCurrentConversation().catch((error) => {
|
| 313 |
+
console.error("Failed to save conversation:", error);
|
| 314 |
+
});
|
| 315 |
+
}, 1000);
|
| 316 |
|
| 317 |
+
// Cleanup on unmount
|
| 318 |
+
return () => {
|
| 319 |
+
if (conversationSaveTimer.current) {
|
| 320 |
+
clearTimeout(conversationSaveTimer.current);
|
| 321 |
+
}
|
| 322 |
};
|
|
|
|
|
|
|
|
|
|
|
|
|
| 323 |
}, [messages, currentConversationId, conversations]);
|
| 324 |
|
| 325 |
const updateToolInDB = async (tool: Tool): Promise<void> => {
|
|
|
|
| 611 |
setMessages([...currentMessages, { role: "assistant", content: "" }]);
|
| 612 |
|
| 613 |
let accumulatedContent = "";
|
| 614 |
+
let lastUpdateTime = 0;
|
| 615 |
+
let pendingUpdate = false;
|
| 616 |
+
const THROTTLE_MS = 50; // Update UI at most every 50ms
|
| 617 |
+
|
| 618 |
+
const updateUI = () => {
|
| 619 |
+
setMessages((current) => {
|
| 620 |
+
const updated = [...current];
|
| 621 |
+
updated[updated.length - 1] = {
|
| 622 |
+
role: "assistant",
|
| 623 |
+
content: accumulatedContent,
|
| 624 |
+
};
|
| 625 |
+
return updated;
|
| 626 |
+
});
|
| 627 |
+
lastUpdateTime = Date.now();
|
| 628 |
+
pendingUpdate = false;
|
| 629 |
+
};
|
| 630 |
+
|
| 631 |
const response = await generateResponse(
|
| 632 |
messagesForGeneration,
|
| 633 |
toolSchemas,
|
| 634 |
(token: string) => {
|
| 635 |
accumulatedContent += token;
|
| 636 |
+
const now = Date.now();
|
| 637 |
+
|
| 638 |
+
// Throttle updates: only update if enough time has passed
|
| 639 |
+
if (now - lastUpdateTime >= THROTTLE_MS) {
|
| 640 |
+
updateUI();
|
| 641 |
+
} else if (!pendingUpdate) {
|
| 642 |
+
// Schedule an update if one isn't already pending
|
| 643 |
+
pendingUpdate = true;
|
| 644 |
+
setTimeout(() => {
|
| 645 |
+
if (pendingUpdate) {
|
| 646 |
+
updateUI();
|
| 647 |
+
}
|
| 648 |
+
}, THROTTLE_MS - (now - lastUpdateTime));
|
| 649 |
+
}
|
| 650 |
}
|
| 651 |
);
|
| 652 |
|
| 653 |
+
// Ensure final update is applied
|
| 654 |
+
if (accumulatedContent !== "") {
|
| 655 |
+
updateUI();
|
| 656 |
+
}
|
| 657 |
+
|
| 658 |
currentMessages.push({ role: "assistant", content: response });
|
| 659 |
const toolCallContent = extractToolCallContent(response);
|
| 660 |
|
|
|
|
| 804 |
setMessages([...currentMessages, { role: "assistant", content: "" }]);
|
| 805 |
|
| 806 |
let accumulatedContent = "";
|
| 807 |
+
let lastUpdateTime = 0;
|
| 808 |
+
let pendingUpdate = false;
|
| 809 |
+
const THROTTLE_MS = 50; // Update UI at most every 50ms
|
| 810 |
+
|
| 811 |
+
const updateUI = () => {
|
| 812 |
+
setMessages((current) => {
|
| 813 |
+
const updated = [...current];
|
| 814 |
+
updated[updated.length - 1] = {
|
| 815 |
+
role: "assistant",
|
| 816 |
+
content: accumulatedContent,
|
| 817 |
+
};
|
| 818 |
+
return updated;
|
| 819 |
+
});
|
| 820 |
+
lastUpdateTime = Date.now();
|
| 821 |
+
pendingUpdate = false;
|
| 822 |
+
};
|
| 823 |
+
|
| 824 |
const response = await generateResponse(
|
| 825 |
messagesForGeneration,
|
| 826 |
toolSchemas,
|
| 827 |
(token: string) => {
|
| 828 |
accumulatedContent += token;
|
| 829 |
+
const now = Date.now();
|
| 830 |
+
|
| 831 |
+
// Throttle updates: only update if enough time has passed
|
| 832 |
+
if (now - lastUpdateTime >= THROTTLE_MS) {
|
| 833 |
+
updateUI();
|
| 834 |
+
} else if (!pendingUpdate) {
|
| 835 |
+
// Schedule an update if one isn't already pending
|
| 836 |
+
pendingUpdate = true;
|
| 837 |
+
setTimeout(() => {
|
| 838 |
+
if (pendingUpdate) {
|
| 839 |
+
updateUI();
|
| 840 |
+
}
|
| 841 |
+
}, THROTTLE_MS - (now - lastUpdateTime));
|
| 842 |
+
}
|
| 843 |
}
|
| 844 |
);
|
| 845 |
|
| 846 |
+
// Ensure final update is applied
|
| 847 |
+
if (accumulatedContent !== "") {
|
| 848 |
+
updateUI();
|
| 849 |
+
}
|
| 850 |
+
|
| 851 |
currentMessages.push({ role: "assistant", content: response });
|
| 852 |
const toolCallContent = extractToolCallContent(response);
|
| 853 |
|
src/components/ResultBlock.tsx
CHANGED
|
@@ -1,4 +1,4 @@
|
|
| 1 |
-
import
|
| 2 |
|
| 3 |
interface ResultBlockProps {
|
| 4 |
error?: string;
|
|
@@ -8,21 +8,68 @@ interface ResultBlockProps {
|
|
| 8 |
const ResultBlock: React.FC<ResultBlockProps> = ({
|
| 9 |
error,
|
| 10 |
result,
|
| 11 |
-
}) =>
|
| 12 |
-
|
| 13 |
-
|
| 14 |
-
|
| 15 |
-
|
| 16 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 17 |
}
|
| 18 |
-
|
| 19 |
-
|
| 20 |
-
|
| 21 |
-
|
| 22 |
-
|
| 23 |
-
|
| 24 |
-
|
| 25 |
-
|
| 26 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 27 |
|
| 28 |
export default ResultBlock;
|
|
|
|
| 1 |
+
import React, { useState, useMemo } from "react";
|
| 2 |
|
| 3 |
interface ResultBlockProps {
|
| 4 |
error?: string;
|
|
|
|
| 8 |
const ResultBlock: React.FC<ResultBlockProps> = ({
|
| 9 |
error,
|
| 10 |
result,
|
| 11 |
+
}) => {
|
| 12 |
+
const [isExpanded, setIsExpanded] = useState(false);
|
| 13 |
+
const MAX_LENGTH = 10000; // Characters to show before truncating
|
| 14 |
+
|
| 15 |
+
const formattedResult = useMemo(() => {
|
| 16 |
+
if (result === undefined || result === null) {
|
| 17 |
+
return "No result";
|
| 18 |
+
}
|
| 19 |
+
|
| 20 |
+
if (typeof result !== "object") {
|
| 21 |
+
return String(result);
|
| 22 |
+
}
|
| 23 |
+
|
| 24 |
+
try {
|
| 25 |
+
const fullString = JSON.stringify(result, null, 2);
|
| 26 |
+
|
| 27 |
+
// If the result is very large, provide truncation
|
| 28 |
+
if (fullString.length > MAX_LENGTH && !isExpanded) {
|
| 29 |
+
return fullString.substring(0, MAX_LENGTH);
|
| 30 |
+
}
|
| 31 |
+
|
| 32 |
+
return fullString;
|
| 33 |
+
} catch (err) {
|
| 34 |
+
// Handle circular references or other JSON.stringify errors
|
| 35 |
+
return String(result);
|
| 36 |
}
|
| 37 |
+
}, [result, isExpanded]);
|
| 38 |
+
|
| 39 |
+
const isTruncated = useMemo(() => {
|
| 40 |
+
if (typeof result !== "object" || result === null) return false;
|
| 41 |
+
try {
|
| 42 |
+
return JSON.stringify(result, null, 2).length > MAX_LENGTH;
|
| 43 |
+
} catch {
|
| 44 |
+
return false;
|
| 45 |
+
}
|
| 46 |
+
}, [result]);
|
| 47 |
+
|
| 48 |
+
return (
|
| 49 |
+
<div
|
| 50 |
+
className={
|
| 51 |
+
error
|
| 52 |
+
? "bg-red-900 border border-red-600 rounded p-3"
|
| 53 |
+
: "bg-gray-700 border border-gray-600 rounded p-3"
|
| 54 |
+
}
|
| 55 |
+
>
|
| 56 |
+
{error ? <p className="text-red-300 text-sm">Error: {error}</p> : null}
|
| 57 |
+
<pre className="text-sm text-gray-300 whitespace-pre-wrap overflow-auto mt-2 max-h-96">
|
| 58 |
+
{formattedResult}
|
| 59 |
+
{isTruncated && !isExpanded && (
|
| 60 |
+
<span className="text-yellow-400">... (truncated)</span>
|
| 61 |
+
)}
|
| 62 |
+
</pre>
|
| 63 |
+
{isTruncated && (
|
| 64 |
+
<button
|
| 65 |
+
onClick={() => setIsExpanded(!isExpanded)}
|
| 66 |
+
className="mt-2 text-xs text-blue-400 hover:text-blue-300 underline"
|
| 67 |
+
>
|
| 68 |
+
{isExpanded ? "Show less" : "Show full result"}
|
| 69 |
+
</button>
|
| 70 |
+
)}
|
| 71 |
+
</div>
|
| 72 |
+
);
|
| 73 |
+
};
|
| 74 |
|
| 75 |
export default ResultBlock;
|
src/services/mcpClient.ts
CHANGED
|
@@ -157,11 +157,7 @@ export class MCPClientService {
|
|
| 157 |
version: MCP_CLIENT_CONFIG.VERSION,
|
| 158 |
},
|
| 159 |
{
|
| 160 |
-
capabilities: {
|
| 161 |
-
tools: {},
|
| 162 |
-
resources: {},
|
| 163 |
-
prompts: {},
|
| 164 |
-
},
|
| 165 |
}
|
| 166 |
);
|
| 167 |
|
|
@@ -378,9 +374,7 @@ export class MCPClientService {
|
|
| 378 |
version: MCP_CLIENT_CONFIG.VERSION,
|
| 379 |
},
|
| 380 |
{
|
| 381 |
-
capabilities: {
|
| 382 |
-
tools: {},
|
| 383 |
-
},
|
| 384 |
}
|
| 385 |
);
|
| 386 |
|
|
|
|
| 157 |
version: MCP_CLIENT_CONFIG.VERSION,
|
| 158 |
},
|
| 159 |
{
|
| 160 |
+
capabilities: {},
|
|
|
|
|
|
|
|
|
|
|
|
|
| 161 |
}
|
| 162 |
);
|
| 163 |
|
|
|
|
| 374 |
version: MCP_CLIENT_CONFIG.VERSION,
|
| 375 |
},
|
| 376 |
{
|
| 377 |
+
capabilities: {},
|
|
|
|
|
|
|
| 378 |
}
|
| 379 |
);
|
| 380 |
|
src/services/oauth.ts
CHANGED
|
@@ -5,12 +5,22 @@ import {
|
|
| 5 |
exchangeAuthorization,
|
| 6 |
registerClient,
|
| 7 |
} from "@modelcontextprotocol/sdk/client/auth.js";
|
|
|
|
| 8 |
import { secureStorage } from "../utils/storage";
|
| 9 |
import { MCP_CLIENT_CONFIG, STORAGE_KEYS, DEFAULTS } from "../config/constants";
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 10 |
// Utility to fetch .well-known/modelcontextprotocol for OAuth endpoints
|
| 11 |
export async function discoverOAuthEndpoints(serverUrl: string) {
|
| 12 |
// ...existing code...
|
| 13 |
-
let resourceMetadata, authMetadata, authorizationServerUrl;
|
| 14 |
try {
|
| 15 |
resourceMetadata = await discoverOAuthProtectedResourceMetadata(serverUrl);
|
| 16 |
if (resourceMetadata?.authorization_servers?.length) {
|
|
|
|
| 5 |
exchangeAuthorization,
|
| 6 |
registerClient,
|
| 7 |
} from "@modelcontextprotocol/sdk/client/auth.js";
|
| 8 |
+
import type { AuthorizationServerMetadata } from "@modelcontextprotocol/sdk/shared/auth.js";
|
| 9 |
import { secureStorage } from "../utils/storage";
|
| 10 |
import { MCP_CLIENT_CONFIG, STORAGE_KEYS, DEFAULTS } from "../config/constants";
|
| 11 |
+
|
| 12 |
+
// Extended type to include custom properties for OAuth flow
|
| 13 |
+
type ExtendedAuthMetadata = AuthorizationServerMetadata & {
|
| 14 |
+
client_id?: string;
|
| 15 |
+
client_secret?: string;
|
| 16 |
+
redirect_uri?: string;
|
| 17 |
+
scopes?: string[];
|
| 18 |
+
};
|
| 19 |
+
|
| 20 |
// Utility to fetch .well-known/modelcontextprotocol for OAuth endpoints
|
| 21 |
export async function discoverOAuthEndpoints(serverUrl: string) {
|
| 22 |
// ...existing code...
|
| 23 |
+
let resourceMetadata, authMetadata: ExtendedAuthMetadata | undefined, authorizationServerUrl;
|
| 24 |
try {
|
| 25 |
resourceMetadata = await discoverOAuthProtectedResourceMetadata(serverUrl);
|
| 26 |
if (resourceMetadata?.authorization_servers?.length) {
|