diff --git a/packages/app/src/components/chat/ChatHeader.vue b/packages/app/src/components/chat/ChatHeader.vue index a3c211ef..80bea125 100644 --- a/packages/app/src/components/chat/ChatHeader.vue +++ b/packages/app/src/components/chat/ChatHeader.vue @@ -45,6 +45,20 @@ + + + + + + () const { activeProvider, activeModel, availableProviders, setProvider, setModel } = useAI() +const comparison = useComparisonMode() const chatStore = useChatStore() const webSearchEnabled = computed(() => chatStore.webSearchEnabled) const showModelPicker = ref(false) diff --git a/packages/app/src/components/chat/ChatWindow.vue b/packages/app/src/components/chat/ChatWindow.vue index 4276409a..5527db30 100644 --- a/packages/app/src/components/chat/ChatWindow.vue +++ b/packages/app/src/components/chat/ChatWindow.vue @@ -81,9 +81,15 @@ + + + (null) const chatSearchRef = ref | null>(null) @@ -216,6 +225,7 @@ function handleNewChat() { function handleStop() { stopGeneration() + comparison.stopComparison() } async function handleEdit(messageId: string, newContent: string) { @@ -302,6 +312,16 @@ async function handleSend(text: string) { finalText = `${quoteLine}\n\n${text}` clearReply() } + + // Comparison mode: stream to both models simultaneously + if (comparison.isComparing.value) { + const convId = chatStore.activeConversationId ?? chatStore.createConversation() + chatStore.addMessage(convId, { role: 'user', content: finalText }) + const history = chatStore.messages.map(m => ({ role: m.role, content: m.content })) + await comparison.streamBothModels(streamWithModel, history) + return + } + await sendMessage(finalText) } diff --git a/packages/app/src/components/chat/ComparisonView.vue b/packages/app/src/components/chat/ComparisonView.vue new file mode 100644 index 00000000..65e21fcb --- /dev/null +++ b/packages/app/src/components/chat/ComparisonView.vue @@ -0,0 +1,79 @@ + + + + + + {{ tab }} + + + + + + + + + {{ model1Label }} + streaming... + + + + {{ error1 }} + Waiting for response... + + + + + + + {{ model2Label }} + streaming... + + + + {{ error2 }} + Waiting for response... + + + + + + + diff --git a/packages/app/src/composables/useAI.ts b/packages/app/src/composables/useAI.ts index 4b047833..c8b36f87 100644 --- a/packages/app/src/composables/useAI.ts +++ b/packages/app/src/composables/useAI.ts @@ -326,6 +326,32 @@ async function generateAutoTitle(conversationId: string) { } } +/** Stream a specific provider/model — used by comparison mode */ +export async function streamWithModel( + provider: string, + model: string, + messages: { role: string; content: string }[], + onToken: (text: string) => void, + onError: (err: string) => void, + signal: AbortSignal, +): Promise { + const history = messages.map(m => ({ role: m.role as 'user' | 'assistant', content: m.content })) + const savedModel = activeModel.value + activeModel.value = model + + try { + if (provider === 'claude') { + await streamClaude(history, onToken, onError, 'You are a helpful assistant.', false, signal) + } else if (provider === 'openrouter') { + await streamOpenRouter(history, onToken, onError, 'You are a helpful assistant.', signal) + } else { + await streamMock(history, onToken, signal) + } + } finally { + activeModel.value = savedModel + } +} + export function useAI() { const chatStore = useChatStore() diff --git a/packages/app/src/composables/useComparisonMode.ts b/packages/app/src/composables/useComparisonMode.ts new file mode 100644 index 00000000..20149766 --- /dev/null +++ b/packages/app/src/composables/useComparisonMode.ts @@ -0,0 +1,101 @@ +import { ref, computed } from 'vue' + +const comparisonEnabled = ref(false) +const model1 = ref({ provider: 'claude' as string, model: 'claude-haiku-4.5' }) +const model2 = ref({ provider: 'openrouter' as string, model: 'meta-llama/llama-4-maverick' }) +const response1 = ref('') +const response2 = ref('') +const isStreaming1 = ref(false) +const isStreaming2 = ref(false) +const error1 = ref(null) +const error2 = ref(null) +let abortController: AbortController | null = null + +export function useComparisonMode() { + const isComparing = computed(() => comparisonEnabled.value) + const isAnyStreaming = computed(() => isStreaming1.value || isStreaming2.value) + + function toggleComparison() { + comparisonEnabled.value = !comparisonEnabled.value + } + + function setModel(slot: 1 | 2, provider: string, modelId: string) { + const target = slot === 1 ? model1 : model2 + target.value = { provider, model: modelId } + } + + function clearResponses() { + response1.value = '' + response2.value = '' + error1.value = null + error2.value = null + } + + function stopComparison() { + if (abortController) { + abortController.abort() + abortController = null + } + isStreaming1.value = false + isStreaming2.value = false + } + + async function streamBothModels( + streamFn: ( + provider: string, + model: string, + messages: { role: string; content: string }[], + onToken: (text: string) => void, + onError: (err: string) => void, + signal: AbortSignal, + ) => Promise, + messages: { role: string; content: string }[], + ) { + clearResponses() + abortController = new AbortController() + const signal = abortController.signal + + isStreaming1.value = true + isStreaming2.value = true + + const p1 = streamFn( + model1.value.provider, + model1.value.model, + messages, + (token) => { response1.value += token }, + (err) => { error1.value = err }, + signal, + ).finally(() => { isStreaming1.value = false }) + + const p2 = streamFn( + model2.value.provider, + model2.value.model, + messages, + (token) => { response2.value += token }, + (err) => { error2.value = err }, + signal, + ).finally(() => { isStreaming2.value = false }) + + await Promise.allSettled([p1, p2]) + abortController = null + } + + return { + comparisonEnabled, + isComparing, + isAnyStreaming, + model1, + model2, + response1, + response2, + isStreaming1, + isStreaming2, + error1, + error2, + toggleComparison, + setModel, + clearResponses, + stopComparison, + streamBothModels, + } +}