spb/zyquo-cloud-web Public MIT
Zyquo Cloud Web — every cloud model, one beautiful chat, entirely in your browser.
TypeScript 81.9%
CSS 8.9%
JavaScript 7.5%
Shell 1.1%
HTML 0.6%
1/*2 * CompareView.tsx3 * Zyquo Cloud Web4 *5 * Author: Simon-Pierre Boucher6 * Mail: contact@spboucher.ai7 *8 * Multi-model compare: one prompt broadcast to 2–4 models in side-by-side9 * columns, each streaming independently with its own Stop/copy/regenerate,10 * plus "promote this answer into the thread".11 */1213import { useMemo, useRef, useState } from 'react'14import { CATALOG, defaultModel, pricedCost } from '../features/catalogHelpers'15import { clientFor } from '../providers/registry'16import { ProviderError } from '../providers/types'17import { uid, useStore } from '../state/store'18import { getKey } from '../storage/keys'19import type { AIModel, Message } from '../types'20import ModelMenu from './ModelMenu'21import { Markdown } from './Markdown'22import {23 IconChevronDown,24 IconCopy,25 IconPlus,26 IconRefresh,27 IconSend,28 IconStop,29 IconX,30 ProviderGlyph,31} from './icons'3233interface Column {34 id: string35 model: AIModel36 text: string37 reasoning: string38 error: string | null39 streaming: boolean40 outputTokens: number | null41 abort: AbortController | null42}4344function freshColumn(model: AIModel): Column {45 return {46 id: uid(),47 model,48 text: '',49 reasoning: '',50 error: null,51 streaming: false,52 outputTokens: null,53 abort: null,54 }55}5657export default function CompareView({ onClose }: { onClose: () => void }) {58 const keyedProviders = useStore((s) => s.keyedProviders)59 const selectedID = useStore((s) => s.selectedID)60 const toast = useStore((s) => s.toast)6162 const seeds = useMemo(() => {63 const keyed = CATALOG.filter((m) => m.isRecommended && keyedProviders.includes(m.provider))64 const picks = keyed.slice(0, 2)65 while (picks.length < 2) picks.push(defaultModel())66 return picks67 }, [keyedProviders])6869 const [columns, setColumns] = useState<Column[]>(() => seeds.map(freshColumn))70 const [prompt, setPrompt] = useState('')71 const [lastPrompt, setLastPrompt] = useState('')72 const [menuFor, setMenuFor] = useState<string | null>(null)73 const columnsRef = useRef(columns)74 columnsRef.current = columns7576 const patchColumn = (id: string, patch: Partial<Column>) => {77 setColumns((cols) => cols.map((c) => (c.id === id ? { ...c, ...patch } : c)))78 }7980 const runColumn = async (column: Column, text: string) => {81 const apiKey = getKey(column.model.provider)82 if (!apiKey) {83 patchColumn(column.id, { error: ProviderError.missingAPIKey(column.model.provider).message })84 return85 }86 const abort = new AbortController()87 patchColumn(column.id, {88 text: '',89 reasoning: '',90 error: null,91 streaming: true,92 outputTokens: null,93 abort,94 })95 const message: Message = { id: uid(), role: 'user', text, createdAt: Date.now() }96 try {97 const events = clientFor(column.model).streamChat(98 { model: column.model, messages: [message], parameters: {}, stream: true },99 apiKey,100 abort.signal101 )102 for await (const event of events) {103 const current = columnsRef.current.find((c) => c.id === column.id)104 if (!current) break105 if (event.type === 'textDelta') {106 patchColumn(column.id, { text: current.text + event.text })107 } else if (event.type === 'reasoningDelta') {108 patchColumn(column.id, { reasoning: current.reasoning + event.text })109 } else if (event.type === 'usage') {110 patchColumn(column.id, { outputTokens: event.usage.outputTokens })111 }112 }113 } catch (err) {114 if (!(err instanceof ProviderError && err.kind === 'cancelled')) {115 patchColumn(column.id, { error: err instanceof Error ? err.message : String(err) })116 }117 } finally {118 patchColumn(column.id, { streaming: false, abort: null })119 }120 }121122 const broadcast = () => {123 const text = prompt.trim()124 if (text === '') return125 setLastPrompt(text)126 setPrompt('')127 for (const column of columnsRef.current) void runColumn(column, text)128 }129130 const promote = (column: Column) => {131 const state = useStore.getState()132 const conversationID = selectedID ?? state.newConversation({ model: column.model }).id133 const conversation = state.conversations.find((c) => c.id === conversationID)134 if (!conversation) return135 const userMessage: Message = {136 id: uid(),137 role: 'user',138 text: lastPrompt,139 createdAt: Date.now(),140 }141 const assistantMessage: Message = {142 id: uid(),143 role: 'assistant',144 text: column.text,145 ...(column.reasoning !== '' ? { reasoning: column.reasoning } : {}),146 modelID: column.model.id,147 provider: column.model.provider,148 ...(column.outputTokens !== null149 ? {150 usage: { inputTokens: 0, outputTokens: column.outputTokens },151 estimatedCost: pricedCost(column.model, {152 inputTokens: 0,153 outputTokens: column.outputTokens,154 }),155 }156 : {}),157 createdAt: Date.now(),158 }159 state.updateConversation(conversationID, {160 messages: [...conversation.messages, userMessage, assistantMessage],161 })162 toast(`Promoted ${column.model.displayName}'s answer into the thread`, 'success')163 onClose()164 }165166 return (167 <div className="overlay" onMouseDown={(e) => e.target === e.currentTarget && onClose()}>168 <div className="panel" style={{ width: 'min(1180px, 96vw)', height: '84vh', maxHeight: '84vh' }}>169 <div className="panel-search" style={{ justifyContent: 'space-between' }}>170 <strong style={{ fontSize: 14 }}>Compare Models</strong>171 <span style={{ display: 'flex', gap: 8 }}>172 <button173 className="button"174 disabled={columns.length >= 4}175 onClick={() => setColumns((cols) => [...cols, freshColumn(defaultModel())])}176 >177 <IconPlus size={10} /> Add column178 </button>179 <button className="button" onClick={onClose}>180 Done181 </button>182 </span>183 </div>184 <div style={{ flex: 1, display: 'flex', minHeight: 0 }}>185 {columns.map((column, index) => (186 <div187 key={column.id}188 style={{189 flex: 1,190 minWidth: 0,191 display: 'flex',192 flexDirection: 'column',193 borderLeft: index > 0 ? 'var(--z-hairline) solid var(--z-border)' : 'none',194 }}195 >196 <div197 style={{198 display: 'flex',199 alignItems: 'center',200 gap: 4,201 padding: '4px 8px',202 borderBottom: 'var(--z-hairline) solid var(--z-border)',203 }}204 >205 <button className="model-chip" onClick={() => setMenuFor(column.id)}>206 <span className="glyph">207 <ProviderGlyph provider={column.model.provider} />208 </span>209 {column.model.displayName}210 <span className="chevron">211 <IconChevronDown />212 </span>213 </button>214 <span style={{ marginLeft: 'auto', fontSize: 11, color: 'var(--z-text-tertiary)' }}>215 {column.outputTokens !== null ? `${column.outputTokens} tok` : ''}216 </span>217 {column.streaming ? (218 <button className="icon-button" title="Stop" onClick={() => column.abort?.abort()}>219 <IconStop size={9} />220 </button>221 ) : (222 <>223 {column.text !== '' && (224 <>225 <button226 className="icon-button"227 title="Copy answer"228 onClick={() => void navigator.clipboard.writeText(column.text)}229 >230 <IconCopy size={10} />231 </button>232 <button233 className="icon-button"234 title="Promote into thread"235 onClick={() => promote(column)}236 >237 <IconSend size={10} />238 </button>239 </>240 )}241 <button242 className="icon-button"243 title="Regenerate"244 disabled={lastPrompt === ''}245 onClick={() => lastPrompt !== '' && void runColumn(column, lastPrompt)}246 >247 <IconRefresh size={10} />248 </button>249 </>250 )}251 {columns.length > 2 && (252 <button253 className="icon-button"254 title="Remove column"255 onClick={() => {256 column.abort?.abort()257 setColumns((cols) => cols.filter((c) => c.id !== column.id))258 }}259 >260 <IconX size={9} />261 </button>262 )}263 </div>264 <div style={{ flex: 1, overflowY: 'auto', padding: 12 }}>265 {column.reasoning !== '' && (266 <div267 style={{268 fontFamily: 'var(--z-font-mono)',269 fontSize: 'calc(var(--z-chat-font-size) - 2.5px)',270 color: 'var(--z-text-tertiary)',271 marginBottom: 8,272 maxHeight: 96,273 overflow: 'hidden',274 }}275 >276 {column.reasoning}277 </div>278 )}279 {column.error ? (280 <div style={{ color: 'var(--z-danger)', fontSize: 12.5 }}>{column.error}</div>281 ) : (282 <>283 <Markdown text={column.text} />284 {column.streaming && <span className="streaming-caret" />}285 </>286 )}287 </div>288 </div>289 ))}290 </div>291 <div292 style={{293 display: 'flex',294 gap: 8,295 padding: 12,296 borderTop: 'var(--z-hairline) solid var(--z-border)',297 }}298 >299 <textarea300 className="input-editor"301 style={{ flex: 1, maxHeight: 120 }}302 rows={1}303 placeholder="Prompt all models…"304 value={prompt}305 onChange={(e) => setPrompt(e.target.value)}306 onKeyDown={(e) => {307 if (e.key === 'Enter' && !e.shiftKey) {308 e.preventDefault()309 broadcast()310 }311 }}312 />313 <button className="send-button" disabled={prompt.trim() === ''} onClick={broadcast}>314 <IconSend />315 </button>316 </div>317 {menuFor && (318 <ModelMenu319 title="Pick a model for this column"320 onPick={(model) => patchColumn(menuFor, { model })}321 onClose={() => setMenuFor(null)}322 />323 )}324 </div>325 </div>326 )327}328