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 (
Strata Bio OS
Conversation + Structured Reasoning
{sessionId ? `Session ${sessionId.slice(0, 8)}…` : "Initializing…"}
Conversation
{messages.map((msg, i) => (
msg.role === "assistant" && setActiveAssistantIndex(i)}
/>
))}
{isStreaming && (
)}
Reasoning
{currentAssistant ? (
) : (
Run a request to see the agent reasoning breakdown.
)}
);
}
function ConversationItem({ message, isActive, onSelect }) {
if (message.role === "user") {
return (
);
}
const finalStep = getAssistantFinalStep(message);
return (
);
}
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 (
{artifacts.length > 0 &&
}
{blocks.map((block, idx) => {
if (block.type === "table") {
return
;
}
return
;
})}
);
}
function ArtifactDownloads({ artifacts }) {
return (
);
}
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 (
{sections.map((section, idx) => {
if (section.type === "heading") {
return (
{renderInlineMarkdown(section.text)}
);
}
if (section.type === "list") {
return (
{section.items.map((item, i) => (
- {renderInlineMarkdown(item)}
))}
);
}
if (section.type === "quote") {
return
{renderInlineMarkdown(section.text)}
;
}
if (section.type === "sequence") {
return (
);
}
return
{renderInlineMarkdown(section.text)}
;
})}
);
}
function ResultTable({ headers, rows }) {
return (
{headers.map((h, i) => (
| {h} |
))}
{rows.map((row, rIdx) => (
{headers.map((_, cIdx) => (
| {renderInlineMarkdown(row[cIdx] || "")} |
))}
))}
);
}
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(/([\s\S]*?)<\/solution>/i);
if (solutionMatch?.[1]) {
text = solutionMatch[1];
}
text = text.replace(/[\s\S]*?<\/execute>/gi, " ");
text = text.replace(/[\s\S]*?<\/observation>/gi, " ");
text = text.replace(/