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:
co-authored by
Claude Opus 4.6
parent
5fdf62520c
commit
0ae497f1db
@@ -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>
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user