czty's picture
Add files using upload-large-folder tool
b2c86fd verified
Raw
History Blame Contribute Delete
45.3 kB
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);
// 初始化 session
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: [], // 每个 step: { type, content, lang?, tool? }
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;
}
// 把每个事件追加到最后一条 assistant 消息的 steps 中
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];
// fenced code block
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;
}
// markdown table block
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;
}
// plain text block
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");
// Split packed XML/HTML-like tags into lines for readability.
const lineBroken = text.replace(/>\s*</g, ">\n<");
// Render angle-bracket tokens as markdown-style inline code.
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;
}
// 连续 thinking 聚合成一个“部分”,避免按行碎裂
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);
}