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>
|
</svg>
|
||||||
</button>
|
</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
|
<button
|
||||||
ref="menuTriggerRef"
|
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"
|
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 { useContentPanel } from '@/composables/useContentPanel'
|
||||||
import { downloadConversation, type ExportFormat } from '@/utils/conversation-export'
|
import { downloadConversation, type ExportFormat } from '@/utils/conversation-export'
|
||||||
import { parseImportFile } from '@/utils/conversation-import'
|
import { parseImportFile } from '@/utils/conversation-import'
|
||||||
|
import { useComparisonMode } from '@/composables/useComparisonMode'
|
||||||
|
|
||||||
defineProps<{
|
defineProps<{
|
||||||
title: string
|
title: string
|
||||||
@@ -249,6 +264,7 @@ defineEmits<{
|
|||||||
}>()
|
}>()
|
||||||
|
|
||||||
const { activeProvider, activeModel, availableProviders, setProvider, setModel } = useAI()
|
const { activeProvider, activeModel, availableProviders, setProvider, setModel } = useAI()
|
||||||
|
const comparison = useComparisonMode()
|
||||||
const chatStore = useChatStore()
|
const chatStore = useChatStore()
|
||||||
const webSearchEnabled = computed(() => chatStore.webSearchEnabled)
|
const webSearchEnabled = computed(() => chatStore.webSearchEnabled)
|
||||||
const showModelPicker = ref(false)
|
const showModelPicker = ref(false)
|
||||||
|
|||||||
@@ -81,9 +81,15 @@
|
|||||||
</div>
|
</div>
|
||||||
</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
|
<ChatInput
|
||||||
:disabled="isStreaming"
|
:disabled="isStreaming || comparison.isAnyStreaming.value"
|
||||||
:streaming="isStreaming"
|
:streaming="isStreaming || comparison.isAnyStreaming.value"
|
||||||
:placeholder="isStreaming ? 'Waiting for response...' : 'Message AIUI...'"
|
:placeholder="isStreaming ? 'Waiting for response...' : 'Message AIUI...'"
|
||||||
:reply-to="replyTo"
|
:reply-to="replyTo"
|
||||||
@send="handleSend"
|
@send="handleSend"
|
||||||
@@ -98,8 +104,9 @@
|
|||||||
import { computed, ref, watch, nextTick } from 'vue'
|
import { computed, ref, watch, nextTick } from 'vue'
|
||||||
import { useVirtualizer } from '@tanstack/vue-virtual'
|
import { useVirtualizer } from '@tanstack/vue-virtual'
|
||||||
import { useChatStore } from '@/stores/chat'
|
import { useChatStore } from '@/stores/chat'
|
||||||
import { useAI } from '@/composables/useAI'
|
import { useAI, streamWithModel } from '@/composables/useAI'
|
||||||
import { useContentPanel } from '@/composables/useContentPanel'
|
import { useContentPanel } from '@/composables/useContentPanel'
|
||||||
|
import { useComparisonMode } from '@/composables/useComparisonMode'
|
||||||
import ChatHeader from './ChatHeader.vue'
|
import ChatHeader from './ChatHeader.vue'
|
||||||
import ChatMessage from './ChatMessage.vue'
|
import ChatMessage from './ChatMessage.vue'
|
||||||
import ChatInput from './ChatInput.vue'
|
import ChatInput from './ChatInput.vue'
|
||||||
@@ -108,6 +115,7 @@ import PromptIndex from './PromptIndex.vue'
|
|||||||
import BranchSwitcher from './BranchSwitcher.vue'
|
import BranchSwitcher from './BranchSwitcher.vue'
|
||||||
import ChatSearch from './ChatSearch.vue'
|
import ChatSearch from './ChatSearch.vue'
|
||||||
import ContextBar from './ContextBar.vue'
|
import ContextBar from './ContextBar.vue'
|
||||||
|
import ComparisonView from './ComparisonView.vue'
|
||||||
import ErrorBoundary from '@/components/ui/ErrorBoundary.vue'
|
import ErrorBoundary from '@/components/ui/ErrorBoundary.vue'
|
||||||
import type { Message } from '@aiui/core/types/message'
|
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()
|
const { updatePanelFromText, panelOpen, activeTab, availableTabs, setActiveTab } = useContentPanel()
|
||||||
import { useCodeContext } from '@/composables/useCodeContext'
|
import { useCodeContext } from '@/composables/useCodeContext'
|
||||||
const codeContext = useCodeContext()
|
const codeContext = useCodeContext()
|
||||||
|
const comparison = useComparisonMode()
|
||||||
const messageListRef = ref<HTMLElement | null>(null)
|
const messageListRef = ref<HTMLElement | null>(null)
|
||||||
const chatSearchRef = ref<InstanceType<typeof ChatSearch> | null>(null)
|
const chatSearchRef = ref<InstanceType<typeof ChatSearch> | null>(null)
|
||||||
|
|
||||||
@@ -216,6 +225,7 @@ function handleNewChat() {
|
|||||||
|
|
||||||
function handleStop() {
|
function handleStop() {
|
||||||
stopGeneration()
|
stopGeneration()
|
||||||
|
comparison.stopComparison()
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleEdit(messageId: string, newContent: string) {
|
async function handleEdit(messageId: string, newContent: string) {
|
||||||
@@ -302,6 +312,16 @@ async function handleSend(text: string) {
|
|||||||
finalText = `${quoteLine}\n\n${text}`
|
finalText = `${quoteLine}\n\n${text}`
|
||||||
clearReply()
|
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)
|
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() {
|
export function useAI() {
|
||||||
const chatStore = useChatStore()
|
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