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
+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,
}
}