| import { useState, useEffect, useRef } from "react"; |
| import { createSession, resolveApiUrl, stopSessionRun, streamChat, uploadSessionFiles } from "./api"; |
|
|
| const DEFAULT_LLM_PROVIDER = |
| (import.meta.env.VITE_DEFAULT_LLM_PROVIDER || "deepseek").trim().toLowerCase(); |
|
|
| export default function ChatBox() { |
| const [sessionId, setSessionId] = useState(null); |
| const [llmProvider, setLlmProvider] = useState(DEFAULT_LLM_PROVIDER); |
| const [messages, setMessages] = useState([]); |
| const [input, setInput] = useState(""); |
| const [isStreaming, setIsStreaming] = useState(false); |
| const [isUploading, setIsUploading] = useState(false); |
| const [selectedFiles, setSelectedFiles] = useState([]); |
| const [uploadedFiles, setUploadedFiles] = useState([]); |
| const [activeAssistantIndex, setActiveAssistantIndex] = useState(null); |
| const cancelRef = useRef(null); |
| const fileInputRef = useRef(null); |
| const folderInputRef = useRef(null); |
| const conversationBottomRef = useRef(null); |
| const reasoningBottomRef = useRef(null); |
|
|
| |
| useEffect(() => { |
| setSessionId(null); |
| createSession(llmProvider).then(setSessionId); |
| return () => cancelRef.current?.(); |
| }, [llmProvider]); |
|
|
| |
| useEffect(() => { |
| conversationBottomRef.current?.scrollIntoView({ behavior: "smooth" }); |
| reasoningBottomRef.current?.scrollIntoView({ behavior: "smooth" }); |
| }, [messages]); |
|
|
| const sendMessage = async () => { |
| if (!input.trim() || isStreaming || !sessionId || isUploading) return; |
|
|
| let finalMessage = input; |
| if (selectedFiles.length > 0) { |
| const uploadedSignatures = new Set( |
| uploadedFiles.map((f) => `${f.name}::${f.size}::${f.lastModified}`) |
| ); |
| const pendingFiles = selectedFiles.filter( |
| (f) => !uploadedSignatures.has(`${f.name}::${f.size}::${f.lastModified}`) |
| ); |
|
|
| if (pendingFiles.length > 0) { |
| setIsUploading(true); |
| try { |
| const uploadRes = await uploadSessionFiles(sessionId, pendingFiles); |
| const uploadedNow = pendingFiles.map((f, idx) => ({ |
| ...(uploadRes?.files?.[idx] || {}), |
| name: f.name, |
| size: f.size, |
| lastModified: f.lastModified, |
| })); |
| setUploadedFiles((prev) => [...prev, ...uploadedNow]); |
| } catch (err) { |
| setMessages((prev) => [ |
| ...prev, |
| { role: "assistant", steps: [{ type: "error", content: String(err) }], done: true }, |
| ]); |
| setIsUploading(false); |
| return; |
| } |
| setIsUploading(false); |
| } |
|
|
| finalMessage = `${finalMessage}\n\n[Use uploaded files: ${selectedFiles |
| .map((f) => f.name) |
| .join(", ")}]`; |
| } |
|
|
| const userMsg = { role: "user", content: input }; |
| const agentMsg = { |
| role: "assistant", |
| steps: [], |
| done: false, |
| }; |
|
|
| setMessages((prev) => { |
| const next = [...prev, userMsg, agentMsg]; |
| setActiveAssistantIndex(next.length - 1); |
| return next; |
| }); |
| setIsStreaming(true); |
| setInput(""); |
|
|
| const cancel = streamChat(finalMessage, sessionId, (event) => { |
| if (event.type === "heartbeat") return; |
|
|
| if (event.type === "done") { |
| setIsStreaming(false); |
| setMessages((prev) => { |
| const msgs = [...prev]; |
| msgs[msgs.length - 1] = { ...msgs[msgs.length - 1], done: true }; |
| return msgs; |
| }); |
| return; |
| } |
|
|
| if (event.type === "error") { |
| setIsStreaming(false); |
| setMessages((prev) => { |
| const msgs = [...prev]; |
| const last = msgs[msgs.length - 1]; |
| msgs[msgs.length - 1] = { |
| ...last, |
| done: true, |
| steps: [...last.steps, event], |
| }; |
| return msgs; |
| }); |
| return; |
| } |
|
|
| |
| setMessages((prev) => { |
| const msgs = [...prev]; |
| const last = msgs[msgs.length - 1]; |
| const steps = last.steps || []; |
|
|
| msgs[msgs.length - 1] = { |
| ...last, |
| steps: [...steps, event], |
| }; |
| return msgs; |
| }); |
| }, { llmProvider }); |
|
|
| cancelRef.current = cancel; |
| }; |
|
|
| const stopCurrentRun = async () => { |
| if (sessionId) { |
| try { |
| await stopSessionRun(sessionId); |
| } catch (err) { |
| console.warn("Failed to notify backend stop:", err); |
| } |
| } |
| cancelRef.current?.(); |
| cancelRef.current = null; |
| setIsStreaming(false); |
| setMessages((prev) => { |
| if (prev.length === 0) return prev; |
| const msgs = [...prev]; |
| const last = msgs[msgs.length - 1]; |
| if (last?.role !== "assistant" || last.done) return prev; |
| msgs[msgs.length - 1] = { |
| ...last, |
| done: true, |
| steps: [...last.steps, { type: "error", content: "Run cancelled by user." }], |
| }; |
| return msgs; |
| }); |
| }; |
|
|
| const handleKeyDown = (e) => { |
| if (e.key === "Enter" && !e.shiftKey) { |
| e.preventDefault(); |
| sendMessage(); |
| } |
| }; |
|
|
| const appendSelectedFiles = (incomingFiles) => { |
| setSelectedFiles((prev) => { |
| const merged = [...prev]; |
| const seen = new Set(prev.map((f) => `${f.name}::${f.size}::${f.lastModified}`)); |
| for (const file of incomingFiles) { |
| const key = `${file.name}::${file.size}::${file.lastModified}`; |
| if (!seen.has(key)) { |
| merged.push(file); |
| seen.add(key); |
| } |
| } |
| return merged; |
| }); |
| setUploadedFiles([]); |
| }; |
|
|
| const onFileChange = (e) => { |
| const files = Array.from(e.target.files || []); |
| appendSelectedFiles(files); |
| e.target.value = ""; |
| }; |
|
|
| const onFolderChange = (e) => { |
| const files = Array.from(e.target.files || []); |
| appendSelectedFiles(files); |
| e.target.value = ""; |
| }; |
|
|
| const assistantIndexes = messages |
| .map((msg, idx) => ({ msg, idx })) |
| .filter((item) => item.msg.role === "assistant"); |
| const currentAssistant = |
| activeAssistantIndex != null && messages[activeAssistantIndex]?.role === "assistant" |
| ? messages[activeAssistantIndex] |
| : assistantIndexes.length > 0 |
| ? assistantIndexes[assistantIndexes.length - 1].msg |
| : null; |
|
|
| return ( |
| <div className="chat-workspace"> |
| <div className="workspace-header"> |
| <div> |
| <div className="header-title">Strata Bio OS</div> |
| <div className="header-subtitle">Conversation + Structured Reasoning</div> |
| </div> |
| <span className="session-badge"> |
| {sessionId ? `Session ${sessionId.slice(0, 8)}…` : "Initializing…"} |
| </span> |
| </div> |
| |
| <div className="workspace-body"> |
| <section className="conversation-pane"> |
| <div className="pane-title">Conversation</div> |
| <div className="conversation-list"> |
| {messages.map((msg, i) => ( |
| <ConversationItem |
| key={i} |
| message={msg} |
| isActive={i === activeAssistantIndex} |
| onSelect={() => msg.role === "assistant" && setActiveAssistantIndex(i)} |
| /> |
| ))} |
| {isStreaming && ( |
| <div className="streaming-indicator"> |
| <span className="dot" /><span className="dot" /><span className="dot" /> |
| </div> |
| )} |
| <div ref={conversationBottomRef} /> |
| </div> |
| </section> |
| |
| <section className="reasoning-pane"> |
| <div className="pane-title">Reasoning</div> |
| <div className="reasoning-list"> |
| {currentAssistant ? ( |
| <ReasoningPanel steps={currentAssistant.steps} done={currentAssistant.done} /> |
| ) : ( |
| <div className="empty-hint"> |
| Run a request to see the agent reasoning breakdown. |
| </div> |
| )} |
| <div ref={reasoningBottomRef} /> |
| </div> |
| </section> |
| </div> |
| |
| <div className="input-dock"> |
| <div className="file-row"> |
| <select |
| value={llmProvider} |
| onChange={(e) => setLlmProvider(e.target.value)} |
| disabled={isStreaming || isUploading} |
| > |
| <option value="deepseek">DeepSeek</option> |
| <option value="gemini">Gemini</option> |
| <option value="claude">Claude</option> |
| <option value="openai">OpenAI</option> |
| </select> |
| <input |
| ref={fileInputRef} |
| className="upload-hidden" |
| type="file" |
| multiple |
| onChange={onFileChange} |
| disabled={isStreaming || !sessionId || isUploading} |
| /> |
| <input |
| ref={folderInputRef} |
| className="upload-hidden" |
| type="file" |
| multiple |
| webkitdirectory="true" |
| directory="true" |
| onChange={onFolderChange} |
| disabled={isStreaming || !sessionId || isUploading} |
| /> |
| <div className="upload-actions"> |
| <button |
| type="button" |
| className="upload-btn" |
| onClick={() => fileInputRef.current?.click()} |
| disabled={isStreaming || !sessionId || isUploading} |
| > |
| 📄 Upload Files |
| </button> |
| <button |
| type="button" |
| className="upload-btn" |
| onClick={() => folderInputRef.current?.click()} |
| disabled={isStreaming || !sessionId || isUploading} |
| > |
| 📁 Upload Folder |
| </button> |
| </div> |
| <span className="file-hint"> |
| {`Backend: ${ |
| llmProvider === "openai" |
| ? "OpenAI" |
| : llmProvider === "deepseek" |
| ? "DeepSeek" |
| : llmProvider === "gemini" |
| ? "Gemini" |
| : "Claude" |
| } | `} |
| {selectedFiles.length > 0 |
| ? `Selected ${selectedFiles.length} file(s): ${selectedFiles |
| .map((f) => f.name) |
| .join(", ")}${uploadedFiles.length > 0 ? " (uploaded)" : ""}` |
| : "Optional: upload one or multiple files for this run"} |
| </span> |
| </div> |
| <div className="composer-row"> |
| <textarea |
| value={input} |
| onChange={(e) => setInput(e.target.value)} |
| onKeyDown={handleKeyDown} |
| placeholder="Enter a biomedical task, e.g.: Predict ADMET for CC(C)CC1=CC=C(C=C1)C(C)C(=O)O" |
| disabled={isStreaming || !sessionId || isUploading} |
| rows={5} |
| /> |
| <button |
| onClick={isStreaming ? stopCurrentRun : sendMessage} |
| disabled={!sessionId || isUploading || (!isStreaming && !input.trim())} |
| > |
| {isStreaming ? "Stop" : (isUploading ? "Uploading..." : "Run")} |
| </button> |
| </div> |
| </div> |
| </div> |
| ); |
| } |
|
|
| function ConversationItem({ message, isActive, onSelect }) { |
| if (message.role === "user") { |
| return ( |
| <div className="conversation-item user-item"> |
| <div className="item-label">User</div> |
| <div className="item-content">{message.content}</div> |
| </div> |
| ); |
| } |
|
|
| const finalStep = getAssistantFinalStep(message); |
| return ( |
| <button |
| type="button" |
| className={`conversation-item assistant-item ${isActive ? "active-item" : ""}`} |
| onClick={onSelect} |
| > |
| <div className="item-top"> |
| <span className="item-label">Strata Agent Results</span> |
| </div> |
| <div className="item-content"> |
| <RenderedFinalResult |
| content={finalStep?.content || ""} |
| artifacts={Array.isArray(finalStep?.artifacts) ? finalStep.artifacts : []} |
| /> |
| </div> |
| </button> |
| ); |
| } |
|
|
| function getAssistantFinalStep(message) { |
| const result = [...message.steps].reverse().find((s) => s.type === "result"); |
| if (result?.content) return result; |
| const err = [...message.steps].reverse().find((s) => s.type === "error"); |
| if (err?.content) return err; |
| return { |
| content: message.done ? "No final result output." : "Running... (final result pending)", |
| artifacts: [], |
| }; |
| } |
|
|
| function RenderedFinalResult({ content, artifacts = [] }) { |
| const blocks = parseMarkdownLikeBlocks(prepareFinalResultContent(content || "")); |
| return ( |
| <div className="final-render"> |
| {artifacts.length > 0 && <ArtifactDownloads artifacts={artifacts} />} |
| {blocks.map((block, idx) => { |
| if (block.type === "table") { |
| return <ResultTable key={idx} headers={block.headers} rows={block.rows} />; |
| } |
| return <ResultTextBlock key={idx} content={block.content} />; |
| })} |
| </div> |
| ); |
| } |
|
|
| function ArtifactDownloads({ artifacts }) { |
| return ( |
| <div className="artifact-panel"> |
| <div className="artifact-title">Download Files</div> |
| <div className="artifact-list"> |
| {artifacts.map((artifact, idx) => ( |
| <a |
| key={`${artifact.root}:${artifact.relative_path}:${idx}`} |
| className="artifact-link" |
| href={resolveApiUrl(artifact.download_path)} |
| target="_blank" |
| rel="noreferrer" |
| > |
| <span className="artifact-name">{artifact.name || artifact.relative_path}</span> |
| <span className="artifact-size">{formatArtifactSize(artifact.size_bytes)}</span> |
| </a> |
| ))} |
| </div> |
| </div> |
| ); |
| } |
|
|
| function formatArtifactSize(sizeBytes) { |
| const size = Number(sizeBytes || 0); |
| if (!Number.isFinite(size) || size <= 0) return "0 B"; |
| if (size < 1024) return `${size} B`; |
| if (size < 1024 * 1024) return `${(size / 1024).toFixed(2)} KB`; |
| if (size < 1024 * 1024 * 1024) return `${(size / (1024 * 1024)).toFixed(2)} MB`; |
| return `${(size / (1024 * 1024 * 1024)).toFixed(2)} GB`; |
| } |
|
|
| function ResultTextBlock({ content }) { |
| const sections = parseRichTextSections(normalizeForDisplay(content || "")); |
| return ( |
| <div className="result-rich"> |
| {sections.map((section, idx) => { |
| if (section.type === "heading") { |
| return ( |
| <div key={idx} className={`result-heading h${section.level}`}> |
| {renderInlineMarkdown(section.text)} |
| </div> |
| ); |
| } |
| if (section.type === "list") { |
| return ( |
| <ul key={idx} className="result-list"> |
| {section.items.map((item, i) => ( |
| <li key={i}>{renderInlineMarkdown(item)}</li> |
| ))} |
| </ul> |
| ); |
| } |
| if (section.type === "quote") { |
| return <blockquote key={idx} className="result-quote">{renderInlineMarkdown(section.text)}</blockquote>; |
| } |
| if (section.type === "sequence") { |
| return ( |
| <div key={idx} className="result-seq-wrap"> |
| <div className="result-code-lang">Sequence</div> |
| <pre className="result-seq">{section.text}</pre> |
| </div> |
| ); |
| } |
| return <div key={idx} className="result-text">{renderInlineMarkdown(section.text)}</div>; |
| })} |
| </div> |
| ); |
| } |
|
|
| function ResultTable({ headers, rows }) { |
| return ( |
| <div className="result-table-wrap"> |
| <table className="result-table"> |
| <thead> |
| <tr> |
| {headers.map((h, i) => ( |
| <th key={i}>{h}</th> |
| ))} |
| </tr> |
| </thead> |
| <tbody> |
| {rows.map((row, rIdx) => ( |
| <tr key={rIdx}> |
| {headers.map((_, cIdx) => ( |
| <td key={cIdx}>{renderInlineMarkdown(row[cIdx] || "")}</td> |
| ))} |
| </tr> |
| ))} |
| </tbody> |
| </table> |
| </div> |
| ); |
| } |
|
|
| function parseMarkdownLikeBlocks(input) { |
| const lines = input.split("\n"); |
| const blocks = []; |
| let i = 0; |
|
|
| while (i < lines.length) { |
| const line = lines[i]; |
|
|
| |
| if (line.trim().startsWith("```")) { |
| const lang = line.trim().slice(3).trim(); |
| i += 1; |
| const codeLines = []; |
| while (i < lines.length && !lines[i].trim().startsWith("```")) { |
| codeLines.push(lines[i]); |
| i += 1; |
| } |
| if (i < lines.length) i += 1; |
| blocks.push({ type: "code", lang, content: codeLines.join("\n") }); |
| continue; |
| } |
|
|
| |
| if (isTableHeaderLine(line) && i + 1 < lines.length && isTableDividerLine(lines[i + 1])) { |
| const headerCells = splitTableCells(line); |
| i += 2; |
| const rows = []; |
| while (i < lines.length && isTableRowLine(lines[i])) { |
| rows.push(splitTableCells(lines[i])); |
| i += 1; |
| } |
| blocks.push({ type: "table", headers: headerCells, rows }); |
| continue; |
| } |
|
|
| |
| const textLines = [line]; |
| i += 1; |
| while (i < lines.length) { |
| const atCode = lines[i].trim().startsWith("```"); |
| const atTable = isTableHeaderLine(lines[i]) && i + 1 < lines.length && isTableDividerLine(lines[i + 1]); |
| if (atCode || atTable) break; |
| textLines.push(lines[i]); |
| i += 1; |
| } |
| blocks.push({ type: "text", content: textLines.join("\n") }); |
| } |
|
|
| return blocks.filter((b) => { |
| if (b.type === "text") return b.content.trim().length > 0; |
| if (b.type === "code") return b.content.trim().length > 0; |
| return true; |
| }); |
| } |
|
|
| function isTableHeaderLine(line) { |
| const trimmed = line.trim(); |
| return trimmed.includes("|") && splitTableCells(trimmed).length >= 2; |
| } |
|
|
| function isTableDividerLine(line) { |
| return /^\s*\|?\s*:?-{3,}:?\s*(\|\s*:?-{3,}:?\s*)+\|?\s*$/.test(line); |
| } |
|
|
| function isTableRowLine(line) { |
| const trimmed = line.trim(); |
| return trimmed.length > 0 && trimmed.includes("|"); |
| } |
|
|
| function splitTableCells(line) { |
| return line |
| .trim() |
| .replace(/^\|/, "") |
| .replace(/\|$/, "") |
| .split("|") |
| .map((cell) => cell.trim()); |
| } |
|
|
| function prepareFinalResultContent(raw) { |
| let text = String(raw || ""); |
| const solutionMatch = text.match(/<solution>([\s\S]*?)<\/solution>/i); |
| if (solutionMatch?.[1]) { |
| text = solutionMatch[1]; |
| } |
|
|
| text = text.replace(/<execute>[\s\S]*?<\/execute>/gi, " "); |
| text = text.replace(/<observation>[\s\S]*?<\/observation>/gi, " "); |
| text = text.replace(/<output>[\s\S]*?<\/output>/gi, " "); |
| text = text.replace(/<\/?(solution|observation|output|execute)>/gi, " "); |
| text = normalizeForDisplay(text); |
| text = text.replace(/`<\/?(solution|observation|output|execute)>`/gi, " "); |
| text = text.replace(/^\s*```[\s\S]*?```\s*$/gm, " "); |
| text = text.replace(/^\s*with\s+open\(.*$/gim, " "); |
| text = text.replace(/^\s*print\(.*$/gim, " "); |
| text = text.replace(/^\s*for\s+.+:\s*$/gim, " "); |
| text = text.replace(/^\s*\w+\s*=\s*os\.path\..*$/gim, " "); |
| text = text.replace(/\n{3,}/g, "\n\n"); |
| return text.trim(); |
| } |
|
|
| function renderInlineMarkdown(text) { |
| const lines = String(text || "").split("\n"); |
| return lines.map((line, index) => ( |
| <span key={index}> |
| {renderInlineMarkdownSegments(line)} |
| {index < lines.length - 1 ? <br /> : null} |
| </span> |
| )); |
| } |
|
|
| function renderInlineMarkdownSegments(text) { |
| const source = String(text || ""); |
| const regex = /(\*\*([^*]+)\*\*|`([^`]+)`)/g; |
| const parts = []; |
| let lastIndex = 0; |
| let match; |
|
|
| while ((match = regex.exec(source)) !== null) { |
| if (match.index > lastIndex) { |
| parts.push(source.slice(lastIndex, match.index)); |
| } |
| if (match[2] != null) { |
| parts.push(<strong key={`b-${match.index}`} className="result-inline-bold">{match[2]}</strong>); |
| } else if (match[3] != null) { |
| parts.push(<code key={`c-${match.index}`} className="result-inline-code">{match[3]}</code>); |
| } |
| lastIndex = regex.lastIndex; |
| } |
|
|
| if (lastIndex < source.length) { |
| parts.push(source.slice(lastIndex)); |
| } |
| return parts; |
| } |
|
|
| function ReasoningPanel({ steps, done }) { |
| const expandedSteps = normalizeReasoningSteps(steps); |
| const reasoningSteps = expandedSteps.filter((step) => |
| ["thinking", "code", "tool_use", "observation", "visualization"].includes(step.type) |
| ); |
| const readableSteps = reasoningSteps |
| .map((step) => ({ |
| ...step, |
| readableContent: summarizeReasoningStep(step), |
| })) |
| .filter((step) => Boolean((step.readableContent || "").trim())); |
|
|
| const hasNonProcessFinal = expandedSteps.some((step) => step.type === "result" || step.type === "error"); |
| const stats = getReasoningStats(readableSteps); |
| const progress = readableSteps.length |
| ? Math.min(100, Math.round((stats.observation + stats.tool + stats.thinking) / readableSteps.length * 100)) |
| : 0; |
|
|
| if (!readableSteps.length) { |
| return <div className="empty-hint">Agent is preparing reasoning steps…</div>; |
| } |
|
|
| return ( |
| <div className="reasoning-shell"> |
| <div className="reasoning-summary"> |
| <span className="summary-chip">Total {readableSteps.length}</span> |
| <span className="summary-chip">Planning {stats.thinking}</span> |
| <span className="summary-chip">Actions {stats.tool}</span> |
| <span className="summary-chip">Progress {stats.observation}</span> |
| </div> |
| <div className="reasoning-progress"> |
| <div className="reasoning-progress-bar" style={{ width: `${progress}%` }} /> |
| </div> |
| <div className="reasoning-timeline"> |
| {readableSteps.map((step, idx) => ( |
| <div |
| key={idx} |
| className={`timeline-item ${!done && idx === readableSteps.length - 1 ? "live-step" : ""}`} |
| > |
| <div className="timeline-node" /> |
| <ReasoningCard |
| step={step} |
| index={idx + 1} |
| isLive={!done && idx === readableSteps.length - 1} |
| /> |
| </div> |
| ))} |
| </div> |
| {!done && <div className="running-hint">Reasoning in progress…</div>} |
| {done && hasNonProcessFinal && ( |
| <div className="running-hint">Final result is shown in the left conversation pane.</div> |
| )} |
| </div> |
| ); |
| } |
|
|
| function ReasoningCard({ step, index, isLive }) { |
| const meta = getStepMeta(step, index); |
| return ( |
| <div className={`reasoning-card ${getStepToneClass(step.type)}`}> |
| <div className="card-header"> |
| <span className="card-tag">{meta.title}</span> |
| <span className={`state-pill ${isLive ? "live-pill" : ""}`}>{isLive ? "LIVE" : "DONE"}</span> |
| </div> |
| <div className="card-subtitle">{meta.subtitle}</div> |
| <div className="card-icon">{meta.icon}</div> |
| <ReasoningContent step={step} isLive={isLive} /> |
| </div> |
| ); |
| } |
|
|
| function ReasoningContent({ step, isLive = false }) { |
| if (step.type === "visualization") { |
| const imageSrc = step.image_data_url || (step.download_path ? resolveApiUrl(step.download_path) : ""); |
| const caption = String(step.readableContent || step.content || step.title || "Generated figure").trim(); |
| if (!imageSrc) return null; |
| return ( |
| <div className="visualization-card"> |
| <div className="visualization-caption">{caption}</div> |
| <a |
| className="visualization-link" |
| href={imageSrc} |
| target="_blank" |
| rel="noreferrer" |
| > |
| <img className="visualization-image" src={imageSrc} alt={step.title || "Generated visualization"} /> |
| </a> |
| </div> |
| ); |
| } |
|
|
| const content = String(step.readableContent || "").trim(); |
| if (!content) return null; |
|
|
| if (step.type === "thinking") { |
| return ( |
| <div className="plain-block planning-block"> |
| <div className="planning-text">{content}</div> |
| </div> |
| ); |
| } |
|
|
| if (step.type === "tool_use") { |
| const toolName = formatToolDisplayName(step.tool || "MCP Tool"); |
| return ( |
| <div className={`tool-activity-card ${isLive ? "tool-activity-live" : ""}`}> |
| <div className="tool-activity-top"> |
| <span className="tool-activity-label">MCP Invocation</span> |
| <span className={`tool-activity-state ${isLive ? "running" : "finished"}`}> |
| {isLive ? "Calling" : "Completed"} |
| </span> |
| </div> |
| <div className="tool-activity-name-row"> |
| <span className="tool-activity-dot" /> |
| <span className="tool-activity-name">{toolName}</span> |
| </div> |
| <div className="tool-activity-track" aria-hidden="true"> |
| <span className="tool-activity-wave wave-1" /> |
| <span className="tool-activity-wave wave-2" /> |
| <span className="tool-activity-wave wave-3" /> |
| </div> |
| <div className="tool-activity-desc">{content}</div> |
| </div> |
| ); |
| } |
|
|
| if (step.type === "observation") { |
| const blocks = parseObservationDisplayBlocks(content); |
| return ( |
| <div className="reasoning-rich"> |
| {blocks.map((block, idx) => { |
| if (block.type === "heading") { |
| return ( |
| <div key={idx} className={`observation-heading level-${block.level || 1}`}> |
| {block.text} |
| </div> |
| ); |
| } |
| if (block.type === "note") { |
| return <div key={idx} className="observation-note">{block.text}</div>; |
| } |
| if (block.type === "table") { |
| return <ResultTable key={idx} headers={block.headers} rows={block.rows} />; |
| } |
| if (block.type === "list") { |
| return ( |
| <ul key={idx} className="result-list observation-list"> |
| {block.items.map((item, i) => ( |
| <li key={i}>{item}</li> |
| ))} |
| </ul> |
| ); |
| } |
| return <pre key={idx} className="plain-block observation-block">{block.text}</pre>; |
| })} |
| </div> |
| ); |
| } |
|
|
| return <pre className="plain-block">{content}</pre>; |
| } |
|
|
| function formatToolDisplayName(toolName) { |
| return String(toolName || "MCP Tool") |
| .replace(/_/g, " ") |
| .replace(/-/g, " ") |
| .replace(/\s+/g, " ") |
| .trim(); |
| } |
|
|
| function normalizeForDisplay(raw) { |
| const text = String(raw ?? "") |
| .replace(/biomni\s*agent\s*results?/gi, "Strata Agent Results") |
| .replace(/biommi\s*agent\s*results?/gi, "Strata Agent Results") |
| .replace(/biomni\s*a1\s*is\s*analyzing(?:\s+query\s+and\s+input\s+data)?\.*/gi, "Agent is analyzing...") |
| .replace(/\bbiomni\b/gi, "Strata"); |
| |
| const lineBroken = text.replace(/>\s*</g, ">\n<"); |
| |
| return lineBroken.replace(/(<\/?[A-Za-z][^>\n]{0,120}>)/g, "`$1`"); |
| } |
|
|
| function normalizeReasoningSteps(steps) { |
| const merged = []; |
| let thinkingBuffer = null; |
|
|
| const flushThinking = () => { |
| if (!thinkingBuffer?.content?.trim()) return; |
| const sections = splitThinkingSections(thinkingBuffer.content); |
| if (sections.length <= 1) { |
| merged.push(thinkingBuffer); |
| thinkingBuffer = null; |
| return; |
| } |
| sections.forEach((section, idx) => { |
| merged.push({ |
| ...thinkingBuffer, |
| content: section, |
| thinking_part: idx + 1, |
| thinking_total: sections.length, |
| }); |
| }); |
| thinkingBuffer = null; |
| }; |
|
|
| for (const step of steps) { |
| if (step.type !== "thinking") { |
| flushThinking(); |
| merged.push(step); |
| continue; |
| } |
|
|
| const text = (step.content || "").trim(); |
| if (!text) continue; |
|
|
| if (!thinkingBuffer) { |
| thinkingBuffer = { ...step, content: text }; |
| continue; |
| } |
|
|
| |
| thinkingBuffer = { |
| ...thinkingBuffer, |
| content: `${thinkingBuffer.content}\n${text}`.trim(), |
| }; |
| } |
|
|
| flushThinking(); |
| return merged; |
| } |
|
|
| function splitThinkingSections(text) { |
| const lines = text.split("\n"); |
| const delimiter = /^=+\s*Ai Message\s*=+$/i; |
| const sections = []; |
| let current = []; |
|
|
| const flush = () => { |
| const block = current.join("\n").trim(); |
| if (block) sections.push(block); |
| current = []; |
| }; |
|
|
| for (const line of lines) { |
| if (delimiter.test(line.trim()) && current.length > 0) { |
| flush(); |
| current.push(line); |
| continue; |
| } |
| current.push(line); |
| } |
| flush(); |
| return sections.length ? sections : [text.trim()].filter(Boolean); |
| } |
|
|
| function parseRichTextSections(content) { |
| const lines = content.split("\n"); |
| const sections = []; |
| let i = 0; |
|
|
| const pushText = (text) => { |
| const t = text.trim(); |
| if (!t) return; |
| sections.push({ type: detectSequenceBlock(t) ? "sequence" : "text", text: t }); |
| }; |
|
|
| while (i < lines.length) { |
| const line = lines[i]; |
| const trimmed = line.trim(); |
|
|
| if (!trimmed) { |
| i += 1; |
| continue; |
| } |
|
|
| const headingMatch = /^(#{1,4})\s+(.*)$/.exec(trimmed); |
| if (headingMatch) { |
| sections.push({ |
| type: "heading", |
| level: headingMatch[1].length, |
| text: headingMatch[2].trim(), |
| }); |
| i += 1; |
| continue; |
| } |
|
|
| if (trimmed.startsWith(">")) { |
| const quoteLines = []; |
| while (i < lines.length && lines[i].trim().startsWith(">")) { |
| quoteLines.push(lines[i].trim().replace(/^>\s?/, "")); |
| i += 1; |
| } |
| sections.push({ type: "quote", text: quoteLines.join("\n") }); |
| continue; |
| } |
|
|
| if (/^[-*]\s+/.test(trimmed) || /^\d+\.\s+/.test(trimmed)) { |
| const items = []; |
| while (i < lines.length) { |
| const t = lines[i].trim(); |
| if (!(/^[-*]\s+/.test(t) || /^\d+\.\s+/.test(t))) break; |
| items.push(t.replace(/^([-*]|\d+\.)\s+/, "")); |
| i += 1; |
| } |
| sections.push({ type: "list", items }); |
| continue; |
| } |
|
|
| const para = [line]; |
| i += 1; |
| while (i < lines.length) { |
| const t = lines[i].trim(); |
| if (!t) break; |
| if (/^(#{1,4})\s+/.test(t)) break; |
| if (t.startsWith(">")) break; |
| if (/^[-*]\s+/.test(t) || /^\d+\.\s+/.test(t)) break; |
| para.push(lines[i]); |
| i += 1; |
| } |
| pushText(para.join("\n")); |
| } |
|
|
| return sections; |
| } |
|
|
| function detectSequenceBlock(text) { |
| const lines = text.split("\n").map((l) => l.trim()).filter(Boolean); |
| if (lines.length < 2) return false; |
| const dnaLike = lines.filter((l) => /^[ACGTUNacgtun\s]+$/.test(l) && l.replace(/\s/g, "").length >= 20); |
| return dnaLike.length >= 2; |
| } |
|
|
| function getReasoningStats(steps) { |
| return steps.reduce( |
| (acc, step) => { |
| if (step.type === "thinking") acc.thinking += 1; |
| if (step.type === "tool_use") acc.tool += 1; |
| if (step.type === "observation") acc.observation += 1; |
| if (step.type === "visualization") acc.observation += 1; |
| return acc; |
| }, |
| { thinking: 0, tool: 0, observation: 0 } |
| ); |
| } |
|
|
| function getStepToneClass(stepType) { |
| if (stepType === "thinking") return "thinking-card"; |
| if (stepType === "tool_use") return "tool-card"; |
| if (stepType === "observation") return "observation-card"; |
| if (stepType === "visualization") return "observation-card"; |
| if (stepType === "code") return "code-card"; |
| return "thinking-card"; |
| } |
|
|
| function getStepMeta(step, index) { |
| if (step.type === "thinking") { |
| return { |
| title: `Step ${index} · Reasoning`, |
| subtitle: "Planning and decomposition of the task", |
| icon: "💭", |
| }; |
| } |
| if (step.type === "code") { |
| return { |
| title: `Step ${index} · Executing Task`, |
| subtitle: "Performing an analysis action", |
| icon: "🛠️", |
| }; |
| } |
| if (step.type === "tool_use") { |
| return { |
| title: `Step ${index} · Tool Call`, |
| subtitle: "Invoking external tool", |
| icon: "🧰", |
| }; |
| } |
| if (step.type === "visualization") { |
| return { |
| title: `Step ${index} · Output Progress`, |
| subtitle: "Preparing visual outputs", |
| icon: "🖼️", |
| }; |
| } |
| return { |
| title: `Step ${index} · Observation`, |
| subtitle: "Execution output and tool response", |
| icon: "📊", |
| }; |
| } |
|
|
| function summarizeReasoningStep(step) { |
| const compact = extractAiNarrative(step.content || "", step.type); |
| if (step.type === "thinking") { |
| if (!compact) return ""; |
| return compact; |
| } |
| if (step.type === "tool_use") { |
| return "正在调用分析工具并获取中间结果。"; |
| } |
| if (step.type === "observation") { |
| if (!compact) return ""; |
| return compact; |
| } |
| if (step.type === "visualization") { |
| return step.title || step.content || "Generated figure"; |
| } |
| return ""; |
| } |
|
|
| function extractAiNarrative(raw, stepType = "") { |
| let text = String(raw || ""); |
| text = text.replace(/<execute>[\s\S]*?<\/execute>/gi, " "); |
| text = text.replace(/```[\s\S]*?```/g, " "); |
| text = stripObservationTags(text); |
| text = stripHumanMessageSections(text); |
| text = normalizeForDisplay(text); |
| text = text.replace(/=+\s*Ai Message\s*=+/gi, " "); |
|
|
| if (stepType === "observation") { |
| text = formatObservationText(text); |
| } else { |
| text = text.replace(/<\/?observation>/gi, " "); |
| } |
|
|
| const filteredLines = text |
| .split("\n") |
| .map((line) => line.trim()) |
| .filter(Boolean) |
| .filter((line) => !/(traceback|error|exception|module.*not found|failed|no module named)/i.test(line)) |
| .filter((line) => !/(seem to be running but|not being created|take a different approach|alternative approach|instead, I will|use .* instead|fallback to|switching to|using .* as a fallback)/i.test(line)) |
| .filter((line) => !/^={3,}$/.test(line)); |
|
|
| if (stepType === "observation") { |
| const compact = filteredLines.join("\n").trim(); |
| if (!compact) return ""; |
| return truncateMultilineText(compact, 48, 2000); |
| } |
|
|
| const compact = filteredLines.join(" ").replace(/\s+/g, " ").trim(); |
| return compact; |
| } |
|
|
| function stripHumanMessageSections(text) { |
| return String(text || "") |
| .replace( |
| /=+\s*Human Message\s*=+[\s\S]*?(?==+\s*(?:Ai|AI)\s*Message\s*=+|$)/gi, |
| " " |
| ) |
| .replace(/^Human Message:.*$/gim, " "); |
| } |
|
|
| function formatObservationText(text) { |
| const jsonSummary = formatObservationJson(text); |
| if (jsonSummary) { |
| return jsonSummary; |
| } |
|
|
| const sectionMatch = text.match(/===\s*([^=]+?)\s*===/i); |
| const sectionTitle = sectionMatch ? sectionMatch[1].trim() : ""; |
| const lines = text.split("\n").map((line) => line.trim()).filter(Boolean); |
| const rowLines = lines.filter((line) => /^\d+\s+/.test(line)); |
| const parsedRows = []; |
|
|
| for (const line of rowLines) { |
| const parts = line.split(/\s{2,}/).map((x) => x.trim()).filter(Boolean); |
| if (parts.length < 6) continue; |
| const term = parts[2] || ""; |
| const overlap = parts[3] || ""; |
| const adjP = parts[5] || ""; |
| const genes = parts[parts.length - 1] || ""; |
| if (!term) continue; |
| parsedRows.push({ term, overlap, adjP, genes }); |
| } |
|
|
| if (!parsedRows.length) { |
| return formatObservationNarrative(text); |
| } |
|
|
| const topRows = parsedRows.slice(0, 5); |
| const bulletText = topRows |
| .map((row, idx) => `${idx + 1}. ${row.term} | Overlap=${row.overlap} | Adj.P=${row.adjP} | Genes=${row.genes}`) |
| .join("\n"); |
| return `${sectionTitle ? `${sectionTitle}\n` : ""}Top enriched terms:\n${bulletText}`; |
| } |
|
|
| function stripObservationTags(text) { |
| return String(text || "") |
| .replace(/<\/?observation>/gi, " ") |
| .replace(/<\/?observation[^>]*>/gi, " "); |
| } |
|
|
| function formatObservationNarrative(text) { |
| const lines = String(text || "") |
| .split("\n") |
| .map((line) => line.trim()) |
| .filter(Boolean) |
| .map((line) => line.replace(/^["'`]+|["'`]+$/g, "").trim()) |
| .filter(Boolean); |
|
|
| if (!lines.length) return ""; |
|
|
| const cleaned = []; |
| for (const line of lines) { |
| if (cleaned.length === 0 || cleaned[cleaned.length - 1] !== line) { |
| cleaned.push(line); |
| } |
| } |
|
|
| const formatted = cleaned.map((line) => { |
| if (/completed successfully!?$/i.test(line)) { |
| return `Completed: ${line.replace(/completed successfully!?$/i, "").trim() || "Step finished successfully"}`; |
| } |
| if (/running /i.test(line) || /loading /i.test(line) || /processing /i.test(line)) { |
| return `In progress: ${line}`; |
| } |
| return line; |
| }); |
|
|
| return formatted.join("\n"); |
| } |
|
|
| function formatObservationJson(text) { |
| const source = String(text || ""); |
| const parsed = extractFirstJsonObject(source); |
| if (!parsed) return ""; |
|
|
| const lines = source |
| .split("\n") |
| .map((line) => line.trim()) |
| .filter(Boolean); |
| const fileHint = lines.find((line) => /saved to .*\.json/i.test(line)) || ""; |
|
|
| try { |
| const pretty = JSON.stringify(JSON.parse(parsed), null, 2); |
| const head = fileHint ? `${fileHint}\n` : ""; |
| return `${head}JSON summary:\n${truncateMultilineText(pretty, 40, 1700)}`; |
| } catch { |
| return ""; |
| } |
| } |
|
|
| function extractFirstJsonObject(text) { |
| const source = String(text || ""); |
| const start = source.indexOf("{"); |
| if (start < 0) return ""; |
|
|
| let depth = 0; |
| let inString = false; |
| let escaped = false; |
|
|
| for (let i = start; i < source.length; i += 1) { |
| const ch = source[i]; |
|
|
| if (inString) { |
| if (escaped) { |
| escaped = false; |
| continue; |
| } |
| if (ch === "\\") { |
| escaped = true; |
| continue; |
| } |
| if (ch === "\"") { |
| inString = false; |
| } |
| continue; |
| } |
|
|
| if (ch === "\"") { |
| inString = true; |
| continue; |
| } |
| if (ch === "{") { |
| depth += 1; |
| continue; |
| } |
| if (ch === "}") { |
| depth -= 1; |
| if (depth === 0) { |
| return source.slice(start, i + 1); |
| } |
| } |
| } |
| return ""; |
| } |
|
|
| function truncateMultilineText(text, maxLines = 40, maxChars = 1800) { |
| const lines = String(text || "").split("\n"); |
| const clippedLines = lines.slice(0, maxLines); |
| let clipped = clippedLines.join("\n").trim(); |
|
|
| if (clipped.length > maxChars) { |
| clipped = `${clipped.slice(0, maxChars).trimEnd()}\n...`; |
| } else if (lines.length > maxLines) { |
| clipped = `${clipped}\n...`; |
| } |
| return clipped; |
| } |
|
|
| function parseObservationDisplayBlocks(content) { |
| const jsonBlocks = parseObservationJsonBlocks(content); |
| if (jsonBlocks) return jsonBlocks; |
| return parseObservationTextBlocks(content); |
| } |
|
|
| function parseObservationJsonBlocks(content) { |
| const source = String(content || ""); |
| const jsonText = extractFirstJsonObject(source); |
| if (!jsonText) return null; |
|
|
| try { |
| const parsed = JSON.parse(jsonText); |
| const lines = source.split("\n").map((line) => line.trim()).filter(Boolean); |
| const fileHint = lines.find((line) => /saved to .*\.json/i.test(line)) || ""; |
| const blocks = []; |
|
|
| if (fileHint) { |
| blocks.push({ type: "note", text: fileHint }); |
| } |
| blocks.push({ type: "heading", text: "JSON Summary", level: 1 }); |
| blocks.push(...buildJsonDisplayBlocks(parsed)); |
| return blocks; |
| } catch { |
| return null; |
| } |
| } |
|
|
| function buildJsonDisplayBlocks(value, title = "", depth = 0) { |
| const blocks = []; |
| const safeLevel = Math.min(depth + 2, 4); |
|
|
| if (title) { |
| blocks.push({ type: "heading", text: humanizeKey(title), level: safeLevel }); |
| } |
|
|
| if (Array.isArray(value)) { |
| if (!value.length) { |
| blocks.push({ type: "text", text: "No items." }); |
| return blocks; |
| } |
|
|
| if (value.every(isPrimitiveValue)) { |
| blocks.push({ |
| type: "list", |
| items: value.slice(0, 12).map((item) => formatDisplayValue(item)), |
| }); |
| if (value.length > 12) { |
| blocks.push({ type: "note", text: `Showing first 12 of ${value.length} items.` }); |
| } |
| return blocks; |
| } |
|
|
| if (value.every((item) => isPlainObject(item) && Object.values(item).every(isPrimitiveValue))) { |
| const headers = collectObjectKeys(value); |
| const rows = value.slice(0, 10).map((item) => headers.map((key) => formatDisplayValue(item[key]))); |
| blocks.push({ |
| type: "table", |
| headers: headers.map((key) => humanizeKey(key)), |
| rows, |
| }); |
| if (value.length > 10) { |
| blocks.push({ type: "note", text: `Showing first 10 of ${value.length} rows.` }); |
| } |
| return blocks; |
| } |
|
|
| value.slice(0, 5).forEach((item, index) => { |
| blocks.push(...buildJsonDisplayBlocks(item, `Item ${index + 1}`, depth + 1)); |
| }); |
| if (value.length > 5) { |
| blocks.push({ type: "note", text: `Showing first 5 of ${value.length} items.` }); |
| } |
| return blocks; |
| } |
|
|
| if (isPlainObject(value)) { |
| const entries = Object.entries(value); |
| const primitiveEntries = entries.filter(([, v]) => isPrimitiveValue(v)); |
| const complexEntries = entries.filter(([, v]) => !isPrimitiveValue(v)); |
|
|
| if (primitiveEntries.length) { |
| blocks.push({ |
| type: "table", |
| headers: ["Field", "Value"], |
| rows: primitiveEntries.map(([key, val]) => [humanizeKey(key), formatDisplayValue(val)]), |
| }); |
| } |
|
|
| if ( |
| !primitiveEntries.length && |
| entries.length > 0 && |
| entries.every(([, v]) => isPlainObject(v) && Object.values(v).every(isPrimitiveValue)) |
| ) { |
| const rowHeaders = collectObjectKeys(entries.map(([, v]) => v)); |
| blocks.push({ |
| type: "table", |
| headers: ["Name", ...rowHeaders.map((key) => humanizeKey(key))], |
| rows: entries.map(([name, row]) => [ |
| humanizeKey(name), |
| ...rowHeaders.map((key) => formatDisplayValue(row[key])), |
| ]), |
| }); |
| return blocks; |
| } |
|
|
| complexEntries.forEach(([key, val]) => { |
| blocks.push(...buildJsonDisplayBlocks(val, key, depth + 1)); |
| }); |
| return blocks; |
| } |
|
|
| blocks.push({ type: "text", text: formatDisplayValue(value) }); |
| return blocks; |
| } |
|
|
| function parseObservationTextBlocks(content) { |
| const lines = String(content || "") |
| .split("\n") |
| .map((line) => line.replace(/\s+$/g, "")); |
| const blocks = []; |
| let buffer = []; |
| let i = 0; |
|
|
| const flushBuffer = () => { |
| const text = buffer.map((line) => line.trim()).filter(Boolean).join("\n").trim(); |
| if (text) { |
| blocks.push({ type: "text", text }); |
| } |
| buffer = []; |
| }; |
|
|
| while (i < lines.length) { |
| const raw = lines[i]; |
| const trimmed = raw.trim(); |
|
|
| if (!trimmed) { |
| flushBuffer(); |
| i += 1; |
| continue; |
| } |
|
|
| const table = parseWhitespaceTable(lines, i); |
| if (table) { |
| flushBuffer(); |
| blocks.push({ type: "table", headers: table.headers, rows: table.rows }); |
| i = table.nextIndex; |
| continue; |
| } |
|
|
| if (isObservationHeading(trimmed)) { |
| flushBuffer(); |
| blocks.push({ type: "heading", text: trimmed, level: 2 }); |
| i += 1; |
| continue; |
| } |
|
|
| if (/saved to .*\.json/i.test(trimmed)) { |
| flushBuffer(); |
| blocks.push({ type: "note", text: trimmed }); |
| i += 1; |
| continue; |
| } |
|
|
| buffer.push(trimmed); |
| i += 1; |
| } |
|
|
| flushBuffer(); |
| return blocks.length ? blocks : [{ type: "text", text: content }]; |
| } |
|
|
| function parseWhitespaceTable(lines, start) { |
| const headerCells = splitObservationColumns(lines[start]); |
| if (headerCells.length < 3) return null; |
|
|
| const rows = []; |
| let nextIndex = start + 1; |
| let maxCols = headerCells.length; |
|
|
| while (nextIndex < lines.length) { |
| const cells = splitObservationColumns(lines[nextIndex]); |
| if (cells.length < 3) break; |
| rows.push(cells); |
| maxCols = Math.max(maxCols, cells.length); |
| nextIndex += 1; |
| } |
|
|
| if (rows.length < 2) return null; |
|
|
| let headers = [...headerCells]; |
| if (maxCols === headerCells.length + 1 && /^id$/i.test(headerCells[0] || "")) { |
| headers = ["id", "symbol", ...headerCells.slice(1)]; |
| } |
| while (headers.length < maxCols) { |
| headers.push(`Column ${headers.length + 1}`); |
| } |
|
|
| const normalizedRows = rows.map((cells) => { |
| const next = [...cells]; |
| while (next.length < headers.length) { |
| next.push(""); |
| } |
| return next.slice(0, headers.length); |
| }); |
|
|
| return { |
| headers: headers.map((cell) => humanizeKey(cell)), |
| rows: normalizedRows, |
| nextIndex, |
| }; |
| } |
|
|
| function splitObservationColumns(line) { |
| const trimmed = String(line || "").trim(); |
| if (!trimmed) return []; |
| return trimmed.split(/\s{2,}|\t+/).map((cell) => cell.trim()).filter(Boolean); |
| } |
|
|
| function isObservationHeading(text) { |
| const value = String(text || "").trim(); |
| if (!value) return false; |
| if (/^json summary:?$/i.test(value)) return true; |
| return /^[A-Z0-9_/'"()\-\s:]{12,}$/.test(value) && !/[a-z]{3,}/.test(value); |
| } |
|
|
| function isPlainObject(value) { |
| return Boolean(value) && typeof value === "object" && !Array.isArray(value); |
| } |
|
|
| function isPrimitiveValue(value) { |
| return value == null || ["string", "number", "boolean"].includes(typeof value); |
| } |
|
|
| function collectObjectKeys(items) { |
| const seen = []; |
| for (const item of items) { |
| for (const key of Object.keys(item || {})) { |
| if (!seen.includes(key)) { |
| seen.push(key); |
| } |
| } |
| } |
| return seen; |
| } |
|
|
| function humanizeKey(value) { |
| return String(value || "") |
| .replace(/_/g, " ") |
| .replace(/\s+/g, " ") |
| .trim(); |
| } |
|
|
| function formatDisplayValue(value) { |
| if (value == null) return ""; |
| if (typeof value === "number") { |
| if (!Number.isFinite(value)) return String(value); |
| if (Math.abs(value) >= 1000) return value.toLocaleString("en-US", { maximumFractionDigits: 4 }); |
| if (Math.abs(value) > 0 && Math.abs(value) < 0.001) return value.toExponential(3); |
| return Number(value.toFixed(6)).toString(); |
| } |
| if (typeof value === "boolean") { |
| return value ? "Yes" : "No"; |
| } |
| return String(value); |
| } |
|
|