mirror of
https://github.com/hexastack/hexabot
synced 2025-06-26 18:27:28 +00:00
441 lines
14 KiB
TypeScript
441 lines
14 KiB
TypeScript
/*
|
|
* Copyright © 2024 Hexastack. All rights reserved.
|
|
*
|
|
* Licensed under the GNU Affero General Public License v3.0 (AGPLv3) with the following additional terms:
|
|
* 1. The name "Hexabot" is a trademark of Hexastack. You may not use this name in derivative works without express written permission.
|
|
* 2. All derivative works must include clear attribution to the original creator and software, Hexastack and Hexabot, in a prominent location (e.g., in the software's "About" section, documentation, and README file).
|
|
*/
|
|
|
|
import BrokenImageOutlinedIcon from "@mui/icons-material/BrokenImageOutlined";
|
|
import ChatBubbleOutlineOutlinedIcon from "@mui/icons-material/ChatBubbleOutlineOutlined";
|
|
import ExtensionOutlinedIcon from "@mui/icons-material/ExtensionOutlined";
|
|
import FastForwardRoundedIcon from "@mui/icons-material/FastForwardRounded";
|
|
import MenuRoundedIcon from "@mui/icons-material/MenuRounded";
|
|
import PlayArrowRoundedIcon from "@mui/icons-material/PlayArrowRounded";
|
|
import ReplyIcon from "@mui/icons-material/Reply";
|
|
import { Chip, styled } from "@mui/material";
|
|
import { DiagramEngine, PortWidget } from "@projectstorm/react-diagrams-core";
|
|
import clsx from "clsx";
|
|
import * as React from "react";
|
|
import { FC } from "react";
|
|
import { withTranslation, WithTranslation } from "react-i18next";
|
|
|
|
import { UnifiedIcon } from "@/app-components/icons/UnifiedIcon";
|
|
import AttachmentIcon from "@/app-components/svg/toolbar/AttachmentIcon";
|
|
import ButtonsIcon from "@/app-components/svg/toolbar/ButtonsIcon";
|
|
import ListIcon from "@/app-components/svg/toolbar/ListIcon";
|
|
import PluginIcon from "@/app-components/svg/toolbar/PluginIcon";
|
|
import QuickRepliesIcon from "@/app-components/svg/toolbar/QuickRepliesIcon";
|
|
import SimpleTextIcon from "@/app-components/svg/toolbar/SimpleTextIcon";
|
|
import TriggerIcon from "@/app-components/svg/TriggerIcon";
|
|
import { IBlockFull, Pattern } from "@/types/block.types";
|
|
import { BlockPorts, BlockTypes, TBlock } from "@/types/visual-editor.types";
|
|
import { truncate } from "@/utils/text";
|
|
|
|
import { NodeModel } from "./NodeModel";
|
|
|
|
export const BLOCK_WIDTH = 360;
|
|
export const BLOCK_HEIGHT = 200;
|
|
|
|
export interface NodeWidgetProps {
|
|
node: NodeModel;
|
|
engine: DiagramEngine;
|
|
color: string;
|
|
}
|
|
|
|
const IconContainer = styled("div")(({ theme }) => ({
|
|
display: "flex",
|
|
justifyContent: "center",
|
|
alignItems: "center",
|
|
backgroundColor: theme.palette.background.paper,
|
|
borderRadius: "100%",
|
|
padding: "1px",
|
|
}));
|
|
|
|
export interface NodeWidgetState {}
|
|
|
|
class NodeAbstractWidget extends React.Component<
|
|
NodeWidgetProps & WithTranslation,
|
|
NodeWidgetState
|
|
> {
|
|
constructor(props: NodeWidgetProps & WithTranslation) {
|
|
super(props);
|
|
this.state = {};
|
|
}
|
|
|
|
render() {
|
|
return (
|
|
<div className={clsx("custom-node-port")}>
|
|
{!this.props.node.source ? (
|
|
<PortWidget
|
|
engine={this.props.engine}
|
|
//TODO: need to be updated
|
|
// @ts-ignore
|
|
port={this.props.node.getPort(BlockPorts.inPort)}
|
|
className={clsx("circle-porter", "circle-porter-in")}
|
|
>
|
|
<IconContainer
|
|
style={{
|
|
borderWidth: "3px",
|
|
borderColor: this.props.color,
|
|
borderStyle: "solid",
|
|
}}
|
|
>
|
|
<PlayArrowRoundedIcon
|
|
sx={{
|
|
color: this.props.color,
|
|
fontSize: 23,
|
|
}}
|
|
/>
|
|
</IconContainer>
|
|
</PortWidget>
|
|
) : null}
|
|
{this.props.node.content}
|
|
<div className="circle-out-porters">
|
|
{Object.entries(this.props.node.getPorts())
|
|
//TODO: need to be updated
|
|
// @ts-ignore
|
|
.filter(([, value]) => !value.options.in)
|
|
.map(([, value], index) => (
|
|
<PortWidget
|
|
//TODO: need to be updated
|
|
// @ts-ignore
|
|
key={value.options.id}
|
|
engine={this.props.engine}
|
|
//TODO: need to be updated
|
|
// @ts-ignore
|
|
port={this.props.node.getPort(value.options.name)}
|
|
className={clsx(
|
|
"circle-porter",
|
|
"circle-porter-out",
|
|
//TODO: need to be updated
|
|
// @ts-ignore
|
|
this.props.node.getPort(value.options.name)?.isLocked()
|
|
? "circle-porter-out-off"
|
|
: "",
|
|
)}
|
|
>
|
|
<IconContainer
|
|
style={{
|
|
borderWidth: "3px",
|
|
borderColor: this.props.color,
|
|
borderStyle: "solid",
|
|
}}
|
|
>
|
|
{index === 0 ? (
|
|
<PlayArrowRoundedIcon
|
|
sx={{
|
|
color: this.props.color,
|
|
fontSize: 23,
|
|
}}
|
|
/>
|
|
) : (
|
|
<FastForwardRoundedIcon
|
|
sx={{
|
|
color: this.props.color,
|
|
fontSize: 23,
|
|
}}
|
|
/>
|
|
)}
|
|
</IconContainer>
|
|
</PortWidget>
|
|
))}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
}
|
|
|
|
class NodeFunctionWidget extends React.Component<
|
|
NodeWidgetProps & WithTranslation,
|
|
NodeWidgetState
|
|
> {
|
|
constructor(props: NodeWidgetProps & WithTranslation) {
|
|
super(props);
|
|
this.state = {};
|
|
}
|
|
|
|
render() {
|
|
const { t } = this.props;
|
|
|
|
return (
|
|
<>
|
|
{this.props.node.inputs.length > 0 ? (
|
|
<div className="custom-node-subheader">{t("label.inputs")}</div>
|
|
) : null}
|
|
{this.props.node.inputs.map((input, index) => (
|
|
<div
|
|
key={"i-" + index}
|
|
className={clsx("custom-node-port", "custom-node-port-in")}
|
|
>
|
|
<PortWidget
|
|
engine={this.props.engine}
|
|
//TODO: need to be updated
|
|
// @ts-ignore
|
|
port={this.props.node.getPort(input)}
|
|
className={clsx("circle-porter", "circle-porter-in")}
|
|
>
|
|
<div className={clsx("circle-port")} />
|
|
</PortWidget>
|
|
{input}
|
|
</div>
|
|
))}
|
|
|
|
{this.props.node.outputs.length > 0 ? (
|
|
<div className="custom-node-subheader">Outputs</div>
|
|
) : null}
|
|
{this.props.node.outputs.map((output, index) => (
|
|
<div
|
|
key={"o-" + index}
|
|
className={clsx("custom-node-port", "custom-node-port-out")}
|
|
>
|
|
{output}
|
|
<PortWidget
|
|
engine={this.props.engine}
|
|
//TODO: need to be updated
|
|
// @ts-ignore
|
|
port={this.props.node.getPort(output)}
|
|
className={clsx("circle-porter", "circle-porter-out")}
|
|
>
|
|
<div className={clsx("circle-port")} />
|
|
</PortWidget>
|
|
</div>
|
|
))}
|
|
</>
|
|
);
|
|
}
|
|
}
|
|
|
|
function determineCase(blockMessage: IBlockFull["message"]) {
|
|
if (typeof blockMessage === "string" || Array.isArray(blockMessage))
|
|
return "text";
|
|
if ("attachment" in blockMessage) return "attachment";
|
|
if ("quickReplies" in blockMessage) return "quickReplies";
|
|
if ("buttons" in blockMessage) return "buttons";
|
|
if ("elements" in blockMessage) return "list";
|
|
|
|
return "plugin";
|
|
}
|
|
|
|
const getBlockConfig = (
|
|
blockMessage: IBlockFull["message"],
|
|
): { type: TBlock; color: string; Icon: FC<React.SVGProps<SVGSVGElement>> } => {
|
|
switch (determineCase(blockMessage)) {
|
|
case "text":
|
|
return { type: "text", color: "#009185", Icon: SimpleTextIcon };
|
|
case "attachment":
|
|
return { type: "attachment", color: "#e6a23c", Icon: AttachmentIcon };
|
|
case "quickReplies":
|
|
return { type: "quickReplies", color: "#a80551", Icon: QuickRepliesIcon };
|
|
case "buttons":
|
|
return { type: "buttons", color: "#570063", Icon: ButtonsIcon };
|
|
case "list":
|
|
return { type: "list", color: "#108aa8", Icon: ListIcon };
|
|
case "plugin":
|
|
return { type: "plugin", color: "#a8ba33", Icon: PluginIcon };
|
|
default:
|
|
throw new Error("Unexpected case");
|
|
}
|
|
};
|
|
|
|
class NodeWidget extends React.Component<
|
|
NodeWidgetProps & WithTranslation,
|
|
NodeWidgetState
|
|
> {
|
|
config: {
|
|
type: TBlock;
|
|
color: string;
|
|
Icon: FC<React.SVGProps<SVGSVGElement>>;
|
|
};
|
|
constructor(props: NodeWidgetProps & WithTranslation) {
|
|
super(props);
|
|
this.state = {};
|
|
this.config = getBlockConfig(this.props.node.message as any);
|
|
}
|
|
|
|
render() {
|
|
const { t, i18n, tReady } = this.props;
|
|
|
|
return (
|
|
<div
|
|
className={clsx(
|
|
"custom-node",
|
|
this.props.node.isSelected() ? "selected" : "",
|
|
)}
|
|
style={{
|
|
border: `6px solid ${this.config.color}`,
|
|
}}
|
|
>
|
|
{this.props.node.starts_conversation ? (
|
|
<div className="start-point-container">
|
|
<div className="start-point" />
|
|
</div>
|
|
) : null}
|
|
<div
|
|
className="node-title"
|
|
style={{
|
|
backgroundColor: this.config.color,
|
|
}}
|
|
>
|
|
<div
|
|
style={{
|
|
display: "flex",
|
|
backgroundColor: "white",
|
|
borderRadius: "100%",
|
|
padding: "2px",
|
|
}}
|
|
>
|
|
<this.config.Icon width={32} height={32} />
|
|
</div>
|
|
{this.props.node.title}
|
|
</div>
|
|
<div className="node-block-field-container">
|
|
<div className="node-block-field">
|
|
<TriggerIcon color={this.config.color} />
|
|
|
|
{this.props.node.patterns.length > 0 ? (
|
|
this.props.node.patterns
|
|
.map((pattern: Pattern) => {
|
|
if (typeof pattern === "string") {
|
|
return pattern;
|
|
} else if (typeof pattern === "object") {
|
|
if (pattern && "label" in pattern) {
|
|
return pattern.label;
|
|
} else if (Array.isArray(pattern)) {
|
|
return pattern
|
|
.map((p) => {
|
|
return `${p.entity}=${"value" in p ? p.value : "*"}`;
|
|
})
|
|
.join(" & ");
|
|
}
|
|
}
|
|
})
|
|
.map((p, idx) => (
|
|
<Chip
|
|
variant="inbox"
|
|
size="medium"
|
|
key={`${p}_${idx}`}
|
|
label={p}
|
|
/>
|
|
))
|
|
) : (
|
|
<span style={{ color: "#939393" }}>{t("label.no_patterns")}</span>
|
|
)}
|
|
</div>
|
|
{[BlockTypes.attachment].includes(BlockTypes[this.config.type]) ? (
|
|
<div className="node-block-field">
|
|
<UnifiedIcon
|
|
Icon={BrokenImageOutlinedIcon}
|
|
color={this.config.color}
|
|
size="21px"
|
|
/>
|
|
{t("label.attachment")}:{" "}
|
|
{(this.props.node.message as any).attachment.type}
|
|
</div>
|
|
) : null}
|
|
{[BlockTypes.plugin].includes(BlockTypes[this.config.type]) ? (
|
|
<div className="node-block-field ">
|
|
<UnifiedIcon
|
|
Icon={ExtensionOutlinedIcon}
|
|
color={this.config.color}
|
|
size="21px"
|
|
/>
|
|
<span>Plugin: {(this.props.node.message as any).plugin}</span>
|
|
</div>
|
|
) : null}
|
|
{[BlockTypes.list].includes(BlockTypes[this.config.type]) ? (
|
|
<div className="node-block-field ">
|
|
<UnifiedIcon
|
|
Icon={ChatBubbleOutlineOutlinedIcon}
|
|
color={this.config.color}
|
|
size="21px"
|
|
/>
|
|
{t("label.list")}
|
|
</div>
|
|
) : null}
|
|
{[BlockTypes.text].includes(BlockTypes[this.config.type]) &&
|
|
this.props.node.message.length ? (
|
|
<div className="node-block-field ">
|
|
<UnifiedIcon
|
|
Icon={ChatBubbleOutlineOutlinedIcon}
|
|
color={this.config.color}
|
|
size="21px"
|
|
/>
|
|
{truncate(this.props.node.message[0])}
|
|
</div>
|
|
) : null}
|
|
{[BlockTypes.quickReplies, BlockTypes.buttons].includes(
|
|
BlockTypes[this.config.type],
|
|
) ? (
|
|
<div className="node-block-field ">
|
|
<UnifiedIcon
|
|
Icon={ChatBubbleOutlineOutlinedIcon}
|
|
color={this.config.color}
|
|
size="21px"
|
|
/>
|
|
{
|
|
//TODO: need to be updated
|
|
// @ts-ignore
|
|
truncate(this.props.node.message.text)
|
|
}
|
|
</div>
|
|
) : null}
|
|
{[BlockTypes.quickReplies, BlockTypes.buttons].includes(
|
|
BlockTypes[this.config.type],
|
|
) ? (
|
|
<div className="node-block-field">
|
|
<UnifiedIcon
|
|
Icon={
|
|
this.config.type === "quickReplies"
|
|
? ReplyIcon
|
|
: MenuRoundedIcon
|
|
}
|
|
color={this.config.color}
|
|
size="21px"
|
|
/>
|
|
<div className="node-block-chips">
|
|
{
|
|
//TODO: need to be updated
|
|
// @ts-ignore
|
|
(
|
|
(this.props.node.message as any).buttons ||
|
|
(this.props.node.message as any).quickReplies
|
|
).map((button, idx: number) => (
|
|
<Chip
|
|
key={`${button.title}_${idx}`}
|
|
variant="inbox"
|
|
size="medium"
|
|
label={button.title}
|
|
/>
|
|
))
|
|
}
|
|
</div>
|
|
</div>
|
|
) : null}{" "}
|
|
</div>
|
|
{this.props.node.content ? (
|
|
<NodeAbstractWidget
|
|
node={this.props.node}
|
|
engine={this.props.engine}
|
|
color={this.config.color}
|
|
t={t}
|
|
i18n={i18n}
|
|
tReady={tReady}
|
|
/>
|
|
) : (
|
|
<NodeFunctionWidget
|
|
node={this.props.node}
|
|
engine={this.props.engine}
|
|
color={this.config.color}
|
|
t={t}
|
|
i18n={i18n}
|
|
tReady={tReady}
|
|
/>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
}
|
|
|
|
export default withTranslation()(NodeWidget);
|