feat(chat): add multi-model comparison mode (M9.1)

Split-screen comparison of two AI models streaming simultaneously.
Toggle via header button. Desktop shows side-by-side panes, mobile
shows swipeable tabs. Uses streamWithModel() for provider-agnostic
parallel streaming.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Dorian
2026-03-03 23:19:46 +00:00
co-authored by Claude Opus 4.6
parent 5fdf62520c
commit 0ae497f1db
5 changed files with 245 additions and 3 deletions
@@ -45,6 +45,20 @@
</svg>
</button>
<button
class="w-9 h-9 rounded-xl path-glass-icon flex items-center justify-center transition-colors"
:class="comparison.isComparing.value
? 'text-accent'
: 'text-white/70 hover:text-white'"
:title="comparison.isComparing.value ? 'Comparison mode on' : 'Compare models'"
aria-label="Toggle model comparison"
@click="comparison.toggleComparison()"
>
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 17V7m0 10a2 2 0 01-2 2H5a2 2 0 01-2-2V7a2 2 0 012-2h2a2 2 0 012 2m0 10a2 2 0 002 2h2a2 2 0 002-2M9 7a2 2 0 012-2h2a2 2 0 012 2m0 10V7" />
</svg>
</button>
<button
ref="menuTriggerRef"
class="w-9 h-9 rounded-xl path-glass-icon flex items-center justify-center transition-colors text-white/70 hover:text-white"
@@ -234,6 +248,7 @@ import { useAI } from '@/composables/useAI'
import { useContentPanel } from '@/composables/useContentPanel'
import { downloadConversation, type ExportFormat } from '@/utils/conversation-export'
import { parseImportFile } from '@/utils/conversation-import'
import { useComparisonMode } from '@/composables/useComparisonMode'
defineProps<{
title: string
@@ -249,6 +264,7 @@ defineEmits<{
}>()
const { activeProvider, activeModel, availableProviders, setProvider, setModel } = useAI()
const comparison = useComparisonMode()
const chatStore = useChatStore()
const webSearchEnabled = computed(() => chatStore.webSearchEnabled)
const showModelPicker = ref(false)
@@ -81,9 +81,15 @@
</div>
</div>
<!-- Comparison mode split view -->
<ComparisonView
v-if="comparison.isComparing.value && (comparison.response1.value || comparison.response2.value)"
class="flex-1 min-h-0"
/>
<ChatInput
:disabled="isStreaming"
:streaming="isStreaming"
:disabled="isStreaming || comparison.isAnyStreaming.value"
:streaming="isStreaming || comparison.isAnyStreaming.value"
:placeholder="isStreaming ? 'Waiting for response...' : 'Message AIUI...'"
:reply-to="replyTo"
@send="handleSend"
@@ -98,8 +104,9 @@
import { computed, ref, watch, nextTick } from 'vue'
import { useVirtualizer } from '@tanstack/vue-virtual'
import { useChatStore } from '@/stores/chat'
import { useAI } from '@/composables/useAI'
import { useAI, streamWithModel } from '@/composables/useAI'
import { useContentPanel } from '@/composables/useContentPanel'
import { useComparisonMode } from '@/composables/useComparisonMode'
import ChatHeader from './ChatHeader.vue'
import ChatMessage from './ChatMessage.vue'
import ChatInput from './ChatInput.vue'
@@ -108,6 +115,7 @@ import PromptIndex from './PromptIndex.vue'
import BranchSwitcher from './BranchSwitcher.vue'
import ChatSearch from './ChatSearch.vue'
import ContextBar from './ContextBar.vue'
import ComparisonView from './ComparisonView.vue'
import ErrorBoundary from '@/components/ui/ErrorBoundary.vue'
import type { Message } from '@aiui/core/types/message'
@@ -134,6 +142,7 @@ const { sendMessage, stopGeneration, editAndResend, regenerateLastResponse } = u
const { updatePanelFromText, panelOpen, activeTab, availableTabs, setActiveTab } = useContentPanel()
import { useCodeContext } from '@/composables/useCodeContext'
const codeContext = useCodeContext()
const comparison = useComparisonMode()
const messageListRef = ref<HTMLElement | null>(null)
const chatSearchRef = ref<InstanceType<typeof ChatSearch> | 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)
}
@@ -0,0 +1,79 @@
<template>
<div class="flex flex-col h-full">
<!-- Mobile tabs -->
<div class="flex md:hidden gap-1 px-3 pt-2">
<button
v-for="(tab, i) in tabs"
:key="i"
class="flex-1 text-xs py-1.5 rounded-lg transition-all"
:class="activeTab === i
? 'bg-accent/20 text-accent'
: 'text-white/50 hover:text-white/70'"
@click="activeTab = i"
>
{{ tab }}
</button>
</div>
<!-- Split panes -->
<div class="flex-1 min-h-0 flex gap-0.5 p-2">
<!-- Model 1 -->
<div
class="flex-1 min-w-0 flex flex-col glass rounded-xl overflow-hidden"
:class="{ 'hidden md:flex': activeTab !== 0 }"
>
<div class="px-3 py-2 flex items-center justify-between border-b border-white/5">
<span class="text-[10px] text-accent font-medium truncate">{{ model1Label }}</span>
<span v-if="isStreaming1" class="text-[10px] text-white/30 animate-pulse">streaming...</span>
</div>
<div class="flex-1 overflow-y-auto p-3">
<div
v-if="response1"
class="chat-markdown text-sm leading-relaxed break-words text-white/90"
v-html="rendered1"
/>
<div v-else-if="error1" class="text-sm text-red-400/80">{{ error1 }}</div>
<div v-else class="text-sm text-white/25 italic">Waiting for response...</div>
</div>
</div>
<!-- Model 2 -->
<div
class="flex-1 min-w-0 flex flex-col glass rounded-xl overflow-hidden"
:class="{ 'hidden md:flex': activeTab !== 1 }"
>
<div class="px-3 py-2 flex items-center justify-between border-b border-white/5">
<span class="text-[10px] text-accent font-medium truncate">{{ model2Label }}</span>
<span v-if="isStreaming2" class="text-[10px] text-white/30 animate-pulse">streaming...</span>
</div>
<div class="flex-1 overflow-y-auto p-3">
<div
v-if="response2"
class="chat-markdown text-sm leading-relaxed break-words text-white/90"
v-html="rendered2"
/>
<div v-else-if="error2" class="text-sm text-red-400/80">{{ error2 }}</div>
<div v-else class="text-sm text-white/25 italic">Waiting for response...</div>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, computed } from 'vue'
import MarkdownIt from 'markdown-it'
import { useComparisonMode } from '@/composables/useComparisonMode'
const { model1, model2, response1, response2, isStreaming1, isStreaming2, error1, error2 } = useComparisonMode()
const activeTab = ref(0)
const tabs = computed(() => [model1Label.value, model2Label.value])
const model1Label = computed(() => `${model1.value.provider}/${model1.value.model}`.split('/').pop() ?? 'Model 1')
const model2Label = computed(() => `${model2.value.provider}/${model2.value.model}`.split('/').pop() ?? 'Model 2')
const md = new MarkdownIt({ html: false, linkify: true, breaks: true })
const rendered1 = computed(() => md.render(response1.value))
const rendered2 = computed(() => md.render(response2.value))
</script>
+26
View File
@@ -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<void> {
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()
@@ -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<string | null>(null)
const error2 = ref<string | null>(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<void>,
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,
}
}