/* * CompareView.tsx * Zyquo Cloud Web * * Author: Simon-Pierre Boucher * Mail: contact@spboucher.ai * * Multi-model compare: one prompt broadcast to 2–4 models in side-by-side * columns, each streaming independently with its own Stop/copy/regenerate, * plus "promote this answer into the thread". */ import { useMemo, useRef, useState } from 'react' import { CATALOG, defaultModel, pricedCost } from '../features/catalogHelpers' import { clientFor } from '../providers/registry' import { ProviderError } from '../providers/types' import { uid, useStore } from '../state/store' import { getKey } from '../storage/keys' import type { AIModel, Message } from '../types' import ModelMenu from './ModelMenu' import { Markdown } from './Markdown' import { IconChevronDown, IconCopy, IconPlus, IconRefresh, IconSend, IconStop, IconX, ProviderGlyph, } from './icons' interface Column { id: string model: AIModel text: string reasoning: string error: string | null streaming: boolean outputTokens: number | null abort: AbortController | null } function freshColumn(model: AIModel): Column { return { id: uid(), model, text: '', reasoning: '', error: null, streaming: false, outputTokens: null, abort: null, } } export default function CompareView({ onClose }: { onClose: () => void }) { const keyedProviders = useStore((s) => s.keyedProviders) const selectedID = useStore((s) => s.selectedID) const toast = useStore((s) => s.toast) const seeds = useMemo(() => { const keyed = CATALOG.filter((m) => m.isRecommended && keyedProviders.includes(m.provider)) const picks = keyed.slice(0, 2) while (picks.length < 2) picks.push(defaultModel()) return picks }, [keyedProviders]) const [columns, setColumns] = useState(() => seeds.map(freshColumn)) const [prompt, setPrompt] = useState('') const [lastPrompt, setLastPrompt] = useState('') const [menuFor, setMenuFor] = useState(null) const columnsRef = useRef(columns) columnsRef.current = columns const patchColumn = (id: string, patch: Partial) => { setColumns((cols) => cols.map((c) => (c.id === id ? { ...c, ...patch } : c))) } const runColumn = async (column: Column, text: string) => { const apiKey = getKey(column.model.provider) if (!apiKey) { patchColumn(column.id, { error: ProviderError.missingAPIKey(column.model.provider).message }) return } const abort = new AbortController() patchColumn(column.id, { text: '', reasoning: '', error: null, streaming: true, outputTokens: null, abort, }) const message: Message = { id: uid(), role: 'user', text, createdAt: Date.now() } try { const events = clientFor(column.model).streamChat( { model: column.model, messages: [message], parameters: {}, stream: true }, apiKey, abort.signal ) for await (const event of events) { const current = columnsRef.current.find((c) => c.id === column.id) if (!current) break if (event.type === 'textDelta') { patchColumn(column.id, { text: current.text + event.text }) } else if (event.type === 'reasoningDelta') { patchColumn(column.id, { reasoning: current.reasoning + event.text }) } else if (event.type === 'usage') { patchColumn(column.id, { outputTokens: event.usage.outputTokens }) } } } catch (err) { if (!(err instanceof ProviderError && err.kind === 'cancelled')) { patchColumn(column.id, { error: err instanceof Error ? err.message : String(err) }) } } finally { patchColumn(column.id, { streaming: false, abort: null }) } } const broadcast = () => { const text = prompt.trim() if (text === '') return setLastPrompt(text) setPrompt('') for (const column of columnsRef.current) void runColumn(column, text) } const promote = (column: Column) => { const state = useStore.getState() const conversationID = selectedID ?? state.newConversation({ model: column.model }).id const conversation = state.conversations.find((c) => c.id === conversationID) if (!conversation) return const userMessage: Message = { id: uid(), role: 'user', text: lastPrompt, createdAt: Date.now(), } const assistantMessage: Message = { id: uid(), role: 'assistant', text: column.text, ...(column.reasoning !== '' ? { reasoning: column.reasoning } : {}), modelID: column.model.id, provider: column.model.provider, ...(column.outputTokens !== null ? { usage: { inputTokens: 0, outputTokens: column.outputTokens }, estimatedCost: pricedCost(column.model, { inputTokens: 0, outputTokens: column.outputTokens, }), } : {}), createdAt: Date.now(), } state.updateConversation(conversationID, { messages: [...conversation.messages, userMessage, assistantMessage], }) toast(`Promoted ${column.model.displayName}'s answer into the thread`, 'success') onClose() } return (
e.target === e.currentTarget && onClose()}>
Compare Models
{columns.map((column, index) => (
0 ? 'var(--z-hairline) solid var(--z-border)' : 'none', }} >
{column.outputTokens !== null ? `${column.outputTokens} tok` : ''} {column.streaming ? ( ) : ( <> {column.text !== '' && ( <> )} )} {columns.length > 2 && ( )}
{column.reasoning !== '' && (
{column.reasoning}
)} {column.error ? (
{column.error}
) : ( <> {column.streaming && } )}
))}