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 * 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 >208 ‹209 </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 >223 ›224 </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