Upgrade Alias-Agent to 0.2.0 (#51)

Upgrade Alias-Agent to 0.2.0

---------

Co-authored-by: ZiTao-Li <zitao.l@alibaba-inc.com>
Co-authored-by: xieyxclack <yuexiang.xyx@alibaba-inc.com>
Co-authored-by: Zexi Li <tomleeze@qq.com>
Co-authored-by: SSSuperDan <dlaura2218@gmail.com>
Co-authored-by: lalaliat <78087788+lalaliat@users.noreply.github.com>
Co-authored-by: jinli.yl <jinli.yl@alibaba-inc.com>
Co-authored-by: Dengjiaji <dengjiaji.djj@alibaba-inc.com>
Co-authored-by: 于南 <zengtianjing.ztj@alibaba-inc.com>
Co-authored-by: JustinDing <166603159+sleepy-bird-world@users.noreply.github.com>
Co-authored-by: y1y5 <269557841@qq.com>
Co-authored-by: 柳佚 <yly287738@alibaba-inc.com>
Co-authored-by: LiangguiWeng <347185100@qq.com>
Co-authored-by: 潜星 <zhijian.mzj@alibaba-inc.com>
Co-authored-by: StCarmen <1106135234@qq.com>
Co-authored-by: LuYi <yilu_2000@outlook.com>
Co-authored-by: 刺葳 <ciwei.cy@alibaba-inc.com>
This commit is contained in:
Yue Cui
2025-12-03 20:58:25 +08:00
committed by GitHub
parent 8af2dc6477
commit cb87558efe
430 changed files with 49058 additions and 3471 deletions

View File

@@ -0,0 +1,20 @@
import { memo } from "react";
import { classNames } from "../../utils/classNames";
interface PanelHeaderProps {
className?: string;
children: React.ReactNode;
}
export const PanelHeader = memo(({ className, children }: PanelHeaderProps) => {
return (
<div
className={classNames(
"flex items-center gap-2 bg-bolt-elements-background-depth-2 text-bolt-elements-textSecondary border-b border-bolt-elements-borderColor px-4 py-1 min-h-[34px] text-sm",
className,
)}
>
{children}
</div>
);
});

View File

@@ -0,0 +1,49 @@
// Tree.scss
$indent-base: 24px; // Base indent unit
.node {
display: flex !important;
align-items: center;
// padding: 4px 8px;
cursor: pointer;
width: 100% !important;
box-sizing: border-box;
transition: padding 0.2s ease;
// Dynamically generate level styles (supports 1-8 levels)
@for $i from 0 through 100 {
&.level-#{$i} {
padding-left: ($indent-base * $i) !important;
}
}
&:hover {
background-color: var(--message-option-bg-hover);
}
&.selected {
background-color: var(--filenode-selected-bg);
}
.node-content {
display: flex;
align-items: center;
width: 100%;
overflow: hidden;
.icon {
flex-shrink: 0;
width: 18px;
height: 18px;
margin-right: 8px;
}
.node-text {
font-family: system-ui;
font-size: 14px;
white-space: nowrap;
text-overflow: ellipsis;
overflow: hidden;
}
}
}

View File

@@ -0,0 +1,169 @@
import type { NodeRendererProps } from "react-arborist";
import { Tree } from "react-arborist";
import {
BsFile,
BsFiles,
BsFiletypeCss,
BsFiletypeHtml,
BsFiletypeJava,
BsFiletypeJs,
BsFiletypeJsx,
BsFiletypeMd,
BsFiletypePy,
BsFiletypeTsx,
BsFolder2Open,
BsImage,
} from "react-icons/bs";
import { FaFileExcel, FaFilePowerpoint, FaFileWord } from "react-icons/fa";
import { FiArchive, FiFilm, FiFolder } from "react-icons/fi";
import "./Tree.scss";
// Node data type
interface FileNode {
name: string;
type: "directory" | "file";
path: string;
children?: FileNode[];
}
interface FileTreeProps {
data: FileNode[];
onExpand: (node: FileNode) => void;
show: string;
}
// Add sorting function
const sortFileTree = (nodes: FileNode[]): FileNode[] => {
return nodes
.sort((a, b) => {
// First sort by type (directories first)
if (a.type !== b.type) {
return a.type === "directory" ? -1 : 1;
}
// Then sort by name alphabetically
return a.name.localeCompare(b.name);
})
.map((node) => ({
...node,
children: node.children ? sortFileTree(node.children) : undefined,
}));
};
function getFileType(name: string, isFolder: boolean, isOpen: boolean) {
const ext = name.split(".").pop()?.toLowerCase();
const iconStyle = { color: "#6b7280" };
if (isFolder) {
return isOpen ? (
<BsFolder2Open className="icon" />
) : (
<FiFolder className="icon" />
);
}
if (!ext) {
return <BsFile style={iconStyle} className="icon" />;
}
switch (ext) {
case "js":
return <BsFiletypeJs style={{ color: "#f1e05a" }} className="icon" />;
case "py":
return <BsFiletypePy style={iconStyle} className="icon" />;
case "md":
return <BsFiletypeMd style={iconStyle} className="icon" />;
case "tsx":
return <BsFiletypeTsx style={iconStyle} className="icon" />;
case "jsx":
return <BsFiletypeJsx style={{ color: "#f1e05a" }} className="icon" />;
case "java":
return <BsFiletypeJava style={iconStyle} className="icon" />;
case "json":
return <BsFiles style={{ color: "#f5de19" }} className="icon" />;
case "css":
return <BsFiletypeCss style={iconStyle} className="icon" />;
case "html":
return <BsFiletypeHtml style={iconStyle} className="icon" />;
case "png":
case "jpg":
case "jpeg":
return <BsImage style={iconStyle} className="icon" />;
case "docx":
case "doc":
return <FaFileWord style={{ color: "#2b579a" }} className="icon" />;
case "zip":
case "tar.gz":
case "rar":
return <FiArchive style={iconStyle} className="icon" />;
case "xlsx":
case "xls":
return <FaFileExcel style={{ color: "#217346" }} className="icon" />;
case "pptx":
case "ppt":
return <FaFilePowerpoint style={{ color: "#d24726" }} className="icon" />;
case "mp4":
case "mov":
case "avi":
return <FiFilm style={iconStyle} className="icon" />;
default:
return <BsFile style={iconStyle} className="icon" />;
}
}
export const FileTree = ({ data, onExpand, show }: FileTreeProps) => {
// Sort data
const sortedData = sortFileTree(data);
// Custom node renderer
const CustomNode = ({
node,
style,
dragHandle,
}: NodeRendererProps<FileNode>) => {
const isFolder = node.data.type === "directory";
const icon = getFileType(node.data.name, isFolder, node.isOpen);
// Correct method to get node level
const getNodeLevel = () => {
let level = 0;
let parent = node.parent;
while (parent) {
level++;
parent = parent.parent;
}
return level - 1;
};
return (
<div
ref={dragHandle}
// className={`node ${node.state.isSelected ? 'selected' : ''}` }
className={`node
${node.state.isSelected ? "selected" : ""}
level-${getNodeLevel()}
`}
onClick={() => {
node.isInternal && node.toggle();
if (show === "noShow") {
onExpand(node?.data);
}
}}
aria-expanded={node.isOpen}
>
<div className="node-content">
{icon}
<span className="node-text">{node.data.name}</span>
{/* {node.isInternal && (
<span className="arrow">{node.isOpen ? '▼' : '▶'}</span>
)} */}
</div>
</div>
);
};
return (
<Tree<FileNode>
data={sortedData}
idAccessor={(node) => node.path}
openByDefault={false}
indent={20}
width={"100%"}
>
{CustomNode}
</Tree>
);
};

View File

@@ -0,0 +1,83 @@
.artifactsWrap {
width: 100%;
height: calc(100vh - 134px);;
border-top: solid 1px var(--sps-color-border-secondary);
// background-color: var(--artifacts-wrap-bg);
// box-shadow: 0 0 rgb(0 0 0 / 0);
// border-radius: 10px;
}
.codeWrap{
height: 100%;
overflow: auto;
}
.treeWrap{
height: 100%;
overflow: auto;
padding: 12px;
}
.terminalWrapper {
height: 200px;
min-height: 150px;
max-height: 30vh;
border-top: 1px solid var(--border-color);
background-color: #ffffff;
flex-shrink: 0;
display: flex;
flex-direction: column;
}
.terminalHeader {
height: 30px;
min-height: 30px;
display: flex;
align-items: center;
padding: 0 10px;
background-color: var(--sidebar-bg);
border-bottom: 1px solid var(--border-color);
color: var(--text-color);
flex-shrink: 0;
}
.terminalContainer {
flex: 1;
padding: 10px;
overflow: auto;
}
.modalTitle {
display: flex;
align-items: center;
gap: 8px;
flex-wrap: wrap;
width: 90%;
}
.saveStatus {
font-size: 12px;
margin-left: 8px;
transition: all 0.3s ease;
opacity: 0.8;
&.modified {
color: #856404;
}
&.saved {
color: #155724;
animation: fadeOut 3s ease-in-out forwards;
}
}
.modalContent {
max-height: 600px;
overflow-y: auto;
}
@keyframes fadeOut {
0% {
opacity: 0.8;
}
70% {
opacity: 0.8;
}
100% {
opacity: 0;
}
}

View File

@@ -0,0 +1,105 @@
.terminal {
overflow: auto;
height: 100%;
padding: 0 !important;
margin: 0 !important;
border-bottom-right-radius: 10px;
border-bottom-left-radius: 10px;
}
.terminal .xterm {
height: 100% !important;
}
.header {
display: flex;
justify-content: space-between;
width: 100%;
}
.markdown-body {
// box-sizing: border-box;
// min-width: 200px;
// max-width: 680px;
height: 100%;
margin: 0 auto;
padding: 45px;
overflow: auto;
}
@media (max-width: 767px) {
.markdown-body {
padding: 15px;
}
}
.fileName {
line-height: 32px;
flex: 1;
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
display: flex;
align-items: center;
}
.iframe-box {
width: 100%;
height: 500px;
margin: 0 auto;
padding: 45px;
overflow: auto;
}
// Add dark theme support
.markdown-body.markdown-dark {
color: #e0e0e0;
background-color: #1a1a1a;
h1,
h2,
h3,
h4,
h5,
h6 {
color: #ffffff;
}
a {
color: #4da6ff;
}
code {
background-color: #2d2d2d;
color: #f8f8f2;
}
pre {
background-color: #2d2d2d;
border: 1px solid #444444;
code {
background-color: transparent;
}
}
blockquote {
color: #b0b0b0;
border-left-color: #444444;
}
table {
th {
background-color: #2d2d2d;
}
td,
th {
border-color: #444444;
}
}
hr {
background-color: #444444;
}
}

View File

@@ -0,0 +1,567 @@
import { useTheme } from "@/context/ThemeContext";
import { Button, Modal, Tooltip } from "@agentscope-ai/design";
import { css } from "@codemirror/lang-css";
import { html } from "@codemirror/lang-html";
import { java } from "@codemirror/lang-java";
import { javascript } from "@codemirror/lang-javascript";
import { markdown } from "@codemirror/lang-markdown";
import { python } from "@codemirror/lang-python";
import { Prec } from "@codemirror/state";
import { keymap } from "@codemirror/view";
import { materialDark, materialLight } from "@uiw/codemirror-theme-material";
import CodeMirror from "@uiw/react-codemirror";
import { FitAddon } from "@xterm/addon-fit";
import { Terminal as XTerm } from "@xterm/xterm";
import { createTwoFilesPatch } from "diff";
import "github-markdown-css/github-markdown-light.css";
import "highlight.js/styles/github-dark.css";
import "katex/dist/katex.min.css";
import React, { memo, useEffect, useRef, useState } from "react";
import ReactMarkdown from "react-markdown";
import { Panel, PanelGroup, PanelResizeHandle } from "react-resizable-panels";
import rehypeHighlight from "rehype-highlight";
import rehypeKatex from "rehype-katex";
import remarkGfm from "remark-gfm";
import remarkMath from "remark-math";
import io, { Socket } from "socket.io-client";
import "xterm/css/xterm.css";
import { classNames } from "../../utils/classNames";
import styles from "./index.module.scss";
import "./index.scss";
import { PanelHeader } from "./PanelHeader";
import { FileTree } from "./Tree";
const DEFAULT_TERMINAL_SIZE = 25;
const DEFAULT_EDITOR_SIZE = 100 - DEFAULT_TERMINAL_SIZE;
// const { DirectoryTree } = Tree;
let socket: Socket;
const darkTheme = {
background: "#131414",
foreground: "#ffffff",
cursor: "#dddddd",
};
const lightTheme = {
background: "#ffffff",
foreground: "#333333",
cursor: "#333333",
};
const Artifacts = (Props: {
webSocketUrl: { artifactsSio: any };
runtimeToken: string;
}) => {
const [fileTree, setFileTree] = useState([]);
const [codeValue, setCodeValue] = useState("");
const [extensions, setExtensions] = useState([javascript()]);
const [fileName, setFileName] = useState("");
const [markdownValue, setMarkdownValue] = useState("");
const [htmlModal, setHtmlModal] = useState("");
const [modal, setModal] = useState(false);
const [saveStatus, setSaveStatus] = useState<"saved" | "modified" | null>(
null,
);
const [originalContent, setOriginalContent] = useState("");
const terminalElementRef = useRef<HTMLDivElement>(null);
const artifactsSio = Props?.webSocketUrl?.artifactsSio;
const saveTimeoutRef = useRef<NodeJS.Timeout>();
const runtimeToken = Props?.runtimeToken;
const fitAddonRef = useRef<FitAddon | null>(null);
const terminalRef = useRef<XTerm | null>(null);
const [lastCommand, setLastCommand] = useState("");
const [isCommandRunning, setIsCommandRunning] = useState(false);
const [commandOutput, setCommandOutput] = useState("");
const lastCommandRef = useRef("");
const { theme } = useTheme();
// Move handleOutput function to component top
const handleOutput = (data: string) => {
if (terminalElementRef.current) {
const terminal = terminalElementRef.current.querySelector(
".xterm",
) as any;
if (terminal && terminal.terminal) {
terminal.terminal.write(data);
}
}
};
const getMessage = () => {
let data: never[] = [];
try {
socket = io(artifactsSio, {
path: "/artifacts/socket.io",
auth: {
token: runtimeToken,
},
});
} catch (error) {
console.error("Failed to connect to WebSocket:", error);
// Handle connection failures, such as displaying an error message to the user
}
// socket = io("http://localhost:4500/artifacts");
socket.on("connect", () => {
console.log("Connection successful");
// Reset all state
setFileTree([]);
setCodeValue("");
setFileName("");
setMarkdownValue("");
setHtmlModal("");
setModal(false);
});
socket.on("error", (event) => {
// Error exception message
console.log("error", event);
});
socket.emit("requestFileList");
socket.on("fileTree", (fileTree) => {
setFileTree(fileTree || []);
data = fileTree;
});
return data;
};
useEffect(() => {
getMessage();
// Add timer to refresh file tree every 3 seconds
const timer = setInterval(() => {
if (socket) {
socket.emit("requestFileList");
}
}, 3000);
// Cleanup function
return () => {
clearInterval(timer);
if (socket) {
socket.off("connect");
socket.off("error");
socket.off("fileTree");
socket.off("fileContent");
socket.off("output", handleOutput);
}
};
}, [artifactsSio]);
const onExpand = (info: { type: string; path: any; name: string }) => {
if (info?.type !== "file") {
return;
}
const filePath = info?.path;
socket.emit("loadFile", filePath);
const handleFileContent = ({
content,
path,
}: {
content: string;
path: string;
}) => {
setFileName(path);
detectLanguage(path);
setCodeValue(content);
setOriginalContent(content);
// Clear modification status when switching files
setSaveStatus(null);
if (path.split(".").slice(-1)[0] === "md") {
setMarkdownValue(content);
}
if (path.split(".").slice(-1)[0] === "html") {
setHtmlModal(content);
}
};
socket.on("fileContent", handleFileContent);
return () => {
socket.off("fileContent", handleFileContent);
};
};
const langMap = (fileExtension: string | undefined) => {
let mode = javascript();
switch (fileExtension) {
case "css":
mode = css();
break;
case "java":
mode = java();
break;
case "ts":
mode = javascript();
break;
case "js":
mode = javascript();
break;
case "html":
mode = html();
break;
case "py":
mode = python();
break;
case "md":
mode = markdown();
break;
}
return mode;
};
// Language switching
const detectLanguage = (fileName: string) => {
const ext = fileName.split(".").slice(-1)[0];
const langLoader = langMap(ext);
setExtensions([langLoader]);
};
// Save file
const saveKeymap = Prec.high(
// Ensure shortcut key priority
keymap.of([
{
key: "Ctrl-s",
mac: "Cmd-s",
run: (editor) => {
saveCurrentFile(fileName);
return true; // Prevent browser default behavior
},
},
]),
);
const handleCodeChange = (value: string) => {
setCodeValue(value);
setSaveStatus("modified");
// Also update preview content
const fileExt = fileName.split(".").slice(-1)[0];
if (fileExt === "md") {
setMarkdownValue(value);
} else if (fileExt === "html") {
setHtmlModal(value);
}
};
const saveCurrentFile = (fileName: string) => {
if (fileName) {
socket.emit("saveFile", {
filename: fileName,
content: codeValue,
});
const diffResult = createTwoFilesPatch(
fileName,
fileName,
originalContent, // Original content
codeValue, // Current content
);
// TODO: push event
console.warn("diffResult", diffResult);
// Update original content after successful save
setOriginalContent(codeValue); // Add this line
// Clear save status after 3 seconds
if (saveTimeoutRef.current) {
clearTimeout(saveTimeoutRef.current);
}
saveTimeoutRef.current = setTimeout(() => {
setSaveStatus(null);
}, 3000);
}
};
// Terminal
useEffect(() => {
const element = terminalElementRef.current!;
const fitAddon = new FitAddon();
fitAddonRef.current = fitAddon;
const terminal = new XTerm({
fontSize: 14,
fontFamily: 'Consolas, "Courier New", monospace',
theme: theme === "dark" ? darkTheme : lightTheme,
});
// Save terminal instance reference
terminalRef.current = terminal;
terminal.loadAddon(fitAddon);
terminal.open(element);
fitAddon.fit();
let currentCommand = "";
terminal.onData((data) => {
socket.emit("input", data);
if (data === "\r" || data === "\n") {
if (currentCommand.trim()) {
const command = currentCommand.trim();
setLastCommand(command);
lastCommandRef.current = command;
setIsCommandRunning(true);
setCommandOutput(""); // Clear previous output
currentCommand = "";
}
} else if (data === "\u007f") {
// Backspace key
currentCommand = currentCommand.slice(0, -1);
} else if (data.length === 1 && data.charCodeAt(0) >= 32) {
// Printable character
currentCommand += data;
}
});
const handleOutput = (data: string) => {
terminal.write(data);
if (isCommandRunning) {
setCommandOutput((prev) => {
const newOutput = prev + data;
const cleanedOutput = newOutput
.replace(/\x1b\[[0-9;]*[a-zA-Z]/g, "")
.replace(/\x1b\][0-9];[^\x07]*\x07/g, "")
.replace(/\x1b\][0-9];[^\x1b]*\x1b\\/g, "")
.replace(/\[?\?[0-9]+[hl]/g, ""); // More lenient prompt matching
const promptPattern = /root@[a-f0-9]+:[^#]*#\s*$/;
if (promptPattern.test(cleanedOutput)) {
setIsCommandRunning(false);
const promptMatch = cleanedOutput.match(
/root@[a-f0-9]+:[^#]*#\s*$/,
);
if (promptMatch) {
const cleanOutput = cleanedOutput
.substring(0, promptMatch.index)
.trim();
// TODO: push event
console.warn(
`Command: ${lastCommandRef.current}\nResults: ${cleanOutput}`,
);
}
return "";
}
return newOutput;
});
}
};
socket.on("output", handleOutput);
return () => {
terminal.dispose();
socket.off("connect");
socket.off("error");
socket.off("fileTree");
socket.off("fileContent");
socket.off("output", handleOutput);
};
}, [artifactsSio]);
useEffect(() => {
if (terminalRef.current) {
terminalRef.current.options.theme =
theme === "dark" ? darkTheme : lightTheme;
}
}, [theme]);
const onClose = () => {
setModal(false);
};
const modalTitle = (
<div className={styles.modalTitle}>
<span
style={{
wordBreak: "break-all",
maxWidth: "100%",
}}
>
{fileName}
</span>
{saveStatus && (
<span
className={classNames(styles.saveStatus, {
[styles.modified]: saveStatus === "modified",
[styles.saved]: saveStatus === "saved",
})}
>
{saveStatus === "modified" ? "• Modified • Ctrl + S" : "• Saved"}
</span>
)}
</div>
);
return (
<div className={styles.artifactsWrap}>
<Modal
title={modalTitle}
open={modal}
centered={true}
// footer={null}
onCancel={onClose}
onOk={onClose}
width={960}
>
<div className={styles.modalContent}>
{fileName.split(".").slice(-1)[0] === "md" ? (
<div
className={`markdown-body ${
theme === "dark" ? "markdown-dark" : "markdown-light"
}`}
>
<ReactMarkdown
remarkPlugins={[remarkGfm, remarkMath]}
rehypePlugins={[rehypeHighlight, rehypeKatex]}
components={{
code(props: any) {
const { node, inline, className, children, ...rest } =
props;
const match = /language-(\w+)/.exec(className || "");
return !inline && match ? (
<pre>
<code className={match[1]} {...rest}>
{children}
</code>
</pre>
) : (
<code className={className} {...rest}>
{children}
</code>
);
},
}}
>
{markdownValue}
</ReactMarkdown>
</div>
) : fileName.split(".").slice(-1)[0] === "html" ? (
<div className="iframe-box">
<iframe
srcDoc={htmlModal}
sandbox="allow-scripts"
style={{
width: "100%",
height: "100%",
border: "none",
}}
/>
</div>
) : (
<CodeMirror
value={codeValue}
height="500px"
extensions={[...extensions, saveKeymap]}
onChange={handleCodeChange}
theme={theme === "dark" ? materialDark : materialLight}
/>
)}
</div>
</Modal>
<PanelGroup
direction="vertical"
onLayout={() => {
fitAddonRef.current?.fit();
}}
>
<Panel defaultSize={50} minSize={20}>
<PanelGroup direction="horizontal">
<Panel defaultSize={40} minSize={10} collapsible>
<div
className="flex flex-col border-r border-bolt-elements-borderColor h-full"
style={{ backgroundColor: "transparent" }}
>
<PanelHeader>Files</PanelHeader>
<div className={styles.treeWrap}>
<FileTree
data={fileTree}
onExpand={onExpand}
show={"noShow"}
/>
</div>
</div>
</Panel>
<PanelResizeHandle />
<Panel className="flex flex-col" defaultSize={80} minSize={20}>
<PanelHeader className="overflow-x-auto">
<div className="header">
<div className="fileName">
<Tooltip title={fileName} placement="top">
<span
style={{
overflow: "hidden",
textOverflow: "ellipsis",
}}
>
{fileName}
</span>
</Tooltip>
{saveStatus && (
<span
className={classNames(styles.saveStatus, {
[styles.modified]: saveStatus === "modified",
[styles.saved]: saveStatus === "saved",
})}
>
{saveStatus === "modified"
? "• Modified • Ctrl + S"
: "• Saved"}
</span>
)}
</div>
{(fileName.split(".").slice(-1)[0] === "md" ||
fileName.split(".").slice(-1)[0] === "html") && (
<Button
type="link"
style={{ flexShrink: 0 }}
onClick={() => {
// Update preview content before opening preview
const fileExt = fileName.split(".").slice(-1)[0];
if (fileExt === "md") {
setMarkdownValue(codeValue);
} else if (fileExt === "html") {
setHtmlModal(codeValue);
}
setModal(true);
}}
>
Preview
</Button>
)}
</div>
</PanelHeader>
<div className={styles.codeWrap}>
<CodeMirror
value={codeValue}
extensions={[...extensions, saveKeymap]}
theme={theme === "dark" ? materialDark : materialLight}
onChange={(value) => {
setCodeValue(value);
setSaveStatus("modified");
}}
/>
</div>
</Panel>
</PanelGroup>
</Panel>
<PanelResizeHandle />
<Panel defaultSize={20} minSize={10}>
<div className="h-full" style={{ height: "100%" }}>
<div
className="bg-bolt-elements-terminals-background h-full flex flex-col"
style={{ height: "100%" }}
>
<div className="flex items-center bg-bolt-elements-background-depth-2 border-y border-bolt-elements-borderColor gap-1.5 min-h-[34px] p-2">
<React.Fragment>
<div
className={classNames(
"flex items-center text-sm cursor-pointer gap-1.5 px-3 py-2 h-full whitespace-nowrap rounded-full",
{
"bg-bolt-elements-terminals-buttonBackground text-bolt-elements-textSecondary hover:text-bolt-elements-textPrimary":
false,
"bg-bolt-elements-background-depth-2 text-bolt-elements-textSecondary hover:bg-bolt-elements-terminals-buttonBackground":
!false,
},
)}
>
Terminal
</div>
</React.Fragment>
</div>
<div className="terminal" ref={terminalElementRef} />
</div>
</div>
</Panel>
</PanelGroup>
</div>
);
};
export default memo(Artifacts);

View File

@@ -0,0 +1,384 @@
/* CSS Variables for themes */
html[data-theme="dark"] {
--bg-primary: #272725;
--bg-secondary: #171717;
--border-color: #383838;
--text-color: #ffffff;
--tab-active-bg: #272725;
--tab-hover-bg: #333333;
--icon-color: #8a8a8a;
--icon-hover-color: #ffffff;
--error-color: #e53935;
--offline-indicator-color: #e53935;
--loading-overlay-bg: rgba(30, 30, 30, 0.8);
--loading-spinner-color: #ffffff;
}
html[data-theme="light"] {
--bg-primary: #ffffff;
--bg-secondary: #f5f5f5;
--border-color: #e0e0e0;
--text-color: #000000;
--tab-active-bg: #e8e8e8;
--tab-hover-bg: #efefef;
--icon-color: #666666;
--icon-hover-color: #000000;
--error-color: #e53935;
--offline-indicator-color: #e53935;
--loading-overlay-bg: rgba(240, 240, 240, 0.8);
--loading-spinner-color: #333333;
}
.container {
width: 100%;
height: 100%;
background: var(--bg-primary);
border: none;
display: flex;
flex-direction: column;
box-sizing: border-box;
overflow: hidden;
}
.browser-chrome {
display: flex;
flex-direction: column;
width: 100%;
position: relative;
}
.tab-bar {
display: flex;
padding: 6px;
gap: 4px;
height: 36px;
background: var(--bg-secondary);
border-bottom: 1px solid var(--border-color);
overflow-x: auto;
scrollbar-width: none;
-ms-overflow-style: none;
align-items: center;
&::-webkit-scrollbar {
display: none;
}
}
.tab {
display: flex;
align-items: center;
padding: 0 12px;
min-width: 120px;
max-width: 200px;
height: 36px;
border-radius: 8px;
color: var(--text-color);
font-size: 12px;
cursor: pointer;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
position: relative;
gap: 8px;
transition: background-color 0.2s;
&:hover {
background-color: var(--tab-hover-bg);
}
&.active {
background-color: var(--tab-active-bg);
}
}
.tab-favicon {
width: 16px;
height: 16px;
object-fit: contain;
}
.tab-title {
flex: 1;
overflow: hidden;
text-overflow: ellipsis;
}
.tab-close {
width: 16px;
height: 16px;
display: flex;
align-items: center;
justify-content: center;
border-radius: 50%;
opacity: 0.6;
font-size: 14px;
line-height: 1;
&:hover {
background: rgba(255, 255, 255, 0.1);
opacity: 1;
}
}
.address-bar {
display: flex;
align-items: center;
padding: 0 8px;
height: 40px;
background: var(--bg-secondary);
border-bottom: 1px solid var(--border-color);
}
.nav-buttons {
display: flex;
gap: 4px;
margin-left: 8px;
margin-right: 8px;
}
.nav-button {
width: 28px;
height: 28px;
border: none;
background: transparent;
color: var(--icon-color);
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
font-size: 18px;
padding: 0;
border-radius: 4px;
transition: all 0.2s;
&:hover {
color: var(--icon-hover-color);
background: rgba(255, 255, 255, 0.1);
}
&:disabled {
cursor: default;
&:hover {
background: transparent;
}
}
}
.url-bar {
width: 100%;
height: 28px;
padding: 0 12px;
background: var(--bg-primary);
border-radius: 4px;
border: 1px solid var(--border-color);
display: flex;
align-items: center;
gap: 8px;
color: var(--text-color);
font-family: system-ui, -apple-system, sans-serif;
&:focus-within {
outline: none;
background: var(--tab-hover-bg);
}
}
.url-input {
flex: 1;
border: none;
background: transparent;
color: var(--text-color);
font-family: 'Geist', sans-serif;
font-size: 13px;
outline: none;
width: 100%;
}
.content {
min-height: 0;
flex: 1;
overflow: hidden;
background: white;
display: flex;
align-items: center;
justify-content: center;
position: relative;
}
.canvas-container {
position: absolute;
height: 100%;
width: 100%;
display: none;
&.active {
display: flex;
align-items: center;
justify-content: center;
}
&.loading::before {
content: "Loading...";
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
color: var(--text-color);
font-family: system-ui, -apple-system, sans-serif;
font-size: 16px;
z-index: 5;
}
&.error::before {
content: "Session released";
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
color: #fff;
font-family: system-ui, -apple-system, sans-serif;
font-size: 16px;
z-index: 5;
}
&.tab-switching::after {
content: "";
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: var(--loading-overlay-bg);
z-index: 10;
}
&.tab-switching::before {
content: "";
position: absolute;
width: 40px;
height: 40px;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
border: 4px solid transparent;
border-top-color: var(--loading-spinner-color);
border-radius: 50%;
animation: spin 1s linear infinite;
z-index: 11;
}
}
.canvas {
max-width: 100%;
max-height: 100%;
width: auto;
height: auto;
display: block;
margin: auto;
object-fit: contain;
}
.connection-status {
display: flex;
align-items: center;
padding: 0 12px;
height: 36px;
color: var(--text-color);
font-family: system-ui, -apple-system, sans-serif;
font-size: 13px;
box-sizing: border-box;
min-width: 140px;
flex-shrink: 0;
&.offline {
display: flex;
}
&.online {
display: none;
}
&.connecting {
display: none;
}
}
.status-indicator {
width: 8px;
height: 8px;
border-radius: 50%;
margin-right: 8px;
display: inline-block;
flex-shrink: 0;
&.offline {
background-color: var(--offline-indicator-color);
}
}
.url-security-icon {
width: 18px;
height: 18px;
display: flex;
align-items: center;
justify-content: center;
svg {
width: 18px;
height: 18px;
fill: var(--icon-color);
}
&.secure svg {
fill: #4CAF50;
}
}
.tab-favicon-spinner {
width: 16px;
height: 16px;
display: none;
position: relative;
&::after {
content: '';
position: absolute;
width: 12px;
height: 12px;
top: 2px;
left: 2px;
border: 2px solid var(--icon-color);
border-top-color: transparent;
border-radius: 50%;
animation: spinner-rotation 0.8s linear infinite;
}
}
.tab.loading {
.tab-favicon {
display: none;
}
.tab-favicon-spinner {
display: block;
}
}
@keyframes spin {
0% {
transform: translate(-50%, -50%) rotate(0deg);
}
100% {
transform: translate(-50%, -50%) rotate(360deg);
}
}
@keyframes spinner-rotation {
0% {
transform: rotate(0deg);
}
100% {
transform: rotate(360deg);
}
}

View File

@@ -0,0 +1,691 @@
import React, { memo, useCallback, useEffect, useRef, useState } from "react";
import "./index.scss";
interface Tab {
id: string;
url: string;
title: string;
favicon: string | null;
ws: WebSocket | null;
receivedFirstFrame: boolean;
lastImageData: string | null;
isLoading: boolean;
frameCount: number;
canvasRef: React.RefObject<HTMLCanvasElement>;
containerRef: React.RefObject<HTMLDivElement>;
currentImageWidth: number;
currentImageHeight: number;
reconnecting: boolean;
intentionalClose: boolean;
error: boolean;
}
type ConnectionStatus = "online" | "offline" | "connecting";
const defaultWidth = 1920;
const defaultHeight = 1080;
interface BrowserProps {
webSocketUrl: {
browserWs: string;
artifactsSio?: string;
};
activeKey?: string;
}
const Browser: React.FC<BrowserProps> = ({ webSocketUrl, activeKey }) => {
const [tabs, setTabs] = useState<Record<string, Tab>>({});
const [activeTabId, setActiveTabId] = useState<string | null>(null);
const [connectionStatus, setConnectionStatus] =
useState<ConnectionStatus>("connecting");
const [tabOrder, setTabOrder] = useState<string[]>([]);
const [isUrlBarFocused, setIsUrlBarFocused] = useState(false);
const urlTextRef = useRef<HTMLInputElement>(null);
const wsDiscoveryRef = useRef<WebSocket | null>(null);
const activeConnectionRetries = useRef<Record<string, number>>({});
const singlePageMode = false;
const interactive = true;
// Initialize tab-discovery ws
useEffect(() => {
if (singlePageMode) return;
const ws = new WebSocket(webSocketUrl.browserWs + "?tabInfo=true");
wsDiscoveryRef.current = ws;
ws.onopen = () => setConnectionStatus("online");
ws.onclose = () => setConnectionStatus("offline");
ws.onerror = () => setConnectionStatus("offline");
ws.onmessage = (event) => {
const payload = JSON.parse(event.data);
if (payload.type === "tabList" && payload.tabs) {
handleTabList(payload.tabs, payload.firstTabId);
} else if (payload.type === "tabClosed" && payload.pageId) {
handleTabClosed(payload.pageId);
} else if (payload.type === "activeTabChange" && payload.pageId) {
setActiveTabId(payload.pageId);
}
};
return () => ws.close();
// eslint-disable-next-line
}, [webSocketUrl.browserWs]);
// When activating tab, close previous tab's ws and create new ws
useEffect(() => {
if (!activeTabId) return;
const tab = tabs[activeTabId];
if (!tab) return;
if (tab.ws) return; // Already has ws
connectTabWebSocket(activeTabId);
// eslint-disable-next-line
}, [activeTabId, tabs]);
// Handle tabList, automatically create/activate tabs
const handleTabList = useCallback((tabList: any[], firstTabId?: string) => {
const newTabs: Record<string, Tab> = {};
const order: string[] = [];
tabList.forEach((tab) => {
newTabs[tab.id] = {
id: tab.id,
url: tab.url,
title: tab.title,
favicon: tab.favicon,
ws: null,
receivedFirstFrame: false,
lastImageData: null,
isLoading: false,
frameCount: 0,
canvasRef: React.createRef(),
containerRef: React.createRef(),
currentImageWidth: defaultWidth,
currentImageHeight: defaultHeight,
reconnecting: false,
intentionalClose: false,
error: false,
};
order.push(tab.id);
});
setTabs(newTabs);
setTabOrder(order);
if (firstTabId && newTabs[firstTabId]) {
setActiveTabId(firstTabId);
} else if (tabList.length > 0) {
setActiveTabId(tabList[0].id);
}
}, []);
// Close tab
const handleTabClosed = useCallback(
(pageId: string) => {
setTabs((prev) => {
const updated = { ...prev };
if (updated[pageId]?.ws) updated[pageId].ws?.close();
delete updated[pageId];
return updated;
});
setTabOrder((prev) => prev.filter((id) => id !== pageId));
if (activeTabId === pageId) {
const tabIds = tabOrder.filter((id) => id !== pageId);
if (tabIds.length > 0) setActiveTabId(tabIds[0]);
else setActiveTabId(null);
}
},
[activeTabId, tabOrder],
);
// Update tab information
const updateTabInfo = useCallback(
(pageId: string, url: string, title: string, favicon: string | null) => {
setTabs((prev) => ({
...prev,
[pageId]: {
...prev[pageId],
url,
title,
favicon,
},
}));
},
[],
);
// Connect tab ws
const connectTabWebSocket = (pageId: string) => {
setTabs((prev) => {
if (!prev[pageId]) return prev;
return {
...prev,
[pageId]: {
...prev[pageId],
isLoading: true,
error: false,
reconnecting: true,
},
};
});
const ws = new WebSocket(
webSocketUrl.browserWs + `?pageId=${encodeURIComponent(pageId)}`,
);
ws.onopen = () => {
setTabs((prev) => {
if (!prev[pageId]) return prev;
return {
...prev,
[pageId]: {
...prev[pageId],
ws,
isLoading: false,
error: false,
reconnecting: false,
frameCount: 0,
},
};
});
setConnectionStatus("online");
};
ws.onclose = () => {
setTabs((prev) => {
if (!prev[pageId]) return prev;
return {
...prev,
[pageId]: {
...prev[pageId],
isLoading: false,
error: true,
reconnecting: false,
ws: null,
},
};
});
setConnectionStatus("offline");
};
ws.onerror = () => {
setTabs((prev) => {
if (!prev[pageId]) return prev;
return {
...prev,
[pageId]: {
...prev[pageId],
isLoading: false,
error: true,
reconnecting: false,
},
};
});
setConnectionStatus("offline");
};
ws.onmessage = (event) => {
const payload = JSON.parse(event.data);
if (payload.type === "tabUpdate") {
updateTabInfo(
pageId,
payload.url || "",
payload.title || "",
payload.favicon || null,
);
} else if (payload.type === "targetClosed") {
handleTabClosed(pageId);
}
if (payload.data) {
renderCanvasImage(
pageId,
payload.data,
payload.url,
payload.title,
payload.favicon,
);
}
};
setTabs((prev) => {
if (!prev[pageId]) return prev;
return {
...prev,
[pageId]: {
...prev[pageId],
ws,
},
};
});
};
// Render image to canvas
const renderCanvasImage = (
pageId: string,
imageData: string,
url?: string,
title?: string,
favicon?: string,
) => {
setTabs((prev) => {
const updated = { ...prev };
if (!updated[pageId]) return updated;
updated[pageId].receivedFirstFrame = true;
updated[pageId].lastImageData = imageData.startsWith(
"data:image/jpeg;base64,",
)
? imageData
: `data:image/jpeg;base64,${imageData}`;
updated[pageId].isLoading = false;
updated[pageId].error = false;
if (url && !isUrlBarFocused) updated[pageId].url = url;
if (title) updated[pageId].title = title;
if (favicon) updated[pageId].favicon = favicon;
updated[pageId].frameCount++;
return updated;
});
setTimeout(() => {
const tab = tabs[pageId];
const canvas = tab?.canvasRef.current;
if (!canvas) return;
const ctx = canvas.getContext("2d", { alpha: false });
if (!ctx) return;
const img = new window.Image();
img.src = imageData.startsWith("data:image/jpeg;base64,")
? imageData
: `data:image/jpeg;base64,${imageData}`;
img.onload = () => {
setTabs((prev) => {
const updated = { ...prev };
if (!updated[pageId]) return updated;
updated[pageId].currentImageWidth = img.naturalWidth;
updated[pageId].currentImageHeight = img.naturalHeight;
return updated;
});
const dpr = window.devicePixelRatio || 1;
const container = tab?.containerRef.current;
const targetHeight = container?.clientHeight || defaultHeight;
const targetWidth =
targetHeight * (img.naturalWidth / img.naturalHeight);
canvas.width = targetWidth * dpr;
canvas.height = targetHeight * dpr;
ctx.setTransform(1, 0, 0, 1, 0, 0);
ctx.scale(dpr, dpr);
canvas.style.height = "100%";
canvas.style.width = "auto";
ctx.clearRect(0, 0, canvas.width, canvas.height);
ctx.drawImage(
img,
0,
0,
Math.floor(canvas.width / dpr),
Math.floor(canvas.height / dpr),
);
};
}, 0);
};
// Canvas native event binding
useEffect(() => {
if (!activeTabId || activeKey !== "3") return;
const tab = tabs[activeTabId];
if (!tab) return;
const canvas = tab.canvasRef.current;
if (!canvas) return;
// Mouse events
const getScaledCoordinates = (e: MouseEvent) => {
const rect = canvas.getBoundingClientRect();
const scaleX = tab.currentImageWidth / rect.width;
const scaleY = tab.currentImageHeight / rect.height;
return {
x: Math.max(
0,
Math.min(
Math.round((e.clientX - rect.left) * scaleX),
tab.currentImageWidth,
),
),
y: Math.max(
0,
Math.min(
Math.round((e.clientY - rect.top) * scaleY),
tab.currentImageHeight,
),
),
};
};
const handleMouse = (e: MouseEvent, type: string) => {
if (!tab.ws || tab.ws.readyState !== WebSocket.OPEN) return;
const coords = getScaledCoordinates(e);
const modifiers =
(e.ctrlKey ? 2 : 0) |
(e.shiftKey ? 8 : 0) |
(e.altKey ? 1 : 0) |
(e.metaKey ? 4 : 0);
let button = "none";
if (type === "mousePressed" || type === "mouseReleased") {
button = e.button === 0 ? "left" : e.button === 1 ? "middle" : "right";
}
const eventData = JSON.stringify({
type: "mouseEvent",
pageId: activeTabId,
event: {
type,
x: coords.x,
y: coords.y,
button,
modifiers,
clickCount: (e as any).detail || 1,
},
});
// console.warn("Mouse Event:", {
// eventString: eventData,
// currentUrl: tab.url,
// pageTitle: tab.title,
// currentBase64Data: tab.lastImageData,
// });
tab.ws.send(eventData);
};
let moveTimeout: any = null;
const handleMouseMove = (e: MouseEvent) => {
if (moveTimeout) clearTimeout(moveTimeout);
moveTimeout = setTimeout(() => handleMouse(e, "mouseMoved"), 20);
};
const handleWheel = (e: WheelEvent) => {
if (!tab.ws || tab.ws.readyState !== WebSocket.OPEN) return;
const coords = getScaledCoordinates(e as any);
const modifiers =
(e.ctrlKey ? 2 : 0) |
(e.shiftKey ? 8 : 0) |
(e.altKey ? 1 : 0) |
(e.metaKey ? 4 : 0);
const eventData = JSON.stringify({
type: "mouseEvent",
pageId: activeTabId,
event: {
type: "mouseWheel",
x: coords.x,
y: coords.y,
button: "none",
modifiers,
deltaX: e.deltaX,
deltaY: e.deltaY,
},
});
// console.warn("Wheel Event:", {
// eventString: eventData,
// currentUrl: tab.url,
// pageTitle: tab.title,
// currentBase64Data: tab.lastImageData,
// });
tab.ws.send(eventData);
e.preventDefault();
};
canvas.addEventListener("mousedown", (e) => handleMouse(e, "mousePressed"));
canvas.addEventListener("mouseup", (e) => handleMouse(e, "mouseReleased"));
canvas.addEventListener("mousemove", handleMouseMove);
canvas.addEventListener("wheel", handleWheel, { passive: false });
// Keyboard events (global)
const handleKey = (e: KeyboardEvent, type: "keyDown" | "keyUp") => {
if (document.activeElement === urlTextRef.current) return;
if (!tab.ws || tab.ws.readyState !== WebSocket.OPEN) return;
const eventData = JSON.stringify({
type: "keyEvent",
pageId: activeTabId,
event: {
type,
text: e.key.length === 1 ? e.key : undefined,
code: e.code,
key: e.key,
keyCode: e.keyCode,
},
});
// console.warn("Browser Keyboard Event:", {
// eventString: eventData,
// currentUrl: tab.url,
// pageTitle: tab.title,
// currentBase64Data: tab.lastImageData,
// key: e.key,
// code: e.code,
// });
tab.ws.send(eventData);
};
const keydown = (e: KeyboardEvent) => handleKey(e, "keyDown");
const keyup = (e: KeyboardEvent) => handleKey(e, "keyUp");
document.addEventListener("keydown", keydown);
document.addEventListener("keyup", keyup);
return () => {
canvas.removeEventListener("mousedown", (e) =>
handleMouse(e, "mousePressed"),
);
canvas.removeEventListener("mouseup", (e) =>
handleMouse(e, "mouseReleased"),
);
canvas.removeEventListener("mousemove", handleMouseMove);
canvas.removeEventListener("wheel", handleWheel);
document.removeEventListener("keydown", keydown);
document.removeEventListener("keyup", keyup);
};
}, [activeTabId, tabs]);
// Address bar input
const handleUrlSubmit = (e: React.FormEvent) => {
e.preventDefault();
if (!urlTextRef.current || !activeTabId) return;
const url = urlTextRef.current.value;
handleNavigation("url", url);
urlTextRef.current.blur();
};
// Navigation buttons
const handleNavigation = (
action: "back" | "forward" | "refresh" | "url",
url?: string,
) => {
if (!activeTabId || !tabs[activeTabId]?.ws) return;
const ws = tabs[activeTabId].ws;
if (ws.readyState !== WebSocket.OPEN) return;
setTabs((prev) => ({
...prev,
[activeTabId]: {
...prev[activeTabId],
isLoading: true,
frameCount: 0,
},
}));
const eventData = JSON.stringify({
type: "navigation",
pageId: activeTabId,
event: action === "url" ? { url } : { action },
});
// TODO: push event
console.warn("Navigation Event:", {
eventString: eventData,
currentUrl: tabs[activeTabId].url,
pageTitle: tabs[activeTabId].title,
currentBase64Data: tabs[activeTabId].lastImageData,
action,
targetUrl: url,
});
ws.send(eventData);
if (action === "url" && url) {
window.parent.postMessage(
{
type: "navigation",
url,
},
"*",
);
}
};
// Security lock icon
const isSecure = (url: string) =>
url &&
(url.toLowerCase().startsWith("https://") ||
url.toLowerCase().startsWith("https:"));
// UI
return (
<div className="container">
<div className="browser-chrome">
<div className="tab-bar" id="tab-bar">
<div
className={`connection-status ${connectionStatus}`}
id="connection-status"
>
<div className={`status-indicator ${connectionStatus}`}></div>
<span>
{connectionStatus === "online"
? "Session Online"
: connectionStatus === "offline"
? "Session Offline"
: "Session Connecting..."}
</span>
</div>
{tabOrder.map((id) => {
const tab = tabs[id];
return (
<div
key={id}
className={`tab${activeTabId === id ? " active" : ""}${
tab.isLoading ? " loading" : ""
}`}
onClick={() => setActiveTabId(id)}
>
<img
className="tab-favicon"
src={tab.favicon || ""}
style={{ display: tab.favicon ? "block" : "none" }}
alt=""
/>
<div className="tab-favicon-spinner"></div>
<div className="tab-title">{tab.title || "New Tab"}</div>
<div
className="tab-close"
onClick={(e) => {
e.stopPropagation();
handleTabClosed(id);
}}
>
&times;
</div>
</div>
);
})}
</div>
<div className="address-bar">
<div className="nav-buttons">
<button
className="nav-button"
onClick={() => handleNavigation("back")}
disabled={!activeTabId}
>
<svg className="icon" viewBox="0 0 24 24">
<path d="M20 11H7.83l5.59-5.59L12 4l-8 8 8 8 1.41-1.41L7.83 13H20v-2z" />
</svg>
</button>
<button
className="nav-button"
onClick={() => handleNavigation("forward")}
disabled={!activeTabId}
>
<svg className="icon" viewBox="0 0 24 24">
<path d="M12 4l-1.41 1.41L16.17 11H4v2h12.17l-5.58 5.59L12 20l8-8-8-8z" />
</svg>
</button>
<button
className="nav-button"
onClick={() => handleNavigation("refresh")}
disabled={!activeTabId}
>
<svg className="icon" viewBox="0 0 24 24">
<path d="M17.65 6.35C16.2 4.9 14.21 4 12 4c-4.42 0-7.99 3.58-7.99 8s3.57 8 7.99 8c3.73 0 6.84-2.55 7.73-6h-2.08c-.82 2.33-3.04 4-5.65 4-3.31 0-6-2.69-6-6s2.69-6 6-6c1.66 0 3.14.69 4.22 1.78L13 11h7V4l-2.35 2.35z" />
</svg>
</button>
</div>
<form className="url-bar" onSubmit={handleUrlSubmit}>
<div
className={`url-security-icon${
isSecure(tabs[activeTabId || ""]?.url || "") ? " secure" : ""
}`}
id="url-security-icon"
>
<svg
viewBox="0 0 24 24"
id="lock-icon"
style={{
display: isSecure(tabs[activeTabId || ""]?.url || "")
? "block"
: "none",
}}
>
<path d="M18 8h-1V6c0-2.76-2.24-5-5-5S7 3.24 7 6v2H6c-1.1 0-2 .9-2 2v10c0 1.1.9 2 2 2h12c1.1 0 2-.9 2-2V10c0-1.1-.9-2-2-2zm-6 9c-1.1 0-2-.9-2-2s.9-2 2-2 2 .9 2 2-.9 2-2 2zm3.1-9H8.9V6c0-1.71 1.39-3.1 3.1-3.1 1.71 0 3.1 1.39 3.1 3.1v2z" />
</svg>
<svg
viewBox="0 0 24 24"
id="unlock-icon"
style={{
display: isSecure(tabs[activeTabId || ""]?.url || "")
? "none"
: "block",
}}
>
<path d="M12 17c1.1 0 2-.9 2-2s-.9-2-2-2-2 .9-2 2 .9 2 2 2zm6-9h-1V6c0-2.76-2.24-5-5-5S7 3.24 7 6h1.9c0-1.71 1.39-3.1 3.1-3.1 1.71 0 3.1 1.39 3.1 3.1v2H6c-1.1 0-2 .9-2 2v10c0 1.1.9 2 2 2h12c1.1 0 2-.9 2-2V10c0-1.1-.9-2-2-2zm0 12H6V10h12v10z" />
</svg>
</div>
<input
type="text"
id="url-text"
className="url-input"
ref={urlTextRef}
value={tabs[activeTabId || ""]?.url || ""}
onChange={(e) => {
if (!activeTabId || activeKey !== "3") return;
setTabs((prev) => ({
...prev,
[activeTabId]: {
...prev[activeTabId],
url: e.target.value,
},
}));
}}
onFocus={() => setIsUrlBarFocused(true)}
onBlur={() => setIsUrlBarFocused(false)}
disabled={!activeTabId}
/>
</form>
</div>
</div>
<div className="content">
{tabOrder.map((id) => {
const tab = tabs[id];
return (
<div
key={id}
ref={tab.containerRef}
className={`canvas-container${
activeTabId === id ? " active" : ""
}${tab.isLoading ? " loading" : ""}${tab.error ? " error" : ""}`}
style={{
display: activeTabId === id ? "flex" : "none",
width: "100%",
height: "100%",
position: "relative",
}}
>
<canvas
ref={tab.canvasRef}
className="canvas"
width={defaultWidth}
height={defaultHeight}
style={{ height: "100%", width: "auto" }}
tabIndex={0}
/>
</div>
);
})}
</div>
</div>
);
};
export default memo(Browser);

View File

@@ -0,0 +1,80 @@
import { Message, MessageRole, MessageState } from "@/types/message";
import React from "react";
import ReactMarkdown from "react-markdown";
import remarkGfm from "remark-gfm";
import styles from "./Message.module.scss";
interface BaseMessageProps {
message: Message;
children: React.ReactNode;
onFeedback?: (messageId: string, feedback: "like" | "dislike" | null) => void;
}
export const BaseMessage: React.FC<BaseMessageProps> = ({
message,
children,
onFeedback,
}) => {
const isAssistant = message.role === MessageRole.ASSISTANT;
const isRunning = message.status === MessageState.RUNNING;
const isWaiting = message.status === MessageState.WAITING;
const isError = message.status === MessageState.ERROR;
const renderContent = (content: React.ReactNode) => {
if (typeof content === "string") {
return (
<div className={styles.markdown}>
<ReactMarkdown remarkPlugins={[remarkGfm]}>{content}</ReactMarkdown>
</div>
);
}
return content;
};
return (
<div
className={`${styles.message} ${
isAssistant ? styles.assistant : styles.user
}`}
>
<div className={styles.content}>
{renderContent(children)}
{isRunning && <div className={styles.loading}>...</div>}
{isWaiting && (
<div className={styles.waiting}>Waiting for user selection...</div>
)}
{isError && <div className={styles.error}>An error occurred</div>}
</div>
{isAssistant && onFeedback && (
<div className={styles.feedback}>
<button
className={`${styles.feedbackBtn} ${
message.feedback === "like" ? styles.active : ""
}`}
onClick={() =>
onFeedback(
message.id,
message.feedback === "like" ? null : "like",
)
}
>
👍
</button>
<button
className={`${styles.feedbackBtn} ${
message.feedback === "dislike" ? styles.active : ""
}`}
onClick={() =>
onFeedback(
message.id,
message.feedback === "dislike" ? null : "dislike",
)
}
>
👎
</button>
</div>
)}
</div>
);
};

View File

@@ -0,0 +1,79 @@
import {
ClarificationMessage as ClarificationType,
SelectionType,
} from "@/types/message";
import React, { useState } from "react";
import styles from "./Message.module.scss";
interface ClarificationMessageProps {
message: ClarificationType;
onSelect?: (options: string[]) => void;
}
export const ClarificationMessage: React.FC<ClarificationMessageProps> = ({
message,
onSelect,
}) => {
const [selectedOptions, setSelectedOptions] = useState<string[]>([]);
const handleOptionClick = (option: string) => {
if (message.selection_type === SelectionType.SINGLE) {
setSelectedOptions([option]);
onSelect?.([option]);
} else {
const newSelection = selectedOptions.includes(option)
? selectedOptions.filter((item) => item !== option)
: [...selectedOptions, option];
setSelectedOptions(newSelection);
}
};
const handleConfirm = () => {
if (
message.selection_type === SelectionType.MULTIPLE &&
selectedOptions.length > 0
) {
onSelect?.(selectedOptions);
}
};
return (
// <BaseMessage message={message} onFeedback={onFeedback}>
<div className={styles.clarificationMessage}>
{message.content && (
<div className={styles.question}>{message.content}</div>
)}
{Array.isArray(message?.options) && message.options.length > 0 && (
<div className={styles.options}>
{message.options?.map((option, index) => (
<button
key={index}
className={`${styles.option} ${
selectedOptions.includes(option) ? styles.selected : ""
}`}
onClick={(e) => {
e.stopPropagation();
handleOptionClick(option);
}}
disabled={
selectedOptions.length > 0 && !selectedOptions.includes(option)
}
>
{option}
</button>
))}
</div>
)}
{message.selection_type === SelectionType.MULTIPLE && (
<button
className={styles.confirmButton}
onClick={handleConfirm}
disabled={selectedOptions.length === 0}
>
Confirm
</button>
)}
</div>
// {/* </BaseMessage> */}
);
};

View File

@@ -0,0 +1,83 @@
import React, { useState, memo } from "react";
import { SparkDoubleRightLine } from "@agentscope-ai/icons";
import styles from "./Message.module.scss";
import type { Message as MessageType } from "@/types/message";
import ReactMarkdown from "react-markdown";
import remarkGfm from "remark-gfm";
import { markdownRegex, codeBlockRegex } from "@/utils/constant";
const CollapsibleMessage: React.FC<{ message: MessageType }> = ({
message,
}) => {
const messageContent = message.content;
const [expandedStates, setExpandedStates] = useState<Record<string, boolean>>(
{},
);
const handleExpand = (messageId: string) => {
setExpandedStates((prev) => ({
...prev,
[messageId]: !prev[messageId],
}));
};
if (messageContent === null || messageContent === undefined) {
return null;
}
if (typeof messageContent !== "string") {
return JSON.stringify(messageContent, null, 2);
}
let content = messageContent;
if (markdownRegex.test(messageContent)) {
content = messageContent?.match(markdownRegex)?.[1] || messageContent;
} else if (codeBlockRegex.test(messageContent)) {
content = messageContent?.match(codeBlockRegex)?.[1] || messageContent;
}
const lines = content.split("\n");
const showLines = 10;
const isExpanded = expandedStates[message.id];
// Determine if we should show full content
const shouldShowFullContent =
message.isGenerating || lines.length <= showLines || isExpanded;
// Calculate visible content
const visibleContent = shouldShowFullContent
? content
: lines.slice(0, showLines).join("\n");
return (
<div className={styles.collapsibleContent}>
<div className={styles.markdown}>
<ReactMarkdown remarkPlugins={[remarkGfm]}>
{visibleContent}
</ReactMarkdown>
</div>
{!shouldShowFullContent && (
<div
className={`${styles.gradientWrapper} ${
isExpanded ? styles.expanded : ""
}`}
>
<button
className={styles.collapseButton}
onClick={(e) => {
e.stopPropagation();
handleExpand(message.id);
}}
>
<SparkDoubleRightLine
className={`${styles.arrow} ${
isExpanded ? styles.up : styles.down
}`}
/>
</button>
</div>
)}
</div>
);
};
export default memo(CollapsibleMessage);

View File

@@ -0,0 +1,145 @@
import iconDoc from "@/assets/icons/files/doc.svg";
import iconFile from "@/assets/icons/files/file.svg";
import iconHtml from "@/assets/icons/files/html.svg";
import iconImage from "@/assets/icons/files/image.svg";
import iconPdf from "@/assets/icons/files/pdf.svg";
import iconXml from "@/assets/icons/files/xml.svg";
import { FileItem } from "@/types/message";
import { formatFileSize } from "@/utils/fileNameUtils";
import React from "react";
import { useLocation, useParams } from "react-router-dom";
import styles from "./Message.module.scss";
interface FileItemsProps {
files: FileItem[];
}
const getFileIcon = (filename: string) => {
if (!filename) return iconFile;
const ext = filename.split(".").pop()?.toLowerCase();
switch (ext) {
case "jpg":
return iconImage;
case "jpeg":
return iconImage;
case "png":
return iconImage;
case "gif":
return iconImage;
case "pdf":
return iconPdf;
case "doc":
return iconDoc;
case "docx":
return iconDoc;
case "html":
return iconHtml;
case "xml":
return iconXml;
default:
return iconFile;
}
};
const getFileTypeText = (filename: string) => {
if (!filename) return "FILE";
const ext = filename.split(".").pop()?.toUpperCase();
if (ext === "XLS" || ext === "XLSX") return "XLS";
if (ext === "PDF") return "PDF";
if (ext === "DOC" || ext === "DOCX") return "DOC";
if (["JPG", "JPEG", "PNG", "GIF"].includes(ext || "")) return "IMG";
return ext || "FILE";
};
export const FileItems: React.FC<FileItemsProps> = ({ files }) => {
const location = useLocation();
const isSharePage = location.pathname.includes("/share/");
const { sessionId, userId } = useParams<{
userId: string;
sessionId: string;
}>();
if (!files || !Array.isArray(files) || files.length === 0) {
return null;
}
const handlePreview = (file: FileItem) => {
if (!file.id) return;
const baseUrl = import.meta.env.VITE_API_URL || "http://localhost:8000";
// const endpoint = isSharePage ? "public" : "preview";
// const url = `${baseUrl}/api/v1/files/${file.id}/${endpoint}`;
let url: string;
if (isSharePage && userId && sessionId) {
url = `${baseUrl}/api/v1/share/conversations/${userId}/${sessionId}/files/${file.id}/public`;
} else {
url = `${baseUrl}/api/v1/files/${file.id}/preview`;
}
// If not share page, need to add authentication header
if (!isSharePage) {
const token =
localStorage.getItem("access_token") ||
import.meta.env.VITE_API_ACCESS_TOKEN ||
import.meta.env.VITE_API_TOKEN;
if (!token) {
console.error("No access token available");
return;
}
// Create a request with authentication header
fetch(url, {
headers: {
Authorization: `Bearer ${token}`,
},
})
.then((response) => response.blob())
.then((blob) => {
const objectUrl = URL.createObjectURL(blob);
window.open(objectUrl, "_blank");
// Clean up URL
setTimeout(() => URL.revokeObjectURL(objectUrl), 1000);
})
.catch((error) => {
console.error("Failed to preview file:", error);
});
} else {
// Share page opens link directly
window.open(url, "_blank");
}
};
return (
<>
{files.map((file, index) => (
<div key={index} className={styles.fileItem}>
<div className={styles.fileLeft}>
<img
src={getFileIcon(file?.filename || "")}
className={styles.fileIcon}
alt="file icon"
/>
</div>
<div className={styles.fileInfoBlock}>
<div className={styles.fileName} title={file?.filename}>
{file?.filename || "Unknown file"}
</div>
<div className={styles.fileSize}>
{getFileTypeText(file?.filename || "")}{" "}
{formatFileSize(file?.size || 0)}
</div>
</div>
{file?.id && (
<button
onClick={(e) => {
e.stopPropagation();
handlePreview(file);
}}
className={styles.downloadBtn}
>
Preview
</button>
)}
</div>
))}
</>
);
};

View File

@@ -0,0 +1,25 @@
import React from "react";
import { FilesMessage as FilesMessageType } from "@/types/message";
import { BaseMessage } from "./BaseMessage";
import styles from "./Message.module.scss";
import { FileItems } from "./FileItems";
interface FilesMessageProps {
message: FilesMessageType;
onFeedback?: (messageId: string, feedback: "like" | "dislike" | null) => void;
}
export const FilesMessage: React.FC<FilesMessageProps> = ({
message,
onFeedback,
}) => {
return (
<BaseMessage message={message} onFeedback={onFeedback}>
<div className={styles.filesMessage}>
<div className={styles.filesList}>
<FileItems files={message.files} />
</div>
</div>
</BaseMessage>
);
};

View File

@@ -0,0 +1,888 @@
.messageWrapper {
display: flex;
flex-direction: column;
gap: 8px;
align-items: flex-start;
margin: 16px 0;
}
.messageContent {
flex: 1;
width: 100%; // Ensure container does not exceed parent width
max-width: 100%; // Limit maximum width
overflow-x: hidden; // Prevent content overflow
}
.avatar {
width: 24px;
height: 24px;
border-radius: 50%;
overflow: hidden;
flex-shrink: 0;
img {
width: 100%;
height: 100%;
object-fit: cover;
}
}
.assistantTitle {
font-weight: 500;
}
.response {
font-family: "Alibaba PuHuiTi 2.0";
font-size: 13px;
line-height: 20px;
letter-spacing: 0.2px;
color: var(--text-primary);
display: flex;
align-items: flex-start;
gap: 8px;
width: 100%;
position: relative;
width: 100%; // Ensure container does not exceed parent width
max-width: 100%; // Limit maximum width
overflow-x: hidden; // Prevent content overflow
}
.thought {
font-size: 12px;
line-height: 18px;
color: #a6a6a6;
padding-left: 12px;
border-left: 1px solid #a6a6a6;
margin: 4px 0 4px 4px;
width: calc(100% - 16px);
}
// Add a container to wrap all sub_response
.subResponsesContainer {
position: relative;
display: flex;
flex-direction: column;
gap: 8px;
width: 100%; // Ensure container does not exceed parent width
max-width: 100%; // Limit maximum width
overflow-x: hidden; // Prevent content overflow
color: var(--text-primary);
}
.subResponse {
position: relative;
display: flex;
gap: 8px;
align-items: center;
padding: 4px 0;
width: 100%;
overflow: hidden;
transition: background-color 0.2s ease;
&:hover {
background-color: rgba(0, 0, 0, 0.02); // Add hover effect
}
.icon {
width: 24px;
height: 24px;
z-index: 1;
position: relative;
flex-shrink: 0;
}
.content {
display: flex;
align-items: flex-start;
gap: 4px;
min-width: 0;
flex: 1;
overflow: hidden;
}
}
.expandButton {
width: 16px;
height: 16px;
padding: 2px;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
margin-right: auto;
order: -1;
img {
width: 12px;
height: 12px;
transition: transform 0.2s ease;
transform: rotate(0deg);
}
&.expanded img {
transform: rotate(180deg);
}
}
.subMessageItem {
margin-left: 32px;
&.hidden {
display: none;
}
.thought {
padding: 8px;
background: var(--sidebar-conversation-bg);
border-radius: 4px;
border: none;
margin: 0;
}
}
.toolCallWrapper {
// margin-left: 32px;
margin-bottom: 8px;
}
.icon {
width: 16px;
height: 16px;
flex-shrink: 0;
margin-top: 2px;
}
.responseGroup {
display: flex;
flex-direction: column;
gap: 12px;
}
.subMessages {
display: flex;
flex-direction: column;
position: relative;
&::before {
content: "";
position: absolute;
left: 8px;
top: 0;
bottom: 0;
width: 1px;
background-color: #e0e0e0;
}
}
.subResponseContainer {
display: flex;
flex-direction: column;
gap: 4px;
margin-bottom: 8px;
&:last-child {
margin-bottom: 0;
}
}
.messageItem {
border: 2px solid transparent;
border-radius: 8px;
transition: all 0.2s ease;
margin: 4px 0;
&.selectedMessage {
background-color: var(--message-selected-bg, rgba(37, 99, 235, 0.05));
border-color: var(--message-selected-border, #2563eb);
}
// Can add mouse hover effect
&:hover {
background-color: var(--message-hover-bg, rgba(37, 99, 235, 0.02));
}
}
.messageContainer {
display: flex;
flex-direction: column;
flex: 1;
min-width: 0;
}
.agentContentWrapper {
padding: 16px;
display: flex;
flex-direction: column;
gap: 12px;
}
.mainResponse {
color: var(--text-primary);
white-space: pre-wrap;
line-height: 1.6;
}
.toolCall {
margin-left: 24px;
background-color: rgba(0, 0, 0, 0.02);
border-radius: 4px;
padding: 8px;
}
.expandableContent {
max-height: 0;
overflow: hidden;
transition: max-height 0.3s ease-out;
opacity: 0;
&.expanded {
max-height: 1000px;
opacity: 1;
transition: max-height 0.3s ease-in, opacity 0.2s ease-in;
}
}
.toolSection,
.fileSection,
.thoughtSection,
.clarificationSection {
margin-top: 12px;
padding: 12px;
background-color: rgba(0, 0, 0, 0.02);
border-radius: 6px;
}
.thoughtSection {
color: #666;
font-style: italic;
}
.fileSection {
.fileItem {
display: flex;
align-items: center;
gap: 8px;
padding: 8px;
background-color: rgba(0, 0, 0, 0.02);
border-radius: 6px;
margin-bottom: 8px;
&:last-child {
margin-bottom: 0;
}
}
}
.clarificationSection {
.optionsContainer {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.optionButton {
padding: 6px 12px;
border-radius: 6px;
background-color: rgba(0, 0, 0, 0.02);
border: 1px solid rgba(0, 0, 0, 0.1);
cursor: pointer;
transition: all 0.2s;
&:hover:not(:disabled) {
background-color: rgba(0, 0, 0, 0.05);
}
&:disabled {
cursor: not-allowed;
opacity: 0.5;
}
&.selected {
background-color: rgba(0, 0, 0, 0.1);
border-color: rgba(0, 0, 0, 0.2);
color: rgba(0, 0, 0, 0.8);
font-weight: 500;
&:disabled {
opacity: 1; // Selected button remains fully opaque
}
}
transform: rotate(180deg);
}
}
.messageFooter {
display: flex;
align-items: center;
gap: 8px;
margin-top: 8px;
padding-left: 16px;
font-size: 12px;
}
.timestamp {
font-size: 12px;
color: #9ca3af;
margin-top: 4px;
}
.status {
padding: 2px 8px;
border-radius: 4px;
font-size: 12px;
&.running {
background: #e6f4ff;
color: #1677ff;
}
&.error {
background: #fff2f0;
color: #ff4d4f;
}
&.waiting {
background: #f5f5f5;
color: #666;
}
}
.feedback {
display: flex;
flex-direction: row; // Changed to left to right
justify-content: flex-start; // Ensure left alignment
margin: 0 12px;
}
.textContent {
line-height: 1.5;
}
.files {
display: flex;
flex-direction: column;
gap: 8px;
}
.filesMessage {
padding: 16px 0;
background: none;
}
.filesList {
display: flex;
flex-wrap: wrap;
gap: 16px;
}
.fileItem {
position: relative;
display: flex;
align-items: center;
background: var(--message-fileItem-bg);
border-radius: 12px;
box-shadow: 0 1px 6px 0 rgba(99, 102, 241, 0.06);
padding: 10px 18px;
min-width: 200px;
max-width: 260px;
margin-bottom: 0;
transition: box-shadow 0.2s, border 0.2s, background 0.2s;
border: 1px solid var(--sps-color-border-secondary);
&:hover,
&.selected {
background: var(--message-content);
box-shadow: 0 2px 12px 0 rgba(99, 102, 241, 0.12);
}
}
.fileLeft {
display: flex;
align-items: center;
margin-right: 5px;
}
.fileIcon {
width: 28px;
height: 28px;
margin-right: 3px;
}
.fileInfoBlock {
display: flex;
flex-direction: column;
flex: 1;
min-width: 0;
}
.fileName {
font-weight: 600;
color: var(--message-fileItem-text);
font-size: 15px;
line-height: 1.2;
margin-bottom: 4px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.fileSize {
color: #60a5fa;
font-size: 13px;
font-weight: 400;
}
.downloadBtn {
position: absolute;
right: 12px;
bottom: 10px;
text-decoration: none;
font-weight: 600;
color: #6366f1;
font-size: 13px;
margin-top: auto;
transition: color 0.2s;
&:hover {
color: #4338ca;
text-decoration: none;
}
}
@keyframes blink {
0%,
100% {
opacity: 0;
}
50% {
opacity: 1;
}
}
.markdown {
// Basic text styles
font-size: 14px;
line-height: 1.4;
// Ensure container does not exceed parent width
width: 100%;
// Limit maximum width
max-width: 100%;
// Heading styles
h1,
h2,
h3,
h4,
h5,
h6 {
margin: 0.3em 0 0.1em;
font-weight: 600;
line-height: 1.2;
}
h1 {
font-size: 1.4em;
}
h2 {
font-size: 1.2em;
}
h3 {
font-size: 1.1em;
}
h4 {
font-size: 1em;
}
h5 {
font-size: 0.9em;
}
h6 {
font-size: 0.8em;
}
// Paragraph styles
p {
margin: 0;
line-height: 1.4;
&+p {
margin-top: 0.2em;
}
}
// List styles
ul,
ol {
margin: 0.1em 0;
padding-left: 1.2em;
}
li {
// display: flex;
// flex-direction: column;
margin: 0;
padding: 0;
line-height: 1.4;
p {
margin: 0;
display: inline-block;
}
&+li {
margin-top: 0.1em;
}
}
// Code styles
code {
padding: 0.1em 0.2em;
margin: 0;
font-size: 85%;
background-color: rgba(175, 184, 193, 0.2);
border-radius: 4px;
font-family: ui-monospace, SFMono-Regular, SF Mono, Menlo, Consolas,
Liberation Mono, monospace;
}
pre {
padding: 8px;
margin: 0.2em 0;
font-size: 85%;
line-height: 1.4;
background-color: rgba(175, 184, 193, 0.2);
border-radius: 4px;
code {
padding: 0;
margin: 0;
background-color: transparent;
border: 0;
// display: block; // Make code block occupy a single line
width: 100%;
// Allow code to wrap
white-space: pre-wrap;
// Ensure long words can wrap
word-wrap: break-word;
}
}
// Quote styles
blockquote {
margin: 0.2em 0;
padding: 0 0.5em;
color: #656d76;
border-left: 0.2em solid #d0d7de;
}
// Table styles
table {
margin: 0.2em 0;
border-spacing: 0;
border-collapse: collapse;
width: 100%;
th,
td {
padding: 4px 8px;
border: 1px solid #d0d7de;
}
tr {
background-color: #ffffff;
border-top: 1px solid #d0d7de;
&:nth-child(2n) {
background-color: #f6f8fa;
}
}
}
// Image styles
img {
max-width: 100%;
height: auto;
margin: 0.2em 0;
}
// Divider styles
hr {
height: 1px;
margin: 0.2em 0;
background-color: #d0d7de;
border: 0;
}
// Link styles
a {
color: #0969da;
text-decoration: none;
&:hover {
text-decoration: underline;
}
}
// Specifically handle spaces created by line breaks
br {
// Changed to inline display
display: inline;
// Preserve line break effect
white-space: pre;
margin: 0;
padding: 0;
height: 0.3em;
visibility: visible;
}
// Handle consecutive line breaks
br+br {
display: none;
}
// Handle line breaks in paragraphs
p {
br {
display: inline;
white-space: pre;
height: 0.3em;
}
}
// Handle line breaks in list items
li {
br {
display: inline;
white-space: pre;
height: 0.3em;
}
}
// Task list styles
input[type="checkbox"] {
margin: 0 0.3em 0 0;
vertical-align: middle;
}
// Task list item styles
li:has(input[type="checkbox"]) {
list-style: none;
margin-left: -1.2em;
padding-left: 1.2em;
display: flex;
align-items: flex-start;
gap: 0.2em;
p {
margin: 0;
flex: 1;
}
}
// Remove extra spacing from first and last elements
*:first-child {
margin-top: 0;
}
*:last-child {
margin-bottom: 0;
}
}
.thinkingContent {
display: flex;
align-items: center;
gap: 8px;
color: #666;
}
.thinkingDots {
display: flex;
gap: 4px;
margin-left: 4px;
span {
width: 6px;
height: 6px;
background-color: #666;
border-radius: 50%;
animation: thinking 1.4s infinite ease-in-out;
&:nth-child(1) {
animation-delay: 0s;
}
&:nth-child(2) {
animation-delay: 0.2s;
}
&:nth-child(3) {
animation-delay: 0.4s;
}
}
}
.thinkingText {
font-size: 14px;
}
@keyframes thinking {
0%,
80%,
100% {
transform: scale(0.6);
opacity: 0.6;
}
40% {
transform: scale(1);
opacity: 1;
}
}
.responseGroup {
display: flex;
flex-direction: column;
gap: 8px;
}
.response {
font-size: 14px;
line-height: 1.6;
white-space: pre-wrap;
word-break: break-word;
}
.subMessages {
display: flex;
flex-direction: column;
gap: 8px;
}
.clarificationMessage {
border-radius: 20px;
opacity: 1;
background: var(--message-clarification-bg);
padding: 11px 12px;
margin-bottom: 12px;
.question {
font-family: PingFang SC;
font-size: 14px;
font-weight: normal;
line-height: 20px;
letter-spacing: 0px;
font-variation-settings: "opsz" auto;
color: var(--text-secondary);
margin-bottom: 12px;
}
.options {
display: flex;
flex-direction: column;
gap: 4px;
width: 100%;
.option {
width: 100%;
border-radius: 12px;
opacity: 1;
background: var(--message-option-bg);
box-sizing: border-box;
border: 1px solid var(--message-option-border);
padding: 10px 30px;
text-align: left;
cursor: pointer;
transition: all 0.2s ease;
color: var(--text-primary);
&:hover {
background: var(--message-option-bg-hover);
border-color: #2563eb;
}
&.selected {
background: var(--message-option-bg-hover);
border-color: #2563eb;
color: #2563eb;
}
&:disabled {
cursor: not-allowed;
opacity: 0.5;
}
}
}
.confirmButton {
margin-top: 12px;
padding: 8px 16px;
border-radius: 8px;
background: #2563eb;
color: var(--text-primary);
border: none;
cursor: pointer;
float: right;
&:disabled {
opacity: 0.5;
cursor: not-allowed;
}
&:hover:not(:disabled) {
background: #1d4ed8;
}
}
}
.collapsibleContent {
position: relative;
}
.gradientWrapper {
position: relative;
height: 30px;
background: linear-gradient(to bottom,
rgba(255, 255, 255, 0) 0%,
rgba(255, 255, 255, 0.1) 50%,
rgba(255, 255, 255, 0.3) 100%);
}
.collapseButton {
width: 100%;
height: 30px;
background: transparent;
border: none;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
}
.arrow {
color: #666;
font-size: 12px;
transition: transform 0.2s ease;
}
.arrow.up {
transform: rotate(-90deg);
}
.arrow.down {
transform: rotate(90deg);
}
/* Mouse hover effect */
.collapseButton:hover .arrow {
color: #333;
}

View File

@@ -0,0 +1,266 @@
import AssistantAvatar from "@/assets/icons/avatar/assistantHeader.png";
import correctCheckIcon from "@/assets/icons/check/correct-checkCircle-line.svg";
import pendingCheckIcon from "@/assets/icons/check/pending-checkCircle-line.svg";
import type {
ClarificationMessage,
FilesMessage,
Message as MessageType,
ToolCallMessage,
} from "@/types/message";
import {
MessageRole,
MessageState,
MessageType as MsgType,
} from "@/types/message";
import { Flex } from "antd";
import React, { memo, useMemo, useState } from "react";
import { useLocation } from "react-router-dom";
import { ClarificationMessage as ClarificationMessageComponent } from "./ClarificationMessage";
import CollapsibleMessage from "./CollapsibleMessage";
import { FilesMessage as FilesMessageComponent } from "./FilesMessage";
import styles from "./Message.module.scss";
import { ToolCallMessage as ToolCallMessageComponent } from "./ToolCallMessage";
import { UserMessage } from "./UserMessage";
interface MessageProps {
message: MessageType;
onClarificationSelect?: (options: string[]) => void;
isReplayMode?: boolean;
isGenerating?: boolean;
messages?: MessageType[];
}
const deduplicateMessages = (messages: MessageType[]): MessageType[] => {
const result: MessageType[] = [];
let consecutiveSubResponses: MessageType[] = [];
// Handle consecutive SUB_RESPONSE
const processConsecutiveSubResponses = () => {
if (consecutiveSubResponses.length > 0) {
// Deduplicate consecutive SUB_RESPONSE
const uniqueMessages = new Map<string, MessageType>();
consecutiveSubResponses.forEach((msg) => {
const key = `${msg.name}-${msg.content}`;
if (uniqueMessages.has(key)) {
const existingMsg = uniqueMessages.get(key)!;
if (
new Date(msg.create_time).getTime() >
new Date(existingMsg.create_time).getTime()
) {
uniqueMessages.set(key, msg);
}
} else {
uniqueMessages.set(key, msg);
}
});
// Add deduplicated messages to result
result.push(...Array.from(uniqueMessages.values()));
consecutiveSubResponses = []; // Clear temporary array
}
};
messages.forEach((msg, index) => {
if (msg.type === MsgType.SUB_RESPONSE) {
consecutiveSubResponses.push(msg);
} else {
// When encountering non-SUB_RESPONSE message, process previously collected consecutive SUB_RESPONSE
processConsecutiveSubResponses();
// Add non-SUB_RESPONSE message
result.push(msg);
}
});
// Process last group of consecutive SUB_RESPONSE
processConsecutiveSubResponses();
return result;
};
export const Message: React.FC<MessageProps> = ({
message,
onClarificationSelect,
isReplayMode = false,
isGenerating = false,
messages = [],
}) => {
const isUser = message.role === MessageRole.USER;
const [openPopoverId, setOpenPopoverId] = useState<string | null>(null);
const location = useLocation();
const isSharePage = location.pathname.includes("/share/");
const renderAvatar = () => {
return (
<Flex gap="middle">
<div className={styles.avatar}>
<img src={AssistantAvatar} alt="Assistant" />
</div>
<div className={styles.assistantTitle}>Alias Agent</div>
</Flex>
);
};
// Render directly if it's a user message
if (isUser && message.type === MsgType.USER)
return <UserMessage message={message} />;
// For agent messages, only render the entire group for the first message
const isFirstMessage =
messages.find(
(msg) =>
msg.role === MessageRole.ASSISTANT &&
msg.parent_message_id === message.parent_message_id,
)?.id === message.id;
if (!isFirstMessage) {
return null;
}
// Modify renderStatus function
const renderStatus = () => {
if (!message.isGenerating) {
return null;
}
if (
message.status !== MessageState.WAITING &&
message.status !== MessageState.ERROR
) {
return (
<div className={`${styles.status} ${styles.running}`}>
Generating...
</div>
);
}
return (
<div
className={`${styles.status} ${styles[message.status.toLowerCase()]}`}
>
{message.status === MessageState.WAITING && "Waiting..."}
{message.status === MessageState.ERROR && "Generation failed"}
</div>
);
};
const renderSingleMessage = (
msg: MessageType,
allMessages: MessageType[],
) => {
switch (msg.type) {
case MsgType.RESPONSE:
return (
<div key={msg.id} className={styles.response}>
<div className={styles.content}>
<CollapsibleMessage message={msg} />
</div>
</div>
);
case MsgType.THOUGHT:
return (
<div key={msg.id} className={styles.thought}>
<CollapsibleMessage message={msg} />
</div>
);
case MsgType.SUB_RESPONSE:
return (
<div key={msg.id}>
<div className={styles.subResponse}>
<img
src={
msg.status === MessageState.FINISHED
? correctCheckIcon
: pendingCheckIcon
}
alt="status"
className={styles.icon}
/>
<div className={styles.content}>
<CollapsibleMessage message={msg} />
</div>
</div>
</div>
);
case MsgType.SUB_THOUGHT:
return (
<div className={styles.subMessageItem}>
<div key={msg.id} className={styles.thought}>
<CollapsibleMessage message={msg} />
</div>
</div>
);
case MsgType.TOOL_CALL:
case MsgType.TOOL_USE:
case MsgType.TOOL_RESULT:
return (
<div
key={msg.id}
className={styles.toolCallWrapper}
id={`message-toolcall-${msg.id}`}
>
<ToolCallMessageComponent message={msg as ToolCallMessage} />
</div>
);
case MsgType.CLARIFICATION:
if (!msg.content && (msg?.options?.length === 0 || !msg?.options))
return null;
return (
<div key={msg.id} className={styles.clarificationMessage}>
<ClarificationMessageComponent
message={msg as ClarificationMessage}
onSelect={onClarificationSelect}
/>
</div>
);
case MsgType.FILES:
return (
<div key={msg.id} className={styles.fileSection}>
<FilesMessageComponent message={msg as FilesMessage} />
</div>
);
default:
return null;
}
};
// Get all non-user messages with the same parent_message_id
const relatedMessages = useMemo(() => {
const filteredMessages = messages.filter(
(msg) =>
msg.role === MessageRole.ASSISTANT &&
msg.parent_message_id === message.parent_message_id,
);
// Deduplicate messages
const uniqueMessages = deduplicateMessages(filteredMessages);
return uniqueMessages;
}, [messages, message.parent_message_id]);
if (relatedMessages && relatedMessages.length === 0) {
return null;
}
return (
<div className={styles.messageWrapper}>
{renderAvatar()}
<div className={styles.messageContent}>
{relatedMessages.map((msg, index) => {
return (
<div
className={`${styles.messageItem} ${
openPopoverId === msg.id ? styles.selectedMessage : ""
}`}
key={msg.id}
>
{renderSingleMessage(msg, relatedMessages)}
</div>
);
})}
<div className={styles.messageFooter}>{renderStatus()}</div>
</div>
</div>
);
};
export default memo(Message);

View File

@@ -0,0 +1,21 @@
.messageList {
flex: 1;
overflow-y: auto;
&::-webkit-scrollbar {
width: 6px;
}
&::-webkit-scrollbar-track {
background: transparent;
}
&::-webkit-scrollbar-thumb {
background-color: #e5e7eb;
border-radius: 3px;
&:hover {
background-color: #d1d5db;
}
}
}

View File

@@ -0,0 +1,195 @@
import { MessageState, Message as MessageType } from "@/types/message";
import { isAtBottom } from "@/utils/sharedRefs";
import React, {
memo,
useEffect,
useLayoutEffect,
useMemo,
useRef,
useState,
} from "react";
import Message from "./Message";
import styles from "./MessageList.module.scss";
import { ThinkingMessage } from "./ThinkingMessage";
interface MessageListProps {
messages?: MessageType[];
onClarificationSelect?: (options: string[]) => void;
onAddMessage?: (message: MessageType) => void;
isThinking?: boolean;
isReplayMode?: boolean;
isGenerating?: boolean;
currentStep?: number;
ScrollToBottomButtonRef?: any;
currentConversationId?: string;
startTimer: () => void;
}
export const MessageList: React.FC<MessageListProps> = ({
messages = [],
onClarificationSelect,
onAddMessage,
isThinking = false,
isReplayMode = false,
isGenerating = false,
currentStep,
currentConversationId,
ScrollToBottomButtonRef = useRef<any>(),
startTimer = () => {},
}) => {
const messagesEndRef = useRef<HTMLDivElement>(null);
const [displayedMessages, setDisplayedMessages] = useState<MessageType[]>([]);
const [currentMessageIndex, setCurrentMessageIndex] = useState(0);
const [toBottomBtn, setToBottomBtn] = useState(true);
const messageListRef = useRef<HTMLDivElement>(null);
const scrollToBottom = () => {
// console.log(
// ScrollToBottomButtonRef.current,
// "ScrollToBottomButtonRef.current"
// );
if (ScrollToBottomButtonRef.current) {
// console.log("--------");
ScrollToBottomButtonRef.current.scrollToBottom("auto");
}
// messagesEndRef.current?.scrollIntoView({ behavior: "smooth" });
};
// Modify message processing logic, add conversation ID filtering
const processedMessages = useMemo(() => {
if (!Array.isArray(messages)) {
return [];
}
// First filter out messages belonging to current conversation
// const currentConversationMessages = messages.filter(msg =>
// !currentConversationId || msg.conversation_id === currentConversationId
// );
// Sort by time
const sortedMessages = [...messages].sort(
(a, b) =>
new Date(a.create_time).getTime() - new Date(b.create_time).getTime(),
);
// Merge consecutive running messages
return sortedMessages.reduce((acc: MessageType[], curr, index) => {
// Add directly if it's the first message
if (index === 0) {
return [curr];
}
const prevMessage = acc[acc.length - 1];
const isSameConversation =
curr.conversation_id === prevMessage.conversation_id;
const isPrevRunning = prevMessage.status === MessageState.RUNNING;
const isCurrRunning = curr.status === MessageState.RUNNING;
const isSameType = curr.type === prevMessage.type;
// If previous message is running state, and current message is also running or finished state
// and is the same type of message from the same conversation, update the previous message
if (
isSameConversation &&
isPrevRunning &&
isSameType &&
(isCurrRunning || curr.status === MessageState.FINISHED)
) {
acc[acc.length - 1] = {
...curr,
id: prevMessage.id, // Keep original id to maintain React key stability
};
} else {
acc.push(curr);
}
return acc;
}, []);
}, [messages, currentConversationId]);
// Message streaming output in replay mode
useEffect(() => {
// console.log(messages,"processedMessages")
if (!isReplayMode) {
setDisplayedMessages(processedMessages);
return;
}
if (currentMessageIndex < processedMessages.length) {
const currentMessage = processedMessages[currentMessageIndex];
const messageWithStreaming = {
...currentMessage,
status: MessageState.RUNNING,
};
setDisplayedMessages((prev) => [...prev, messageWithStreaming]);
// Simulate streaming output
const timer = setTimeout(() => {
setDisplayedMessages((prev) => {
const newMessages = [...prev];
newMessages[newMessages.length - 1] = {
...currentMessage,
status: MessageState.FINISHED,
};
return newMessages;
});
setCurrentMessageIndex((prev) => prev + 1);
}, 500); // Display each message for 0.5 seconds
return () => clearTimeout(timer);
}
}, [isReplayMode, currentMessageIndex, processedMessages]);
// Reset state when message list changes
useEffect(() => {
if (isReplayMode) {
setCurrentMessageIndex(0);
setDisplayedMessages([]);
} else {
setDisplayedMessages(processedMessages);
}
}, [messages, isReplayMode, processedMessages]);
// Update displayed messages when currentStep changes
useEffect(() => {
if (currentStep !== undefined) {
const messagesToShow = processedMessages.slice(0, currentStep);
setDisplayedMessages(messagesToShow);
setCurrentMessageIndex(currentStep);
}
}, [currentStep, processedMessages]);
const shouldScrollRef = useRef(isAtBottom.current);
useEffect(() => {
// Sync latest scroll condition to ref
shouldScrollRef.current = isAtBottom.current;
}, [isAtBottom.current]);
useLayoutEffect(() => {
// Exit directly if scrolling is not needed
if (!shouldScrollRef.current) return;
startTimer();
}, [displayedMessages, isThinking, startTimer]);
useEffect(() => {
if (toBottomBtn && displayedMessages.length > 0) {
scrollToBottom();
setToBottomBtn(false);
}
}, [displayedMessages, isThinking]);
return (
<div className={styles.messageList} ref={messageListRef}>
{displayedMessages.map((message) => (
<Message
key={message.id}
message={message}
messages={displayedMessages}
onClarificationSelect={onClarificationSelect}
isReplayMode={isReplayMode}
isGenerating={isGenerating}
/>
))}
{isThinking && <ThinkingMessage />}
<div ref={messagesEndRef} />
</div>
);
};
export default memo(MessageList);

View File

@@ -0,0 +1,26 @@
import React from "react";
import { ResponseMessage as ResponseMessageType } from "@/types/message";
import { BaseMessage } from "./BaseMessage";
import styles from "./Message.module.scss";
import ReactMarkdown from "react-markdown";
import remarkGfm from "remark-gfm";
interface ResponseMessageProps {
message: ResponseMessageType;
onFeedback?: (messageId: string, feedback: "like" | "dislike" | null) => void;
}
export const ResponseMessage: React.FC<ResponseMessageProps> = ({
message,
onFeedback,
}) => {
return (
<BaseMessage message={message} onFeedback={onFeedback}>
<div className={styles.markdown}>
<ReactMarkdown remarkPlugins={[remarkGfm]}>
{message.content}
</ReactMarkdown>
</div>
</BaseMessage>
);
};

View File

@@ -0,0 +1,23 @@
import React from "react";
import { SubResponseMessage as SubResponseMessageType } from "@/types/message";
import { BaseMessage } from "./BaseMessage";
import styles from "./Message.module.scss";
interface SubResponseMessageProps {
message: SubResponseMessageType;
onFeedback?: (messageId: string, feedback: "like" | "dislike" | null) => void;
}
export const SubResponseMessage: React.FC<SubResponseMessageProps> = ({
message,
onFeedback,
}) => {
return (
<BaseMessage message={message} onFeedback={onFeedback}>
<div className={styles.subResponseMessage}>
{/* <img src={iconCheckbox} alt="checkbox" className={styles.checkbox} /> */}
<div className={styles.subResponseContent}>{message.content}</div>
</div>
</BaseMessage>
);
};

View File

@@ -0,0 +1,22 @@
import React from "react";
import { SubThoughtMessage as SubThoughtMessageType } from "@/types/message";
import { BaseMessage } from "./BaseMessage";
import styles from "./Message.module.scss";
interface SubThoughtMessageProps {
message: SubThoughtMessageType;
onFeedback?: (messageId: string, feedback: "like" | "dislike" | null) => void;
}
export const SubThoughtMessage: React.FC<SubThoughtMessageProps> = ({
message,
onFeedback,
}) => {
return (
<BaseMessage message={message} onFeedback={onFeedback}>
<div className={styles.subThoughtMessage}>
<div className={styles.subThoughtContent}>{message.content}</div>
</div>
</BaseMessage>
);
};

View File

@@ -0,0 +1,19 @@
import React from "react";
import { SystemMessage as SystemMessageType } from "@/types/message";
import { BaseMessage } from "./BaseMessage";
interface SystemMessageProps {
message: SystemMessageType;
onFeedback?: (messageId: string, feedback: "like" | "dislike" | null) => void;
}
export const SystemMessage: React.FC<SystemMessageProps> = ({
message,
onFeedback,
}) => {
return (
<BaseMessage message={message} onFeedback={onFeedback}>
<div>{message.content}</div>
</BaseMessage>
);
};

View File

@@ -0,0 +1,20 @@
import React from "react";
import styles from "./Message.module.scss";
import AssistantAvatar from "@/assets/icons/avatar/assistantHeader.png";
import { Flex } from "antd";
export const ThinkingMessage: React.FC = () => {
return (
<Flex gap="middle" align="center">
<div className={styles.avatar}>
<img src={AssistantAvatar} alt="Agent" />
</div>
<div className={styles.thinkingText}>Agent is thinking</div>
<div className={styles.thinkingDots}>
<span></span>
<span></span>
<span></span>
</div>
</Flex>
);
};

View File

@@ -0,0 +1,16 @@
import React from "react";
import { ThoughtMessage as ThoughtMessageType } from "@/types/message";
interface ThoughtMessageProps {
message: ThoughtMessageType;
onFeedback?: (messageId: string, feedback: "like" | "dislike" | null) => void;
}
export const ThoughtMessage: React.FC<ThoughtMessageProps> = ({ message }) => {
return (
<div className="flex items-start gap-2 p-2 bg-gray-50 rounded">
<span className="text-lg">💭</span>
<div className="whitespace-pre-wrap">{message.content}</div>
</div>
);
};

View File

@@ -0,0 +1,18 @@
.toolCallMessage {
background-color: var(--sps-color-fill-tertiary);
border-radius: 8px;
.toolCallHeader {
height: 32px;
line-height: 32px;
padding: 0 12px;
cursor: pointer;
}
.arguments {
border-left: solid 1px var(--sps-color-border-secondary);
margin: 0px 12px 12px 20px;
}
.contents {
white-space: pre-wrap;
margin-left: 16px;
}
}

View File

@@ -0,0 +1,121 @@
import { useWorkspace } from "@/context/WorkspaceContext.tsx";
import type { ToolCallMessage as ToolCallMessageType } from "@/types/message";
import {
SparkBrowseLine,
SparkDownLine,
SparkLocalFileLine,
SparkToolLine,
SparkUpLine,
} from "@agentscope-ai/icons";
import { Flex } from "antd";
import React, { memo, useEffect, useState } from "react";
import styles from "./index.module.scss";
interface ToolCallMessageProps {
message: ToolCallMessageType;
onFeedback?: (messageId: string, feedback: "like" | "dislike" | null) => void;
}
export const ToolCallMessage: React.FC<ToolCallMessageProps> = memo(
({ message }) => {
const { setDisplayedContent, setActiveKey, setArgs, setMessageList } =
useWorkspace();
const [isExpanded, setIsExpanded] = useState(false);
useEffect(() => {
setDisplayedContent(message.content);
setArgs(message?.arguments);
setMessageList((prev) => {
// Filter out old messages with same id
const filtered = prev.filter(
(m) => m.id !== message.id && m.content !== message.content,
);
// Add new message
return [...filtered, message];
});
}, [message]); // Depend on entire message object
const getIcon = () => {
// Determine icon based on tool name if icon is not specified
if (!message.icon) {
const toolName =
("tool_name" in message ? message.tool_name : undefined) ||
message.name ||
"";
if (toolName.toLowerCase().includes("browser")) {
return <SparkBrowseLine />;
}
if (toolName.toLowerCase().includes("file")) {
return <SparkLocalFileLine />;
}
} else {
switch (message.icon) {
case "browser":
return <SparkBrowseLine />;
case "file":
return <SparkLocalFileLine />;
default:
return <SparkToolLine />;
}
}
return <SparkToolLine />;
};
const getToolName = () => {
if (message.type === "tool_use")
return `Using Tool: ${message?.tool_name || message.name}`;
if (message.type === "tool_result")
return `Tool result: ${message?.tool_name || message.name}`;
return message?.tool_name || message.name;
};
const getToolArguments = () => {
const argContents = JSON.stringify(message.arguments, null, 2);
if (!argContents || argContents === "{}" || argContents === "{ }") {
try {
const content = JSON.parse(message.content);
if (Array.isArray(content) && content.length > 0) {
const output = content[0]?.output;
if (typeof output === "string") {
return output;
}
if (Array.isArray(output) && output.length > 0) {
return JSON.stringify(output?.[0], null, 2);
}
}
} catch (error) {
// Return original content or empty string if parsing fails
console.error("Failed to parse message content:", error);
return message.content || "";
}
}
return argContents;
};
return (
<Flex vertical className={styles.toolCallMessage}>
<Flex
gap="small"
justify="space-between"
className={styles.toolCallHeader}
onClick={(e) => {
e.stopPropagation();
setIsExpanded(!isExpanded);
setDisplayedContent(message.content);
setArgs(message.arguments);
setActiveKey("1");
}}
>
<Flex gap="small">
{getIcon()}
{getToolName()}
</Flex>
{!isExpanded ? <SparkDownLine /> : <SparkUpLine />}
</Flex>
{isExpanded && message.arguments && (
<div className={styles.arguments}>
<div className={styles.contents}>{getToolArguments()}</div>
</div>
)}
</Flex>
);
},
);

View File

@@ -0,0 +1,74 @@
.userMessageFlex {
margin-bottom: 24px;
width: 100%;
display: flex;
flex-direction: column;
align-items: flex-end;
.userMessageText {
width: 100%;
}
.userContentFlex {
display: flex;
flex-direction: column;
background-color: var(--sps-color-primary-bg);
padding: 12px 16px;
border-radius: 8px;
max-width: 80%;
}
}
.roadmapCard {
display: flex;
align-items: center;
border-radius: 8px;
cursor: pointer;
box-shadow: 0 1px 6px 0 rgba(99, 102, 241, 0.06);
padding: 10px 18px;
min-width: 140px;
max-width: 260px;
margin-bottom: 0;
background-color: var(--sps-color-bg-base);
box-sizing: border-box;
border: 1px solid var(--sps-color-border-secondary);
&:hover,
&.selected {
box-shadow: 0 2px 12px 0 rgba(99, 102, 241, 0.12);
}
}
.roadmapLeft {
display: flex;
align-items: center;
margin-right: 8px;
padding: 8px;
border-radius: 8px;
background-color: var(--sps-color-fill-tertiary);
}
.roadmapRight {
display: flex;
flex-direction: column;
flex: 1;
min-width: 0;
.description {
color: var(--sps-color-text-quaternary);
}
.title {
font-size: 14px;
color: var(--sps-color-text);
font-weight: 500;
}
}
.diffRoadmap {
height: 600px;
border-top: 1px solid var(--sps-color-border-secondary);
.left {
border-right: 1px solid var(--sps-color-border-secondary);
}
.diffRoadmapJson {
width: 50%;
height: 100%;
overflow: auto;
padding: 20px 18px;
}
}

View File

@@ -0,0 +1,107 @@
import { useTheme } from "@/context/ThemeContext";
import { UserMessage as UserMessageType } from "@/types/message";
import { Modal } from "@agentscope-ai/design";
import { SparkProcessJudgmentLine } from "@agentscope-ai/icons";
import { Flex } from "antd";
import React, { useState } from "react";
import { Prism as SyntaxHighlighter } from "react-syntax-highlighter";
import { oneDark, prism } from "react-syntax-highlighter/dist/esm/styles/prism";
import { FileItems } from "../FileItems";
import styles from "./index.module.scss";
interface UserMessageProps {
message: UserMessageType;
}
export const UserMessage: React.FC<UserMessageProps> = ({ message }) => {
const { files, roadmap } = message;
const { theme } = useTheme();
const [diffOpen, setDiffOpen] = useState(false);
const fontSize = { fontSize: 20 };
const viewRoadmapDiff = () => {
setDiffOpen(true);
};
const onCancel = () => {
setDiffOpen(false);
};
const customStyle = {
borderTopRightRadius: 0,
borderTopLeftRadius: 0,
overflow: "auto",
padding: 0,
backgroundColor: "var(--sps-color-bg-base)",
};
const CodeView = ({ value }: { value: string }) => {
return (
<SyntaxHighlighter
language="JSON"
showLineNumbers={true}
wrapLines={true}
style={theme === "dark" ? oneDark : prism}
customStyle={
theme === "dark"
? customStyle
: { ...customStyle, background: "transparent" }
}
>
{value}
</SyntaxHighlighter>
);
};
return (
<Flex gap="small" vertical className={styles.userMessageFlex}>
<Flex justify="flex-end" className={styles.userMessageText}>
<div className={styles.userContentFlex}>{message.content}</div>
</Flex>
{files && files?.length > 0 && (
<Flex wrap gap="small" justify="flex-end">
<FileItems files={files} />
</Flex>
)}
{roadmap && (
<Flex justify="flex-end">
<div className={styles.roadmapCard} onClick={viewRoadmapDiff}>
<SparkProcessJudgmentLine
className={styles.roadmapLeft}
style={fontSize}
/>
<div className={styles.roadmapRight}>
<div className={styles.title}>Roadmap</div>
<div className={styles.description}>{`${
roadmap.current?.subtasks?.length || 0
} Tasks`}</div>
</div>
</div>
</Flex>
)}
{diffOpen && roadmap && (
<Modal
width={960}
open={diffOpen}
showDivider={false}
title="Roadmap Diff"
footer={null}
onCancel={onCancel}
styles={{
body: { padding: 0 },
}}
>
<Flex className={styles.diffRoadmap}>
<div className={`${styles.left} ${styles.diffRoadmapJson}`}>
<div>Previous JSON</div>
{roadmap.previous && (
<CodeView value={JSON.stringify(roadmap.previous, null, 2)} />
)}
</div>
<div className={styles.diffRoadmapJson}>
<div>Current JSON</div>
{roadmap.current && (
<CodeView value={JSON.stringify(roadmap.current, null, 2)} />
)}
</div>
</Flex>
</Modal>
)}
</Flex>
);
};

View File

@@ -0,0 +1,34 @@
.modalWrap {
display: grid;
place-items: center;
h1 {
color: var(--sps-color-text);
font-weight: 600;
font-size: 2rem;
line-height: 1.5;
word-wrap: break-word;
text-align: center;
}
.tips {
width: 336px;
text-align: center;
color: var(--sps-color-text-secondary);
font-size: 0.875rem;
line-height: 1.25rem;
margin-top: 5px;
}
.logBtn {
height: 2.5rem;
width: 336px;
margin-top: 30px;
font-size: 100%;
opacity: 1;
}
.registerBtn {
height: 2.5rem;
width: 336px;
margin-top: 10px;
font-size: 100%;
border-width: 1px;
}
}

View File

@@ -0,0 +1,63 @@
import { Button, Modal } from "@agentscope-ai/design";
import { memo, useState } from "react";
import { useNavigate } from "react-router-dom";
import styles from "./index.module.scss";
const LoginModal = () => {
const [isModalOpen, setIsModalOpen] = useState<boolean>(true);
const navigate = useNavigate();
const showModal = () => {
setIsModalOpen(true);
};
const handleOk = () => {
setIsModalOpen(false);
};
const handleCancel = () => {
setIsModalOpen(false);
};
if (
localStorage.getItem("access_token") === null &&
localStorage.getItem("refresh_token") === null
)
return (
<Modal
// title="Basic Modal"
open={isModalOpen}
onOk={handleOk}
onCancel={handleCancel}
footer={null}
centered={true}
width={384}
closable={false}
>
<div className={styles.modalWrap}>
<h1>Welcome</h1>
<p className={styles.tips}>
Login or register to chat with AgentScope, upload files and images,
generate images or videos, etc.
</p>
<Button
type="primary"
className={styles.logBtn}
onClick={() => {
navigate("/login?mode=login");
}}
>
Login
</Button>
<Button
className={styles.registerBtn}
onClick={() => {
navigate("/login?mode=register");
}}
>
Register
</Button>
</div>
</Modal>
);
return null;
};
export default memo(LoginModal);

View File

@@ -0,0 +1,32 @@
import type { GetProps } from "antd";
import Icon from "@ant-design/icons";
type CustomIconComponentProps = GetProps<typeof Icon>;
const LogoIconSvg = () => (
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
version="1.1"
width="77"
height="48"
viewBox="0 0 77 48"
>
<g>
<g>
<g>
<path
d="M37.5018,17.8196C36.7176,14.3736,33.9865,11.68249,30.4893,10.90978C33.9865,10.13707,36.7176,7.44597,37.5018,4C38.286,7.44597,41.0171,10.13707,44.5143,10.90978C41.0171,11.68249,38.286,14.3736,37.5018,17.8196ZM9.877,13.9301L0,35.7632L5.26642,35.7632L7.23737,31.0924L17.5179,31.0924L19.4889,35.7632L24.8438,35.7632L14.9481,13.9301L9.877,13.9301ZM15.8999,27.258L12.3777,18.911099999999998L8.85542,27.258L15.8999,27.258ZM26.199,14.4227L26.199,35.7632L31.137,35.7632L31.137,14.4227L26.199,14.4227ZM54.4086,33.6971L54.4086,35.7632L59.0503,35.7632L59.0503,26.179Q59.0503,22.3578,56.9028,20.5531Q54.7553,18.747999999999998,50.846,18.747999999999998Q48.8202,18.747999999999998,46.8616,19.266Q44.903,19.7836,43.4984,20.7758L45.2616,24.1619Q46.1929,23.444,47.51,23.0293Q48.8276,22.6141,50.1709,22.6141Q52.179,22.6141,53.1454,23.4762Q54.1123,24.3378,54.1123,25.9056L54.1123,25.9085L50.1877,25.9085Q47.5926,25.9085,45.9788,26.5484Q44.3654,27.1878,43.6255,28.3141Q42.8856,29.4398,42.8856,30.9233Q42.8856,32.3703,43.6423,33.5243Q44.399,34.677800000000005,45.8234,35.339200000000005Q47.2479,36,49.2346,36Q51.4821,36,52.907,35.1584Q53.858,34.5968,54.4086,33.6971ZM54.1123,30.456L54.1123,28.7439L50.7352,28.7439Q48.9844,28.7439,48.338,29.3005Q47.6915,29.8565,47.6915,30.7245Q47.6915,31.6368,48.4275,32.187Q49.1634,32.7367,50.4513,32.7367Q51.6952,32.7367,52.6849,32.168Q53.6746,31.5993,54.1123,30.456ZM64.4221,35.484899999999996Q66.4316,36,68.59,36Q71.1579,36,72.9513,35.3177Q74.7452,34.6349,75.6884,33.4317Q76.6316,32.2284,76.6316,30.6811Q76.6316,29.2532,76.0742,28.3584Q75.5168,27.4632,74.6018,26.949Q73.6868,26.4344,72.5883,26.1571Q71.4898,25.8798,70.3913,25.7302Q69.2928,25.5801,68.3778,25.4017Q67.4628,25.2233,66.9054,24.9046Q66.348,24.5859,66.348,23.967Q66.348,23.2974,67.1062,22.8597Q67.8644,22.4221,69.5312,22.4221Q70.6965,22.4221,71.9399,22.6872Q73.1838,22.9523,74.4178,23.6697L76.0747,20.1891Q74.8664,19.4907,73.072,19.119300000000003Q71.2781,18.747999999999998,69.5124,18.747999999999998Q67.0395,18.747999999999998,65.2807,19.439999999999998Q63.5224,20.1316,62.5822,21.3475Q61.6425,22.5634,61.6425,24.1478Q61.6425,25.5947,62.1999,26.4992Q62.7573,27.4032,63.6718,27.9271Q64.5868,28.4505,65.6917,28.722Q66.7966,28.9934,67.8951,29.1431Q68.9941,29.2927,69.9091,29.4525Q70.8241,29.6119,71.381,29.915Q71.9384,30.2181,71.9384,30.7995Q71.9384,31.5125,71.2212,31.9195Q70.5046,32.325900000000004,68.8056,32.325900000000004Q67.2353,32.325900000000004,65.6338,31.8829Q64.0323,31.4394,62.8745,30.7221L61.2176,34.2027Q62.4131,34.9698,64.4221,35.484899999999996ZM35.0938,20.7869L35.0938,35.7632L40.0318,35.7632L40.0318,20.7869L35.0938,20.7869Z"
fillRule="evenodd"
fill="currentColor"
fillOpacity="1"
/>
</g>
</g>
</g>
</svg>
);
const LogoIcon = (props: Partial<CustomIconComponentProps>) => (
<Icon component={LogoIconSvg} {...props} />
);
export default LogoIcon;

View File

@@ -0,0 +1,28 @@
.roadmap {
width: 100%;
max-height: calc(100vh - 123px);
height: 100%;
overflow-y: auto;
border-top: solid 1px var(--sps-color-border-secondary);
padding: 16px 20px;
.title {
font-size: 18px;
font-weight: 500;
line-height: 32px;
letter-spacing: normal;
}
.roadmapList {
border-radius: 8px;
border: solid 1px var(--sps-color-border-secondary);
margin-top: 16px;
}
.listItem {
padding: 12px;
}
.itemFlex {
width: 100%;
}
.success {
color: var(--sps-color-text-tertiary);
}
}

View File

@@ -0,0 +1,375 @@
import React, {
useState,
useEffect,
useContext,
useMemo,
createContext,
memo,
} from "react";
import diff from "deep-diff";
import {
Button,
message,
Input,
AlertDialog,
IconButton,
Modal,
} from "@agentscope-ai/design";
import { List, Flex, GetProps } from "antd";
import {
SparkPlusLine,
SparkSaveLine,
SparkDeleteLine,
SparkEditLine,
SparkDragDotLine,
SparkIncompleteLine,
SparkLoadingLine,
SparkCheckCircleLine,
SparkProcessFailedLine,
} from "@agentscope-ai/icons";
import classNames from "classnames";
import styles from "./index.module.scss";
import { SubtasksProps, RoadMapDataProps, RoadMapType } from "@/types/roadmap";
import { conversationApi } from "@/services/api/conversation";
import type { DragEndEvent, DraggableAttributes } from "@dnd-kit/core";
import { DndContext } from "@dnd-kit/core";
import type { SyntheticListenerMap } from "@dnd-kit/core/dist/hooks/utilities";
import { restrictToVerticalAxis } from "@dnd-kit/modifiers";
import {
arrayMove,
SortableContext,
useSortable,
verticalListSortingStrategy,
} from "@dnd-kit/sortable";
import { CSS } from "@dnd-kit/utilities";
interface RoadmapProps {
data?: RoadMapDataProps | null;
conversationId: string;
editable?: boolean;
onSave?: (data: RoadMapDataProps) => void;
}
interface SortableListItemContextProps {
setActivatorNodeRef?: (element: HTMLElement | null) => void;
listeners?: SyntheticListenerMap;
attributes?: DraggableAttributes;
}
const fontSize = { fontSize: 20 };
const SortableListItemContext = createContext<SortableListItemContextProps>({});
const DragHandle: React.FC = () => {
const { setActivatorNodeRef, listeners, attributes } = useContext(
SortableListItemContext,
);
return (
<Button
type="text"
size="small"
icon={<SparkDragDotLine />}
style={{ cursor: "move" }}
ref={setActivatorNodeRef}
{...attributes}
{...listeners}
/>
);
};
const SortableListItem: React.FC<
GetProps<typeof List.Item> & { itemKey: number }
> = (props) => {
const { itemKey, style, ...rest } = props;
const {
attributes,
listeners,
setNodeRef,
setActivatorNodeRef,
transform,
transition,
isDragging,
} = useSortable({ id: itemKey });
const listStyle: React.CSSProperties = {
...style,
transform: CSS.Translate.toString(transform),
transition,
...(isDragging ? { position: "relative", zIndex: 9999 } : {}),
};
const memoizedValue = useMemo<SortableListItemContextProps>(
() => ({ setActivatorNodeRef, listeners, attributes }),
[setActivatorNodeRef, listeners, attributes],
);
return (
<SortableListItemContext.Provider value={memoizedValue}>
<List.Item {...rest} ref={setNodeRef} style={listStyle} />
</SortableListItemContext.Provider>
);
};
const ReadListItem: React.FC<
GetProps<typeof List.Item> & { item: SubtasksProps }
> = ({ item }) => {
const { state, description } = item || {};
return (
<List.Item className={styles.listItem}>
<Flex
gap="small"
align="center"
className={classNames(styles.itemFlex, {
[styles.success]: state === RoadMapType.DONE,
})}
>
<Flex gap="small">
{state === RoadMapType.IN_PROGRESS && (
<SparkLoadingLine style={{ ...fontSize, color: "#0B83F1" }} />
)}
{state === RoadMapType.DONE && (
<SparkCheckCircleLine style={fontSize} />
)}
{state === RoadMapType.TODO && (
<SparkIncompleteLine style={fontSize} />
)}
{state === RoadMapType.ABANDONED && (
<SparkProcessFailedLine style={fontSize} />
)}
{description}
</Flex>
</Flex>
</List.Item>
);
};
const Roadmap: React.FC<RoadmapProps> = ({
data,
conversationId,
editable,
onSave = (data: RoadMapDataProps) => {},
}) => {
const [isEditing, setIsEditing] = useState(false);
const [taskValue, setTaskValue] = useState("");
const [open, setOpen] = useState(false);
const [list, setList] = useState<SubtasksProps[]>(data?.subtasks || []);
const [taskKey, setTaskKey] = useState<number | undefined>();
useEffect(() => {
if (data?.subtasks && Array.isArray(data?.subtasks)) {
const newList = data.subtasks.map((item, index) => {
return {
...item,
key: index + 1,
};
});
setList(newList);
}
}, [data?.subtasks]);
const onDragEnd = ({ active, over }: DragEndEvent) => {
if (!active || !over) {
return;
}
if (active.id !== over.id) {
setList((prevState) => {
const activeIndex = prevState.findIndex((i) => i.key === active.id);
const overIndex = prevState.findIndex((i) => i.key === over.id);
return arrayMove(prevState, activeIndex, overIndex);
});
}
};
const onSaveHandle = () => {
AlertDialog.info({
title: "Confirm save?",
children:
"The result you edited will overwrite the original data and become the new roadmap and start execution.",
centered: true,
okText: "Save",
onOk: async () => {
const newList = list.map(({ key, ...rest }) => rest);
const newData = { subtasks: newList };
const differences: any = diff(data, newData);
if (differences) {
try {
const response: any = await conversationApi.setRoadmap(
conversationId,
newData,
);
if (response.status && response?.payload) {
onSave(response?.payload);
}
} catch (error) {
message.error("Failed to update roadmap");
console.error("Error updating roadmap:", error);
}
} else {
message.info("No changes detected, nothing to update.");
setIsEditing(false);
}
onCancel();
},
});
};
const onAddTask = () => {
setOpen(true);
setTaskValue("");
setTaskKey(undefined);
};
const deletedHandle = (key: number) => {
AlertDialog.warning({
title: "Confirm deletion of this task?",
children:
"Once deleted, it cannot be recovered. Please proceed with caution.",
centered: true,
okText: "Confirm deletion",
onOk: () => {
setList(list.filter((item) => item.key !== key));
},
});
};
const updateTaskHandle = (content: string, key: number) => {
setOpen(true);
setTaskValue(content);
setTaskKey(key);
};
const onCancel = () => {
setOpen(false);
};
const onOk = () => {
const trimmedValue = taskValue.trim();
if (!trimmedValue) {
message.info("Please enter the task");
return;
}
if (taskKey) {
setList(
list.map((item) =>
item.key === taskKey ? { ...item, description: taskValue } : item,
),
);
} else {
setList([
...list,
{
key: new Date().getTime(),
state: RoadMapType.TODO,
description: taskValue.trim(),
},
]);
}
onCancel();
};
// const renderReadItem =
return (
<div className={styles.roadmap}>
<Flex align="center" justify="space-between">
<div className={styles.title}>Roadmap</div>
{editable && isEditing && (
<Flex gap="small">
<Button onClick={onAddTask}>
<SparkPlusLine /> Add Task
</Button>
<Button type="primary" onClick={onSaveHandle}>
<SparkSaveLine /> Save
</Button>
</Flex>
)}
{editable && !isEditing && (
<Button
onClick={() => {
setIsEditing(true);
}}
>
<SparkEditLine /> Edit
</Button>
)}
</Flex>
<DndContext
modifiers={[restrictToVerticalAxis]}
onDragEnd={onDragEnd}
id="list-drag-sorting-handler"
>
<SortableContext
items={list.map((item, index) => item.key || index)}
strategy={verticalListSortingStrategy}
>
<List
className={styles.roadmapList}
dataSource={list}
renderItem={(item, index) => {
const { description, state, key } = item || {};
if (isEditing && editable)
return (
<SortableListItem
key={key}
itemKey={key || index}
className={styles.listItem}
>
<Flex
gap="small"
justify="space-between"
align="center"
className={styles.itemFlex}
>
<Flex gap="small">
<DragHandle />
{state === RoadMapType.IN_PROGRESS && (
<SparkLoadingLine
style={{ ...fontSize, color: "#0B83F1" }}
/>
)}
{state === RoadMapType.DONE && (
<SparkCheckCircleLine style={fontSize} />
)}
{state === RoadMapType.TODO && (
<SparkIncompleteLine style={fontSize} />
)}
{state === RoadMapType.ABANDONED && (
<SparkProcessFailedLine style={fontSize} />
)}
{description}
</Flex>
<Flex gap="small">
{item.state === RoadMapType.TODO && (
<IconButton
icon={<SparkEditLine style={fontSize} />}
bordered={false}
onClick={() => {
updateTaskHandle(description, key || index);
}}
/>
)}
<IconButton
icon={<SparkDeleteLine style={fontSize} />}
bordered={false}
onClick={() => {
deletedHandle(key || index);
}}
/>
</Flex>
</Flex>
</SortableListItem>
);
return <ReadListItem item={item} />;
}}
/>
</SortableContext>
</DndContext>
<Modal
open={open}
onCancel={onCancel}
onOk={onOk}
okText="Sure"
title="Edit Task"
>
<Input.TextArea
rows={Math.min(Math.max(3, taskValue.split("\n").length + 1), 20)}
onChange={(v) => {
setTaskValue(v.target.value || "");
}}
value={taskValue}
autoSize={{ minRows: 10, maxRows: 20 }}
/>
</Modal>
</div>
);
};
export default memo(Roadmap);

View File

@@ -0,0 +1,22 @@
.sandbox {
width: 100%;
height: calc(100vh - 134px);
overflow: auto;
position: relative;
display: flex;
flex-direction: column;
.title {
display: flex;
justify-content: center;
align-items: center;
width: 100%;
padding: 12px;
background-color: var(--sps-color-fill-tertiary);
}
.sandboxIframe {
width: 100%;
height: 100%;
border: none;
flex: 1;
}
}

View File

@@ -0,0 +1,33 @@
import React, { useEffect, memo } from "react";
import { Result } from "@agentscope-ai/design";
import styles from "./index.module.scss";
interface SandBoxProps {
sandboxUrl: string;
}
const SandBox: React.FC<SandBoxProps> = ({ sandboxUrl }) => {
return (
<div className={styles.sandbox}>
{/* <div className={styles.title}>{sandboxUrl}</div> */}
{sandboxUrl && (
<iframe
src={sandboxUrl}
className={styles.sandboxIframe}
title="Sandbox"
allowFullScreen
frameBorder="0"
/>
)}
{!sandboxUrl && (
<Result
type="error"
title="Error"
description="Please try again later"
/>
)}
</div>
);
};
export default memo(SandBox);

View File

@@ -0,0 +1,54 @@
// Key stylesheet section
.scrollRoot {
position: relative;
height: 100%;
overflow: auto;
scroll-behavior: smooth;
-webkit-overflow-scrolling: touch;
// Hide scrollbar but keep scroll functionality
scrollbar-width: none;
&::-webkit-scrollbar {
display: none;
}
}
.scrollButton {
position: absolute;
right: 50%;
bottom: 20px;
z-index: 100;
width: 40px;
height: 40px;
border-radius: 50%;
border: none;
background: var(--scroll-button-bg);
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15);
cursor: pointer;
transition: all 0.2s ease;
display: flex;
align-items: center;
justify-content: center;
font-size: 18px;
&:hover {
background: var(--scroll-button-bg-hover);
box-shadow: 0 3px 12px rgba(0, 0, 0, 0.2);
transform: translateY(-1px);
}
}
@keyframes fadeIn {
from {
opacity: 0;
transform: translateY(10px);
}
to {
opacity: 1;
transform: translateY(0);
}
}

View File

@@ -0,0 +1,117 @@
import { isAtBottom, isManualScrolling } from "@/utils/sharedRefs";
import React, {
forwardRef,
useCallback,
useImperativeHandle,
useRef,
useState,
} from "react";
import styles from "./index.module.scss";
// import { debounce } from "lodash";
interface ScrollToBottomButtonProps
extends React.HTMLAttributes<HTMLDivElement> {
autoScrollThreshold?: number;
}
interface ScrollToBottomButtonHandles {
scrollToBottom: (behavior?: ScrollBehavior) => void;
}
const ScrollToBottomButton = forwardRef<
ScrollToBottomButtonHandles,
ScrollToBottomButtonProps
>(({ children, autoScrollThreshold = 1, className, ...props }, ref) => {
const [showButton, setShowButton] = useState(false);
const containerRef = useRef<HTMLDivElement>(null);
const isScroll = useRef(false);
const isAutoScrolling = useRef<boolean>(false);
const scrollTimeout = useRef<NodeJS.Timeout | null>(null);
// Precise bottom detection
const checkIsAtBottom = useCallback(() => {
const container = containerRef.current;
if (!container) return true;
// const { scrollTop, scrollHeight, clientHeight } = container;
// Handle floating point precision: round to integer
const { scrollTop, scrollHeight, clientHeight } = container;
// Calculate difference from bottom and take absolute value
const diff = scrollHeight - (scrollTop + clientHeight);
const isBottom = Math.abs(diff) <= Math.max(30, autoScrollThreshold);
isAtBottom.current = isBottom;
return isAtBottom.current;
}, [autoScrollThreshold]);
// Scroll method with lock
const scrollToBottom = useCallback((behavior: ScrollBehavior = "smooth") => {
const container = containerRef.current;
if (!container) return;
// Clear previous timer
if (scrollTimeout.current) {
clearTimeout(scrollTimeout.current);
}
isScroll.current = false;
isAutoScrolling.current = true;
setShowButton(false);
// container.style.scrollBehavior = behavior;
container.scrollTop = container.scrollHeight;
container.style.scrollBehavior = "auto";
isAtBottom.current = true;
isManualScrolling.current = true;
// Set new timer
scrollTimeout.current = setTimeout(() => {
if (container) {
isAutoScrolling.current = false;
// isManualScrolling.current = true;
}
// Clean up timer reference
scrollTimeout.current = null;
}, 0);
}, []);
// Optimized scroll handling
const handleScroll = useCallback(() => {
requestAnimationFrame(() => {
if (isAutoScrolling.current) return;
setShowButton(!checkIsAtBottom());
isScroll.current = !checkIsAtBottom();
isManualScrolling.current = false;
});
}, [checkIsAtBottom]);
useImperativeHandle(ref, () => ({
scrollToBottom,
checkIsAtBottom,
}));
return (
<div
{...props}
ref={containerRef}
className={`${styles.scrollRoot} ${className}`}
onScroll={handleScroll}
>
{children}
<div
className={styles.scrollAnchor}
style={{ position: "absolute", bottom: 0 }}
/>
{isScroll.current && showButton && (
<button
className={styles.scrollButton}
onClick={() => scrollToBottom("auto")}
style={{
position: "sticky",
bottom: "20px",
float: "right",
animation: `${styles.fadeIn} 0.3s forwards`,
}}
>
</button>
)}
</div>
);
});
export default ScrollToBottomButton;

View File

@@ -0,0 +1,22 @@
.container {
padding: 16px 0;
}
.share {
margin-bottom: 24px;
margin-top: 12px;
}
.urlContainer {
display: flex;
gap: 8px;
margin-bottom: 16px;
}
.urlInput {
flex: 1;
}
.description {
color: var(--sps-color-text-tertiary);
font-size: 14px;
margin: 0;
}

View File

@@ -0,0 +1,89 @@
import { conversationApi } from "@/services/api/conversation";
import { Conversation } from "@/types/api";
import { Button, Input, message, Modal, Switch } from "@agentscope-ai/design";
import copy from "copy-to-clipboard";
import React, { useState } from "react";
import styles from "./index.module.scss";
interface ShareModalProps {
isOpen: boolean;
onClose: () => void;
shareUrl: string;
shared: boolean;
conversationId: string;
setCurrentConversation: (con: Conversation) => void;
}
export const ShareModal: React.FC<ShareModalProps> = ({
isOpen,
onClose,
shareUrl,
shared,
conversationId,
setCurrentConversation,
}) => {
const [copied, setCopied] = useState(false);
const [isShared, setIsShared] = useState(shared);
const handleCopy = () => {
// navigator.clipboard.writeText(shareUrl);
copy(shareUrl);
setCopied(true);
message.success("Share link copied to clipboard");
setTimeout(() => setCopied(false), 2000);
onClose();
};
const onChangeShare = (share: boolean) => {
if (!!conversationId) {
conversationApi
.shareConversations(conversationId, share)
.then((res: any) => {
if (res?.payload) {
setCurrentConversation(res.payload);
setIsShared(share);
}
})
.catch((error) => {
message.error("network error");
});
} else setIsShared(share);
};
return (
<Modal
title="Share This Conversation"
open={isOpen}
onCancel={onClose}
footer={null}
width={400}
>
<div className={styles.container}>
<div>Are you sure you want to share this conversation?</div>
<Switch
className={styles.share}
checked={isShared}
onChange={onChangeShare}
label={isShared ? "Opening" : "Closed"}
/>
<span></span>
<div className={styles.urlContainer}>
<Input
disabled={!isShared}
value={shareUrl}
readOnly
className={styles.urlInput}
/>
<Button type="primary" disabled={!isShared} onClick={handleCopy}>
{copied ? "Copied" : "Copy Link"}
</Button>
</div>
<p className={styles.description}>
Copy this link and share it with others, they can view the contents of
this conversation.
</p>
</div>
</Modal>
);
};

View File

@@ -0,0 +1,193 @@
import React from "react";
import { BaseViewerProps } from "./types";
export const CSVViewer: React.FC<BaseViewerProps> = ({ content, style }) => {
const parseCSV = (csvContent: string) => {
try {
// Normalize line breaks
const normalizedContent = csvContent
.replace(/\r\n/g, "\n")
.replace(/\r/g, "\n");
const rows = normalizedContent.split("\n").filter((row) => row.trim());
if (rows.length === 0) {
throw new Error("Empty CSV content");
}
// Simple CSV parsing
const parseRow = (row: string) => {
try {
let cells = [];
let cell = "";
let inQuotes = false;
for (let i = 0; i < row.length; i++) {
const char = row[i];
if (char === '"') {
inQuotes = !inQuotes;
} else if (char === "," && !inQuotes) {
cells.push(cell);
cell = "";
} else {
cell += char;
}
}
cells.push(cell);
return cells.map((c) => c.trim().replace(/^"|"$/g, ""));
} catch (error) {
console.error("Error parsing CSV row:", error);
throw new Error(`Failed to parse row: ${row}`);
}
};
const headers = parseRow(rows[0]);
if (headers.length === 0) {
throw new Error("No headers found in CSV");
}
const data = rows.slice(1).map((row, index) => {
try {
const parsedRow = parseRow(row);
// Ensure each row has the same number of columns as header
if (parsedRow.length !== headers.length) {
console.warn(
`Row ${index + 1} has ${parsedRow.length} columns, expected ${
headers.length
}`,
);
// Pad or truncate columns
return parsedRow.length > headers.length
? parsedRow.slice(0, headers.length)
: [
...parsedRow,
...Array(headers.length - parsedRow.length).fill(""),
];
}
return parsedRow;
} catch (error) {
console.error(`Error parsing row ${index + 1}:`, error);
// Return empty row instead of interrupting entire parsing
return Array(headers.length).fill("");
}
});
return { headers, data };
} catch (error) {
console.error("Error parsing CSV:", error);
return {
headers: [],
data: [],
error: error instanceof Error ? error.message : "Failed to parse CSV",
};
}
};
try {
const { headers, data, error } = parseCSV(content);
// Show error message if parsing fails
if (error) {
return (
<div
style={{
padding: "16px",
color: "#ff4d4f",
backgroundColor: "#fff2f0",
border: "1px solid #ffccc7",
borderRadius: "4px",
}}
>
Error: {error}
</div>
);
}
// Show prompt message if there is no data
if (headers.length === 0 || data.length === 0) {
return (
<div
style={{
padding: "16px",
color: "#666",
backgroundColor: "#fafafa",
border: "1px solid #f0f0f0",
borderRadius: "4px",
}}
>
No valid CSV data found
</div>
);
}
return (
<div
style={{
...style,
overflow: "auto",
// maxHeight: '500px'
}}
>
<table
style={{
width: "100%",
borderCollapse: "collapse",
fontSize: "14px",
}}
>
<thead>
<tr>
{headers.map((header, index) => (
<th
key={index}
style={{
padding: "8px",
backgroundColor: "#fafafa",
borderBottom: "1px solid #f0f0f0",
position: "sticky",
top: 0,
textAlign: "left",
}}
>
{header}
</th>
))}
</tr>
</thead>
<tbody>
{data.map((row, rowIndex) => (
<tr key={rowIndex}>
{row.map((cell, cellIndex) => (
<td
key={cellIndex}
style={{
padding: "8px",
borderBottom: "1px solid #f0f0f0",
}}
>
{cell}
</td>
))}
</tr>
))}
</tbody>
</table>
</div>
);
} catch (error) {
// Component-level error handling
console.error("Component error:", error);
return (
<div
style={{
padding: "16px",
color: "#ff4d4f",
backgroundColor: "#fff2f0",
border: "1px solid #ffccc7",
borderRadius: "4px",
}}
>
An unexpected error occurred while rendering the CSV viewer
</div>
);
}
};

View File

@@ -0,0 +1,57 @@
import React, { useEffect, useState } from "react";
import { BaseViewerProps } from "./types";
export const ChartViewer: React.FC<BaseViewerProps> = ({ content, style }) => {
const [imageUrl, setImageUrl] = useState<string>("");
useEffect(() => {
// Try to parse URL in content
try {
// If content itself is a URL
if (content.startsWith("http")) {
setImageUrl(content);
} else {
// If content contains URL (e.g., in JSON)
const matches = content.match(/(https?:\/\/[^\s"]+)/g);
if (matches && matches.length > 0) {
setImageUrl(matches[0]);
}
}
} catch (error) {
console.error("Error parsing chart URL:", error);
}
}, [content]);
if (!imageUrl) {
return <div>Invalid chart URL</div>;
}
return (
<div
style={{
display: "flex",
flex: 1,
position: "relative",
overflow: "hidden",
justifyContent: "center",
alignItems: "center",
padding: "20px",
...style,
}}
>
<img
src={imageUrl}
alt="Chart"
style={{
maxWidth: "100%",
maxHeight: "100%",
objectFit: "contain",
}}
onError={(e) => {
console.error("Error loading chart image");
e.currentTarget.style.display = "none";
}}
/>
</div>
);
};

View File

@@ -0,0 +1,72 @@
import { useTheme } from "@/context/ThemeContext";
import React from "react";
import { Prism as SyntaxHighlighter } from "react-syntax-highlighter";
import { oneDark, prism } from "react-syntax-highlighter/dist/esm/styles/prism"; // Use more modern theme
import { BaseViewerProps } from "./types";
interface CodeViewerProps extends BaseViewerProps {
language: string;
title?: string;
}
export const CodeViewer: React.FC<CodeViewerProps> = ({
content,
language,
title,
style,
}) => {
const { theme } = useTheme();
return (
<div
style={{
display: "flex",
flexDirection: "column",
height: "fit-content", // Changed to adaptive height
// backgroundColor: '#282c34', // Match oneDark theme
borderRadius: "4px",
...style,
}}
>
{title && (
<div
style={{
padding: "8px 16px",
borderBottom: "1px solid #3e4451",
color: "#abb2bf",
fontSize: "14px",
fontFamily: "monospace",
display: "flex",
justifyContent: "space-between",
alignItems: "center",
}}
>
<span>{title}</span>
<span style={{ opacity: 0.7 }}>{language}</span>
</div>
)}
<div style={{ overflow: "auto" }}>
<SyntaxHighlighter
language={language}
style={theme === "dark" ? oneDark : prism}
showLineNumbers={true}
wrapLines={true}
customStyle={{
borderTopRightRadius: 0,
borderTopLeftRadius: 0,
// maxHeight: 500,
overflow: "auto",
background: theme === "dark" ? "" : "transparent",
}}
// lineNumberStyle={{
// minWidth: '3em',
// paddingRight: '1em',
// color: '#495162',
// textAlign: 'right',
// }}
>
{content}
</SyntaxHighlighter>
</div>
</div>
);
};

View File

@@ -0,0 +1,118 @@
import React, { ReactNode, useEffect, useState } from "react";
import { BaseViewerProps } from "./types";
export interface DiffLine {
line: string;
type: "addition" | "deletion" | "info" | "context";
}
export const DiffViewer: React.FC<BaseViewerProps> = ({ content, style }) => {
const [component, setComponent] = useState<ReactNode>(null);
useEffect(() => {
// Parse diff content
const parseDiff = (content: string): DiffLine[] => {
// Remove diff markers and file information
const cleanDiffContent = (raw: string) => {
return raw
.trim() // First clean leading and trailing whitespace
.replace(/^```diff\n|\n```$/g, "") // Remove markdown diff markers
.replace(/\\n/g, "\n") // Replace escaped newlines
.replace(/\\"/g, '"') // Replace escaped quotes
.replace(/\n+$/, "") // Remove trailing newlines
.trimEnd(); // Finally clean trailing whitespace
};
const diffContent = cleanDiffContent(content);
// show all lines
const lines = diffContent.split("\n").filter(
(line) => true,
// !line.startsWith('Index:') &&
// !line.startsWith('===')
// !line.startsWith('---') &&
// !line.startsWith('+++')
);
return lines.map((line) => {
const type = line.startsWith("+")
? "addition"
: line.startsWith("-")
? "deletion"
: line.startsWith("@")
? "info"
: "context";
return { line, type };
});
};
const diffLines = parseDiff(content);
setComponent(
<div
style={{
width: "100%",
height: "100%",
display: "flex",
flexDirection: "column",
overflow: "auto",
fontFamily: "monospace",
fontSize: "14px",
// backgroundColor: '#282c34',
color: "#abb2bf",
...style,
}}
>
{diffLines.map((item, index) => (
<div
key={index}
style={{
display: "flex",
backgroundColor:
item.type === "addition"
? "rgba(40, 167, 69, 0.2)"
: item.type === "deletion"
? "rgba(203, 36, 49, 0.2)"
: item.type === "info"
? "rgba(88, 96, 105, 0.2)"
: "transparent",
padding: "2px 10px",
whiteSpace: "pre",
}}
>
<span
style={{
color:
item.type === "addition"
? "#28a745"
: item.type === "deletion"
? "#cb2431"
: item.type === "info"
? "#586069"
: "#abb2bf",
}}
>
{item.line}
</span>
</div>
))}
</div>,
);
}, [content, style]);
return (
<div
style={{
display: "flex",
flex: 1,
// maxHeight: 500,
position: "relative",
overflow: "auto",
// border: '1px solid #ddd',
borderRadius: "4px",
}}
>
{component}
</div>
);
};

View File

@@ -0,0 +1,48 @@
import React, { useState, useEffect, ReactNode } from "react";
import { BaseViewerProps } from "./types";
export const HtmlViewer: React.FC<BaseViewerProps> = ({ content, style }) => {
const [component, setComponent] = useState<ReactNode>(null);
useEffect(() => {
setComponent(
<div
style={{
width: "100%",
height: "100%",
position: "relative",
overflow: "hidden",
...style,
}}
>
<iframe
srcDoc={content}
width="100%"
height="100%"
style={{
border: "none",
position: "absolute",
top: 0,
left: 0,
width: "100%",
height: "100%",
}}
/>
</div>,
);
}, [content]);
return (
<div
style={{
display: "flex",
flex: 1,
height: 500,
position: "relative",
overflow: "hidden",
}}
>
{component}
</div>
);
};

View File

@@ -0,0 +1,227 @@
.markdown {
// Basic text styles
font-size: 14px;
line-height: 1.4;
// Ensure container does not exceed parent width
width: 100%;
// Limit maximum width
max-width: 100%;
// overflow: 'hidden',
// Heading styles
h1,
h2,
h3,
h4,
h5,
h6 {
margin: 0.3em 0 0.1em;
font-weight: 600;
line-height: 1.2;
}
h1 {
font-size: 1.4em;
}
h2 {
font-size: 1.2em;
}
h3 {
font-size: 1.1em;
}
h4 {
font-size: 1em;
}
h5 {
font-size: 0.9em;
}
h6 {
font-size: 0.8em;
}
// Paragraph styles
p {
margin: 0;
line-height: 1.4;
&+p {
margin-top: 0.2em;
}
}
// List styles
ul,
ol {
margin: 0.1em 0;
padding-left: 1.2em;
}
li {
display: flex;
flex-direction: column;
margin: 0;
padding: 0;
line-height: 1.4;
p {
margin: 0;
display: inline-block;
}
&+li {
margin-top: 0.1em;
}
}
// Code styles
code {
padding: 0.1em 0.2em;
margin: 0;
font-size: 85%;
background-color: rgba(175, 184, 193, 0.2);
border-radius: 4px;
font-family: ui-monospace, SFMono-Regular, SF Mono, Menlo, Consolas, Liberation Mono, monospace;
}
pre {
padding: 8px;
margin: 0.2em 0;
font-size: 85%;
line-height: 1.4;
background-color: rgba(175, 184, 193, 0.2);
border-radius: 4px;
code {
padding: 0;
margin: 0;
background-color: transparent;
border: 0;
}
}
// Quote styles
blockquote {
margin: 0.2em 0;
padding: 0 0.5em;
color: #656d76;
border-left: 0.2em solid #d0d7de;
}
// Table styles
table {
margin: 0.2em 0;
border-spacing: 0;
border-collapse: collapse;
width: 100%;
th,
td {
padding: 4px 8px;
border: 1px solid #d0d7de;
}
tr {
background-color: #ffffff;
border-top: 1px solid #d0d7de;
&:nth-child(2n) {
background-color: #f6f8fa;
}
}
}
// Image styles
img {
max-width: 100%;
height: auto;
margin: 0.2em 0;
}
// Divider styles
hr {
height: 1px;
margin: 0.2em 0;
background-color: #d0d7de;
border: 0;
}
// Link styles
a {
color: #0969da;
text-decoration: none;
&:hover {
text-decoration: underline;
}
}
// Specifically handle spaces created by line breaks
br {
// Changed to inline display
display: inline;
// Preserve line break effect
white-space: pre;
margin: 0;
padding: 0;
height: 0.3em;
visibility: visible;
}
// Handle consecutive line breaks
br+br {
display: none;
}
// Handle line breaks in paragraphs
p {
br {
display: inline;
white-space: pre;
height: 0.3em;
}
}
// Handle line breaks in list items
li {
br {
display: inline;
white-space: pre;
height: 0.3em;
}
}
// Task list styles
input[type="checkbox"] {
margin: 0 0.3em 0 0;
vertical-align: middle;
}
// Task list item styles
li:has(input[type="checkbox"]) {
list-style: none;
margin-left: -1.2em;
padding-left: 1.2em;
display: flex;
align-items: flex-start;
gap: 0.2em;
p {
margin: 0;
flex: 1;
}
}
// Remove extra spacing from first and last elements
*:first-child {
margin-top: 0;
}
*:last-child {
margin-bottom: 0;
}
}

View File

@@ -0,0 +1,18 @@
// src/viewers/MarkdownViewer.ts
import React from "react";
import { BaseViewerProps } from "./types";
import ReactMarkdown from "react-markdown";
import remarkGfm from "remark-gfm";
import { markdownRegex } from "@/utils/constant";
export const MarkdownViewer: React.FC<BaseViewerProps> = ({
content,
style,
}) => {
const processed = content?.match(markdownRegex)?.[1] || content;
return (
<div style={style}>
<ReactMarkdown remarkPlugins={[remarkGfm]}>{processed}</ReactMarkdown>
</div>
);
};

View File

@@ -0,0 +1,81 @@
import React from "react";
import { getFileType, languageMap } from "./utils";
import { HtmlViewer } from "./HtmlViewer";
import { MarkdownViewer } from "./MarkdownViewer";
import { CodeViewer } from "./CodeViewer";
import { CSVViewer } from "./CSVViewer";
import { ChartViewer } from "./ChartViewer";
import { DiffViewer } from "./DiffViewer";
import { ViewerStyle } from "./types";
interface UniversalViewerProps {
content: string;
fileName?: string;
style?: ViewerStyle;
}
export const UniversalViewer: React.FC<UniversalViewerProps> = ({
content,
fileName = "",
style = {},
}) => {
const getViewer = () => {
const defaultStyles = {
width: "100%",
height: "100%",
overflow: "auto",
...style,
};
const fileType = fileName?.split(".").pop()?.toLowerCase() || "";
const type = getFileType(fileType);
switch (type) {
case "html":
return <HtmlViewer content={content} style={defaultStyles} />;
case "markdown":
return <MarkdownViewer content={content} style={defaultStyles} />;
case "csv":
return <CSVViewer content={content} style={defaultStyles} />;
// case 'pdf':
// return <PDFViewer content={content} style={defaultStyles} />;
// case 'image':
// return <ImageViewer content={content} style={defaultStyles} />;
case "chart":
return <ChartViewer content={content} style={defaultStyles} />;
case "diff":
return <DiffViewer content={content} style={defaultStyles} />;
default:
const language = languageMap[fileType] || "text";
return (
<CodeViewer
content={content}
language={language}
style={defaultStyles}
/>
);
}
};
return (
<div
style={{
display: "flex",
flex: 1,
// maxHeight: '500px',
position: "relative",
// overflow: 'auto',
// border: '1px solid #ddd',
borderRadius: "4px",
}}
>
{getViewer()}
</div>
);
};

View File

@@ -0,0 +1,8 @@
export { UniversalViewer } from "./UniversalViewer";
export { MarkdownViewer } from "./MarkdownViewer";
export { CodeViewer } from "./CodeViewer";
export { CSVViewer } from "./CSVViewer";
export { HtmlViewer } from "./HtmlViewer";
export { DiffViewer } from "./DiffViewer";
export { ChartViewer } from "./ChartViewer";
export type { ViewerStyle, BaseViewerProps } from "./types";

View File

@@ -0,0 +1,256 @@
// src/viewers/styles.ts
export const COMMON_STYLES = `
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
color: #333;
line-height: 1.6;
padding: 30px;
max-width: 900px;
margin: 0 auto;
background-color: #ffffff;
}
/* Markdown heading styles */
h1, h2, h3, h4, h5, h6 {
position: relative;
margin-top: 1.5rem;
margin-bottom: 1rem;
font-weight: bold;
line-height: 1.4;
cursor: text;
color: #2c3e50;
}
h1 {
font-size: 2.25em;
border-bottom: 1px solid #eceff1;
padding-bottom: 0.3em;
}
h2 {
font-size: 1.75em;
border-bottom: 1px solid #eceff1;
padding-bottom: 0.3em;
}
h3 {
font-size: 1.5em;
}
h4 {
font-size: 1.25em;
}
h5 {
font-size: 1em;
}
h6 {
font-size: 1em;
color: #777;
}
/* Code block styles */
pre {
background-color: #f8f8f8;
border-radius: 3px;
padding: 10px;
font-size: 0.9em;
line-height: 1.5;
overflow-x: auto;
border: 1px solid #e9e9e9;
}
code {
font-family: 'Cascadia Code', 'Fira Code', Consolas, 'Courier New', monospace;
background-color: rgba(0,0,0,0.05);
padding: 0.2em 0.4em;
margin: 0;
border-radius: 3px;
font-size: 85%;
}
pre code {
background-color: transparent;
padding: 0;
margin: 0;
border-radius: 0;
font-size: 100%;
}
/* Table styles */
table {
border-collapse: collapse;
width: 100%;
margin-bottom: 1.5em;
display: block;
overflow-x: auto;
}
table tr {
background-color: #fff;
border-top: 1px solid #c6cbd1;
}
table tr:nth-child(2n) {
background-color: #f6f8fa;
}
table th,
table td {
padding: 10px 15px;
border: 1px solid #dfe2e5;
}
table th {
font-weight: bold;
background-color: #f0f0f0;
text-align: left;
}
/* Blockquote styles */
blockquote {
border-left: 4px solid #dfe2e5;
padding: 0 15px;
color: #777;
margin: 0;
}
/* List styles */
ul, ol {
padding-left: 30px;
margin-top: 0;
margin-bottom: 16px;
}
/* Link styles */
a {
color: #0366d6;
text-decoration: none;
}
a:hover {
text-decoration: underline;
}
/* Horizontal line */
hr {
border: 0;
height: 1px;
background-color: #e1e4e8;
margin: 16px 0;
}
/* Additional styles for inline code and code blocks */
.highlight {
background-color: #f8f8f8;
border-radius: 3px;
}
.linenos {
background-color: #f0f0f0;
padding-right: 10px;
text-align: right;
color: #999;
}
/* Image styles */
img {
max-width: 100%;
height: auto;
display: block;
margin: 1em 0;
}
/* Paragraph styles */
p {
margin-top: 0;
margin-bottom: 16px;
}
/* Emphasis styles */
em {
font-style: italic;
}
strong {
font-weight: bold;
}
/* Strikethrough styles */
del {
text-decoration: line-through;
}
/* Superscript and subscript */
sup, sub {
font-size: 75%;
line-height: 0;
position: relative;
vertical-align: baseline;
}
sup {
top: -0.5em;
}
sub {
bottom: -0.25em;
}
/* Keyboard input styles */
kbd {
background-color: #fafbfc;
border: 1px solid #d1d5da;
border-bottom-color: #c6cbd1;
border-radius: 3px;
box-shadow: inset 0 -1px 0 #c6cbd1;
color: #444d56;
display: inline-block;
font-size: 11px;
line-height: 10px;
padding: 3px 5px;
vertical-align: middle;
}
/* Text selection styles */
::selection {
background: #b3d4fc;
text-shadow: none;
}
/* Scrollbar styles */
::-webkit-scrollbar {
width: 8px;
height: 8px;
}
::-webkit-scrollbar-track {
background: #f1f1f1;
border-radius: 4px;
}
::-webkit-scrollbar-thumb {
background: #c1c1c1;
border-radius: 4px;
}
::-webkit-scrollbar-thumb:hover {
background: #a8a8a8;
}
`;
export const HIGHLIGHT_STYLES = {
tomorrow: {
"hljs-comment": {
color: "#999999",
},
"hljs-keyword": {
color: "#cc99cd",
},
"hljs-string": {
color: "#7ec699",
},
},
};

View File

@@ -0,0 +1,8 @@
export type ViewerStyle = {
[key: string]: string | number;
};
export interface BaseViewerProps {
content: string;
style?: ViewerStyle;
}

View File

@@ -0,0 +1,66 @@
export const getFileType = (extension: string) => {
switch (extension.toLowerCase().replace(".", "")) {
case "html":
case "htm":
return "html";
case "md":
case "markdown":
return "markdown";
case "txt":
return "text";
case "json":
return "json";
case "csv":
return "csv";
case "xml":
return "xml";
case "yaml":
case "yml":
return "yaml";
case "log":
return "log";
case "pdf":
return "pdf";
case "png":
case "jpg":
case "jpeg":
case "gif":
case "webp":
return "image";
case "diff":
case "patch":
return "diff";
case "chart":
return "chart";
default:
return "text";
}
};
export const languageMap: { [key: string]: string } = {
js: "javascript",
jsx: "jsx",
ts: "typescript",
tsx: "typescript",
py: "python",
python: "python",
rb: "ruby",
java: "java",
cpp: "cpp",
c: "c",
cs: "csharp",
go: "go",
rs: "rust",
php: "php",
html: "html",
css: "css",
scss: "scss",
sql: "sql",
yaml: "yaml",
yml: "yaml",
json: "json",
xml: "xml",
md: "markdown",
sh: "bash",
bash: "bash",
diff: "diff",
};

View File

@@ -0,0 +1,91 @@
.workspaceHeader {
@apply flex items-center justify-between;
.titleContainer {
@apply flex items-center;
.title {
@apply font-alibaba text-[17px] font-bold leading-[28px] text-text-primary;
}
}
.computerIcon {
font-size: 20px;
font-weight: bold;
margin-right: 8px;
}
.collectButton {
@apply flex items-center px-4 py-1 rounded-[12px] border border-black;
background-color: var(--message-content);
img {
@apply w-5 h-5 mr-1;
}
span {
@apply font-alibaba text-[13px] font-bold leading-[22px] text-text-primary;
}
}
}
.todoHeader {
@apply flex items-center mt-1;
.stepIcon {
@apply w-5 h-5 mr-[6px];
}
.stepCount {
@apply px-2 py-[2px] mr-[5px] rounded-[8px];
@apply font-alibaba text-[13px] font-bold leading-[22px];
background-color: var(--sps-color-mauve-bg);
color: var(--sps-color-mauve);
}
.stepTitle {
@apply font-alibaba text-[15px] leading-6 tracking-[0.2px] text-black;
width: 100%;
}
.workspaceSelect {
height: 56px;
width: 100%;
}
}
.todoList {
@apply mx-0 my-[14px] mb-[22px];
:global(.ant-collapse-content-box) {
padding: 0;
}
:global {
pre {
background-color: transparent !important;
}
code {
color: var(--sps-color-text-secondary) !important;
background-color: transparent !important;
}
}
// .collapse {
// :global {
// .sps-collapse-content-box {
// padding: 0 !important;
// }
// }
// }
}
.workWrap {
display: flex;
flex-direction: column;
padding: 16px 20px;
border-top: solid 1px var(--sps-color-border-secondary);
.workspaceHeader {
height: 52px;
}
}
.renderLabel {
display: flex;
justify-content: space-between;
.labelId {
color: var(--sps-color-text-tertiary);
margin-left: 12px;
}
}

View File

@@ -0,0 +1,380 @@
import { useTheme } from "@/context/ThemeContext";
import { useWorkspace } from "@/context/WorkspaceContext.tsx";
import { Message, MessageType, ToolCallMessage } from "@/types/message";
import { Collapse, CollapseProps, Select } from "@agentscope-ai/design";
import { SparkComputerLine } from "@agentscope-ai/icons";
import type { SelectProps } from "antd";
import { memo, useEffect, useMemo, useState } from "react";
import { Prism as SyntaxHighlighter } from "react-syntax-highlighter";
import { oneDark, prism } from "react-syntax-highlighter/dist/esm/styles/prism";
import { UniversalViewer } from "../Viewer";
import styles from "./index.module.scss";
const Workspace = () => {
const {
displayedContent,
args,
messageList = [],
setDisplayedContent,
} = useWorkspace();
const { theme } = useTheme();
const [serialNum, setSerialNum] = useState<number>(0);
const [useToolInfo, setUseToolInfo] = useState<{
name: string;
arguments: string;
type: string;
}>({ name: "", arguments: "", type: "" });
type LabelRender = SelectProps["labelRender"];
const updateToolInfo = (toolMsg?: ToolCallMessage) => {
if (
toolMsg &&
toolMsg.arguments &&
Object.keys(toolMsg.arguments).length > 0
) {
setUseToolInfo({
name: toolMsg.tool_name || "",
arguments: JSON.stringify(toolMsg.arguments, null, 2),
type: toolMsg.type,
});
} else {
setUseToolInfo({
name: "",
arguments: "",
type: "",
});
}
};
useEffect(() => {
if (displayedContent) {
const selectedMessage = messageList.find(
(msg: Message) => msg.content === displayedContent,
);
if (selectedMessage) {
findIndex(selectedMessage.id);
if ((selectedMessage as ToolCallMessage).arguments) {
const toolMsg = selectedMessage as ToolCallMessage;
updateToolInfo(toolMsg);
} else {
updateToolInfo(); // Set to null
}
}
} else {
updateToolInfo(); // Set to null
}
}, [displayedContent, messageList]);
const customStyle = {
borderTopRightRadius: 0,
borderTopLeftRadius: 0,
// maxHeight: 500,
overflow: "auto",
padding: 0,
backgroundColor: "var(--sps-color-bg-base)",
};
const renderMarkdown = (content: string, language: string = "text") => {
return (
<SyntaxHighlighter
language={language}
showLineNumbers={true}
wrapLines={true}
style={theme === "dark" ? oneDark : prism}
customStyle={theme === "dark" ? customStyle : { ...customStyle }}
>
{content}
</SyntaxHighlighter>
);
};
const findIndex = (v: string) => {
const index = messageList.findIndex((item) => item.id === v);
setSerialNum(index >= 0 ? index + 1 : 0);
};
const getBaseLabel = () => {
if (!!displayedContent && typeof displayedContent === "string") {
const displayedContentObj = JSON.parse(displayedContent);
if (
Array.isArray(displayedContentObj) &&
displayedContentObj.length > 0
) {
if (displayedContentObj[0].type === MessageType.TOOL_USE)
return "Raw data";
if (displayedContentObj[0].type === MessageType.TOOL_RESULT)
return "Tool result";
}
if (displayedContentObj.hasOwnProperty("type")) {
if (displayedContentObj.type === MessageType.TOOL_USE)
return "Raw data";
if (displayedContentObj.type === MessageType.TOOL_RESULT)
return "Tool result";
}
}
return "Raw result";
};
// Create an array that refreshes each time displayedContent changes
const items: CollapseProps["items"] = useMemo(() => {
const base: CollapseProps["items"] = [];
if (displayedContent === null) {
return [];
}
const currentMessage = messageList.find(
(msg: Message) => msg.content === displayedContent,
);
if (currentMessage) {
findIndex(currentMessage.id);
}
base.push({
key: "1",
label: getBaseLabel(),
children: (
<SyntaxHighlighter
language="JSON"
showLineNumbers={true}
wrapLines={true}
style={theme === "dark" ? oneDark : prism}
// Background color change
customStyle={
theme === "dark"
? customStyle
: { ...customStyle, background: "transparent" }
}
>
{(() => {
try {
return JSON.stringify(JSON.parse(displayedContent), null, 2);
} catch (error) {
return displayedContent;
}
})()}
</SyntaxHighlighter>
),
});
try {
const toolResultBlocks = JSON.parse(displayedContent);
if (Array.isArray(toolResultBlocks)) {
const toolResultBlock = toolResultBlocks[0];
if (
toolResultBlock.hasOwnProperty("name") &&
toolResultBlock.hasOwnProperty("output")
) {
const { name, output } = toolResultBlock;
// show output 0 for temp
switch (name) {
case "edit_file":
base.unshift({
key: "output-0",
label: `Output of 🛠️ ${name}`,
children: (
<UniversalViewer
content={output[0].text}
fileName="fake.diff"
style={customStyle}
/>
),
});
return base;
case "read_file":
base.unshift({
key: "output-0",
label: `Output of 🛠️ ${name}`,
children: (
<UniversalViewer
content={output[0].text}
fileName={args?.path}
style={customStyle}
/>
),
});
return base;
case "write_file":
base.unshift({
key: "output-0",
label: `Output of 🛠️ ${name}`,
children: (
<UniversalViewer
content={args?.content || output[0].text}
fileName={args?.path}
style={customStyle}
/>
),
});
return base;
case "generate_chart":
base.unshift({
key: "output-0",
label: `Output of 🛠️ ${name}`,
children: (
<UniversalViewer
content={output[0].text}
fileName="fake.chart"
style={customStyle}
/>
),
});
return base;
}
if (Array.isArray(output)) {
output.forEach((item, index) => {
if (item.hasOwnProperty("type")) {
switch (item.type) {
case "text":
if (base[0].key === "image") {
// Insert at second position
base.splice(1, 0, {
key: `output-${index}`,
label: `Output of 🛠️ ${name}`,
children: renderMarkdown(item.text),
});
} else {
base.unshift({
key: `output-${index}`,
label: `Output of 🛠️ ${name}`,
children: renderMarkdown(item.text),
});
}
break;
case "image":
base.unshift({
key: `image-${index}`,
label: "Image",
children: (
<img
src={"data:image/jpeg;base64," + item.data}
alt="Image"
style={{
maxWidth: "100%",
maxHeight: 500,
overflow: "auto",
}}
/>
),
});
break;
}
}
});
}
}
}
if (
useToolInfo.arguments &&
useToolInfo.name &&
useToolInfo.type === MessageType.TOOL_USE
) {
base.unshift({
key: "input",
label: `Arguments of 🛠️ ${useToolInfo.name}`,
children: renderMarkdown(useToolInfo.arguments),
});
}
} catch (error) {}
return base;
}, [displayedContent, args, theme, useToolInfo]);
const selectOnchange = (v: string) => {
// v is now id, need to find corresponding message based on id
const selectedMessage = messageList.find((msg: Message) => msg.id === v);
if (selectedMessage && selectedMessage.content !== displayedContent) {
findIndex(v);
setDisplayedContent(selectedMessage.content);
// Find corresponding message and set arguments
if ((selectedMessage as ToolCallMessage).arguments) {
const toolMsg = selectedMessage as ToolCallMessage;
updateToolInfo(toolMsg);
} else {
updateToolInfo(); // Set to null
}
setTimeout(() => {
const element = document.getElementById(
`message-toolcall-${selectedMessage.id}`,
);
if (element) {
element.scrollIntoView({ behavior: "smooth", block: "start" });
}
}, 100);
}
};
const renderLabel = (d: ToolCallMessage | Message) => {
let prefixName = `Output of `;
if (d?.type === MessageType.TOOL_USE) prefixName = `Using tool input of `;
if (d?.type === MessageType.TOOL_RESULT)
prefixName = `Tool result output of `;
return (
<div className={styles.renderLabel}>
<span> {`${prefixName}🛠️ ${d?.tool_name ?? "Unknown Tool"}`}</span>
<span className={styles.labelId}>{d.id}</span>
</div>
);
};
const labelRender: LabelRender = (props) => {
const { value } = props;
// Find corresponding message based on value (id)
const message = messageList.find((msg: Message) => msg.id === value);
if (!message) {
return <span>No option match</span>;
}
const d = message as ToolCallMessage;
let prefixName = `Output of `;
if (d?.type === MessageType.TOOL_USE) prefixName = `Using tool input of `;
if (d?.type === MessageType.TOOL_RESULT)
prefixName = `Tool result output of `;
return <span>{`${prefixName}🛠️ ${d?.tool_name ?? "Unknown Tool"}`}</span>;
};
return (
<div className={styles.workWrap}>
<div className={styles.workspaceHeader}>
<div className={styles.titleContainer}>
<SparkComputerLine className={styles.computerIcon} />
<h2 className={styles.title}>Agent Workspace</h2>
</div>
</div>
{displayedContent && (
<div className={styles.todoHeader}>
<span className={styles.stepTitle}>
<Select
className={styles.workspaceSelect}
prefix={
<span className={styles.stepCount}>
{serialNum}/{messageList?.length}
</span>
}
value={
messageList.find(
(msg: Message) => msg.content === displayedContent,
)?.id
}
onChange={(v) => {
selectOnchange(v);
}}
notFoundContent={null}
options={(messageList || []).map((d: Message) => ({
value: d.id,
label: renderLabel(d),
// disabled: displayedContent === null
}))}
labelRender={labelRender}
/>
</span>
</div>
)}
<div className={styles.todoList}>
{displayedContent ? (
<Collapse
items={items}
className={styles.collapse}
defaultActiveKey={
items.length === 1 ? ["1"] : ["file", "output", "image"]
}
/>
) : null}
</div>
</div>
);
};
export default memo(Workspace);