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:
20
alias/frontend/src/components/Artifacts/PanelHeader.tsx
Normal file
20
alias/frontend/src/components/Artifacts/PanelHeader.tsx
Normal 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>
|
||||
);
|
||||
});
|
||||
49
alias/frontend/src/components/Artifacts/Tree.scss
Normal file
49
alias/frontend/src/components/Artifacts/Tree.scss
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
169
alias/frontend/src/components/Artifacts/Tree.tsx
Normal file
169
alias/frontend/src/components/Artifacts/Tree.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
83
alias/frontend/src/components/Artifacts/index.module.scss
Normal file
83
alias/frontend/src/components/Artifacts/index.module.scss
Normal 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;
|
||||
}
|
||||
}
|
||||
105
alias/frontend/src/components/Artifacts/index.scss
Normal file
105
alias/frontend/src/components/Artifacts/index.scss
Normal 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;
|
||||
}
|
||||
}
|
||||
567
alias/frontend/src/components/Artifacts/index.tsx
Normal file
567
alias/frontend/src/components/Artifacts/index.tsx
Normal 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);
|
||||
384
alias/frontend/src/components/Browser/index.scss
Normal file
384
alias/frontend/src/components/Browser/index.scss
Normal 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);
|
||||
}
|
||||
}
|
||||
691
alias/frontend/src/components/Browser/index.tsx
Normal file
691
alias/frontend/src/components/Browser/index.tsx
Normal 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);
|
||||
}}
|
||||
>
|
||||
×
|
||||
</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);
|
||||
80
alias/frontend/src/components/Chat/BaseMessage.tsx
Normal file
80
alias/frontend/src/components/Chat/BaseMessage.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
79
alias/frontend/src/components/Chat/ClarificationMessage.tsx
Normal file
79
alias/frontend/src/components/Chat/ClarificationMessage.tsx
Normal 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> */}
|
||||
);
|
||||
};
|
||||
83
alias/frontend/src/components/Chat/CollapsibleMessage.tsx
Normal file
83
alias/frontend/src/components/Chat/CollapsibleMessage.tsx
Normal 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);
|
||||
145
alias/frontend/src/components/Chat/FileItems.tsx
Normal file
145
alias/frontend/src/components/Chat/FileItems.tsx
Normal 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>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
};
|
||||
25
alias/frontend/src/components/Chat/FilesMessage.tsx
Normal file
25
alias/frontend/src/components/Chat/FilesMessage.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
888
alias/frontend/src/components/Chat/Message.module.scss
Normal file
888
alias/frontend/src/components/Chat/Message.module.scss
Normal 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;
|
||||
}
|
||||
|
||||
266
alias/frontend/src/components/Chat/Message.tsx
Normal file
266
alias/frontend/src/components/Chat/Message.tsx
Normal 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);
|
||||
21
alias/frontend/src/components/Chat/MessageList.module.scss
Normal file
21
alias/frontend/src/components/Chat/MessageList.module.scss
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
195
alias/frontend/src/components/Chat/MessageList.tsx
Normal file
195
alias/frontend/src/components/Chat/MessageList.tsx
Normal 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);
|
||||
26
alias/frontend/src/components/Chat/ResponseMessage.tsx
Normal file
26
alias/frontend/src/components/Chat/ResponseMessage.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
23
alias/frontend/src/components/Chat/SubResponseMessage.tsx
Normal file
23
alias/frontend/src/components/Chat/SubResponseMessage.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
22
alias/frontend/src/components/Chat/SubThoughtMessage.tsx
Normal file
22
alias/frontend/src/components/Chat/SubThoughtMessage.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
19
alias/frontend/src/components/Chat/SystemMessage.tsx
Normal file
19
alias/frontend/src/components/Chat/SystemMessage.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
20
alias/frontend/src/components/Chat/ThinkingMessage.tsx
Normal file
20
alias/frontend/src/components/Chat/ThinkingMessage.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
16
alias/frontend/src/components/Chat/ThoughtMessage.tsx
Normal file
16
alias/frontend/src/components/Chat/ThoughtMessage.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
121
alias/frontend/src/components/Chat/ToolCallMessage/index.tsx
Normal file
121
alias/frontend/src/components/Chat/ToolCallMessage/index.tsx
Normal 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>
|
||||
);
|
||||
},
|
||||
);
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
107
alias/frontend/src/components/Chat/UserMessage/index.tsx
Normal file
107
alias/frontend/src/components/Chat/UserMessage/index.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
34
alias/frontend/src/components/LoginModal/index.module.scss
Normal file
34
alias/frontend/src/components/LoginModal/index.module.scss
Normal 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;
|
||||
}
|
||||
}
|
||||
63
alias/frontend/src/components/LoginModal/index.tsx
Normal file
63
alias/frontend/src/components/LoginModal/index.tsx
Normal 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);
|
||||
32
alias/frontend/src/components/LogoIcon/index.tsx
Normal file
32
alias/frontend/src/components/LogoIcon/index.tsx
Normal 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;
|
||||
28
alias/frontend/src/components/Roadmap/index.module.scss
Normal file
28
alias/frontend/src/components/Roadmap/index.module.scss
Normal 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);
|
||||
}
|
||||
}
|
||||
375
alias/frontend/src/components/Roadmap/index.tsx
Normal file
375
alias/frontend/src/components/Roadmap/index.tsx
Normal 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);
|
||||
22
alias/frontend/src/components/SandBox/index.module.scss
Normal file
22
alias/frontend/src/components/SandBox/index.module.scss
Normal 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;
|
||||
}
|
||||
}
|
||||
33
alias/frontend/src/components/SandBox/index.tsx
Normal file
33
alias/frontend/src/components/SandBox/index.tsx
Normal 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);
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
117
alias/frontend/src/components/ScrollToBottomButton/index.tsx
Normal file
117
alias/frontend/src/components/ScrollToBottomButton/index.tsx
Normal 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;
|
||||
22
alias/frontend/src/components/ShareModal/index.module.scss
Normal file
22
alias/frontend/src/components/ShareModal/index.module.scss
Normal 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;
|
||||
}
|
||||
89
alias/frontend/src/components/ShareModal/index.tsx
Normal file
89
alias/frontend/src/components/ShareModal/index.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
193
alias/frontend/src/components/Viewer/CSVViewer.tsx
Normal file
193
alias/frontend/src/components/Viewer/CSVViewer.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
};
|
||||
57
alias/frontend/src/components/Viewer/ChartViewer.tsx
Normal file
57
alias/frontend/src/components/Viewer/ChartViewer.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
72
alias/frontend/src/components/Viewer/CodeViewer.tsx
Normal file
72
alias/frontend/src/components/Viewer/CodeViewer.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
118
alias/frontend/src/components/Viewer/DiffViewer.tsx
Normal file
118
alias/frontend/src/components/Viewer/DiffViewer.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
48
alias/frontend/src/components/Viewer/HtmlViewer.tsx
Normal file
48
alias/frontend/src/components/Viewer/HtmlViewer.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
227
alias/frontend/src/components/Viewer/MarkdownViewer.module.scss
Normal file
227
alias/frontend/src/components/Viewer/MarkdownViewer.module.scss
Normal 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;
|
||||
}
|
||||
}
|
||||
18
alias/frontend/src/components/Viewer/MarkdownViewer.tsx
Normal file
18
alias/frontend/src/components/Viewer/MarkdownViewer.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
81
alias/frontend/src/components/Viewer/UniversalViewer.tsx
Normal file
81
alias/frontend/src/components/Viewer/UniversalViewer.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
8
alias/frontend/src/components/Viewer/index.tsx
Normal file
8
alias/frontend/src/components/Viewer/index.tsx
Normal 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";
|
||||
256
alias/frontend/src/components/Viewer/styles.ts
Normal file
256
alias/frontend/src/components/Viewer/styles.ts
Normal 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",
|
||||
},
|
||||
},
|
||||
};
|
||||
8
alias/frontend/src/components/Viewer/types.ts
Normal file
8
alias/frontend/src/components/Viewer/types.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
export type ViewerStyle = {
|
||||
[key: string]: string | number;
|
||||
};
|
||||
|
||||
export interface BaseViewerProps {
|
||||
content: string;
|
||||
style?: ViewerStyle;
|
||||
}
|
||||
66
alias/frontend/src/components/Viewer/utils.ts
Normal file
66
alias/frontend/src/components/Viewer/utils.ts
Normal 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",
|
||||
};
|
||||
91
alias/frontend/src/components/Workspace/index.module.scss
Normal file
91
alias/frontend/src/components/Workspace/index.module.scss
Normal 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;
|
||||
}
|
||||
}
|
||||
380
alias/frontend/src/components/Workspace/index.tsx
Normal file
380
alias/frontend/src/components/Workspace/index.tsx
Normal 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);
|
||||
Reference in New Issue
Block a user