SPB Git

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%
11.1 KB · 328 lines tsx
Raw Blame History
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