From 915c41ac9246f38ab0f3352e8caa1cff47548a1b Mon Sep 17 00:00:00 2001 From: qduc Date: Sun, 28 Dec 2025 20:42:21 +0700 Subject: [PATCH 01/34] feat: Add `skip_ci` input to electron and docker release workflows to prevent redundant CI runs. --- .github/workflows/docker-publish.yml | 7 ++++++- .github/workflows/electron-release.yml | 7 ++++++- .github/workflows/release.yml | 2 ++ 3 files changed, 14 insertions(+), 2 deletions(-) diff --git a/.github/workflows/docker-publish.yml b/.github/workflows/docker-publish.yml index 22090bf2..3450e62c 100644 --- a/.github/workflows/docker-publish.yml +++ b/.github/workflows/docker-publish.yml @@ -7,6 +7,11 @@ on: description: 'Tag to build and publish' required: false type: string + skip_ci: + description: 'Skip CI' + required: false + type: boolean + default: false workflow_dispatch: inputs: tag: @@ -20,7 +25,7 @@ env: jobs: ci: - if: github.event_name != 'workflow_call' + if: github.event_name != 'workflow_call' && inputs.skip_ci != true uses: ./.github/workflows/ci.yml permissions: contents: read diff --git a/.github/workflows/electron-release.yml b/.github/workflows/electron-release.yml index dadd6413..e597319d 100644 --- a/.github/workflows/electron-release.yml +++ b/.github/workflows/electron-release.yml @@ -7,6 +7,11 @@ on: description: 'Tag to build and publish' required: false type: string + skip_ci: + description: 'Skip CI' + required: false + type: boolean + default: false workflow_dispatch: inputs: tag: @@ -19,7 +24,7 @@ permissions: jobs: ci: - if: github.event_name != 'workflow_call' + if: github.event_name != 'workflow_call' && inputs.skip_ci != true uses: ./.github/workflows/ci.yml build: diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 12a481b7..b2d3e8df 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -22,6 +22,7 @@ jobs: uses: ./.github/workflows/docker-publish.yml with: tag: ${{ inputs.tag }} + skip_ci: true secrets: inherit permissions: contents: read @@ -32,6 +33,7 @@ jobs: uses: ./.github/workflows/electron-release.yml with: tag: ${{ inputs.tag }} + skip_ci: true secrets: inherit permissions: contents: write From 33ede02549b7e80a95baaf91c980cf6857188297 Mon Sep 17 00:00:00 2001 From: qduc Date: Mon, 29 Dec 2025 00:29:00 +0700 Subject: [PATCH 02/34] fix: newline not render properly in user's message --- frontend/components/Markdown.tsx | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/frontend/components/Markdown.tsx b/frontend/components/Markdown.tsx index ba3432e6..9c38e6a4 100644 --- a/frontend/components/Markdown.tsx +++ b/frontend/components/Markdown.tsx @@ -730,6 +730,21 @@ export const Markdown: React.FC = ({ text, className, isStreaming const processedText = useMemo(() => { let textToProcess = escapeCurrencyDollarSigns(normalizeLatexDelimiters(throttledText)); + // Convert single newlines to hard breaks for better text formatting + // Split by code blocks and inline code to avoid affecting them + const parts = textToProcess.split(/(```[\s\S]*?```|`[^`]*`)/g); + textToProcess = parts + .map((part) => { + if (part.startsWith('```') || (part.startsWith('`') && part.endsWith('`'))) { + // This is a code block or inline code, leave as-is + return part; + } else { + // This is regular text, convert single newlines to hard breaks + return part.replace(/\n/g, ' \n'); + } + }) + .join(''); + // Check for incomplete thinking blocks (both and variants) // Count opening and closing tags to ensure they match const openingThinkingTags = (textToProcess.match(//g) || []).length; From d07c138990cc1d28194019e038fc872c2ddb4635 Mon Sep 17 00:00:00 2001 From: qduc Date: Mon, 29 Dec 2025 00:38:35 +0700 Subject: [PATCH 03/34] feat: auto-hide functionality for scroll buttons --- frontend/components/ChatV2.tsx | 40 ++++++++++++++++++++++++++++++++-- 1 file changed, 38 insertions(+), 2 deletions(-) diff --git a/frontend/components/ChatV2.tsx b/frontend/components/ChatV2.tsx index 00d997c4..e1e6f840 100644 --- a/frontend/components/ChatV2.tsx +++ b/frontend/components/ChatV2.tsx @@ -36,6 +36,8 @@ export function ChatV2() { const frameRef = useRef(null); const messageListRef = useRef(null!); const [scrollButtons, setScrollButtons] = useState({ showTop: false, showBottom: false }); + const [showScrollButtons, setShowScrollButtons] = useState(false); + const scrollTimeoutRef = useRef(null); const isLoadingConversationRef = useRef(false); const messageInputRef = useRef(null); const messageInputContainerRef = useRef(null); @@ -82,6 +84,36 @@ export function ChatV2() { return () => window.removeEventListener('resize', handleResize); }, []); + // Handle scroll button visibility with auto-hide + useEffect(() => { + const container = messageListRef.current; + if (!container) return; + + const handleScroll = () => { + // Show buttons on scroll activity + setShowScrollButtons(true); + + // Clear existing timeout + if (scrollTimeoutRef.current) { + clearTimeout(scrollTimeoutRef.current); + } + + // Hide buttons after 2 seconds of no scrolling + scrollTimeoutRef.current = setTimeout(() => { + setShowScrollButtons(false); + }, 2000); + }; + + container.addEventListener('scroll', handleScroll, { passive: true }); + + return () => { + container.removeEventListener('scroll', handleScroll); + if (scrollTimeoutRef.current) { + clearTimeout(scrollTimeoutRef.current); + } + }; + }, []); + // Simple event handlers const handleCopy = useCallback(async (text: string) => { try { @@ -600,7 +632,9 @@ export function ChatV2() { {scrollButtons.showTop && ( + {Object.keys(message.comparisonResults || {}).map((modelId) => ( + + ))} + + )} + {isEditing ? ( (
{assistantSegments.length === 0 ? (
- {pending.streaming || pending.abort ? ( + {(isStreaming && activeComparisonTab === 'primary') || + isComparisonStreaming || + pending.abort ? ( ( key={`text-${segmentIndex}`} className="text-base leading-relaxed text-zinc-900 dark:text-zinc-200" > - +
); } @@ -679,7 +740,7 @@ const Message = React.memo(
)} - {!isEditing && (message.content || !isUser) && ( + {!isEditing && (displayMessage.content || !isUser) && (
( {/* Show stats for assistant messages */} {!isUser && (
- {streamingStats && streamingStats.tokensPerSecond > 0 && ( -
- {streamingStats.tokensPerSecond.toFixed(1)} tok/s -
- )} - {message.usage && ( + {streamingStats && + streamingStats.tokensPerSecond > 0 && + activeComparisonTab === 'primary' && ( +
+ {streamingStats.tokensPerSecond.toFixed(1)} tok/s +
+ )} + {displayMessage.usage && (
- {message.usage.provider && ( - {message.usage.provider} + {displayMessage.usage.provider && ( + {displayMessage.usage.provider} )} - {(message.usage.prompt_tokens !== undefined || - message.usage.completion_tokens !== undefined) && ( + {(displayMessage.usage.prompt_tokens !== undefined || + displayMessage.usage.completion_tokens !== undefined) && ( )} - {message.usage.prompt_tokens !== undefined && - message.usage.completion_tokens !== undefined && ( + {displayMessage.usage.prompt_tokens !== undefined && + displayMessage.usage.completion_tokens !== undefined && ( - {message.usage.prompt_tokens + message.usage.completion_tokens}{' '} - tokens ({message.usage.prompt_tokens}↑ +{' '} - {message.usage.completion_tokens}↓) + {displayMessage.usage.prompt_tokens + + displayMessage.usage.completion_tokens}{' '} + tokens ({displayMessage.usage.prompt_tokens}↑ +{' '} + {displayMessage.usage.completion_tokens}↓) )}
@@ -715,12 +779,12 @@ const Message = React.memo( )}
- {message.content && ( + {displayMessage.content && (
+ + {isOpen && ( +
+ {!shouldRenderDropdown ? ( +
Loading...
+ ) : ( + <> + {/* Provider Tabs */} + {providerTabs.length > 1 && ( +
{ + e.preventDefault(); + e.currentTarget.scrollLeft += e.deltaY; + }} + > + {providerTabs.map((tab) => ( + + ))} +
+ )} + + {/* Search Header */} +
+
+ + setSearchQuery(e.target.value)} + placeholder="Search models..." + className="w-full pl-10 pr-3 py-1.5 bg-zinc-50 dark:bg-zinc-800 border border-zinc-200 dark:border-zinc-700 rounded-lg focus:outline-none focus:ring-2 focus:ring-zinc-400 dark:focus:ring-zinc-600 text-sm" + /> +
+
+ + {/* Model List */} +
+ {filteredModels.slice(0, visibleCount).map((model, idx) => ( + + ))} + + {filteredModels.length === 0 && ( +
+ No models found. +
+ )} +
+ + {/* Footer */} +
+ {selectedModels.length} selected + {selectedModels.length > 0 && ( + + )} +
+ + )} +
+ )} +
+ ); +} diff --git a/frontend/hooks/useChat.ts b/frontend/hooks/useChat.ts index ef57bde0..62d9f772 100644 --- a/frontend/hooks/useChat.ts +++ b/frontend/hooks/useChat.ts @@ -42,6 +42,15 @@ export interface Message { total_tokens?: number; reasoning_tokens?: number; }; + comparisonResults?: Record< + string, + { + content: MessageContent; + usage?: any; + status: 'streaming' | 'complete' | 'error'; + error?: string; + } + >; } export interface Conversation { @@ -301,6 +310,9 @@ export function useChat() { const [modelToProvider, setModelToProvider] = useState>({}); const [modelCapabilities, setModelCapabilities] = useState(null); + // Comparison State + const [compareModels, setCompareModels] = useState([]); + // Tool & Quality State const [useTools, setUseTools] = useState(true); const [enabledTools, setEnabledTools] = useState([]); @@ -746,335 +758,566 @@ export function useChat() { // Send message with streaming // Use refs to get the latest values and avoid stale closures - // Extract actual model ID from provider-qualified format (provider::model) - const actualModelId = modelRef.current.includes('::') - ? modelRef.current.split('::')[1] - : modelRef.current; + const primaryModel = modelRef.current; + const activeComparisonModels = compareModels.filter((m) => m !== primaryModel); + + const executeRequest = async ( + targetModel: string, + isPrimary: boolean, + targetConversationId?: string + ) => { + // Extract actual model ID from provider-qualified format (provider::model) + const actualModelId = targetModel.includes('::') + ? targetModel.split('::')[1] + : targetModel; + + // Determine provider for this model + let targetProviderId = providerIdRef.current || ''; + if (targetModel.includes('::')) { + targetProviderId = targetModel.split('::')[0]; + } else if (modelToProviderRef.current[targetModel]) { + targetProviderId = modelToProviderRef.current[targetModel]; + } - // Map qualityLevel to reasoning effort if the model supports reasoning - const reasoning = - qualityLevelRef.current !== 'unset' ? { effort: qualityLevelRef.current } : undefined; + // Map qualityLevel to reasoning effort if the model supports reasoning + const reasoning = + qualityLevelRef.current !== 'unset' ? { effort: qualityLevelRef.current } : undefined; - const currentMessages = messagesRef.current; + const currentMessages = messagesRef.current; - // Prepare the new message object - const newMessageObj = { - id: userMessage.id, - role: 'user' as const, - content: messageContent, - }; + // Prepare the new message object + const newMessageObj = { + id: userMessage.id, + role: 'user' as const, + content: messageContent, + }; - let messagesPayload; - if (conversationId) { - // Optimization for existing conversations - messagesPayload = [newMessageObj]; - } else { - // Full history for new conversations - const history = currentMessages.map((m) => ({ - id: m.id, - role: m.role, - content: m.content, - tool_calls: m.tool_calls, - tool_outputs: m.tool_outputs, - })); - - // Check if the message is already in history (e.g. retry/regenerate) - const exists = history.some((m) => m.id === newMessageObj.id); - if (exists) { - // Update existing message in place (in case content changed) - messagesPayload = history.map((m) => - m.id === newMessageObj.id ? { ...m, ...newMessageObj } : m - ); + let messagesPayload; + if (targetConversationId) { + // Optimization for existing conversations + messagesPayload = [newMessageObj]; } else { - // Append new message - messagesPayload = [...history, newMessageObj]; - } - } + // Full history for new conversations or secondary comparison requests + // (Secondary requests use undefined conversationId, so they need full history) + const history = currentMessages.map((m) => ({ + id: m.id, + role: m.role, + content: m.content, + tool_calls: m.tool_calls, + tool_outputs: m.tool_outputs, + })); - const response = await chat.sendMessage({ - messages: messagesPayload, - model: actualModelId, - providerId: providerIdRef.current || '', - stream: shouldStreamRef.current, - providerStream: providerStreamRef.current, - requestId: messageId, - signal: abortControllerRef.current.signal, - conversationId: conversationId || undefined, - streamingEnabled: shouldStreamRef.current, - toolsEnabled: useToolsRef.current, - tools: enabledToolsRef.current, - qualityLevel: qualityLevelRef.current, - reasoning: reasoning, - systemPrompt: systemPromptRef.current || undefined, - activeSystemPromptId: activeSystemPromptIdRef.current || undefined, - modelCapabilities: modelCapabilities, - onToken: (token: string) => { - // Update token count using ref to avoid re-renders on every token - if (tokenStatsRef.current && tokenStatsRef.current.messageId === messageId) { - // If this is the first token (count is 0), update startTime to now - const isFirstToken = tokenStatsRef.current.count === 0; - tokenStatsRef.current.count += 1; - if (isFirstToken) { - tokenStatsRef.current.startTime = Date.now(); - } - tokenStatsRef.current.lastUpdated = Date.now(); + // Check if the message is already in history (e.g. retry/regenerate) + const exists = history.some((m) => m.id === newMessageObj.id); + if (exists) { + // Update existing message in place (in case content changed) + messagesPayload = history.map((m) => + m.id === newMessageObj.id ? { ...m, ...newMessageObj } : m + ); + } else { + // Append new message + messagesPayload = [...history, newMessageObj]; } + } + // Initialize comparison result for this model if it's secondary + if (!isPrimary) { setMessages((prev) => { const lastIdx = prev.length - 1; if (lastIdx < 0) return prev; - const lastMsg = prev[lastIdx]; if (!lastMsg || lastMsg.role !== 'assistant') return prev; - const newContent = - typeof lastMsg.content === 'string' ? lastMsg.content + token : token; - - return [...prev.slice(0, lastIdx), { ...lastMsg, content: newContent }]; + return [ + ...prev.slice(0, lastIdx), + { + ...lastMsg, + comparisonResults: { + ...(lastMsg.comparisonResults || {}), + [targetModel]: { + content: '', + status: 'streaming', + }, + }, + }, + ]; }); - }, - onEvent: (event) => { - if (event.type === 'text') { - // Update token count using ref to avoid re-renders on every event - if (tokenStatsRef.current && tokenStatsRef.current.messageId === messageId) { - // If this is the first content (count is 0), update startTime to now - const isFirstContent = tokenStatsRef.current.count === 0; - tokenStatsRef.current.count += 1; - if (isFirstContent) { - tokenStatsRef.current.startTime = Date.now(); + } + + try { + const response = await chat.sendMessage({ + messages: messagesPayload, + model: actualModelId, + providerId: targetProviderId, + stream: shouldStreamRef.current, + providerStream: providerStreamRef.current, + requestId: isPrimary ? messageId : `${messageId}-${targetModel}`, + signal: abortControllerRef.current?.signal, + conversationId: targetConversationId || undefined, + streamingEnabled: shouldStreamRef.current, + toolsEnabled: useToolsRef.current, + tools: enabledToolsRef.current, + qualityLevel: qualityLevelRef.current, + reasoning: reasoning, + systemPrompt: systemPromptRef.current || undefined, + activeSystemPromptId: activeSystemPromptIdRef.current || undefined, + modelCapabilities: modelCapabilities, + onToken: (token: string) => { + if (isPrimary) { + // Update token count using ref to avoid re-renders on every token + if (tokenStatsRef.current && tokenStatsRef.current.messageId === messageId) { + const isFirstToken = tokenStatsRef.current.count === 0; + tokenStatsRef.current.count += 1; + if (isFirstToken) { + tokenStatsRef.current.startTime = Date.now(); + } + tokenStatsRef.current.lastUpdated = Date.now(); + } + + setMessages((prev) => { + const lastIdx = prev.length - 1; + if (lastIdx < 0) return prev; + const lastMsg = prev[lastIdx]; + if (!lastMsg || lastMsg.role !== 'assistant') return prev; + const newContent = + typeof lastMsg.content === 'string' ? lastMsg.content + token : token; + return [...prev.slice(0, lastIdx), { ...lastMsg, content: newContent }]; + }); + } else { + // Update secondary model comparison content + setMessages((prev) => { + const lastIdx = prev.length - 1; + if (lastIdx < 0) return prev; + const lastMsg = prev[lastIdx]; + const existingRes = lastMsg.comparisonResults?.[targetModel]; + if (!existingRes) return prev; + + const currentContent = + typeof existingRes.content === 'string' ? existingRes.content : ''; + const newContent = currentContent + token; + + return [ + ...prev.slice(0, lastIdx), + { + ...lastMsg, + comparisonResults: { + ...lastMsg.comparisonResults, + [targetModel]: { ...existingRes, content: newContent }, + }, + }, + ]; + }); } - tokenStatsRef.current.lastUpdated = Date.now(); - } + }, + onEvent: (event) => { + if (event.type === 'text') { + if (isPrimary) { + if (tokenStatsRef.current && tokenStatsRef.current.messageId === messageId) { + const isFirstContent = tokenStatsRef.current.count === 0; + tokenStatsRef.current.count += 1; + if (isFirstContent) { + tokenStatsRef.current.startTime = Date.now(); + } + tokenStatsRef.current.lastUpdated = Date.now(); + } + setMessages((prev) => { + const lastIdx = prev.length - 1; + if (lastIdx < 0) return prev; + const lastMsg = prev[lastIdx]; + if (!lastMsg || lastMsg.role !== 'assistant') return prev; + const currentContent = + typeof lastMsg.content === 'string' ? lastMsg.content : ''; + const newContent = currentContent + event.value; + return [...prev.slice(0, lastIdx), { ...lastMsg, content: newContent }]; + }); + } else { + setMessages((prev) => { + const lastIdx = prev.length - 1; + if (lastIdx < 0) return prev; + const lastMsg = prev[lastIdx]; + const existingRes = lastMsg.comparisonResults?.[targetModel]; + if (!existingRes) return prev; + const currentContent = + typeof existingRes.content === 'string' ? existingRes.content : ''; + const newContent = currentContent + event.value; + return [ + ...prev.slice(0, lastIdx), + { + ...lastMsg, + comparisonResults: { + ...lastMsg.comparisonResults, + [targetModel]: { ...existingRes, content: newContent }, + }, + }, + ]; + }); + } + } else if (event.type === 'tool_call' && isPrimary) { + // Only primary model supports tools for now in comparison mode + setMessages((prev) => { + const lastIdx = prev.length - 1; + if (lastIdx < 0) return prev; + + const lastMsg = prev[lastIdx]; + if (!lastMsg || lastMsg.role !== 'assistant') return prev; + + // Calculate current text length to set as textOffset + const currentTextLength = + typeof lastMsg.content === 'string' ? lastMsg.content.length : 0; + + // Accumulate tool calls by id (unique identifier) to avoid duplicates during streaming + const tcDelta = event.value; + const existingToolCalls = lastMsg.tool_calls || []; + + // Use id as the primary identifier (OpenAI spec), fallback to index for older formats + const existingIdx = tcDelta.id + ? existingToolCalls.findIndex((tc) => tc.id === tcDelta.id) + : existingToolCalls.findIndex( + (tc) => (tc.index ?? 0) === (tcDelta.index ?? 0) + ); + + let updatedToolCalls; + if (existingIdx >= 0) { + // Update existing tool call (merge chunks during streaming) + updatedToolCalls = [...existingToolCalls]; + const existing = { ...updatedToolCalls[existingIdx] }; + if (tcDelta.id) existing.id = tcDelta.id; + if (tcDelta.type) existing.type = tcDelta.type; + if (tcDelta.index !== undefined) existing.index = tcDelta.index; + if (tcDelta.function?.name) { + existing.function = { ...existing.function, name: tcDelta.function.name }; + } + if (tcDelta.function?.arguments) { + existing.function = { + ...existing.function, + arguments: + (existing.function?.arguments || '') + tcDelta.function.arguments, + }; + } + updatedToolCalls[existingIdx] = existing; + } else { + // New tool call - capture textOffset from current content length + updatedToolCalls = [ + ...existingToolCalls, + { + id: tcDelta.id, + type: tcDelta.type || 'function', + index: tcDelta.index ?? existingToolCalls.length, + textOffset: currentTextLength, // Store the position where tool call occurred + function: { + name: tcDelta.function?.name || '', + arguments: tcDelta.function?.arguments || '', + }, + }, + ]; + } + + // Create new array with updated last message (immutable update) + return [ + ...prev.slice(0, lastIdx), + { ...lastMsg, tool_calls: updatedToolCalls }, + ]; + }); + } else if (event.type === 'tool_output' && isPrimary) { + setMessages((prev) => { + const lastIdx = prev.length - 1; + if (lastIdx < 0) return prev; + + const lastMsg = prev[lastIdx]; + if (!lastMsg || lastMsg.role !== 'assistant') return prev; + + // Avoid duplicate tool outputs by checking tool_call_id or name + const outputValue = event.value; + const toolCallId = outputValue.tool_call_id; + const outputName = outputValue.name; + + const existingToolOutputs = lastMsg.tool_outputs || []; + // Check if this tool output already exists + const existingIdx = existingToolOutputs.findIndex((out) => { + if (toolCallId && out.tool_call_id) { + return out.tool_call_id === toolCallId; + } + if (outputName && out.name) { + return out.name === outputName; + } + return false; + }); + + if (existingIdx === -1) { + // Create new array with updated last message (immutable update) + return [ + ...prev.slice(0, lastIdx), + { ...lastMsg, tool_outputs: [...existingToolOutputs, outputValue] }, + ]; + } else { + // If it already exists, ignore the duplicate + return prev; + } + }); + } else if (event.type === 'usage') { + if (isPrimary) { + setMessages((prev) => { + const lastIdx = prev.length - 1; + if (lastIdx < 0) return prev; + + const lastMsg = prev[lastIdx]; + if (!lastMsg || lastMsg.role !== 'assistant') return prev; + + // Only update if usage data has actually changed + const existingUsage = lastMsg.usage; + const newUsage = event.value; + + if (existingUsage) { + const providerSame = existingUsage.provider === newUsage.provider; + const modelSame = existingUsage.model === newUsage.model; + const promptTokensSame = + existingUsage.prompt_tokens === newUsage.prompt_tokens; + const completionTokensSame = + existingUsage.completion_tokens === newUsage.completion_tokens; + const totalTokensSame = + existingUsage.total_tokens === newUsage.total_tokens; + const reasoningTokensSame = + existingUsage.reasoning_tokens === newUsage.reasoning_tokens; + + if ( + providerSame && + modelSame && + promptTokensSame && + completionTokensSame && + totalTokensSame && + reasoningTokensSame + ) { + return prev; + } + } + return [...prev.slice(0, lastIdx), { ...lastMsg, usage: event.value }]; + }); + } else { + setMessages((prev) => { + const lastIdx = prev.length - 1; + if (lastIdx < 0) return prev; + const lastMsg = prev[lastIdx]; + const existingRes = lastMsg.comparisonResults?.[targetModel]; + if (!existingRes) return prev; + + return [ + ...prev.slice(0, lastIdx), + { + ...lastMsg, + comparisonResults: { + ...lastMsg.comparisonResults, + [targetModel]: { ...existingRes, usage: event.value }, + }, + }, + ]; + }); + } + } + }, + } as ChatOptionsExtended); - // Handle text events from tool_events (non-streaming responses) + // Completion handling + if (isPrimary) { setMessages((prev) => { const lastIdx = prev.length - 1; if (lastIdx < 0) return prev; - const lastMsg = prev[lastIdx]; if (!lastMsg || lastMsg.role !== 'assistant') return prev; - // Append text to existing content - const currentContent = typeof lastMsg.content === 'string' ? lastMsg.content : ''; - const newContent = currentContent + event.value; + const responseContent = response.content as MessageContent; + const hasResponseContent = + typeof responseContent === 'string' + ? responseContent.length > 0 + : Array.isArray(responseContent) + ? responseContent.length > 0 + : responseContent != null; - return [...prev.slice(0, lastIdx), { ...lastMsg, content: newContent }]; + const finalContent = hasResponseContent ? responseContent : lastMsg.content; + return [...prev.slice(0, lastIdx), { ...lastMsg, content: finalContent }]; }); - } else if (event.type === 'tool_call') { - setMessages((prev) => { - const lastIdx = prev.length - 1; - if (lastIdx < 0) return prev; - const lastMsg = prev[lastIdx]; - if (!lastMsg || lastMsg.role !== 'assistant') return prev; + // Update conversation metadata if returned + if (response.conversation) { + const isNewConversation = conversationId !== response.conversation.id; + setConversationId(response.conversation.id); + setCurrentConversationTitle(response.conversation.title || null); + + if (isNewConversation) { + const newConversation: Conversation = { + id: response.conversation.id, + title: response.conversation.title || 'Untitled conversation', + created_at: response.conversation.created_at, + updatedAt: response.conversation.created_at, + }; + + setConversations((prev) => { + const exists = prev.some((c) => c.id === newConversation.id); + if (exists) return prev; + return [newConversation, ...prev]; + }); - // Calculate current text length to set as textOffset - const currentTextLength = - typeof lastMsg.content === 'string' ? lastMsg.content.length : 0; - - // Accumulate tool calls by id (unique identifier) to avoid duplicates during streaming - const tcDelta = event.value; - const existingToolCalls = lastMsg.tool_calls || []; - - // Use id as the primary identifier (OpenAI spec), fallback to index for older formats - const existingIdx = tcDelta.id - ? existingToolCalls.findIndex((tc) => tc.id === tcDelta.id) - : existingToolCalls.findIndex((tc) => (tc.index ?? 0) === (tcDelta.index ?? 0)); - - let updatedToolCalls; - if (existingIdx >= 0) { - // Update existing tool call (merge chunks during streaming) - updatedToolCalls = [...existingToolCalls]; - const existing = { ...updatedToolCalls[existingIdx] }; - if (tcDelta.id) existing.id = tcDelta.id; - if (tcDelta.type) existing.type = tcDelta.type; - if (tcDelta.index !== undefined) existing.index = tcDelta.index; - if (tcDelta.function?.name) { - existing.function = { ...existing.function, name: tcDelta.function.name }; - } - if (tcDelta.function?.arguments) { - existing.function = { - ...existing.function, - arguments: (existing.function?.arguments || '') + tcDelta.function.arguments, - }; + if ( + !response.conversation.title || + response.conversation.title === 'Untitled conversation' + ) { + setTimeout(async () => { + try { + const updated = await conversationsApi.get(response.conversation!.id, { + limit: 1, + }); + if (updated.title && updated.title !== response.conversation!.title) { + setCurrentConversationTitle(updated.title); + setConversations((prev) => + prev.map((c) => + c.id === response.conversation!.id + ? { ...c, title: updated.title ?? c.title } + : c + ) + ); + } + } catch (err) { + console.warn('Failed to fetch updated conversation title:', err); + } + }, 2000); } - updatedToolCalls[existingIdx] = existing; - } else { - // New tool call - capture textOffset from current content length - updatedToolCalls = [ - ...existingToolCalls, - { - id: tcDelta.id, - type: tcDelta.type || 'function', - index: tcDelta.index ?? existingToolCalls.length, - textOffset: currentTextLength, // Store the position where tool call occurred - function: { - name: tcDelta.function?.name || '', - arguments: tcDelta.function?.arguments || '', - }, - }, - ]; } + } - // Create new array with updated last message (immutable update) - return [...prev.slice(0, lastIdx), { ...lastMsg, tool_calls: updatedToolCalls }]; - }); - } else if (event.type === 'tool_output') { + // Clear the draft after successful send + if (user?.id) { + clearDraft(user.id, conversationId); + if (response.conversation?.id) { + clearDraft(user.id, response.conversation.id); + } + } + + const effectiveConversationId = response.conversation?.id ?? conversationId; + if (effectiveConversationId) { + conversationsApi.invalidateDetailCache(effectiveConversationId); + } + conversationsApi.clearListCache(); + } else { + // Secondary completion setMessages((prev) => { const lastIdx = prev.length - 1; if (lastIdx < 0) return prev; - const lastMsg = prev[lastIdx]; - if (!lastMsg || lastMsg.role !== 'assistant') return prev; - - // Avoid duplicate tool outputs by checking tool_call_id or name - const outputValue = event.value; - const toolCallId = outputValue.tool_call_id; - const outputName = outputValue.name; + const existingRes = lastMsg.comparisonResults?.[targetModel]; + if (!existingRes) return prev; + + const responseContent = response.content as MessageContent; + const hasResponseContent = + typeof responseContent === 'string' + ? responseContent.length > 0 + : Array.isArray(responseContent) + ? responseContent.length > 0 + : responseContent != null; + const finalContent = hasResponseContent ? responseContent : existingRes.content; + + return [ + ...prev.slice(0, lastIdx), + { + ...lastMsg, + comparisonResults: { + ...lastMsg.comparisonResults, + [targetModel]: { + ...existingRes, + content: finalContent, + status: 'complete', + }, + }, + }, + ]; + }); + } - const existingToolOutputs = lastMsg.tool_outputs || []; - // Check if this tool output already exists - const existingIdx = existingToolOutputs.findIndex((out) => { - if (toolCallId && out.tool_call_id) { - return out.tool_call_id === toolCallId; - } - if (outputName && out.name) { - return out.name === outputName; - } - return false; - }); - - if (existingIdx === -1) { - // Create new array with updated last message (immutable update) - return [ - ...prev.slice(0, lastIdx), - { ...lastMsg, tool_outputs: [...existingToolOutputs, outputValue] }, - ]; - } else { - // If it already exists, ignore the duplicate - return prev; + return response; + } catch (err) { + // Error handling + if (isPrimary) { + // Handle streaming not supported error by retrying with streaming disabled + if (err instanceof StreamingNotSupportedError) { + if (opts?.retried) { + setError('Streaming not supported by provider'); + setStatus('idle'); + setPending((prev) => ({ + ...prev, + streaming: false, + error: 'Streaming not supported by provider', + })); + return; } - }); - } else if (event.type === 'usage') { + console.log( + '[AUTO-RETRY] Streaming not supported, retrying with streaming disabled' + ); + providerStreamRef.current = false; + setMessages((prev) => prev.slice(0, -1)); + setTimeout(() => { + void sendMessage(content, { ...opts, skipLocalUserMessage: true, retried: true }); + }, 0); + return; + } + + let displayError: string; + if (err instanceof APIError) { + displayError = formatUpstreamError(err); + } else if (err instanceof Error && err.name === 'AbortError') { + displayError = 'Message cancelled'; + } else if (err instanceof Error) { + displayError = err.message; + } else { + displayError = 'Failed to send message'; + } + + setError(displayError); + setStatus('idle'); + setPending((prev) => ({ + ...prev, + streaming: false, + error: displayError, + })); + } else { + // Secondary error + const errorMessage = err instanceof Error ? err.message : 'Failed to generate'; setMessages((prev) => { const lastIdx = prev.length - 1; if (lastIdx < 0) return prev; - const lastMsg = prev[lastIdx]; - if (!lastMsg || lastMsg.role !== 'assistant') return prev; - - // Only update if usage data has actually changed - // Compare the usage object properties to avoid infinite loops - const existingUsage = lastMsg.usage; - const newUsage = event.value; - - // Check if usage data is the same (deep equality check) - if (existingUsage) { - const providerSame = existingUsage.provider === newUsage.provider; - const modelSame = existingUsage.model === newUsage.model; - const promptTokensSame = existingUsage.prompt_tokens === newUsage.prompt_tokens; - const completionTokensSame = - existingUsage.completion_tokens === newUsage.completion_tokens; - const totalTokensSame = existingUsage.total_tokens === newUsage.total_tokens; - const reasoningTokensSame = - existingUsage.reasoning_tokens === newUsage.reasoning_tokens; - - if ( - providerSame && - modelSame && - promptTokensSame && - completionTokensSame && - totalTokensSame && - reasoningTokensSame - ) { - return prev; // No change, return existing state - } - } - - return [...prev.slice(0, lastIdx), { ...lastMsg, usage: event.value }]; + const existingRes = lastMsg.comparisonResults?.[targetModel]; + // If it wasn't initialized yet, ignore or init with error + if (!existingRes) return prev; + + return [ + ...prev.slice(0, lastIdx), + { + ...lastMsg, + comparisonResults: { + ...lastMsg.comparisonResults, + [targetModel]: { + ...existingRes, + status: 'error', + error: errorMessage, + }, + }, + }, + ]; }); } - }, - } as ChatOptionsExtended); - - // Update assistant message with final content - // If content was built from tool_events, use that; otherwise use response.content - setMessages((prev) => { - const lastIdx = prev.length - 1; - if (lastIdx < 0) return prev; - - const lastMsg = prev[lastIdx]; - if (!lastMsg || lastMsg.role !== 'assistant') return prev; - - const responseContent = response.content as MessageContent; - const hasResponseContent = - typeof responseContent === 'string' - ? responseContent.length > 0 - : Array.isArray(responseContent) - ? responseContent.length > 0 - : responseContent != null; + } + }; - const finalContent = hasResponseContent ? responseContent : lastMsg.content; + // Execute primary request + const primaryPromise = executeRequest(primaryModel, true, conversationId || undefined); - return [...prev.slice(0, lastIdx), { ...lastMsg, content: finalContent }]; + // Execute secondary requests in parallel (independent) + activeComparisonModels.forEach((modelId) => { + // Secondary requests use undefined conversationId to be independent/stateless-ish + void executeRequest(modelId, false, undefined); }); - // Update conversation metadata if returned - if (response.conversation) { - const isNewConversation = conversationId !== response.conversation.id; - setConversationId(response.conversation.id); - setCurrentConversationTitle(response.conversation.title || null); - - // If this is a new conversation, add it to the sidebar list and select it - if (isNewConversation) { - const newConversation: Conversation = { - id: response.conversation.id, - title: response.conversation.title || 'Untitled conversation', - created_at: response.conversation.created_at, - updatedAt: response.conversation.created_at, - }; - - // Add to the top of the list and ensure it's selected - setConversations((prev) => { - // Check if it already exists (shouldn't happen, but be safe) - const exists = prev.some((c) => c.id === newConversation.id); - if (exists) { - return prev; - } - return [newConversation, ...prev]; - }); - - // Poll for title update after a delay (title generation is async on backend) - // Only poll if we got a generic/empty title initially - if ( - !response.conversation.title || - response.conversation.title === 'Untitled conversation' - ) { - setTimeout(async () => { - try { - const updated = await conversationsApi.get(response.conversation!.id, { - limit: 1, - }); - if (updated.title && updated.title !== response.conversation!.title) { - // Update current conversation title if we're still on this conversation - setCurrentConversationTitle(updated.title); - // Update in sidebar list - setConversations((prev) => - prev.map((c) => - c.id === response.conversation!.id - ? { ...c, title: updated.title ?? c.title } - : c - ) - ); - } - } catch (err) { - // Silent failure - title update is non-critical - console.warn('Failed to fetch updated conversation title:', err); - } - }, 2000); // Poll after 2 seconds to allow title generation to complete - } - } - } + await primaryPromise; + // We don't await secondary promises to allow primary flow to complete naturally + // Secondary promises update state independently setStatus('idle'); setPending((prev) => ({ @@ -1082,82 +1325,18 @@ export function useChat() { streaming: false, tokenStats: tokenStatsRef.current ?? undefined, })); - - // Clear the draft after successful send - if (user?.id) { - // Clear draft for both old and new conversation IDs - clearDraft(user.id, conversationId); - if (response.conversation?.id) { - clearDraft(user.id, response.conversation.id); - } - } - - const effectiveConversationId = response.conversation?.id ?? conversationId; - if (effectiveConversationId) { - conversationsApi.invalidateDetailCache(effectiveConversationId); - } - conversationsApi.clearListCache(); } catch (err) { - // Handle streaming not supported error by retrying with streaming disabled - if (err instanceof StreamingNotSupportedError) { - // Only retry once to avoid infinite retry loops which can cause max update - // depth exceeded when the backend doesn't support streaming at all. - if (opts?.retried) { - // Already retried once; surface a useful error and stop. - setError('Streaming not supported by provider'); - setStatus('idle'); - setPending((prev) => ({ - ...prev, - streaming: false, - error: 'Streaming not supported by provider', - })); - return; - } - - console.log('[AUTO-RETRY] Streaming not supported, retrying with streaming disabled'); - - // Disable streaming - providerStreamRef.current = false; - - // Remove the failed assistant message - setMessages((prev) => prev.slice(0, -1)); - - // Retry by calling sendMessage again (it will use the updated shouldStreamRef) - // Use setTimeout to break out of the current call stack - // Pass skipLocalUserMessage: true to avoid duplicating the user message - // and mark retried=true so we don't loop indefinitely. - setTimeout(() => { - void sendMessage(content, { ...opts, skipLocalUserMessage: true, retried: true }); - }, 0); - - return; - } - - let displayError: string; - - if (err instanceof APIError) { - displayError = formatUpstreamError(err); - } else if (err instanceof Error && err.name === 'AbortError') { - displayError = 'Message cancelled'; - } else if (err instanceof Error) { - displayError = err.message; - } else { - displayError = 'Failed to send message'; - } - - setError(displayError); + // Fallback global error catcher if primary execution fails synchronously before try block + // (should unlikely reach here due to inner try/catch) + setError(err instanceof Error ? err.message : 'Unknown error'); setStatus('idle'); - setPending((prev) => ({ - ...prev, - streaming: false, - error: displayError, - })); + setPending((prev) => ({ ...prev, streaming: false })); } finally { currentRequestIdRef.current = null; abortControllerRef.current = null; } }, - [input, images, files, conversationId, modelCapabilities] + [input, images, files, conversationId, modelCapabilities, compareModels] ); const stopStreaming = useCallback(() => { @@ -1508,6 +1687,7 @@ export function useChat() { user, activeSystemPromptId, systemPrompt, + compareModels, // Actions setMessages, @@ -1521,6 +1701,7 @@ export function useChat() { setImages, setFiles, setActiveSystemPromptId: setActiveSystemPromptIdWrapper, + setCompareModels, toggleSidebar, toggleRightSidebar, selectConversation, diff --git a/frontend/lib/types.ts b/frontend/lib/types.ts index b6c13c7d..f47fa8dd 100644 --- a/frontend/lib/types.ts +++ b/frontend/lib/types.ts @@ -221,6 +221,15 @@ export interface ChatMessage { }; reasoning_details?: any[]; reasoning_tokens?: number | null; + comparisonResults?: Record< + string, + { + content: MessageContent; + usage?: any; + status: 'streaming' | 'complete' | 'error'; + error?: string; + } + >; } export interface MessageEvent { From 4c6e08bab560492f3488c96a2938d15fa4e90487 Mon Sep 17 00:00:00 2001 From: Duc Nguyen Date: Mon, 29 Dec 2025 13:54:31 +0700 Subject: [PATCH 05/34] feat: persist comparison conversation --- backend/src/db/conversations.js | 38 +++++++++++-- .../migrations/023-parent-conversation-id.js | 22 +++++++ .../lib/persistence/ConversationManager.js | 3 +- backend/src/lib/simplifiedPersistence.js | 3 + backend/src/routes/conversations.js | 20 +++++++ frontend/hooks/useChat.ts | 57 ++++++++++++++++--- frontend/lib/api.ts | 20 +++++++ 7 files changed, 151 insertions(+), 12 deletions(-) create mode 100644 backend/src/db/migrations/023-parent-conversation-id.js diff --git a/backend/src/db/conversations.js b/backend/src/db/conversations.js index c5a50b29..928427d2 100644 --- a/backend/src/db/conversations.js +++ b/backend/src/db/conversations.js @@ -15,12 +15,13 @@ export function createConversation({ reasoningEffort = null, verbosity = null, metadata = {}, + parentConversationId = null, }) { const db = getDb(); const now = new Date().toISOString(); db.prepare( - `INSERT INTO conversations (id, session_id, user_id, title, provider_id, model, metadata, streaming_enabled, tools_enabled, quality_level, reasoning_effort, verbosity, created_at, updated_at) - VALUES (@id, @session_id, @user_id, @title, @provider_id, @model, @metadata, @streaming_enabled, @tools_enabled, @quality_level, @reasoning_effort, @verbosity, @now, @now)` + `INSERT INTO conversations (id, session_id, user_id, title, provider_id, model, metadata, streaming_enabled, tools_enabled, quality_level, reasoning_effort, verbosity, parent_conversation_id, created_at, updated_at) + VALUES (@id, @session_id, @user_id, @title, @provider_id, @model, @metadata, @streaming_enabled, @tools_enabled, @quality_level, @reasoning_effort, @verbosity, @parent_conversation_id, @now, @now)` ).run({ id, session_id: sessionId, @@ -34,6 +35,7 @@ export function createConversation({ quality_level: qualityLevel, reasoning_effort: reasoningEffort, verbosity, + parent_conversation_id: parentConversationId || null, now, }); } @@ -44,7 +46,7 @@ export function getConversationById({ id, userId }) { } const db = getDb(); - const query = `SELECT id, title, provider_id, model, metadata, streaming_enabled, tools_enabled, quality_level, reasoning_effort, verbosity, created_at FROM conversations + const query = `SELECT id, title, provider_id, model, metadata, streaming_enabled, tools_enabled, quality_level, reasoning_effort, verbosity, parent_conversation_id, created_at FROM conversations WHERE id=@id AND user_id=@user_id AND deleted_at IS NULL`; const result = db.prepare(query).get({ id, user_id: userId }); @@ -190,8 +192,9 @@ export function listConversations({ userId, cursor, limit }) { const safeLimit = clampLimit(limit, { fallback: 20, min: 1, max: 100 }); const { cursorCreatedAt, cursorId } = parseCreatedAtCursor(cursor); + // Exclude linked/comparison conversations (those with parent_conversation_id) let sql = `SELECT id, title, provider_id, model, created_at FROM conversations - WHERE user_id=@userId AND deleted_at IS NULL`; + WHERE user_id=@userId AND deleted_at IS NULL AND parent_conversation_id IS NULL`; const params = { userId, cursorCreatedAt, cursorId, limit: safeLimit + 1 }; sql = appendCreatedAtCursor(sql, { cursorCreatedAt, cursorId }); @@ -204,6 +207,25 @@ export function listConversations({ userId, cursor, limit }) { return { items, next_cursor }; } +/** + * Get linked/comparison conversations for a parent conversation + * @param {string} parentId - Parent conversation ID + * @param {string} userId - User ID + * @returns {Array} Array of linked conversation metadata + */ +export function getLinkedConversations({ parentId, userId }) { + if (!userId) { + throw new Error('userId is required'); + } + + const db = getDb(); + const query = `SELECT id, title, provider_id, model, created_at, updated_at FROM conversations + WHERE parent_conversation_id=@parentId AND user_id=@userId AND deleted_at IS NULL + ORDER BY datetime(created_at) ASC`; + + return db.prepare(query).all({ parentId, userId }); +} + export function softDeleteConversation({ id, userId }) { if (!userId) { throw new Error('userId is required'); @@ -211,6 +233,14 @@ export function softDeleteConversation({ id, userId }) { const db = getDb(); const now = new Date().toISOString(); + + // Also soft-delete any linked comparison conversations + db.prepare( + `UPDATE conversations SET deleted_at=@now, updated_at=@now + WHERE parent_conversation_id=@id AND user_id=@userId AND deleted_at IS NULL` + ).run({ id, userId, now }); + + // Delete the parent conversation const query = `UPDATE conversations SET deleted_at=@now, updated_at=@now WHERE id=@id AND user_id=@userId AND deleted_at IS NULL`; const info = db.prepare(query).run({ id, userId, now }); return info.changes > 0; diff --git a/backend/src/db/migrations/023-parent-conversation-id.js b/backend/src/db/migrations/023-parent-conversation-id.js new file mode 100644 index 00000000..7833af33 --- /dev/null +++ b/backend/src/db/migrations/023-parent-conversation-id.js @@ -0,0 +1,22 @@ +/** + * Migration: Add parent_conversation_id column for linked comparison conversations + * + * This enables storing comparison/secondary model responses as separate conversations + * that are linked to a primary conversation. Conversations with a non-null parent_conversation_id + * are excluded from the main conversation list. + */ + +export default { + version: 23, + up: ` + -- Add parent_conversation_id column for linked comparison conversations + ALTER TABLE conversations ADD COLUMN parent_conversation_id TEXT DEFAULT NULL; + + -- Create index for efficient lookups of child conversations + CREATE INDEX IF NOT EXISTS idx_conversations_parent_id ON conversations(parent_conversation_id); + `, + down: ` + -- Note: SQLite doesn't support DROP COLUMN, so the column will remain but be unused + DROP INDEX IF EXISTS idx_conversations_parent_id; + ` +}; diff --git a/backend/src/lib/persistence/ConversationManager.js b/backend/src/lib/persistence/ConversationManager.js index 08f48257..bd0886e9 100644 --- a/backend/src/lib/persistence/ConversationManager.js +++ b/backend/src/lib/persistence/ConversationManager.js @@ -81,7 +81,8 @@ export class ConversationManager { qualityLevel: params.qualityLevel || null, reasoningEffort: params.reasoningEffort || null, verbosity: params.verbosity || null, - metadata: params.metadata || {} + metadata: params.metadata || {}, + parentConversationId: params.parentConversationId || null, }); return conversationId; diff --git a/backend/src/lib/simplifiedPersistence.js b/backend/src/lib/simplifiedPersistence.js index 5c6d9bd0..c5fe4f1c 100644 --- a/backend/src/lib/simplifiedPersistence.js +++ b/backend/src/lib/simplifiedPersistence.js @@ -157,10 +157,13 @@ export class SimplifiedPersistence { // Create new conversation if needed if (isNewConversation) { const settings = await this.persistenceConfig.extractRequestSettingsAsync(bodyIn, userId); + // Support linked comparison conversations via parent_conversation_id + const parentConversationId = bodyIn.parent_conversation_id || null; conversationId = this.conversationManager.createNewConversation({ sessionId, userId, providerId: this.providerId, + parentConversationId, ...settings }); convo = this.conversationManager.getConversation(conversationId, userId); diff --git a/backend/src/routes/conversations.js b/backend/src/routes/conversations.js index 976d282b..3388769d 100644 --- a/backend/src/routes/conversations.js +++ b/backend/src/routes/conversations.js @@ -12,6 +12,7 @@ import { softDeleteConversation, listConversationsIncludingDeleted, forkConversationFromMessage, + getLinkedConversations, } from '../db/conversations.js'; import { getMessagesPage, @@ -206,6 +207,25 @@ conversationsRouter.delete('/v1/conversations/:id', (req, res) => { } }); +// GET /v1/conversations/:id/linked (get linked comparison conversations) +conversationsRouter.get('/v1/conversations/:id/linked', (req, res) => { + if (!config.persistence.enabled) return notImplemented(res); + try { + const userId = req.user.id; // Guaranteed by authenticateToken middleware + + getDb(); + // First verify the parent conversation exists and belongs to the user + const parentConvo = getConversationById({ id: req.params.id, userId }); + if (!parentConvo) return res.status(404).json({ error: 'not_found' }); + + const linkedConversations = getLinkedConversations({ parentId: req.params.id, userId }); + return res.json({ conversations: linkedConversations }); + } catch (e) { + logger.error('[conversations] get linked error', e); + return res.status(500).json({ error: 'internal_error' }); + } +}); + // PUT /v1/conversations/:id/messages/:messageId/edit (edit message and fork conversation) conversationsRouter.put('/v1/conversations/:id/messages/:messageId/edit', (req, res) => { if (!config.persistence.enabled) return notImplemented(res); diff --git a/frontend/hooks/useChat.ts b/frontend/hooks/useChat.ts index 62d9f772..0918b49d 100644 --- a/frontend/hooks/useChat.ts +++ b/frontend/hooks/useChat.ts @@ -312,6 +312,8 @@ export function useChat() { // Comparison State const [compareModels, setCompareModels] = useState([]); + // Linked comparison conversations (model -> conversationId) + const [linkedConversations, setLinkedConversations] = useState>({}); // Tool & Quality State const [useTools, setUseTools] = useState(true); @@ -526,6 +528,25 @@ export function useChat() { setActiveSystemPromptId(data.active_system_prompt_id); activeSystemPromptIdRef.current = data.active_system_prompt_id; } + + // Load linked comparison conversations (if any) + try { + const linkedResult = await conversationsApi.getLinked(id); + if (linkedResult.conversations && linkedResult.conversations.length > 0) { + const linkedMap: Record = {}; + for (const linked of linkedResult.conversations) { + if (linked.model) { + linkedMap[linked.model] = linked.id; + } + } + setLinkedConversations(linkedMap); + } else { + setLinkedConversations({}); + } + } catch { + // Non-fatal: if loading linked conversations fails, just clear state + setLinkedConversations({}); + } } catch (err) { setError(err instanceof Error ? err.message : 'Failed to load conversation'); } finally { @@ -543,6 +564,7 @@ export function useChat() { if (conversationId === id) { setConversationId(null); setMessages([]); + setLinkedConversations({}); } } catch (err) { setError(err instanceof Error ? err.message : 'Failed to delete conversation'); @@ -589,6 +611,7 @@ export function useChat() { setImages([]); setFiles([]); setCurrentConversationTitle(null); + setLinkedConversations({}); // Clear draft for the previous conversation when starting a new chat if (user?.id && conversationId) { @@ -764,8 +787,10 @@ export function useChat() { const executeRequest = async ( targetModel: string, isPrimary: boolean, - targetConversationId?: string + options?: { conversationId?: string; parentConversationId?: string } ) => { + const targetConversationId = options?.conversationId; + const parentConversationId = options?.parentConversationId; // Extract actual model ID from provider-qualified format (provider::model) const actualModelId = targetModel.includes('::') ? targetModel.split('::')[1] @@ -854,6 +879,7 @@ export function useChat() { requestId: isPrimary ? messageId : `${messageId}-${targetModel}`, signal: abortControllerRef.current?.signal, conversationId: targetConversationId || undefined, + parentConversationId: parentConversationId || undefined, streamingEnabled: shouldStreamRef.current, toolsEnabled: useToolsRef.current, tools: enabledToolsRef.current, @@ -1229,6 +1255,14 @@ export function useChat() { }, ]; }); + + // Track the linked conversation ID + if (response.conversation?.id) { + setLinkedConversations((prev) => ({ + ...prev, + [targetModel]: response.conversation!.id, + })); + } } return response; @@ -1307,17 +1341,25 @@ export function useChat() { }; // Execute primary request - const primaryPromise = executeRequest(primaryModel, true, conversationId || undefined); + const primaryPromise = executeRequest(primaryModel, true, { + conversationId: conversationId || undefined, + }); // Execute secondary requests in parallel (independent) - activeComparisonModels.forEach((modelId) => { - // Secondary requests use undefined conversationId to be independent/stateless-ish - void executeRequest(modelId, false, undefined); + // They will be linked to the primary conversation once it's created + // We need to wait for the primary response to get the conversation ID first + primaryPromise.then((primaryResponse) => { + const parentId = primaryResponse?.conversation?.id || conversationId || undefined; + activeComparisonModels.forEach((modelId) => { + // Secondary requests use parentConversationId to link to the primary conversation + void executeRequest(modelId, false, { + parentConversationId: parentId, + }); + }); }); await primaryPromise; - // We don't await secondary promises to allow primary flow to complete naturally - // Secondary promises update state independently + // Secondary promises execute after primary completes and update state independently setStatus('idle'); setPending((prev) => ({ @@ -1688,6 +1730,7 @@ export function useChat() { activeSystemPromptId, systemPrompt, compareModels, + linkedConversations, // Actions setMessages, diff --git a/frontend/lib/api.ts b/frontend/lib/api.ts index 440a744a..0f0e5ba0 100644 --- a/frontend/lib/api.ts +++ b/frontend/lib/api.ts @@ -428,6 +428,9 @@ function buildRequestBody(options: ChatOptions | ChatOptionsExtended, stream: bo provider_id: providerId, ...(responseId && { previous_response_id: responseId }), ...(extendedOptions.conversationId && { conversation_id: extendedOptions.conversationId }), + ...((extendedOptions as any).parentConversationId && { + parent_conversation_id: (extendedOptions as any).parentConversationId, + }), ...(extendedOptions.streamingEnabled !== undefined && { streamingEnabled: extendedOptions.streamingEnabled, }), @@ -1087,6 +1090,23 @@ export const conversations = { throw error instanceof HttpError ? new Error(error.message) : error; } }, + + /** + * Get linked comparison conversations for a parent conversation + * @param parentId - The parent conversation ID + * @returns Array of linked conversation metadata + */ + async getLinked(parentId: string): Promise<{ conversations: ConversationMeta[] }> { + await waitForAuthReady(); + try { + const response = await httpClient.get<{ conversations: ConversationMeta[] }>( + `/v1/conversations/${parentId}/linked` + ); + return response.data; + } catch (error) { + throw error instanceof HttpError ? new Error(error.message) : error; + } + }, }; // ============================================================================ From a15a4337e2de7ebc62769531efcff6eddc9fc953 Mon Sep 17 00:00:00 2001 From: Duc Nguyen Date: Mon, 29 Dec 2025 17:43:56 +0700 Subject: [PATCH 06/34] refactor: Unify the shared dropdown/tabs/search/list behavior into a new base component and refactored both model selectors to use it --- frontend/components/ui/CompareSelector.tsx | 236 ++++------- frontend/components/ui/ModelSelectBase.tsx | 307 ++++++++++++++ frontend/components/ui/ModelSelector.tsx | 454 +++++++-------------- 3 files changed, 536 insertions(+), 461 deletions(-) create mode 100644 frontend/components/ui/ModelSelectBase.tsx diff --git a/frontend/components/ui/CompareSelector.tsx b/frontend/components/ui/CompareSelector.tsx index ad5dcd97..0c228d76 100644 --- a/frontend/components/ui/CompareSelector.tsx +++ b/frontend/components/ui/CompareSelector.tsx @@ -1,13 +1,9 @@ -import React, { useState, useEffect, useRef, useMemo, useCallback } from 'react'; -import { Search, ChevronDown, Check, GitFork } from 'lucide-react'; +import React, { useState, useEffect, useMemo, useCallback } from 'react'; +import { ChevronDown, Check, GitFork } from 'lucide-react'; import { type Group as TabGroup } from './TabbedSelect'; +import ModelSelectBase, { type Section, type SelectOption, type Tab } from './ModelSelectBase'; -interface ModelOption { - value: string; - label: string; - provider?: string; - providerId?: string; -} +interface ModelOption extends SelectOption {} interface CompareSelectorProps { primaryModel: string; @@ -100,16 +96,11 @@ export default function CompareSelector({ ariaLabel = 'Select models to compare', }: CompareSelectorProps) { const [isOpen, setIsOpen] = useState(false); - const [shouldRenderDropdown, setShouldRenderDropdown] = useState(false); const [searchQuery, setSearchQuery] = useState(''); const [selectedTab, setSelectedTab] = useState('all'); const [visibleCount, setVisibleCount] = useState(50); const [highlightedIndex, setHighlightedIndex] = useState(null); - const searchInputRef = useRef(null); - const dropdownRef = useRef(null); - const listRef = useRef(null); - // Get all available models with provider info const allModels = useMemo(() => { let result; @@ -132,8 +123,8 @@ export default function CompareSelector({ }, [groups, fallbackOptions]); // Get available provider tabs - const providerTabs = useMemo(() => { - const tabs = [{ id: 'all', label: 'All', count: allModels.length }]; + const providerTabs = useMemo(() => { + const tabs: Tab[] = [{ id: 'all', label: 'All', count: allModels.length }]; if (groups && groups.length > 1) { groups.forEach((group) => { tabs.push({ @@ -180,151 +171,94 @@ export default function CompareSelector({ [primaryModel, selectedModels, onChange] ); - // Close dropdown when clicking outside useEffect(() => { - const handleClickOutside = (event: MouseEvent) => { - if (dropdownRef.current && !dropdownRef.current.contains(event.target as Node)) { - setIsOpen(false); - setSearchQuery(''); - setSelectedTab('all'); - } - }; - - if (isOpen) { - document.addEventListener('mousedown', handleClickOutside); - return () => document.removeEventListener('mousedown', handleClickOutside); - } - }, [isOpen]); - - // Defer dropdown rendering - useEffect(() => { - if (isOpen) { - const timer = setTimeout(() => { - setShouldRenderDropdown(true); - }, 0); - return () => clearTimeout(timer); - } else { - setShouldRenderDropdown(false); + if (!isOpen) { setVisibleCount(50); } }, [isOpen]); - // Focus search input when dropdown opens - useEffect(() => { - if (shouldRenderDropdown && searchInputRef.current) { - searchInputRef.current.focus(); - } - }, [shouldRenderDropdown]); - const activeCount = selectedModels.length; - return ( -
- - - {isOpen && ( -
- {!shouldRenderDropdown ? ( -
Loading...
- ) : ( - <> - {/* Provider Tabs */} - {providerTabs.length > 1 && ( -
{ - e.preventDefault(); - e.currentTarget.scrollLeft += e.deltaY; - }} - > - {providerTabs.map((tab) => ( - - ))} -
- )} - - {/* Search Header */} -
-
- - setSearchQuery(e.target.value)} - placeholder="Search models..." - className="w-full pl-10 pr-3 py-1.5 bg-zinc-50 dark:bg-zinc-800 border border-zinc-200 dark:border-zinc-700 rounded-lg focus:outline-none focus:ring-2 focus:ring-zinc-400 dark:focus:ring-zinc-600 text-sm" - /> -
-
- - {/* Model List */} -
- {filteredModels.slice(0, visibleCount).map((model, idx) => ( - - ))} - - {filteredModels.length === 0 && ( -
- No models found. -
- )} -
+ const sections = useMemo[]>( + () => [ + { + id: 'models', + items: filteredModels.slice(0, visibleCount), + }, + ], + [filteredModels, visibleCount] + ); - {/* Footer */} -
- {selectedModels.length} selected - {selectedModels.length > 0 && ( - - )} -
- + return ( + + isOpen={isOpen} + setIsOpen={setIsOpen} + onClose={() => { + setSearchQuery(''); + setSelectedTab('all'); + }} + ariaLabel={ariaLabel} + className={className} + dropdownAlign="right" + listClassName="max-h-[60vh]" + tabs={providerTabs} + activeTab={selectedTab} + onTabChange={setSelectedTab} + searchQuery={searchQuery} + onSearchChange={setSearchQuery} + sections={sections} + renderItem={(model, index, isHighlighted) => ( + + )} + emptyState={ + filteredModels.length === 0 ? ( +
+ No models found. +
+ ) : null + } + footer={ +
+ {selectedModels.length} selected + {selectedModels.length > 0 && ( + )}
- )} -
+ } + highlightedIndex={highlightedIndex} + setHighlightedIndex={setHighlightedIndex} + onEnter={(model) => handleToggle(model.value)} + enableKeyboardNavigation + getItemId={(index) => `compare-item-${index}`} + trigger={ + + } + /> ); } diff --git a/frontend/components/ui/ModelSelectBase.tsx b/frontend/components/ui/ModelSelectBase.tsx new file mode 100644 index 00000000..10659fd6 --- /dev/null +++ b/frontend/components/ui/ModelSelectBase.tsx @@ -0,0 +1,307 @@ +import React, { useEffect, useMemo, useRef, useState, useCallback } from 'react'; +import { Search } from 'lucide-react'; + +export interface SelectOption { + value: string; + label: string; + provider?: string; + providerId?: string; +} + +export interface Tab { + id: string; + label: string; + count?: number; +} + +export interface Section { + id: string; + header?: React.ReactNode; + items: T[]; +} + +interface ModelSelectBaseProps { + isOpen: boolean; + setIsOpen: (open: boolean) => void; + onClose?: () => void; + + ariaLabel?: string; + className?: string; + trigger: React.ReactNode; + dropdownAlign?: 'left' | 'right'; + dropdownClassName?: string; + listClassName?: string; + + tabs: Tab[]; + activeTab: string; + onTabChange: (id: string) => void; + showTabCounts?: boolean; + + searchQuery: string; + onSearchChange: (value: string) => void; + searchPlaceholder?: string; + + sections: Section[]; + renderItem: (item: T, index: number, isHighlighted: boolean) => React.ReactNode; + emptyState?: React.ReactNode; + footer?: React.ReactNode; + + highlightedIndex: number | null; + setHighlightedIndex: (index: number | null) => void; + onEnter?: (item: T) => void; + enableKeyboardNavigation?: boolean; + getItemId?: (index: number) => string; + + onScrollNearEnd?: () => void; + scrollThreshold?: number; +} + +export default function ModelSelectBase({ + isOpen, + setIsOpen, + onClose, + ariaLabel = 'Select model', + className = '', + trigger, + dropdownAlign = 'left', + dropdownClassName = '', + listClassName = '', + tabs, + activeTab, + onTabChange, + showTabCounts = false, + searchQuery, + onSearchChange, + searchPlaceholder = 'Search models...', + sections, + renderItem, + emptyState, + footer, + highlightedIndex, + setHighlightedIndex, + onEnter, + enableKeyboardNavigation = false, + getItemId, + onScrollNearEnd, + scrollThreshold = 0.8, +}: ModelSelectBaseProps) { + const [shouldRenderDropdown, setShouldRenderDropdown] = useState(false); + const dropdownRef = useRef(null); + const searchInputRef = useRef(null); + const listRef = useRef(null); + + const flatItems = useMemo(() => sections.flatMap((section) => section.items), [sections]); + + const closeDropdown = useCallback(() => { + if (onClose) onClose(); + setIsOpen(false); + }, [onClose, setIsOpen]); + + const scrollHighlightedIntoView = useCallback( + (index: number | null) => { + if (index === null || !getItemId || !listRef.current) return; + const item = document.getElementById(getItemId(index)); + if (!item) return; + + const parent = listRef.current; + const itemTop = item.offsetTop; + const itemBottom = itemTop + item.clientHeight; + if (itemTop < parent.scrollTop) parent.scrollTop = itemTop - 8; + else if (itemBottom > parent.scrollTop + parent.clientHeight) + parent.scrollTop = itemBottom - parent.clientHeight + 8; + }, + [getItemId] + ); + + const handleKeyDown = useCallback( + (e: React.KeyboardEvent) => { + if (!enableKeyboardNavigation) return; + + if (e.key === 'Escape') { + setHighlightedIndex(null); + closeDropdown(); + return; + } + + if (!shouldRenderDropdown) return; + + if (e.key === 'ArrowDown') { + e.preventDefault(); + setHighlightedIndex((prev) => { + const next = prev === null ? 0 : Math.min(prev + 1, flatItems.length - 1); + scrollHighlightedIntoView(next); + return next; + }); + return; + } + + if (e.key === 'ArrowUp') { + e.preventDefault(); + setHighlightedIndex((prev) => { + const next = prev === null ? Math.max(flatItems.length - 1, 0) : Math.max(prev - 1, 0); + scrollHighlightedIntoView(next); + return next; + }); + return; + } + + if (e.key === 'Enter') { + if (highlightedIndex !== null && flatItems[highlightedIndex] && onEnter) { + e.preventDefault(); + onEnter(flatItems[highlightedIndex]); + } + } + }, + [ + enableKeyboardNavigation, + shouldRenderDropdown, + flatItems, + highlightedIndex, + onEnter, + setHighlightedIndex, + scrollHighlightedIntoView, + closeDropdown, + ] + ); + + useEffect(() => { + if (!isOpen) { + setShouldRenderDropdown(false); + setHighlightedIndex(null); + return; + } + + const timer = setTimeout(() => setShouldRenderDropdown(true), 0); + return () => clearTimeout(timer); + }, [isOpen, setHighlightedIndex]); + + useEffect(() => { + if (shouldRenderDropdown && searchInputRef.current) { + searchInputRef.current.focus(); + } + }, [shouldRenderDropdown]); + + useEffect(() => { + const handleClickOutside = (event: MouseEvent) => { + if (dropdownRef.current && !dropdownRef.current.contains(event.target as Node)) { + closeDropdown(); + } + }; + + if (isOpen) { + document.addEventListener('mousedown', handleClickOutside); + return () => document.removeEventListener('mousedown', handleClickOutside); + } + }, [isOpen, closeDropdown]); + + useEffect(() => { + if (!shouldRenderDropdown || !onScrollNearEnd || !listRef.current) return; + + const handleScroll = () => { + const element = listRef.current; + if (!element) return; + + const { scrollTop, scrollHeight, clientHeight } = element; + const scrollPercentage = (scrollTop + clientHeight) / scrollHeight; + if (scrollPercentage > scrollThreshold) { + onScrollNearEnd(); + } + }; + + const element = listRef.current; + element.addEventListener('scroll', handleScroll); + return () => element.removeEventListener('scroll', handleScroll); + }, [shouldRenderDropdown, onScrollNearEnd, scrollThreshold]); + + const dropdownPosition = dropdownAlign === 'right' ? 'right-0' : 'left-0'; + + return ( +
+ {trigger} + + {isOpen && ( +
+ {!shouldRenderDropdown ? ( +
Loading...
+ ) : ( + <> + {tabs.length > 1 && ( +
{ + e.preventDefault(); + e.currentTarget.scrollLeft += e.deltaY; + }} + > + {tabs.map((tab) => ( + + ))} +
+ )} + +
+
+ + onSearchChange(e.target.value)} + onKeyDown={handleKeyDown} + placeholder={searchPlaceholder} + className="w-full pl-10 pr-3 py-1.5 bg-zinc-50 dark:bg-zinc-800 border border-zinc-200 dark:border-zinc-700 rounded-lg focus:outline-none focus:ring-2 focus:ring-zinc-400 dark:focus:ring-zinc-600 text-sm" + /> +
+
+ +
+ {sections.map((section, sectionIndex) => { + const sectionOffset = sections + .slice(0, sectionIndex) + .reduce((acc, current) => acc + current.items.length, 0); + + return ( +
+ {section.header} + {section.items.map((item, itemIndex) => { + const index = sectionOffset + itemIndex; + return renderItem(item, index, highlightedIndex === index); + })} +
+ ); + })} + + {flatItems.length === 0 && emptyState} +
+ + {footer} + + )} +
+ )} +
+ ); +} diff --git a/frontend/components/ui/ModelSelector.tsx b/frontend/components/ui/ModelSelector.tsx index bf042d9b..98018b15 100644 --- a/frontend/components/ui/ModelSelector.tsx +++ b/frontend/components/ui/ModelSelector.tsx @@ -1,13 +1,9 @@ -import React, { useState, useEffect, useRef, useMemo, useCallback } from 'react'; -import { Search, Star, StarOff, ChevronDown } from 'lucide-react'; +import React, { useState, useEffect, useMemo, useCallback } from 'react'; +import { Star, StarOff, ChevronDown } from 'lucide-react'; import { type Group as TabGroup } from './TabbedSelect'; +import ModelSelectBase, { type Section, type SelectOption, type Tab } from './ModelSelectBase'; -interface ModelOption { - value: string; - label: string; - provider?: string; - providerId?: string; -} +interface ModelOption extends SelectOption {} interface ModelSelectorProps { value: string; @@ -100,7 +96,6 @@ export default function ModelSelector({ onAfterChange, }: ModelSelectorProps) { const [isOpen, setIsOpen] = useState(false); - const [shouldRenderDropdown, setShouldRenderDropdown] = useState(false); const [searchQuery, setSearchQuery] = useState(''); const [favorites, setFavorites] = useState>(new Set()); const [recentModels, setRecentModels] = useState([]); @@ -108,10 +103,6 @@ export default function ModelSelector({ const [visibleCount, setVisibleCount] = useState(50); // Start with 50 items const [highlightedIndex, setHighlightedIndex] = useState(null); - const searchInputRef = useRef(null); - const dropdownRef = useRef(null); - const listRef = useRef(null); - // Load favorites and recent models from localStorage useEffect(() => { try { @@ -154,8 +145,8 @@ export default function ModelSelector({ }, [groups, fallbackOptions]); // Get available provider tabs - const providerTabs = useMemo(() => { - const tabs = [{ id: 'all', label: 'All', count: allModels.length }]; + const providerTabs = useMemo(() => { + const tabs: Tab[] = [{ id: 'all', label: 'All', count: allModels.length }]; if (groups && groups.length > 1) { groups.forEach((group) => { @@ -275,130 +266,12 @@ export default function ModelSelector({ [onChange, favorites, recentModels, onAfterChange] ); - // Handle keyboard navigation - // Build a flat list of visible models in the same order they are rendered - const flatVisibleModels = useMemo(() => { - const list: ModelOption[] = []; - if (organizedModels.favorites.length > 0) list.push(...organizedModels.favorites); - if (organizedModels.recent.length > 0) list.push(...organizedModels.recent); - if (organizedModels.other.length > 0) - list.push(...organizedModels.other.slice(0, visibleCount)); - return list; - }, [organizedModels, visibleCount]); - - const scrollHighlightedIntoView = useCallback((index: number | null) => { - if (index === null) return; - const item = document.getElementById(`model-item-${index}`); - if (item && listRef.current) { - const parent = listRef.current; - const itemTop = item.offsetTop; - const itemBottom = itemTop + item.clientHeight; - if (itemTop < parent.scrollTop) parent.scrollTop = itemTop - 8; - else if (itemBottom > parent.scrollTop + parent.clientHeight) - parent.scrollTop = itemBottom - parent.clientHeight + 8; - } - }, []); - - const handleKeyDown = (e: React.KeyboardEvent) => { - if (e.key === 'Escape') { - setIsOpen(false); - setSearchQuery(''); - setSelectedTab('all'); - setHighlightedIndex(null); - return; - } - - if (!shouldRenderDropdown) return; - - if (e.key === 'ArrowDown') { - e.preventDefault(); - setHighlightedIndex((prev) => { - const next = prev === null ? 0 : Math.min(prev + 1, flatVisibleModels.length - 1); - scrollHighlightedIntoView(next); - return next; - }); - return; - } - - if (e.key === 'ArrowUp') { - e.preventDefault(); - setHighlightedIndex((prev) => { - const next = - prev === null ? Math.max(flatVisibleModels.length - 1, 0) : Math.max(prev - 1, 0); - scrollHighlightedIntoView(next); - return next; - }); - return; - } - - if (e.key === 'Enter') { - if (highlightedIndex !== null && flatVisibleModels[highlightedIndex]) { - e.preventDefault(); - handleModelSelect(flatVisibleModels[highlightedIndex].value); - } - return; - } - }; - - // Close dropdown when clicking outside - useEffect(() => { - const handleClickOutside = (event: MouseEvent) => { - if (dropdownRef.current && !dropdownRef.current.contains(event.target as Node)) { - setIsOpen(false); - setSearchQuery(''); - setSelectedTab('all'); - } - }; - - if (isOpen) { - document.addEventListener('mousedown', handleClickOutside); - return () => document.removeEventListener('mousedown', handleClickOutside); - } - }, [isOpen]); - - // Defer dropdown rendering to next frame for smoother opening useEffect(() => { - if (isOpen) { - // Use setTimeout to defer heavy rendering to next frame - const timer = setTimeout(() => { - setShouldRenderDropdown(true); - }, 0); - return () => clearTimeout(timer); - } else { - setShouldRenderDropdown(false); - setVisibleCount(50); // Reset visible count when closing + if (!isOpen) { + setVisibleCount(50); } }, [isOpen]); - // Infinite scroll handler for large lists - useEffect(() => { - if (!shouldRenderDropdown || !listRef.current) return; - - const handleScroll = () => { - const element = listRef.current; - if (!element) return; - - const { scrollTop, scrollHeight, clientHeight } = element; - const scrollPercentage = (scrollTop + clientHeight) / scrollHeight; - - // Load more when scrolled 80% down - if (scrollPercentage > 0.8 && visibleCount < organizedModels.other.length + 100) { - setVisibleCount((prev) => Math.min(prev + 50, organizedModels.other.length + 100)); - } - }; - - const element = listRef.current; - element.addEventListener('scroll', handleScroll); - return () => element.removeEventListener('scroll', handleScroll); - }, [shouldRenderDropdown, visibleCount, organizedModels.other.length]); - - // Focus search input when dropdown opens - useEffect(() => { - if (shouldRenderDropdown && searchInputRef.current) { - searchInputRef.current.focus(); - } - }, [shouldRenderDropdown]); - // When filtered models change, reset highlighted index useEffect(() => { setHighlightedIndex(null); @@ -438,183 +311,144 @@ export default function ModelSelector({ const displayText = currentModel?.label || value || 'Select model'; - return ( -
- + const sections = useMemo[]>(() => { + const result: Section[] = []; + + if (organizedModels.favorites.length > 0) { + result.push({ + id: 'favorites', + header: ( +
+ Favorites +
+ ), + items: organizedModels.favorites, + }); + } + + if (organizedModels.recent.length > 0) { + result.push({ + id: 'recent', + header: ( +
+ Recent +
+ ), + items: organizedModels.recent, + }); + } + + if (organizedModels.other.length > 0) { + const otherHeader = + organizedModels.favorites.length > 0 || organizedModels.recent.length > 0 ? ( +
+ All Models +
+ ) : undefined; + + result.push({ + id: 'other', + header: otherHeader, + items: organizedModels.other.slice(0, visibleCount), + }); + } + + return result; + }, [organizedModels, visibleCount]); + + const emptyState = useMemo(() => { + if (allModels.length === 0) { + return ( +
+ No models available. Please add a provider in settings. +
+ ); + } - {isOpen && ( -
- {!shouldRenderDropdown ? ( -
Loading...
- ) : ( - <> - {/* Provider Tabs */} - {providerTabs.length > 1 && ( -
{ - e.preventDefault(); - e.currentTarget.scrollLeft += e.deltaY; - }} - > - {providerTabs.map((tab) => ( - - ))} -
- )} - - {/* Search Header */} -
-
- - setSearchQuery(e.target.value)} - onKeyDown={handleKeyDown} - placeholder="Search models..." - className="w-full pl-10 pr-3 py-1.5 bg-zinc-50 dark:bg-zinc-800 border border-zinc-200 dark:border-zinc-700 rounded-lg focus:outline-none focus:ring-2 focus:ring-zinc-400 dark:focus:ring-zinc-600 text-sm" - /> -
-
- - {/* Model List */} -
- {organizedModels.favorites.length > 0 && ( -
-
- Favorites -
- {organizedModels.favorites.map((model, idx) => ( - - ))} -
- )} - - {organizedModels.recent.length > 0 && ( -
-
- Recent -
- {organizedModels.recent.map((model, rIdx) => { - const idx = organizedModels.favorites.length + rIdx; - return ( - - ); - })} -
- )} - - {organizedModels.other.length > 0 && ( -
- {(organizedModels.favorites.length > 0 || - organizedModels.recent.length > 0) && ( -
- All Models -
- )} - {organizedModels.other.slice(0, visibleCount).map((model, oIdx) => { - const idx = - organizedModels.favorites.length + organizedModels.recent.length + oIdx; - return ( - - ); - })} - {organizedModels.other.length > visibleCount && ( -
- Showing {visibleCount} of {organizedModels.other.length} models. Scroll for - more... -
- )} -
- )} - - {allModels.length === 0 ? ( -
- No models available. Please add a provider in settings. -
- ) : ( - filteredModels.length === 0 && ( -
- No models found matching "{searchQuery}" -
- ) - )} -
- - )} + if (filteredModels.length === 0) { + return ( +
+ No models found matching "{searchQuery}"
+ ); + } + + return null; + }, [allModels.length, filteredModels.length, searchQuery]); + + const footer = + organizedModels.other.length > visibleCount ? ( +
+ Showing {visibleCount} of {organizedModels.other.length} models. Scroll for more... +
+ ) : null; + + return ( + + isOpen={isOpen} + setIsOpen={setIsOpen} + onClose={() => { + setSearchQuery(''); + setSelectedTab('all'); + }} + ariaLabel={ariaLabel} + className={className} + dropdownAlign="left" + listClassName="max-h-[75vh]" + tabs={providerTabs} + activeTab={selectedTab} + onTabChange={setSelectedTab} + showTabCounts + searchQuery={searchQuery} + onSearchChange={setSearchQuery} + sections={sections} + renderItem={(model, index, isHighlighted) => ( + )} -
+ emptyState={emptyState} + footer={footer} + highlightedIndex={highlightedIndex} + setHighlightedIndex={setHighlightedIndex} + onEnter={(model) => handleModelSelect(model.value)} + enableKeyboardNavigation + getItemId={(index) => `model-item-${index}`} + onScrollNearEnd={() => { + if (visibleCount < organizedModels.other.length + 100) { + setVisibleCount((prev) => Math.min(prev + 50, organizedModels.other.length + 100)); + } + }} + trigger={ + + } + /> ); } From d7fd8c8b16dd5608a19fe89b854c3cbbe633d4a5 Mon Sep 17 00:00:00 2001 From: Duc Nguyen Date: Mon, 29 Dec 2025 19:08:49 +0700 Subject: [PATCH 07/34] fix: comparison mode --- backend/src/lib/openaiProxy.js | 18 +++ backend/src/lib/simplifiedPersistence.js | 18 ++- frontend/components/ChatV2.tsx | 2 + frontend/components/MessageList.tsx | 122 +++++++++++++++++--- frontend/hooks/useChat.ts | 136 +++++++++++++++++++++-- 5 files changed, 272 insertions(+), 24 deletions(-) diff --git a/backend/src/lib/openaiProxy.js b/backend/src/lib/openaiProxy.js index 271927e3..251b2a81 100644 --- a/backend/src/lib/openaiProxy.js +++ b/backend/src/lib/openaiProxy.js @@ -337,6 +337,24 @@ function handleProxyError(error, req, res, persistence) { async function handleRequest(context, req, res) { const { body, bodyIn, flags, provider, providerId, persistence, userId, abortContext } = context; + if (bodyIn?.parent_conversation_id && Array.isArray(bodyIn.messages)) { + const summarized = bodyIn.messages.map((msg) => ({ + role: msg?.role, + id: msg?.id, + contentLen: + typeof msg?.content === 'string' + ? msg.content.length + : Array.isArray(msg?.content) + ? msg.content.length + : 0, + })); + logger.debug('[openaiProxy] comparison request message history', { + parentConversationId: bodyIn.parent_conversation_id, + count: summarized.length, + summary: summarized, + }); + } + if (flags.hasTools) { // Tool orchestration path if (flags.streamToFrontend) { diff --git a/backend/src/lib/simplifiedPersistence.js b/backend/src/lib/simplifiedPersistence.js index c5fe4f1c..db81454c 100644 --- a/backend/src/lib/simplifiedPersistence.js +++ b/backend/src/lib/simplifiedPersistence.js @@ -179,7 +179,23 @@ export class SimplifiedPersistence { * @private */ async _processMessageHistory(sessionId, userId, bodyIn, isNewConversation) { - const messages = this.persistenceConfig.filterNonSystemMessages(bodyIn.messages || []); + let messages = this.persistenceConfig.filterNonSystemMessages(bodyIn.messages || []); + const emptyAssistantMessages = messages.filter( + (msg) => + msg?.role === 'assistant' && + (msg.content === '' || (Array.isArray(msg.content) && msg.content.length === 0)) && + (!Array.isArray(msg.tool_calls) || msg.tool_calls.length === 0) && + (!Array.isArray(msg.tool_outputs) || msg.tool_outputs.length === 0) + ); + if (emptyAssistantMessages.length > 0) { + logger.debug('[SimplifiedPersistence] Dropping empty assistant messages from client history', { + conversationId: this.conversationId, + parentConversationId: bodyIn?.parent_conversation_id ?? null, + count: emptyAssistantMessages.length, + ids: emptyAssistantMessages.map((msg) => msg?.id).filter(Boolean), + }); + messages = messages.filter((msg) => !emptyAssistantMessages.includes(msg)); + } const maxSeq = messages .map(msg => msg.seq) .filter(seq => typeof seq === 'number' && seq > 0) diff --git a/frontend/components/ChatV2.tsx b/frontend/components/ChatV2.tsx index 574c3659..aac16ac8 100644 --- a/frontend/components/ChatV2.tsx +++ b/frontend/components/ChatV2.tsx @@ -609,6 +609,8 @@ export function ChatV2() { messages={chat.messages} pending={chat.pending} conversationId={chat.conversationId} + compareModels={chat.compareModels} + primaryModelLabel={chat.model} editingMessageId={chat.editingMessageId} editingContent={chat.editingContent} onCopy={handleCopy} diff --git a/frontend/components/MessageList.tsx b/frontend/components/MessageList.tsx index ca9405cf..0a6c6b08 100644 --- a/frontend/components/MessageList.tsx +++ b/frontend/components/MessageList.tsx @@ -33,6 +33,8 @@ interface MessageListProps { messages: ChatMessage[]; pending: PendingState; conversationId: string | null; + compareModels: string[]; + primaryModelLabel: string | null; editingMessageId: string | null; editingContent: string; onCopy: (text: string) => void; @@ -55,6 +57,39 @@ function splitMessagesWithToolCalls(messages: ChatMessage[]): ChatMessage[] { return messages; } +// Deep comparison for comparisonResults objects to detect changes from linked conversations +function deepEqualComparisonResults( + a: Record | undefined, + b: Record | undefined +): boolean { + if (a === b) return true; + if (a === undefined || b === undefined) return false; + + const keysA = Object.keys(a); + const keysB = Object.keys(b); + + if (keysA.length !== keysB.length) return false; + + for (const key of keysA) { + if (!keysB.includes(key)) return false; + + const objA = a[key]; + const objB = b[key]; + + // Compare content - stringify for deep comparison + if (JSON.stringify(objA.content) !== JSON.stringify(objB.content)) return false; + + // Compare usage + if (JSON.stringify(objA.usage) !== JSON.stringify(objB.usage)) return false; + + // Compare status and error + if (objA.status !== objB.status) return false; + if (objA.error !== objB.error) return false; + } + + return true; +} + type ToolOutput = NonNullable[number]; type AssistantSegment = @@ -227,6 +262,8 @@ interface MessageProps { message: ChatMessage; isStreaming: boolean; conversationId: string | null; + compareModels: string[]; + primaryModelLabel: string | null; editingMessageId: string | null; editingContent: string; onCopy: (text: string) => void; @@ -253,12 +290,16 @@ interface MessageProps { fileInputRef: React.RefObject; toolbarRef?: React.RefObject; onFork?: (messageId: string) => void; + activeComparisonTab: string; + onComparisonTabChange: (modelId: string) => void; } const Message = React.memo( function Message({ message, isStreaming, + compareModels, + primaryModelLabel, editingMessageId, editingContent, onEditMessage, @@ -283,10 +324,14 @@ const Message = React.memo( fileInputRef, toolbarRef, onFork, + activeComparisonTab, + onComparisonTabChange, }: { message: ChatMessage; isStreaming: boolean; conversationId: string | null; + compareModels: string[]; + primaryModelLabel: string | null; editingMessageId: string | null; editingContent: string; onCopy: (text: string) => void; @@ -312,21 +357,33 @@ const Message = React.memo( onEditingImageUploadClick: () => void; fileInputRef: React.RefObject; onFork?: (messageId: string) => void; + activeComparisonTab: string; + onComparisonTabChange: (modelId: string) => void; }) { const isUser = message.role === 'user'; const isEditing = editingMessageId === message.id; // Comparison Logic - const [activeComparisonTab, setActiveComparisonTab] = useState('primary'); - const hasComparison = - message.comparisonResults && Object.keys(message.comparisonResults).length > 0; + const baseComparisonModels = Object.keys(message.comparisonResults || {}); + const showStreamingTabs = isStreaming && compareModels.length > 0; + const comparisonModels = showStreamingTabs + ? Array.from(new Set([...baseComparisonModels, ...compareModels])) + : baseComparisonModels; + const hasComparison = comparisonModels.length > 0; + + const resolvedComparisonTab = + hasComparison && activeComparisonTab !== 'primary' + ? comparisonModels.includes(activeComparisonTab) + ? activeComparisonTab + : 'primary' + : 'primary'; // Determine content based on active tab let displayMessage = message; let isComparisonStreaming = false; - if (hasComparison && activeComparisonTab !== 'primary') { - const result = message.comparisonResults?.[activeComparisonTab]; + if (hasComparison && resolvedComparisonTab !== 'primary') { + const result = message.comparisonResults?.[resolvedComparisonTab]; if (result) { // Construct a temporary message object for rendering displayMessage = { @@ -334,9 +391,20 @@ const Message = React.memo( content: result.content, tool_calls: undefined, // Secondary models don't support tool calls yet tool_outputs: undefined, + message_events: undefined, usage: result.usage, }; isComparisonStreaming = result.status === 'streaming'; + } else { + displayMessage = { + ...message, + content: '', + tool_calls: undefined, + tool_outputs: undefined, + message_events: undefined, + usage: undefined, + }; + isComparisonStreaming = pending.streaming; } } @@ -362,21 +430,29 @@ const Message = React.memo( {hasComparison && !isUser && (
- {Object.keys(message.comparisonResults || {}).map((modelId) => ( + {comparisonModels.map((modelId) => ( +
+ {filteredModels.length > visibleCount && ( +
+ Showing {visibleCount} of {filteredModels.length} models. Scroll for more... +
)} +
+ {selectedModels.length} selected + {selectedModels.length > 0 && ( + + )} +
} highlightedIndex={highlightedIndex} @@ -243,6 +255,11 @@ export default function CompareSelector({ onEnter={(model) => handleToggle(model.value)} enableKeyboardNavigation getItemId={(index) => `compare-item-${index}`} + onScrollNearEnd={() => { + if (visibleCount < filteredModels.length + 100) { + setVisibleCount((prev) => Math.min(prev + 50, filteredModels.length + 100)); + } + }} trigger={ + {!isCollapsed && hasDetails && ( +
+
+ {(Object.keys(parsedArgs).length > 0 || + (argsParseFailed && argsRaw.trim().length > 0)) && ( +
+
+ Parameters +
+
+ {argsParseFailed ? argsRaw : JSON.stringify(parsedArgs, null, 2)} +
+
+ )} + {outputs.length > 0 && ( +
+
+ Result +
+
+ {outputs.map((out: any, outIdx: number) => { + const raw = out.output ?? out; + let formatted = ''; + if (typeof raw === 'string') { + formatted = raw; + } else { + try { + formatted = JSON.stringify(raw, null, 2); + } catch { + formatted = String(raw); + } + } + return ( +
+ {formatted} +
+ ); + })} +
+
+ )} +
+
+ )} +
+ ); + }; + + // Helper to render a single model response column + const renderModelResponse = (data: (typeof modelDisplayData)[0]) => { + const { modelId, displayMessage: dm, isModelStreaming, assistantSegments: segments } = data; + + return ( +
+ {isMultiColumn && ( +
+ {getModelDisplayName(modelId)} +
+ )} + {segments.length === 0 ? ( +
+ {isModelStreaming || pending.abort ? ( + + + + + + ) : ( + No response content + )} +
+ ) : ( + segments.map((segment, segmentIndex) => { + if (segment.kind === 'text') { + if (!segment.text) return null; + return ( +
+ +
+ ); + } + return renderToolSegment(segment, segmentIndex, modelId); + }) + )} + + {/* Stats row for this model */} + {!isMultiColumn && !isEditing && (dm.content || !isUser) && ( +
+
+ {streamingStats && streamingStats.tokensPerSecond > 0 && modelId === 'primary' && ( +
+ {streamingStats.tokensPerSecond.toFixed(1)} tok/s +
+ )} + {dm.usage && ( +
+ {dm.usage.provider && {dm.usage.provider}} + {(dm.usage.prompt_tokens !== undefined || + dm.usage.completion_tokens !== undefined) && ( + + )} + {dm.usage.prompt_tokens !== undefined && + dm.usage.completion_tokens !== undefined && ( + + {dm.usage.prompt_tokens + dm.usage.completion_tokens} tokens ( + {dm.usage.prompt_tokens}↑ + {dm.usage.completion_tokens}↓) + + )} +
+ )} +
+
+ {dm.content && ( +
+ + {copiedMessageId === message.id && ( +
+ Copied +
+
+ )} +
+ )} + {onFork && ( + + )} + {!pending.streaming && ( + + )} +
+
+ )} + + {/* Compact stats for multi-column mode */} + {isMultiColumn && + dm.usage?.prompt_tokens !== undefined && + dm.usage?.completion_tokens !== undefined && ( +
+ {dm.usage.prompt_tokens + dm.usage.completion_tokens} tokens +
+ )} +
+ ); + }; // For editing, check if we have either text or images const canSaveEdit = editingContent.trim().length > 0 || editingImages.length > 0; @@ -429,37 +770,42 @@ const Message = React.memo( > {hasComparison && !isUser && (
- - {comparisonModels.map((modelId) => ( - - ))} + {allModels.map((modelId) => { + const isSelected = activeModels.includes(modelId); + const displayName = + modelId === 'primary' + ? primaryModelLabel + ? primaryModelLabel.includes('::') + ? primaryModelLabel.split('::')[1] + : primaryModelLabel + : 'Primary' + : modelId.includes('::') + ? modelId.split('::')[1] + : modelId; + + return ( + + ); + })}
)} @@ -551,332 +897,42 @@ const Message = React.memo(
- ) : ( -
- {assistantSegments.length === 0 ? ( -
- {(isStreaming && resolvedComparisonTab === 'primary') || - isComparisonStreaming || - pending.abort ? ( - - - - - - ) : ( - - No response content - - )} -
- ) : ( - assistantSegments.map((segment, segmentIndex) => { - if (segment.kind === 'text') { - if (!segment.text) { - return null; - } - return ( -
- -
- ); - } - - const { toolCall, outputs } = segment; - const toolName = toolCall.function?.name; - let parsedArgs = {}; - const argsRaw = toolCall.function?.arguments || ''; - let argsParseFailed = false; - - // Try to parse arguments if they're a string - if (typeof argsRaw === 'string') { - if (argsRaw.trim()) { - try { - parsedArgs = JSON.parse(argsRaw); - } catch { - // If parse fails, it might be streaming (incomplete JSON) - // Show the raw string instead of empty object - argsParseFailed = true; - } - } - } else { - parsedArgs = argsRaw; - } - - const getToolIcon = (name: string) => { - const iconProps = { - size: 14, - strokeWidth: 1.5, - className: - 'text-zinc-400 dark:text-zinc-500 group-hover/tool-btn:text-zinc-600 dark:group-hover/tool-btn:text-zinc-300 transition-colors duration-300', - }; - switch (name) { - case 'get_time': - return ; - case 'web_search': - return ; - default: - return ; - } - }; - - const getToolDisplayName = (name: string) => { - switch (name) { - case 'get_time': - return 'Check Time'; - case 'web_search': - return 'Search Web'; - default: - return name; - } - }; - - const toggleKey = `${message.id}-${toolCall.id ?? segmentIndex}`; - const isCollapsed = collapsedToolOutputs[toggleKey] ?? true; - - const getOutputSummary = (outputs: any[]) => { - if (!outputs || outputs.length === 0) return null; - - const firstOutput = outputs[0]; - const raw = firstOutput.output ?? firstOutput; - - if (typeof raw === 'string') { - const cleaned = raw.trim().replace(/\s+/g, ' '); - return cleaned.length > 80 ? cleaned.slice(0, 77) + '...' : cleaned; - } - - if (typeof raw === 'object' && raw !== null) { - if ('result' in raw) return String(raw.result).slice(0, 80); - if ('message' in raw) return String(raw.message).slice(0, 80); - if ('data' in raw && typeof raw.data === 'string') - return raw.data.slice(0, 80); - return 'Completed successfully'; - } - - return null; - }; - - const outputSummary = getOutputSummary(outputs); - const getInputSummary = (args: any, raw: string, parseFailed: boolean) => { - // If parsing failed, show the raw incomplete JSON string - if (parseFailed && raw) { - const cleaned = raw.trim().replace(/\s+/g, ' '); - return cleaned.length > 80 ? cleaned.slice(0, 77) + '...' : cleaned; - } - - // If successfully parsed, show formatted JSON - if (!args || (typeof args === 'object' && Object.keys(args).length === 0)) { - return null; - } - - try { - if (typeof args === 'string') { - const cleaned = args.trim().replace(/\s+/g, ' '); - return cleaned.length > 80 ? cleaned.slice(0, 77) + '...' : cleaned; - } - - const str = JSON.stringify(args); - const cleaned = str.replace(/\s+/g, ' '); - return cleaned.length > 80 ? cleaned.slice(0, 77) + '...' : cleaned; - } catch { - return String(args).slice(0, 80); - } - }; - - const inputSummary = getInputSummary(parsedArgs, argsRaw, argsParseFailed); - const hasDetails = - outputs.length > 0 || - Object.keys(parsedArgs).length > 0 || - (argsParseFailed && argsRaw.trim().length > 0); - - const isCompleted = outputs.length > 0; - - return ( -
- - - {!isCollapsed && hasDetails && ( -
-
- {(Object.keys(parsedArgs).length > 0 || - (argsParseFailed && argsRaw.trim().length > 0)) && ( -
-
- Parameters -
-
- {argsParseFailed - ? argsRaw - : JSON.stringify(parsedArgs, null, 2)} -
-
- )} - - {outputs.length > 0 && ( -
-
- Result -
-
- {outputs.map((out: any, outIdx: number) => { - const raw = out.output ?? out; - let formatted = ''; - if (typeof raw === 'string') { - formatted = raw; - } else { - try { - formatted = JSON.stringify(raw, null, 2); - } catch { - formatted = String(raw); - } - } - return ( -
- {formatted} -
- ); - })} -
-
- )} -
-
- )} -
- ); - }) - )} + ) : isMultiColumn ? ( + /* Multi-column side-by-side view */ +
+ {modelDisplayData.map((data) => renderModelResponse(data))}
+ ) : ( + /* Single column view */ + modelDisplayData.map((data) => renderModelResponse(data)) )} - {!isEditing && (displayMessage.content || !isUser) && ( + {/* User message toolbar */} + {isUser && !isEditing && message.content && (
- {/* Show stats for assistant messages */} - {!isUser && ( -
- {streamingStats && - streamingStats.tokensPerSecond > 0 && - resolvedComparisonTab === 'primary' && ( -
- {streamingStats.tokensPerSecond.toFixed(1)} tok/s -
- )} - {displayMessage.usage && ( -
- {displayMessage.usage.provider && ( - {displayMessage.usage.provider} - )} - {(displayMessage.usage.prompt_tokens !== undefined || - displayMessage.usage.completion_tokens !== undefined) && ( - - )} - {displayMessage.usage.prompt_tokens !== undefined && - displayMessage.usage.completion_tokens !== undefined && ( - - {displayMessage.usage.prompt_tokens + - displayMessage.usage.completion_tokens}{' '} - tokens ({displayMessage.usage.prompt_tokens}↑ +{' '} - {displayMessage.usage.completion_tokens}↓) - - )} -
- )} -
- )} -
- {displayMessage.content && ( -
- - {copiedMessageId === message.id && ( -
- Copied -
-
- )} -
- )} + +
+
+ )} + {/* Shared toolbar for multi-column assistant messages */} + {isMultiColumn && !isEditing && ( +
+
{onFork && ( )} - - {isUser - ? message.content && ( - - ) - : !pending.streaming && ( - - )} + {!pending.streaming && ( + + )}
)} @@ -942,7 +983,7 @@ const Message = React.memo( prev.streamingStats?.tokensPerSecond === next.streamingStats?.tokensPerSecond && prev.editingImages === next.editingImages && prev.toolbarRef === next.toolbarRef && - prev.activeComparisonTab === next.activeComparisonTab + prev.selectedComparisonModels === next.selectedComparisonModels ); } ); @@ -972,7 +1013,7 @@ export function MessageList({ const editingTextareaRef = useRef(null); const [collapsedToolOutputs, setCollapsedToolOutputs] = useState>({}); const [copiedMessageId, setCopiedMessageId] = useState(null); - const [activeComparisonTab, setActiveComparisonTab] = useState('primary'); + const [selectedComparisonModels, setSelectedComparisonModels] = useState(['primary']); const { dynamicBottomPadding, lastUserMessageRef, toolbarRef, bottomRef } = useStreamingScroll( messages, pending, @@ -1012,9 +1053,23 @@ export function MessageList({ }, [editingMessageId]); useEffect(() => { - setActiveComparisonTab('primary'); + setSelectedComparisonModels(['primary']); }, [conversationId]); + // Toggle handler for comparison model selection (checkbox behavior) + const handleToggleComparisonModel = useCallback((modelId: string) => { + setSelectedComparisonModels((prev) => { + if (prev.includes(modelId)) { + // Don't allow deselecting the last model + if (prev.length === 1) { + return prev; + } + return prev.filter((m) => m !== modelId); + } + return [...prev, modelId]; + }); + }, []); + // Handle image upload during editing const handleEditingImageFiles = useCallback(async (files: File[]) => { try { @@ -1255,8 +1310,8 @@ export function MessageList({ onEditingImageUploadClick={handleEditingImageUploadClick} fileInputRef={fileInputRef} onFork={onFork} - activeComparisonTab={activeComparisonTab} - onComparisonTabChange={setActiveComparisonTab} + selectedComparisonModels={selectedComparisonModels} + onToggleComparisonModel={handleToggleComparisonModel} /> ); })} diff --git a/frontend/lib/types.ts b/frontend/lib/types.ts index f47fa8dd..44599617 100644 --- a/frontend/lib/types.ts +++ b/frontend/lib/types.ts @@ -225,6 +225,14 @@ export interface ChatMessage { string, { content: MessageContent; + tool_calls?: any[]; + tool_outputs?: Array<{ + tool_call_id?: string; + name?: string; + output: any; + status?: string; + }>; + message_events?: MessageEvent[]; usage?: any; status: 'streaming' | 'complete' | 'error'; error?: string; From 64beb74df152da5f7574c451a1705b28c68b10cb Mon Sep 17 00:00:00 2001 From: qduc Date: Mon, 29 Dec 2025 22:00:25 +0700 Subject: [PATCH 12/34] feat: enhance layout for multi-column comparison mode in MessageList --- frontend/components/MessageList.tsx | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/frontend/components/MessageList.tsx b/frontend/components/MessageList.tsx index 04e99c33..913d80c7 100644 --- a/frontend/components/MessageList.tsx +++ b/frontend/components/MessageList.tsx @@ -623,7 +623,10 @@ const Message = React.memo( const { modelId, displayMessage: dm, isModelStreaming, assistantSegments: segments } = data; return ( -
+
{isMultiColumn && (
{getModelDisplayName(modelId)} @@ -1014,6 +1017,7 @@ export function MessageList({ const [collapsedToolOutputs, setCollapsedToolOutputs] = useState>({}); const [copiedMessageId, setCopiedMessageId] = useState(null); const [selectedComparisonModels, setSelectedComparisonModels] = useState(['primary']); + const hasMultiColumnLayout = selectedComparisonModels.length > 1; const { dynamicBottomPadding, lastUserMessageRef, toolbarRef, bottomRef } = useStreamingScroll( messages, pending, @@ -1262,7 +1266,7 @@ export function MessageList({ style={{ willChange: 'scroll-position' }} >
{messages.length === 0 && } From 334d63bcc82bb7a280bec8b1a1942534e8cb2eeb Mon Sep 17 00:00:00 2001 From: Duc Nguyen Date: Mon, 29 Dec 2025 22:12:33 +0700 Subject: [PATCH 13/34] fix: message toolbar only show in one column --- frontend/components/MessageList.tsx | 95 ++++++++--------------------- 1 file changed, 26 insertions(+), 69 deletions(-) diff --git a/frontend/components/MessageList.tsx b/frontend/components/MessageList.tsx index 913d80c7..71ae6ebe 100644 --- a/frontend/components/MessageList.tsx +++ b/frontend/components/MessageList.tsx @@ -671,47 +671,41 @@ const Message = React.memo( )} {/* Stats row for this model */} - {!isMultiColumn && !isEditing && (dm.content || !isUser) && ( -
-
- {streamingStats && streamingStats.tokensPerSecond > 0 && modelId === 'primary' && ( -
- {streamingStats.tokensPerSecond.toFixed(1)} tok/s -
- )} + {!isEditing && (dm.content || !isUser) && ( +
+
+ {streamingStats && + streamingStats.tokensPerSecond > 0 && + modelId === 'primary' && + !isMultiColumn && ( +
+ {streamingStats.tokensPerSecond.toFixed(1)} t/s +
+ )} {dm.usage && ( -
- {dm.usage.provider && {dm.usage.provider}} - {(dm.usage.prompt_tokens !== undefined || - dm.usage.completion_tokens !== undefined) && ( - - )} - {dm.usage.prompt_tokens !== undefined && - dm.usage.completion_tokens !== undefined && ( - - {dm.usage.prompt_tokens + dm.usage.completion_tokens} tokens ( - {dm.usage.prompt_tokens}↑ + {dm.usage.completion_tokens}↓) - - )} +
+ {dm.usage.prompt_tokens + dm.usage.completion_tokens} tokens
)}
-
+
{dm.content && (
- {copiedMessageId === message.id && ( -
+ {copiedMessageId === `${message.id}-${modelId}` && ( +
Copied -
+
)}
@@ -721,9 +715,9 @@ const Message = React.memo( type="button" onClick={() => onFork(message.id)} title="Fork" - className="p-2 rounded-md bg-white/60 dark:bg-neutral-800/50 hover:bg-white/90 dark:hover:bg-neutral-700/80 text-slate-700 dark:text-slate-200 cursor-pointer transition-colors" + className="p-1.5 rounded-md hover:bg-zinc-100 dark:hover:bg-zinc-800 text-zinc-500 dark:text-zinc-400 transition-colors" > -
)} - {/* Compact stats for multi-column mode */} - {isMultiColumn && - dm.usage?.prompt_tokens !== undefined && - dm.usage?.completion_tokens !== undefined && ( -
- {dm.usage.prompt_tokens + dm.usage.completion_tokens} tokens -
- )}
); }; @@ -932,35 +918,6 @@ const Message = React.memo(
)} - {/* Shared toolbar for multi-column assistant messages */} - {isMultiColumn && !isEditing && ( -
-
- {onFork && ( - - )} - {!pending.streaming && ( - - )} -
-
- )} )}
From f798838bcbc3328bf6e7f913b9e67de16d4da3fe Mon Sep 17 00:00:00 2001 From: Duc Nguyen Date: Mon, 29 Dec 2025 22:23:15 +0700 Subject: [PATCH 14/34] feat: add "select all" and single model mode for comparison model toolbar --- frontend/components/MessageList.tsx | 44 ++++++++++++++++++++++++----- 1 file changed, 37 insertions(+), 7 deletions(-) diff --git a/frontend/components/MessageList.tsx b/frontend/components/MessageList.tsx index 71ae6ebe..3f79680e 100644 --- a/frontend/components/MessageList.tsx +++ b/frontend/components/MessageList.tsx @@ -291,7 +291,8 @@ interface MessageProps { toolbarRef?: React.RefObject; onFork?: (messageId: string) => void; selectedComparisonModels: string[]; - onToggleComparisonModel: (modelId: string) => void; + onToggleComparisonModel: (modelId: string, event?: React.MouseEvent) => void; + onSelectAllComparisonModels: (models: string[]) => void; } const Message = React.memo( @@ -326,6 +327,7 @@ const Message = React.memo( onFork, selectedComparisonModels, onToggleComparisonModel, + onSelectAllComparisonModels, }: { message: ChatMessage; isStreaming: boolean; @@ -358,7 +360,8 @@ const Message = React.memo( fileInputRef: React.RefObject; onFork?: (messageId: string) => void; selectedComparisonModels: string[]; - onToggleComparisonModel: (modelId: string) => void; + onToggleComparisonModel: (modelId: string, event?: React.MouseEvent) => void; + onSelectAllComparisonModels: (models: string[]) => void; }) { const isUser = message.role === 'user'; const isEditing = editingMessageId === message.id; @@ -735,7 +738,6 @@ const Message = React.memo(
)} -
); }; @@ -759,6 +761,16 @@ const Message = React.memo( > {hasComparison && !isUser && (
+ {/* All button */} + {allModels.map((modelId) => { const isSelected = activeModels.includes(modelId); const displayName = @@ -775,7 +787,7 @@ const Message = React.memo( return ( ); })} + {/* Hint text */} + + {navigator.platform.includes('Mac') ? '⌘' : 'Ctrl'}+click for single model +
)} @@ -916,8 +932,7 @@ const Message = React.memo(
- )} - + )} )}
@@ -1018,7 +1033,16 @@ export function MessageList({ }, [conversationId]); // Toggle handler for comparison model selection (checkbox behavior) - const handleToggleComparisonModel = useCallback((modelId: string) => { + // Supports Ctrl/Cmd+click for solo mode (show only that model) + const handleToggleComparisonModel = useCallback((modelId: string, event?: React.MouseEvent) => { + const isSoloClick = event?.metaKey || event?.ctrlKey; + + if (isSoloClick) { + // Solo mode: show only this model + setSelectedComparisonModels([modelId]); + return; + } + setSelectedComparisonModels((prev) => { if (prev.includes(modelId)) { // Don't allow deselecting the last model @@ -1031,6 +1055,11 @@ export function MessageList({ }); }, []); + // Handler to select all comparison models + const handleSelectAllComparisonModels = useCallback((models: string[]) => { + setSelectedComparisonModels(models); + }, []); + // Handle image upload during editing const handleEditingImageFiles = useCallback(async (files: File[]) => { try { @@ -1273,6 +1302,7 @@ export function MessageList({ onFork={onFork} selectedComparisonModels={selectedComparisonModels} onToggleComparisonModel={handleToggleComparisonModel} + onSelectAllComparisonModels={handleSelectAllComparisonModels} /> ); })} From 3235a70f9d3c08b39cfd6d0573446639f8fb691d Mon Sep 17 00:00:00 2001 From: Duc Nguyen Date: Mon, 29 Dec 2025 22:45:29 +0700 Subject: [PATCH 15/34] fix lint --- frontend/components/MessageList.tsx | 11 +++--- frontend/components/ui/CompareSelector.tsx | 39 +++++++++++++++++++--- frontend/components/ui/ModelSelectBase.tsx | 8 ++++- 3 files changed, 47 insertions(+), 11 deletions(-) diff --git a/frontend/components/MessageList.tsx b/frontend/components/MessageList.tsx index 3f79680e..e3d3e14a 100644 --- a/frontend/components/MessageList.tsx +++ b/frontend/components/MessageList.tsx @@ -360,8 +360,8 @@ const Message = React.memo( fileInputRef: React.RefObject; onFork?: (messageId: string) => void; selectedComparisonModels: string[]; - onToggleComparisonModel: (modelId: string, event?: React.MouseEvent) => void; - onSelectAllComparisonModels: (models: string[]) => void; + onToggleComparisonModel: (modelId: string, event?: React.MouseEvent) => void; + onSelectAllComparisonModels: (models: string[]) => void; }) { const isUser = message.role === 'user'; const isEditing = editingMessageId === message.id; @@ -764,10 +764,11 @@ const Message = React.memo( {/* All button */} @@ -932,7 +933,7 @@ const Message = React.memo(
- )} + )} )}
diff --git a/frontend/components/ui/CompareSelector.tsx b/frontend/components/ui/CompareSelector.tsx index 97b2d978..834cd79a 100644 --- a/frontend/components/ui/CompareSelector.tsx +++ b/frontend/components/ui/CompareSelector.tsx @@ -122,22 +122,36 @@ export default function CompareSelector({ return result; }, [groups, fallbackOptions]); - // Get available provider tabs + // Get available provider tabs with hasSelected indicator const providerTabs = useMemo(() => { - const tabs: Tab[] = [{ id: 'all', label: 'All', count: allModels.length }]; + const selectedSet = new Set(selectedModels); + + // Check if any model in a provider group is selected + const hasSelectedInProvider = (providerId: string): boolean => { + return allModels.some( + (model) => model.providerId === providerId && selectedSet.has(model.value) + ); + }; + + const hasAnySelected = selectedModels.length > 0; + + const tabs: Tab[] = [ + { id: 'all', label: 'All', count: allModels.length, hasSelected: hasAnySelected }, + ]; if (groups && groups.length > 1) { groups.forEach((group) => { tabs.push({ id: group.id, label: group.label, count: group.options.length, + hasSelected: hasSelectedInProvider(group.id), }); }); } return tabs; - }, [groups, allModels.length]); + }, [groups, allModels, selectedModels]); - // Filter models based on search query and selected tab + // Filter models based on search query and selected tab, with selected models pinned to top const filteredModels = useMemo(() => { let models = allModels; @@ -155,8 +169,23 @@ export default function CompareSelector({ ); } + // Sort: primary model first, then selected models, then unselected + const selectedSet = new Set(selectedModels); + models = [...models].sort((a, b) => { + const aIsPrimary = a.value === primaryModel; + const bIsPrimary = b.value === primaryModel; + const aIsSelected = selectedSet.has(a.value); + const bIsSelected = selectedSet.has(b.value); + + if (aIsPrimary && !bIsPrimary) return -1; + if (!aIsPrimary && bIsPrimary) return 1; + if (aIsSelected && !bIsSelected) return -1; + if (!aIsSelected && bIsSelected) return 1; + return 0; + }); + return models; - }, [allModels, searchQuery, selectedTab]); + }, [allModels, searchQuery, selectedTab, selectedModels, primaryModel]); const handleToggle = useCallback( (modelValue: string) => { diff --git a/frontend/components/ui/ModelSelectBase.tsx b/frontend/components/ui/ModelSelectBase.tsx index 10659fd6..10570ec4 100644 --- a/frontend/components/ui/ModelSelectBase.tsx +++ b/frontend/components/ui/ModelSelectBase.tsx @@ -12,6 +12,7 @@ export interface Tab { id: string; label: string; count?: number; + hasSelected?: boolean; } export interface Section { @@ -248,7 +249,12 @@ export default function ModelSelectBase({ : 'border-transparent text-zinc-500 dark:text-zinc-400 hover:text-zinc-800 dark:hover:text-zinc-200 hover:bg-zinc-100 dark:hover:bg-zinc-800' }`} > -
{tab.label}
+
+ {tab.label} + {tab.hasSelected && ( + + )} +
{showTabCounts && typeof tab.count === 'number' && (
({tab.count})
)} From 71557f32f609f190f99e4d8e5ccfba1cb00dcd5e Mon Sep 17 00:00:00 2001 From: Duc Nguyen Date: Mon, 29 Dec 2025 23:56:48 +0700 Subject: [PATCH 16/34] feat: hardened comparison mode flow --- frontend/components/ChatHeader.tsx | 12 + frontend/components/ChatV2.tsx | 89 ++++- frontend/components/MessageInput.tsx | 85 +++- frontend/components/MessageList.tsx | 35 +- frontend/components/ui/CompareSelector.tsx | 62 ++- frontend/components/ui/ModelSelectBase.tsx | 6 +- frontend/components/ui/ModelSelector.tsx | 26 +- frontend/hooks/useChat.ts | 431 ++++++++++++++++++--- 8 files changed, 657 insertions(+), 89 deletions(-) diff --git a/frontend/components/ChatHeader.tsx b/frontend/components/ChatHeader.tsx index ef7fc14c..fa7c9fda 100644 --- a/frontend/components/ChatHeader.tsx +++ b/frontend/components/ChatHeader.tsx @@ -27,6 +27,10 @@ interface ChatHeaderProps { showRightSidebarButton?: boolean; selectedComparisonModels?: string[]; onComparisonModelsChange?: (models: string[]) => void; + comparisonLocked?: boolean; + comparisonLockReason?: string; + modelSelectionLocked?: boolean; + modelSelectionLockReason?: string; } export function ChatHeader({ @@ -49,6 +53,10 @@ export function ChatHeader({ onNewChat, selectedComparisonModels = [], onComparisonModelsChange, + comparisonLocked = false, + comparisonLockReason = 'Model comparison is locked after the first message.', + modelSelectionLocked = false, + modelSelectionLockReason = 'Primary model is locked after comparison starts.', }: ChatHeaderProps) { const { theme, setTheme, resolvedTheme } = useTheme(); @@ -140,6 +148,8 @@ export function ChatHeader({ className="text-sm sm:text-base md:text-lg" ariaLabel="Model" onAfterChange={onFocusMessageInput} + disabled={modelSelectionLocked} + disabledReason={modelSelectionLockReason} /> {onComparisonModelsChange && ( )} {onRefreshModels && ( diff --git a/frontend/components/ChatV2.tsx b/frontend/components/ChatV2.tsx index aac16ac8..a9e51f90 100644 --- a/frontend/components/ChatV2.tsx +++ b/frontend/components/ChatV2.tsx @@ -1,5 +1,5 @@ 'use client'; -import { useCallback, useEffect, useRef, useState } from 'react'; +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import type { PointerEvent as ReactPointerEvent } from 'react'; import { usePathname, useRouter, useSearchParams } from 'next/navigation'; import { ArrowUp, ArrowDown, Bot } from 'lucide-react'; @@ -45,6 +45,78 @@ export function ChatV2() { const [isMobile, setIsMobile] = useState(false); const hasCheckedMobileRef = useRef(false); + const modelAvailability = useMemo(() => { + if (chat.isLoadingModels || chat.modelOptions.length === 0) { + return { locked: false, missing: [] as string[] }; + } + + const optionValues = new Set(chat.modelOptions.map((option) => option.value)); + const resolveModel = (modelId: string) => { + if (!modelId) return null; + if (optionValues.has(modelId)) return modelId; + + if (!modelId.includes('::')) { + const providerId = chat.modelToProvider[modelId] || ''; + if (providerId) { + const qualified = `${providerId}::${modelId}`; + if (optionValues.has(qualified)) return qualified; + } + + const suffixMatch = chat.modelOptions.find((option) => + option.value.endsWith(`::${modelId}`) + ); + if (suffixMatch) return suffixMatch.value; + } + + return null; + }; + + const candidates = [chat.model, ...chat.compareModels].filter(Boolean); + const missing: string[] = []; + const seen = new Set(); + + for (const modelId of candidates) { + if (seen.has(modelId)) continue; + seen.add(modelId); + if (!resolveModel(modelId)) { + missing.push(modelId); + } + } + + const hasConversation = chat.messages.length > 0 || !!chat.conversationId; + const hasComparison = chat.compareModels.length > 0; + return { + locked: hasConversation && hasComparison && missing.length > 0, + missing, + }; + }, [ + chat.isLoadingModels, + chat.modelOptions, + chat.modelToProvider, + chat.model, + chat.compareModels, + chat.messages.length, + chat.conversationId, + ]); + + const unavailableModelLabels = useMemo( + () => + modelAvailability.missing.map((modelId) => + modelId.includes('::') ? modelId.split('::')[1] : modelId + ), + [modelAvailability.missing] + ); + const modelLockReason = modelAvailability.locked + ? `Unavailable model${unavailableModelLabels.length === 1 ? '' : 's'}: ${unavailableModelLabels.join( + ', ' + )}. Refresh models to resume.` + : undefined; + const modelSelectionLocked = + modelAvailability.locked || (chat.messages.length > 0 && chat.compareModels.length > 0); + const modelSelectionLockReason = modelAvailability.locked + ? modelLockReason + : 'Primary model is locked for comparison chats after the first message. Start a new chat to change.'; + // Detect mobile screen size and auto-collapse sidebars on mount useEffect(() => { if (typeof window === 'undefined') return; @@ -335,6 +407,14 @@ export function ChatV2() { [chat] ); + const handleRetryComparisonModel = useCallback( + async (messageId: string, modelId: string) => { + if (chat.status === 'streaming') return; + await chat.retryComparisonModel(messageId, modelId); + }, + [chat] + ); + const handleApplyLocalEdit = useCallback( async (messageId: string, updatedContent: MessageContent) => { if (chat.status === 'streaming') { @@ -602,6 +682,10 @@ export function ChatV2() { showRightSidebarButton={true} selectedComparisonModels={chat.compareModels} onComparisonModelsChange={chat.setCompareModels} + comparisonLocked={chat.messages.length > 0} + comparisonLockReason="Model comparison is locked after the first message. Start a new chat to change." + modelSelectionLocked={modelSelectionLocked} + modelSelectionLockReason={modelSelectionLockReason} />
@@ -620,6 +704,7 @@ export function ChatV2() { onApplyLocalEdit={handleApplyLocalEdit} onEditingContentChange={chat.updateEditContent} onRetryMessage={handleRetryMessage} + onRetryComparisonModel={handleRetryComparisonModel} onScrollStateChange={setScrollButtons} containerRef={messageListRef} onSuggestionClick={handleSuggestionClick} @@ -700,6 +785,8 @@ export function ChatV2() { onImagesChange={chat.setImages} files={chat.files} onFilesChange={chat.setFiles} + disabled={modelAvailability.locked} + disabledReason={modelLockReason} /> )}
diff --git a/frontend/components/MessageInput.tsx b/frontend/components/MessageInput.tsx index 2d12b719..3d67ac25 100644 --- a/frontend/components/MessageInput.tsx +++ b/frontend/components/MessageInput.tsx @@ -47,6 +47,8 @@ interface MessageInputProps { onImagesChange?: (images: ImageAttachment[]) => void; files?: FileAttachment[]; onFilesChange?: (files: FileAttachment[]) => void; + disabled?: boolean; + disabledReason?: string; } export interface MessageInputRef { @@ -73,6 +75,8 @@ export const MessageInput = forwardRef(funct onImagesChange, files = [], onFilesChange, + disabled = false, + disabledReason = 'Selected models are unavailable. Refresh models to resume.', }, ref ) { @@ -118,6 +122,8 @@ export const MessageInput = forwardRef(funct // Check if we can send (have text or images) const canSend = input.trim().length > 0 || images.length > 0; + const inputLocked = disabled; + const controlsDisabled = inputLocked || pending.streaming; // Helper to check if a tool is disabled due to missing API key const isToolDisabled = (name: string) => { @@ -193,6 +199,12 @@ export const MessageInput = forwardRef(funct } }, [attachOpen]); + useEffect(() => { + if (!inputLocked) return; + setToolsOpen(false); + setAttachOpen(false); + }, [inputLocked]); + // Load tool specs for the selector UI useEffect(() => { let mounted = true; @@ -247,12 +259,16 @@ export const MessageInput = forwardRef(funct const handleKey = (e: React.KeyboardEvent) => { if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); - if (pending.streaming) onStop(); - else onSend(); + if (pending.streaming) { + onStop(); + } else if (!inputLocked) { + onSend(); + } } }; const handlePaste = (event: React.ClipboardEvent) => { + if (inputLocked) return; if (!onImagesChange) return; const items = Array.from(event.clipboardData?.items || []); @@ -284,6 +300,7 @@ export const MessageInput = forwardRef(funct // Image handling const handleImageFiles = async (imageFiles: File[]) => { + if (inputLocked) return; if (!onImagesChange || !images) return; try { @@ -307,10 +324,12 @@ export const MessageInput = forwardRef(funct }; const handleImageUploadClick = () => { + if (inputLocked) return; imageInputRef.current?.click(); }; const handleImageInputChange = (e: React.ChangeEvent) => { + if (inputLocked) return; const imageFiles = Array.from(e.target.files || []); if (imageFiles.length > 0) { handleImageFiles(imageFiles); @@ -321,6 +340,7 @@ export const MessageInput = forwardRef(funct // File handling const handleFileFiles = async (textFiles: File[]) => { + if (inputLocked) return; if (!onFilesChange || !files) return; try { @@ -338,10 +358,12 @@ export const MessageInput = forwardRef(funct }; const handleFileUploadClick = () => { + if (inputLocked) return; fileInputRef.current?.click(); }; const handleFileInputChange = (e: React.ChangeEvent) => { + if (inputLocked) return; const textFiles = Array.from(e.target.files || []); if (textFiles.length > 0) { handleFileFiles(textFiles); @@ -352,6 +374,7 @@ export const MessageInput = forwardRef(funct // Tools handling const handleSearchToggle = (enabled: boolean) => { + if (inputLocked) return; const searchTools = ['web_search', 'web_search_exa', 'web_search_searxng', 'web_fetch']; let next: string[]; @@ -371,6 +394,7 @@ export const MessageInput = forwardRef(funct // Combined file handler for drag and drop (both images and text files) const handleDroppedFiles = async (droppedFiles: File[]) => { + if (inputLocked) return; // Separate images from text files const imageFiles = droppedFiles.filter((file) => file.type.startsWith('image/')); const textFiles = droppedFiles.filter((file) => !file.type.startsWith('image/')); @@ -390,7 +414,7 @@ export const MessageInput = forwardRef(funct return ( true} // Accept all files, we'll sort them in handleDroppedFiles @@ -399,8 +423,11 @@ export const MessageInput = forwardRef(funct className="" onSubmit={(e) => { e.preventDefault(); - if (pending.streaming) onStop(); - else onSend(); + if (pending.streaming) { + onStop(); + } else if (!inputLocked) { + onSend(); + } }} >
@@ -436,7 +463,7 @@ export const MessageInput = forwardRef(funct