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%
10.9 KB · 318 lines tsx
Raw Blame History
1/*2 *  MessageBubble.tsx3 *  Zyquo Cloud Web4 *5 *  Author: Simon-Pierre Boucher6 *  Mail: contact@spboucher.ai7 *8 *  One chat turn: user right (accent-subtle) / assistant left (surface +9 *  hairline + provider avatar). Markdown body, collapsible reasoning,10 *  citation chips, attachments, error note, hover metadata + actions11 *  (copy, edit & resend, regenerate, quote, branch, read-aloud, delete),12 *  variant switcher, streaming caret.13 */1415import { memo, useState } from 'react'16import { costLabel } from '../features/catalogHelpers'17import { useStore } from '../state/store'18import type { Message } from '../types'19import { Markdown } from './Markdown'20import {21  IconBranch,22  IconCheck,23  IconChevronRight,24  IconCopy,25  IconEdit,26  IconQuote,27  IconRefresh,28  IconSpeaker,29  IconTrash,30  IconX,31  ProviderGlyph,32} from './icons'3334function timeLabel(timestamp: number): string {35  return new Date(timestamp).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })36}3738export const MessageBubble = memo(function MessageBubble({39  conversationID,40  message,41  streaming,42  isLast = false,43  onQuote,44}: {45  conversationID: string46  message: Message47  streaming: boolean48  isLast?: boolean49  onQuote: (text: string) => void50}) {51  const continueGeneration = useStore((s) => s.continueGeneration)52  const regenerate = useStore((s) => s.regenerate)53  const editAndResend = useStore((s) => s.editAndResend)54  const deleteMessage = useStore((s) => s.deleteMessage)55  const branchFrom = useStore((s) => s.branchFrom)56  const setActiveVariant = useStore((s) => s.setActiveVariant)57  const toast = useStore((s) => s.toast)58  const [reasoningOpen, setReasoningOpen] = useState(false)59  const [editing, setEditing] = useState(false)60  const [draft, setDraft] = useState(message.text)61  const [copied, setCopied] = useState(false)6263  const isUser = message.role === 'user'64  const variants = message.variants ?? []65  const activeVariant =66    message.activeVariant !== undefined ? variants[message.activeVariant] : undefined67  const shownText = activeVariant?.text ?? message.text68  const shownReasoning = activeVariant?.reasoning ?? message.reasoning69  const shownCitations = activeVariant?.citations ?? message.citations70  const shownUsage = activeVariant?.usage ?? message.usage71  const shownCost = activeVariant?.estimatedCost ?? message.estimatedCost7273  const copy = () => {74    void navigator.clipboard.writeText(shownText)75    setCopied(true)76    setTimeout(() => setCopied(false), 1200)77  }7879  const readAloud = () => {80    if (!('speechSynthesis' in window)) {81      toast('Speech synthesis not available in this browser', 'error')82      return83    }84    speechSynthesis.cancel()85    speechSynthesis.speak(new SpeechSynthesisUtterance(shownText))86  }8788  return (89    <div className={`turn ${isUser ? 'user' : 'assistant'}`}>90      {!isUser && (91        <div className="avatar">92          <ProviderGlyph provider={message.provider ?? 'custom'} size={12} />93        </div>94      )}95      <div className="turn-body">96        <div className="bubble">97          {(message.attachments ?? []).filter((a) => a.kind === 'image').length > 0 && (98            <div style={{ display: 'flex', gap: 6, flexWrap: 'wrap', marginBottom: 6 }}>99              {(message.attachments ?? [])100                .filter((a) => a.kind === 'image')101                .map((a) => (102                  <img103                    key={a.id}104                    src={`data:${a.mimeType};base64,${a.data}`}105                    alt={a.fileName}106                    style={{ maxWidth: 160, maxHeight: 120, borderRadius: 6, objectFit: 'cover' }}107                  />108                ))}109            </div>110          )}111          {(message.attachments ?? []).filter((a) => a.kind === 'textFile').length > 0 && (112            <div style={{ display: 'flex', gap: 4, flexWrap: 'wrap', marginBottom: 6 }}>113              {(message.attachments ?? [])114                .filter((a) => a.kind === 'textFile')115                .map((a) => (116                  <span key={a.id} className="badge">117                    {a.fileName}118                  </span>119                ))}120            </div>121          )}122123          {shownReasoning !== undefined && shownReasoning !== '' && (124            <div className="reasoning-section">125              <button className="reasoning-toggle" onClick={() => setReasoningOpen((o) => !o)}>126                <span className={`chev${reasoningOpen ? ' open' : ''}`}>127                  <IconChevronRight />128                </span>129                {streaming && shownText === '' ? 'Thinking…' : 'Thought process'}130              </button>131              {reasoningOpen && <div className="reasoning-body">{shownReasoning}</div>}132            </div>133          )}134135          {editing ? (136            <div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>137              <textarea138                className="text-input"139                rows={Math.min(8, Math.max(2, draft.split('\n').length))}140                value={draft}141                onChange={(e) => setDraft(e.target.value)}142                autoFocus143              />144              <div style={{ display: 'flex', gap: 8, justifyContent: 'flex-end' }}>145                <button className="button" onClick={() => setEditing(false)}>146                  Cancel147                </button>148                <button149                  className="button primary"150                  disabled={draft.trim() === ''}151                  onClick={() => {152                    setEditing(false)153                    void editAndResend(conversationID, message.id, draft)154                  }}155                >156                  Resend157                </button>158              </div>159            </div>160          ) : isUser ? (161            shownText162          ) : (163            <>164              <Markdown text={shownText} />165              {streaming && <span className="streaming-caret" />}166            </>167          )}168169          {shownCitations && shownCitations.length > 0 && (170            <div className="citations">171              {shownCitations.map((citation) => (172                <a173                  key={citation.index}174                  className="citation-chip"175                  href={citation.url}176                  target="_blank"177                  rel="noreferrer noopener"178                  title={citation.url}179                >180                  <span className="num">{citation.index}</span>181                  <span className="title">182                    {citation.title ?? new URL(citation.url).hostname}183                  </span>184                </a>185              ))}186            </div>187          )}188189          {message.errorText && (190            <div className="error-note">191              <span>⚠</span>192              <span>{message.errorText}</span>193            </div>194          )}195        </div>196197        <div className={`meta-row${streaming ? ' always' : ''}`}>198          {!isUser && variants.length > 0 && (199            <span className="variant-switch">200              <button201                className="meta-action"202                title="Previous variant"203                onClick={() => {204                  const current = message.activeVariant ?? -1205                  setActiveVariant(conversationID, message.id, Math.max(-1, current - 1) as number)206                }}207              >208209              </button>210              {(message.activeVariant ?? -1) + 2}/{variants.length + 1}211              <button212                className="meta-action"213                title="Next variant"214                onClick={() => {215                  const current = message.activeVariant ?? -1216                  setActiveVariant(217                    conversationID,218                    message.id,219                    Math.min(variants.length - 1, current + 1)220                  )221                }}222              >223224              </button>225            </span>226          )}227          {!isUser && (228            <>229              <button className="meta-action" title="Copy" onClick={copy}>230                {copied ? <IconCheck size={10} /> : <IconCopy size={10} />}231              </button>232              <button233                className="meta-action"234                title="Regenerate"235                onClick={() => void regenerate(conversationID, message.id)}236              >237                <IconRefresh size={10} />238              </button>239              <button className="meta-action" title="Quote reply" onClick={() => onQuote(shownText)}>240                <IconQuote size={10} />241              </button>242              <button243                className="meta-action"244                title="Branch from here"245                onClick={() => branchFrom(conversationID, message.id)}246              >247                <IconBranch size={10} />248              </button>249              <button className="meta-action" title="Read aloud" onClick={readAloud}>250                <IconSpeaker size={10} />251              </button>252              {isLast && !streaming && shownText !== '' && (253                <button254                  className="meta-action"255                  title="Ask the model to keep going"256                  onClick={() => void continueGeneration(conversationID)}257                >258                  continue259                </button>260              )}261            </>262          )}263          {isUser && (264            <>265              <button className="meta-action" title="Copy" onClick={copy}>266                {copied ? <IconCheck size={10} /> : <IconCopy size={10} />}267              </button>268              <button269                className="meta-action"270                title="Edit & resend"271                onClick={() => {272                  setDraft(message.text)273                  setEditing(true)274                }}275              >276                <IconEdit size={10} />277              </button>278            </>279          )}280          <button281            className="meta-action"282            title="Delete"283            onClick={() => deleteMessage(conversationID, message.id)}284          >285            <IconTrash size={10} />286          </button>287          <span>{timeLabel(message.createdAt)}</span>288          {shownUsage && (289            <span>290              {shownUsage.inputTokens}→{shownUsage.outputTokens} tok291            </span>292          )}293          {shownCost !== undefined && shownCost > 0 && <span>{costLabel(shownCost)}</span>}294          {streaming && <StopHint conversationID={conversationID} />}295        </div>296      </div>297    </div>298  )299})300301function StopHint({ conversationID }: { conversationID: string }) {302  const stop = useStore((s) => s.stop)303  const streaming = useStore((s) => s.streaming[conversationID])304  if (!streaming) return null305  const elapsed = (Date.now() - streaming.startedAt) / 1000306  const tps = elapsed > 0 ? Math.round(streaming.outputChars / 4 / elapsed) : 0307  return (308    <>309      <span>310        {elapsed.toFixed(0)}s{tps > 0 ? ` · ~${tps} tok/s` : ''}311      </span>312      <button className="meta-action" onClick={() => stop(conversationID)}>313        <IconX size={9} /> stop314      </button>315    </>316  )317}318