From b0c5212a6e44ec8ce81e30b90986a61226c862aa Mon Sep 17 00:00:00 2001 From: Maks <90211175+MXerFix@users.noreply.github.com> Date: Tue, 27 Aug 2024 10:55:11 +0300 Subject: [PATCH 01/10] fix: Optimize frontend performance (#75) * notifications init * optimization: init optimization features * fix: delete unused console.log's * fix: node id crash * fix: Delete poetry usage from code * refactor: Rename app package to dflowd * chore: Rename in tests & ci & docs * chore: Include lower py versions * ci: Automate testing against many py versions * style: Black up * style: Black up * test: Kill process after timeout * doc: Add cli commands desciption * ci: Delete testing on windows * refactor: Rename dflowd to chatsky-ui * refactor: Delete df_designer dir * refactor: Rename docs to chatsky-ui * refactor: Fix tests workflow * fix: Enable project-dir use in cli cmds (#74) * fix: Enable project-dir use in cli cmds * fix: Update front with new structure * test: Fix tests * fix: Install ui into user proj * fix: Enable project-dir in all cli cmds * test: Update for project-dir * refactor: Add new configs * style: Black up * chore: fix string f-format * global: optimization & bug fixes * fix makefile * chore: Fix logs repetition * refactor: Install whl with pip in dockerfile * chore: Raise ValueError for same name nodes * fix: undefined flow 404 page * feat: renaming * fix: notification badge color --- frontend/index.html | 2 +- frontend/src/api/index.ts | 16 +- frontend/src/components/chat/Chat.tsx | 16 +- .../edges/ButtonEdge/ButtonEdge.tsx | 64 ++++++ .../edges/ButtonEdge/buttonedge.css | 19 ++ frontend/src/components/footbar/FootBar.tsx | 104 +++++---- frontend/src/components/header/Header.tsx | 36 ++-- frontend/src/components/nodes/DefaultNode.tsx | 87 +++++--- frontend/src/components/nodes/LinkNode.tsx | 23 +- .../components/nodes/conditions/Condition.tsx | 8 +- .../components/nodes/responses/Response.tsx | 2 +- .../notifications/NotificationsWindow.tsx | 161 ++++++++++++++ .../components/NotificationComponent.tsx | 139 ++++++++++++ frontend/src/components/sidebar/SideBar.tsx | 32 ++- frontend/src/consts.ts | 77 ------- frontend/src/consts.tsx | 199 ++++++++++++++++++ frontend/src/contexts/buildContext.tsx | 23 +- frontend/src/contexts/chatContext.tsx | 3 +- frontend/src/contexts/flowContext.tsx | 32 +-- frontend/src/contexts/index.tsx | 33 +-- .../src/contexts/notificationsContext.tsx | 166 +++++++++++++++ frontend/src/contexts/runContext.tsx | 25 ++- frontend/src/contexts/themeContext.tsx | 4 +- frontend/src/contexts/workspaceContext.tsx | 56 +++-- frontend/src/icons/Logo.tsx | 53 ++--- .../nodes/conditions/ButtonConditionIcon.tsx | 23 ++ .../nodes/conditions/CodeConditionIcon.tsx | 37 ++++ .../nodes/conditions/CustomConditionIcon.tsx | 24 +++ .../nodes/conditions/LLMConditionIcon.tsx | 23 ++ .../nodes/conditions/SlotsConditionIcon.tsx | 26 +++ frontend/src/index.css | 48 ++++- frontend/src/main.tsx | 7 +- .../modals/ConditionModal/ConditionModal.tsx | 26 ++- .../src/modals/FlowModal/CreateFlowModal.tsx | 21 +- .../src/modals/FlowModal/ManageFlowsModal.tsx | 57 +++-- frontend/src/modals/NodeModal/NodeModal.tsx | 41 ++-- .../modals/ResponseModal/ResponseModal.tsx | 20 +- frontend/src/pages/Flow.tsx | 114 +++++++--- frontend/src/pages/Index.tsx | 4 +- frontend/src/pages/Logs.tsx | 6 +- frontend/src/pages/Settings.tsx | 16 +- frontend/src/types/NodeTypes.ts | 1 + frontend/src/utils.ts | 2 +- 43 files changed, 1459 insertions(+), 417 deletions(-) create mode 100644 frontend/src/components/edges/ButtonEdge/ButtonEdge.tsx create mode 100644 frontend/src/components/edges/ButtonEdge/buttonedge.css create mode 100644 frontend/src/components/notifications/NotificationsWindow.tsx create mode 100644 frontend/src/components/notifications/components/NotificationComponent.tsx delete mode 100644 frontend/src/consts.ts create mode 100644 frontend/src/consts.tsx create mode 100644 frontend/src/contexts/notificationsContext.tsx create mode 100644 frontend/src/icons/nodes/conditions/ButtonConditionIcon.tsx create mode 100644 frontend/src/icons/nodes/conditions/CodeConditionIcon.tsx create mode 100644 frontend/src/icons/nodes/conditions/CustomConditionIcon.tsx create mode 100644 frontend/src/icons/nodes/conditions/LLMConditionIcon.tsx create mode 100644 frontend/src/icons/nodes/conditions/SlotsConditionIcon.tsx diff --git a/frontend/index.html b/frontend/index.html index d659d194..c73f00b9 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -4,7 +4,7 @@ - DF Designer + Chatsky UI
diff --git a/frontend/src/api/index.ts b/frontend/src/api/index.ts index 329f1cbc..93ee05bd 100644 --- a/frontend/src/api/index.ts +++ b/frontend/src/api/index.ts @@ -1,9 +1,15 @@ -import axios from "axios"; -import { DEV, VITE_BASE_API_URL } from "../env.consts"; +import axios from "axios" +import { DEV, VITE_BASE_API_URL } from "../env.consts" + +const clearUrlFromQueries = (url: string) => {} // const baseURL = VITE_BASE_API_URL ?? "http://localhost:8000/api/v1" -const baseURL = DEV ? VITE_BASE_API_URL : window.location.href.replace(window.location.pathname, '/api/v1') +const baseURL = DEV + ? VITE_BASE_API_URL + : window.location.protocol + "//" + window.location.host + "/api/v1" + +console.log(baseURL) export const $v1 = axios.create({ - baseURL: baseURL -}) \ No newline at end of file + baseURL: baseURL, +}) diff --git a/frontend/src/components/chat/Chat.tsx b/frontend/src/components/chat/Chat.tsx index 5b1eca66..355282d4 100644 --- a/frontend/src/components/chat/Chat.tsx +++ b/frontend/src/components/chat/Chat.tsx @@ -2,11 +2,10 @@ import { Button, Textarea, Tooltip } from "@nextui-org/react" import { a, useTransition } from "@react-spring/web" import axios from "axios" import { Paperclip, RefreshCcw, Send, Smile, X } from "lucide-react" -import { useContext, useEffect, useRef, useState } from "react" -import toast from "react-hot-toast" +import { memo, useContext, useEffect, useRef, useState } from "react" import { useSearchParams } from "react-router-dom" -import { buildContext } from "../../contexts/buildContext" import { chatContext } from "../../contexts/chatContext" +import { notificationsContext } from "../../contexts/notificationsContext" import { runContext } from "../../contexts/runContext" import { workspaceContext } from "../../contexts/workspaceContext" import { DEV } from "../../env.consts" @@ -14,13 +13,13 @@ import ChatIcon from "../../icons/buildmenu/ChatIcon" import { parseSearchParams } from "../../utils" import EmojiPicker, { EmojiType } from "./EmojiPicker" -const Chat = () => { - const { logsPage, setLogsPage } = useContext(buildContext) +const Chat = memo(() => { const { chat, setChat, messages, setMessages } = useContext(chatContext) const { run, runStatus } = useContext(runContext) const [searchParams, setSearchParams] = useSearchParams() const ws = useRef(null) const { setMouseOnPane } = useContext(workspaceContext) + const { notification: n } = useContext(notificationsContext) const [isEmoji, setIsEmoji] = useState(false) @@ -124,10 +123,11 @@ const Chat = () => { `ws://${DEV ? "localhost:8000" : window.location.host}/api/v1/bot/run/connect?run_id=${run.id}` ) socket.onopen = (e) => { - toast.success("Chat was successfully connected!") + n.add({ message: "Chat was successfully connected!", title: "Success", type: "success" }) } socket.onmessage = (event: MessageEvent) => { - if (event.data) { + console.log(event) + if (event.data && event.data.includes("response")) { const data = event.data.split(":")[2].split("attachments")[0].slice(0, -2) setTimeout(() => { setMessages((prev) => [...prev, { message: data, type: "bot" }]) @@ -273,6 +273,6 @@ const Chat = () => { ) -} +}) export default Chat diff --git a/frontend/src/components/edges/ButtonEdge/ButtonEdge.tsx b/frontend/src/components/edges/ButtonEdge/ButtonEdge.tsx new file mode 100644 index 00000000..4aa018b8 --- /dev/null +++ b/frontend/src/components/edges/ButtonEdge/ButtonEdge.tsx @@ -0,0 +1,64 @@ +import { X } from "lucide-react" +import { + BaseEdge, + Edge, + EdgeLabelRenderer, + EdgeProps, + getBezierPath, + useReactFlow, +} from "reactflow" +import "./buttonedge.css" + +export default function CustomEdge({ + id, + sourceX, + sourceY, + targetX, + targetY, + sourcePosition, + targetPosition, + style = {}, + markerEnd, +}: EdgeProps) { + const { setEdges } = useReactFlow() + const [edgePath, labelX, labelY] = getBezierPath({ + sourceX, + sourceY, + sourcePosition, + targetX, + targetY, + targetPosition, + }) + + const onEdgeClick = () => { + setEdges((edges: Edge[]) => edges.filter((edge) => edge.id !== id)) + } + + return ( + <> + + +
+ +
+
+ + ) +} diff --git a/frontend/src/components/edges/ButtonEdge/buttonedge.css b/frontend/src/components/edges/ButtonEdge/buttonedge.css new file mode 100644 index 00000000..f45f196d --- /dev/null +++ b/frontend/src/components/edges/ButtonEdge/buttonedge.css @@ -0,0 +1,19 @@ +.edgebutton { + width: 20px; + height: 20px; + background: #eee; + border: 1px solid #fff; + cursor: pointer; + border-radius: 50%; + font-size: 12px; + line-height: 1; +} + +.dark.edgebutton { + background: #333; + border: 1px solid #222; +} + +.edgebutton:hover { + box-shadow: 0 0 6px 2px rgba(0, 0, 0, 0.08); +} \ No newline at end of file diff --git a/frontend/src/components/footbar/FootBar.tsx b/frontend/src/components/footbar/FootBar.tsx index 08403b6c..c9a48664 100644 --- a/frontend/src/components/footbar/FootBar.tsx +++ b/frontend/src/components/footbar/FootBar.tsx @@ -1,18 +1,20 @@ -import { Button, Tab, Tabs, useDisclosure } from "@nextui-org/react" +import { Button, Popover, PopoverTrigger, Tab, Tabs, useDisclosure } from "@nextui-org/react" import classNames from "classnames" import { BellRing, EditIcon, Rocket, Settings } from "lucide-react" -import { Key, useContext } from "react" +import { Key, memo, useCallback, useContext, useState } from "react" import { Link, useSearchParams } from "react-router-dom" -import { MetaContext } from "../../contexts/metaContext" import { buildContext } from "../../contexts/buildContext" +import { MetaContext } from "../../contexts/metaContext" +import { notificationsContext } from "../../contexts/notificationsContext" import { workspaceContext } from "../../contexts/workspaceContext" import { Logo } from "../../icons/Logo" import MonitorIcon from "../../icons/buildmenu/MonitorIcon" import LocalStogareIcon from "../../icons/footbar/LocalStogareIcon" import LocalStorage from "../../modals/LocalStorage/LocalStorage" import { parseSearchParams } from "../../utils" +import { NotificationsWindow } from "../notifications/NotificationsWindow" -const FootBar = () => { +const FootBar = memo(() => { const { isOpen: isLocalStogareOpen, onOpen: onLocalStogareOpen, @@ -23,34 +25,41 @@ const FootBar = () => { const { settingsPage, setSettingsPage } = useContext(workspaceContext) const { logsPage, setLogsPage } = useContext(buildContext) const [searchParams, setSearchParams] = useSearchParams() + const [isNotificationsOpen, setIsNotificationsOpen] = useState(false) + const { notifications } = useContext(notificationsContext) - const onSelectionChange = (key: Key) => { - if (key === "Inspect") { - setLogsPage(true) - setSearchParams({ - ...parseSearchParams(searchParams), - logs_page: "opened", - settings: "closed", - }) - } else if (key === "Settings") { - setSettingsPage(true) - setSearchParams({ - ...parseSearchParams(searchParams), - logs_page: "closed", - settings: "opened", - }) - } else { - setSearchParams({ - ...parseSearchParams(searchParams), - logs_page: "closed", - settings: "closed", - }) - setSettingsPage(false) - setLogsPage(false) - } - } + const onSelectionChange = useCallback( + (key: Key) => { + if (key === "Inspect") { + setLogsPage(true) + setSettingsPage(false) + setSearchParams({ + ...parseSearchParams(searchParams), + logs_page: "opened", + settings: "closed", + }) + } else if (key === "Settings") { + setLogsPage(false) + setSettingsPage(true) + setSearchParams({ + ...parseSearchParams(searchParams), + logs_page: "closed", + settings: "opened", + }) + } else { + setSearchParams({ + ...parseSearchParams(searchParams), + logs_page: "closed", + settings: "closed", + }) + setSettingsPage(false) + setLogsPage(false) + } + }, + [searchParams, setLogsPage, setSearchParams, setSettingsPage] + ) - const findDefaultSelectedKey = () => { + const findDefaultSelectedKey = useCallback(() => { if (settingsPage) { return "Settings" } else if (logsPage) { @@ -58,7 +67,7 @@ const FootBar = () => { } else { return "Edit" } - } + }, [logsPage, settingsPage]) return (
{ Inspect }> - {/* */} { Settings }> - {/* */}
@@ -120,7 +127,7 @@ const FootBar = () => { className='flex items-center gap-1 z-10 cursor-pointer'>
- DF Designer + Chatsky UI v {version}
@@ -135,11 +142,28 @@ const FootBar = () => { Local storage - + + + + + + { /> ) -} +}) export default FootBar diff --git a/frontend/src/components/header/Header.tsx b/frontend/src/components/header/Header.tsx index b6a6cccb..bbff4a1f 100644 --- a/frontend/src/components/header/Header.tsx +++ b/frontend/src/components/header/Header.tsx @@ -1,10 +1,9 @@ -import { Button, Tooltip, useDisclosure } from "@nextui-org/react" +import { Button, Tooltip } from "@nextui-org/react" import classNames from "classnames" -import { useContext } from "react" +import { memo, useContext, useMemo } from "react" import { Link, useLocation } from "react-router-dom" import { flowContext } from "../../contexts/flowContext" import { MetaContext } from "../../contexts/metaContext" -import { themeContext } from "../../contexts/themeContext" import { workspaceContext } from "../../contexts/workspaceContext" import { Logo } from "../../icons/Logo" import GrabModeIcon from "../../icons/header/GrabModeIcon" @@ -13,9 +12,8 @@ import ListViewIcon from "../../icons/header/ListViewIcon" import BuildMenu from "./BuildMenu" import NodeInstruments from "./components/NodeInstruments" -const Header = () => { +const Header = memo(() => { const { version } = useContext(MetaContext) - const { toggleTheme, theme } = useContext(themeContext) const location = useLocation() const { toggleWorkspaceMode, @@ -27,26 +25,24 @@ const Header = () => { toggleManagerMode, } = useContext(workspaceContext) const { flows, tab } = useContext(flowContext) - const flow = flows.find((flow) => flow.name === tab) - const { - isOpen: isSettingsModalOpen, - onOpen: onOpenSettingsModal, - onClose: onCloseSettingsModal, - } = useDisclosure() + const flow = useMemo(() => flows.find((flow) => flow.name === tab), [flows, tab]) return (
- {location.pathname.includes("app/home") && ( - + {location.pathname.includes("app/home") && ( + -
- DF Designer +
+ Chatsky UI v {version}
- )} + )} {location.pathname.includes("flow") && (
@@ -58,7 +54,7 @@ const Header = () => { onClick={toggleManagerMode} className={classNames( " bg-overlay hover:bg-background border border-border rounded-small", - !managerMode ? "bg-background border-border-darker" : "" + managerMode ? "bg-background border-border-darker" : "" )}> @@ -71,7 +67,7 @@ const Header = () => { isIconOnly className={classNames( " bg-overlay hover:bg-background border border-border rounded-small", - !workspaceMode ? "bg-background border-border-darker" : "" + workspaceMode ? "bg-background border-border-darker" : "" )}> @@ -84,7 +80,7 @@ const Header = () => { isIconOnly className={classNames( " bg-overlay hover:bg-background border border-border rounded-small", - !nodesLayoutMode ? "bg-background border-border-darker" : "" + nodesLayoutMode ? "bg-background border-border-darker" : "" )}> {/* {nodesLayoutMode ? "Canvas Mode" : "List mode"} */} @@ -109,6 +105,6 @@ const Header = () => {
) -} +}) export default Header diff --git a/frontend/src/components/nodes/DefaultNode.tsx b/frontend/src/components/nodes/DefaultNode.tsx index c30ca15c..1a59b54d 100644 --- a/frontend/src/components/nodes/DefaultNode.tsx +++ b/frontend/src/components/nodes/DefaultNode.tsx @@ -1,6 +1,7 @@ -import { useDisclosure } from "@nextui-org/react" +import { Button, useDisclosure } from "@nextui-org/react" +import classNames from "classnames" import { PlusIcon } from "lucide-react" -import { memo, useContext } from "react" +import { memo, useContext, useMemo, useState } from "react" import { Handle, Position } from "reactflow" import "reactflow/dist/style.css" import { workspaceContext } from "../../contexts/workspaceContext" @@ -12,41 +13,31 @@ import StartNodeIcon from "../../icons/nodes/StartNodeIcon" import "../../index.css" import ConditionModal from "../../modals/ConditionModal/ConditionModal" import NodeModal from "../../modals/NodeModal/NodeModal" +import ResponseModal from "../../modals/ResponseModal/ResponseModal" import { NodeDataType } from "../../types/NodeTypes" import Condition from "./conditions/Condition" import Response from "./responses/Response" const DefaultNode = memo(({ data }: { data: NodeDataType }) => { - - const { onModalClose, onModalOpen } = useContext(workspaceContext) - const { onOpen: onConditionOpen, onClose: onConditionClose, isOpen: isConditionOpen, } = useDisclosure() - const onConditionOpenHandler = () => { - onModalOpen(onConditionOpen) - } + const { selectedNode } = useContext(workspaceContext) - const onConditionCloseHandler = () => { - onModalClose(onConditionClose) - } + const [nodeDataState, setNodeDataState] = useState(data) const { onOpen: onNodeOpen, onClose: onNodeClose, isOpen: isNodeOpen } = useDisclosure() + const { + onOpen: onResponseOpen, + onClose: onResponseClose, + isOpen: isResponseOpen, + } = useDisclosure() - const onNodeOpenHandler = () => { - onModalOpen(onNodeOpen) - } - - const onNodeCloseHandler = () => { - onModalClose(onNodeClose) - } + const validate_node = useMemo(() => data.response?.data.length && data.conditions?.length, []) - // eslint-disable-next-line @typescript-eslint/no-unused-vars - const { handleNodeFlags } = useContext(workspaceContext) - return ( <>
{ data-testid={data.id} className='default_node'> {data.flags?.includes("start") && ( - - + + )} {data.flags?.includes("fallback") && ( - - + + )} -
+
{!data.id.includes("LOCAL_NODE") && !data.id.includes("GLOBAL_NODE") && ( { {data.name}

- +
+ + +
- +
+ +
{data.conditions?.map((condition) => ( {
@@ -115,13 +126,23 @@ const DefaultNode = memo(({ data }: { data: NodeDataType }) => { + ) diff --git a/frontend/src/components/nodes/LinkNode.tsx b/frontend/src/components/nodes/LinkNode.tsx index 72291a71..21055407 100644 --- a/frontend/src/components/nodes/LinkNode.tsx +++ b/frontend/src/components/nodes/LinkNode.tsx @@ -19,11 +19,13 @@ import { Tooltip, useDisclosure, } from "@nextui-org/react" +import classNames from "classnames" import { AlertTriangle, Link2, Trash2 } from "lucide-react" import { memo, useContext, useEffect, useMemo, useState } from "react" import { Handle, Node, Position } from "reactflow" import "reactflow/dist/style.css" import { flowContext } from "../../contexts/flowContext" +import { notificationsContext } from "../../contexts/notificationsContext" import "../../index.css" import { FlowType } from "../../types/FlowTypes" import { NodeDataType } from "../../types/NodeTypes" @@ -33,6 +35,8 @@ const LinkNode = memo(({ data }: { data: NodeDataType }) => { const { flows, deleteNode } = useContext(flowContext) const [toFlow, setToFlow] = useState() const [toNode, setToNode] = useState>() + const [error, setError] = useState(false) + const { notification: n } = useContext(notificationsContext) // const { openPopUp } = useContext(PopUpContext) useEffect(() => { @@ -70,6 +74,19 @@ const LinkNode = memo(({ data }: { data: NodeDataType }) => { [TO_FLOW?.data.nodes, data.transition.target_node] ) + useEffect(() => { + if (!TO_FLOW || !TO_NODE) { + setError(true) + n.add({ + message: `Link ${data.id} is broken! Please configure it again.`, + title: "Link error", + type: "error", + }) + } else { + setError(false) + } + }, [TO_FLOW, TO_NODE]) + const onDismiss = () => { setToFlow(TO_FLOW) setToNode(TO_NODE) @@ -93,7 +110,10 @@ const LinkNode = memo(({ data }: { data: NodeDataType }) => { <>
+ className={classNames( + 'default_node px-6 py-4', + error && "border-error" + )}>
{ selectedKeys={toNode ? [toNode.data.name] : []} items={ toFlow?.data.nodes.filter((node) => { - console.log(node) return ( !["link_node", "global", "local"].includes(node.type!) && !["LOCAL NODE", "GLOBAL NODE"].includes(node.data.name!) diff --git a/frontend/src/components/nodes/conditions/Condition.tsx b/frontend/src/components/nodes/conditions/Condition.tsx index d1ca913f..e6185e06 100644 --- a/frontend/src/components/nodes/conditions/Condition.tsx +++ b/frontend/src/components/nodes/conditions/Condition.tsx @@ -2,9 +2,8 @@ import { useDisclosure } from "@nextui-org/react" import * as ContextMenu from "@radix-ui/react-context-menu" import { useContext, useEffect, useState } from "react" import { Handle, Position, useReactFlow } from "reactflow" -import { CONDITION_LABELS } from "../../../consts" +import { CONDITION_LABELS, conditionTypeIcons } from "../../../consts" import { workspaceContext } from "../../../contexts/workspaceContext" -import { UserConditionIcon } from "../../../icons/nodes/conditions/UserConditionIcon" import ConditionModal from "../../../modals/ConditionModal/ConditionModal" import { conditionLabelType } from "../../../types/ConditionTypes" import { NodeComponentConditionType } from "../../../types/NodeTypes" @@ -38,10 +37,7 @@ const Condition = ({ data, condition }: NodeComponentConditionType) => {
- + {conditionTypeIcons[condition.type]} {condition.name}

{condition.data.priority}

diff --git a/frontend/src/components/nodes/responses/Response.tsx b/frontend/src/components/nodes/responses/Response.tsx index 112f9ae5..2e4ea468 100644 --- a/frontend/src/components/nodes/responses/Response.tsx +++ b/frontend/src/components/nodes/responses/Response.tsx @@ -5,7 +5,7 @@ const Response = ({ data }: NodeComponentType) => { return (
-

{data.response?.data[0]?.text ?? "No text response"}

+

{data.response?.data[0]?.text ?? "No text response"}

) } diff --git a/frontend/src/components/notifications/NotificationsWindow.tsx b/frontend/src/components/notifications/NotificationsWindow.tsx new file mode 100644 index 00000000..a947c854 --- /dev/null +++ b/frontend/src/components/notifications/NotificationsWindow.tsx @@ -0,0 +1,161 @@ +import { + Button, + PopoverContent, + Select, + SelectItem, + SelectSection, + Tooltip +} from "@nextui-org/react" +import { AlertOctagon, AlertTriangle, BugIcon, CheckCircle2, InfoIcon, Trash } from "lucide-react" +import { useContext, useState } from "react" +import { notificationsContext } from "../../contexts/notificationsContext" +import NotificationComponent from "./components/NotificationComponent" + +type NotificationsWindowProps = { + isOpen: boolean + setIsOpen: React.Dispatch> +} + +// const getNotificationsStack = (notifications: notificationType[], index: number) => { +// const notification_instance: notificationType = notifications[index] +// let counter = 1 +// while ( +// notifications[index + counter]?.title === notification_instance?.title && +// notifications[index + counter]?.message === notification_instance?.message && +// notifications[index + counter]?.type === notification_instance?.type +// ) { +// counter += 1 +// } +// return counter +// } + +export const NotificationsWindow = ({ isOpen, setIsOpen }: NotificationsWindowProps) => { + const { notifications, notification } = useContext(notificationsContext) + const [notificationFilter, setNotificationFilter] = useState([]) + + const handleSelectionChange = (e: React.ChangeEvent) => { + if (e.target.value === "") { + setNotificationFilter([]) + } else { + setNotificationFilter([...e.target.value.split(",")]) + } + }; + + console.log(notificationFilter) + + const renderNotifications = () => { + const filtered_notifications = notifications.filter( + (not) => notificationFilter.includes(not.type) || notificationFilter.length === 0 + ) + const time_sorted_notifications = filtered_notifications.sort( + (a, b) => a.timestamp - b.timestamp + ) + const stack_checked_notifications = time_sorted_notifications.map((not, idx) => { + const stack = not.stack + 1 + const next_not = time_sorted_notifications[idx + 1] + if (next_not && not && next_not.message === not.message && not.stack !== 0) { + console.log(not, next_not) + next_not.stack = stack + not.stack = 0 + } + return not + }) + console.log(stack_checked_notifications) + const stack_filtered_notifications = stack_checked_notifications.filter((not) => not.stack > 0) + if (stack_filtered_notifications.length === 0) { + return ( + <> +

No notifications yet

+ + ) + } + return stack_filtered_notifications.sort((a, b) => b.timestamp - a.timestamp).map((notification) => ( + + )) + } + + const clearNotificationsHandler = () => { + notification.clear() + setIsOpen(false) + } + + return ( + +
+

Notifications

+
+ + + + +
+
+
+ {renderNotifications()} +
+
+ ) +} diff --git a/frontend/src/components/notifications/components/NotificationComponent.tsx b/frontend/src/components/notifications/components/NotificationComponent.tsx new file mode 100644 index 00000000..53170a30 --- /dev/null +++ b/frontend/src/components/notifications/components/NotificationComponent.tsx @@ -0,0 +1,139 @@ +import { Button } from "@nextui-org/react" +import classNames from "classnames" +import { AlertOctagon, AlertTriangle, Bug, CheckCircle2, Info, X } from "lucide-react" +import { useContext, useMemo, useState } from "react" +import { notificationType, notificationsContext } from "../../../contexts/notificationsContext" + +type NotificationComponentType = { + notification: notificationType & { + stack: number + } +} + +const NotificationComponent = ({ notification }: NotificationComponentType) => { + const { notification: nt, notifications } = useContext(notificationsContext) + const [isDelete, setIsDelete] = useState(false) + + const notificationTypeColor = (type: string) => { + switch (type) { + case "success": + return { + body: "bg-[#00CC991a]", + stack: "bg-[#00CC99]", + stroke: "bg-[#00CC99]", + } + case "warning": + return { + body: "bg-[#FF95001A]", + stack: "bg-[#FF9500]", + stroke: "bg-[#FF9500]", + } + case "error": + return { + body: "bg-[#FF33331A]", + stack: "bg-[#FF3333]", + stroke: "bg-[#FF3333]", + } + case "info": + return { + body: "bg-[#3399CC1A]", + stack: "bg-[#3399CC]", + stroke: "bg-[#3399CC]", + } + case "debug": + return { + body: "bg-[#9999991A]", + stack: "bg-[#999999]", + stroke: "bg-[#999999]", + } + } + } + + const notificationColor = useMemo( + () => notificationTypeColor(notification.type)?.body, + [notification.type] + ) + + const notificationStackColor = useMemo( + () => notificationTypeColor(notification.type)?.stack, + [notification.type] + ) + + const notificationTypeIcon = (type: string) => { + switch (type) { + case "success": + return + case "warning": + return + case "error": + return + case "info": + return + case "debug": + return + } + } + + const deleteCurrentNotification = () => { + setIsDelete(true) + setTimeout(() => { + if (notification.stack == 1) { + nt.delete(notification.timestamp) + } + if (notification.stack > 1) { + const index = notifications.findIndex((n) => n.timestamp == notification.timestamp) + for (let i = 0; i <= index; i++) { + console.log(notifications[i]) + if (notifications[i].stack === 0 && notifications[i].message === notification.message) { + console.log("deletion") + nt.delete(notifications[i].timestamp) + } + } + nt.delete(notification.timestamp) + } + }, 300) + } + + return ( +
+
+
+
+
+ {notification.stack <= 1 ? ( + notificationTypeIcon(notification.type) + ) : ( + + {notification.stack} + + )} +

{notification.title}

+
+ +
+ {notification.message ?

{notification.message}

:

{new Date(notification.timestamp).toLocaleString()}

} +
+
+
+ ) +} + +export default NotificationComponent diff --git a/frontend/src/components/sidebar/SideBar.tsx b/frontend/src/components/sidebar/SideBar.tsx index 3185bd2d..f1967b6d 100644 --- a/frontend/src/components/sidebar/SideBar.tsx +++ b/frontend/src/components/sidebar/SideBar.tsx @@ -1,6 +1,6 @@ import { Accordion, AccordionItem, Button, Divider, useDisclosure } from "@nextui-org/react" import { Plus } from "lucide-react" -import { useContext, useMemo } from "react" +import { memo, useContext, useMemo } from "react" import { useParams } from "react-router-dom" import { flowContext } from "../../contexts/flowContext" import EditNodeIcon from "../../icons/nodes/EditNodeIcon" @@ -13,8 +13,9 @@ import { DragList } from "./DragList" import DragListItem from "./DragListItem" import FlowItem from "./FlowItem" -const SideBar = () => { +const SideBar = memo(() => { const { flows } = useContext(flowContext) + const { flowId } = useParams() const { isOpen: isCreateFlowModalOpen, onOpen: onOpenCreateFlowModal, @@ -25,10 +26,9 @@ const SideBar = () => { onOpen: onOpenManageFlowsModal, onClose: onCloseManageFlowsModal, } = useDisclosure() - const { flowId } = useParams() - const activeFlow = useMemo(() => flowId, [flowId]) - const globalFlow = flows.find((flow) => flow.name === "Global") + const activeFlow = useMemo(() => flowId, [flowId]) + const globalFlow = useMemo(() => flows.find((flow) => flow.name === "Global"), [flows]) return (
{
-
+

Flows

- {/* */} - {/* - - df_designer v0.1.0-beta.1 - */}
{ />
) -} +}) export default SideBar diff --git a/frontend/src/consts.ts b/frontend/src/consts.ts deleted file mode 100644 index 065e3443..00000000 --- a/frontend/src/consts.ts +++ /dev/null @@ -1,77 +0,0 @@ -import { conditionLabelType } from "./types/ConditionTypes" - -export const NODE_TYPES = { - default_node: "default_node", - // start_node: "start_node", - // fallback_node: "fallback_node", -} - -export const START_FALLBACK_NODE_FLAGS = ["start", "fallback"] -export const START_NODE_FLAGS = ["start"] -export const FALLBACK_NODE_FLAGS = ["fallback"] - -export const NODES = { - default_node: { - name: "Default Node", - type: "default_node", - conditions: [], - global_conditions: [], - local_conditions: [], - response: { - name: "default_response", - type: "text", - data: [{ text: "Default node response", priority: 1 }], - }, - }, - // start_node: { - // name: "Start Node", - // type: "start_node", - // conditions: [], - // global_conditions: [], - // local_conditions: [], - // response: "Start response", - // }, - // fallback_node: { - // name: "Fallback Node", - // type: "fallback_node", - // conditions: [], - // global_conditions: [], - // local_conditions: [], - // response: "Fallback response", - // }, - link_node: { - name: "Link", - type: "link_node", - conditions: [], - global_conditions: [], - local_conditions: [], - response: { - name: "Link response", - type: "text", - data: [{ text: "Link response", priority: 1 }], - } - }, -} - -export const FLOW_COLORS = [ - "#FF3333", - "#FF9500", - "#FFCC00", - "#00CC99", - "#3300FF", - "#7000FF", - "#CC66CC", - "#FF3366", -] - -export const CONDITION_LABELS: { - [key: string]: conditionLabelType -} = { - manual: "manual", - forward: "forward", - backward: "backward", - repeat: "repeat", - fallback: "fallback", - start: "start", - previous: "previous", -} diff --git a/frontend/src/consts.tsx b/frontend/src/consts.tsx new file mode 100644 index 00000000..0be0e0f0 --- /dev/null +++ b/frontend/src/consts.tsx @@ -0,0 +1,199 @@ +import { Code2, Text } from "lucide-react" +import ButtonConditionIcon from "./icons/nodes/conditions/ButtonConditionIcon" +import CodeConditionIcon from "./icons/nodes/conditions/CodeConditionIcon" +import CustomConditionIcon from "./icons/nodes/conditions/CustomConditionIcon" +import LLMConditionIcon from "./icons/nodes/conditions/LLMConditionIcon" +import SlotsConditionIcon from "./icons/nodes/conditions/SlotsConditionIcon" +import { conditionLabelType } from "./types/ConditionTypes" + +export const NODE_TYPES = { + default_node: "default_node", + // start_node: "start_node", + // fallback_node: "fallback_node", +} + +export const NODE_NAMES = [ + "Beginning of conversation", + "Weather discussion", + "Question about work", + "Talking about hobbies", + "Family talk", + "Weekend plans", + "Movie discussion", + "Exchanging opinions on books", + "Travel talk", + "Sports discussion", + "Talking about friends", + "News discussion", + "Music talk", + "Food discussion", + "Vacation plans", + "Pets discussion", + "Health talk", + "Technology discussion", + "Childhood story", + "Art talk", + "Future plans", + "Politics discussion", + "Culture talk", + "Education story", + "Social problems discussion", + "Evening plans", + "Relationship talk", + "Science talk", + "Interests story", + "Economy discussion", + "Year plans", + "History talk", + "Environmental talk", + "Work experience story", + "Philosophy talk", + "Month plans", + "Rest talk", + "Psychology discussion", + "Study story", + "Transportation discussion", + "Day plans", + "Society talk", + "Medicine discussion", + "Leisure story", + "Fashion discussion", + "Minute plans", + "Technology talk", + "Culinary discussion", + "Friends story", + "Music discussion", + "Second plans", + "Education talk", + "Literature discussion", + "Ideas story", + "Entertainment discussion", + "Hour plans", + "Economy talk", + "Science discussion", + "Experience story", + "Political discussion", + "Now plans", + "Sport talk", + "Movie discussion", + "Family story", + "Travel discussion", + "Second plans", + "Economic talk", + "Art discussion", + "Interest story", + "Finance discussion", + "Morning plans", + "Society talk", + "Medical discussion", + "Study story", + "Transportation discussion", + "Evening plans", + "Fashion talk", + "Psychology discussion", + "Leisure story", + "Entertainment discussion", + "Night plans", + "Literature talk", + "Culinary discussion", + "Technology story", + "Music discussion", + "Next year plans", + "Movie talk", + "Travel discussion", + "Family story", + "Sports talk", + "Plans for the next year", + "Book talk", + "Cooking discussion", + "Technology story", + "Music talk", + "Next year plans" +] + +export const START_FALLBACK_NODE_FLAGS = ["start", "fallback"] +export const START_NODE_FLAGS = ["start"] +export const FALLBACK_NODE_FLAGS = ["fallback"] + +export const NODES = { + default_node: { + name: "Default Node", + type: "default_node", + dragHandle: '.custom-drag-handle', + conditions: [], + global_conditions: [], + local_conditions: [], + response: { + name: "default_response", + type: "text", + data: [{ text: "I am a bot and here is my quote ", priority: 1 }], + }, + }, + // start_node: { + // name: "Start Node", + // type: "start_node", + // conditions: [], + // global_conditions: [], + // local_conditions: [], + // response: "Start response", + // }, + // fallback_node: { + // name: "Fallback Node", + // type: "fallback_node", + // conditions: [], + // global_conditions: [], + // local_conditions: [], + // response: "Fallback response", + // }, + link_node: { + name: "Link", + type: "link_node", + dragHandle: '', + conditions: [], + global_conditions: [], + local_conditions: [], + response: { + name: "Link response", + type: "text", + data: [{ text: "Link response", priority: 1 }], + } + }, +} + +export const FLOW_COLORS = [ + "#FF3333", + "#FF9500", + "#FFCC00", + "#00CC99", + "#3300FF", + "#7000FF", + "#CC66CC", + "#FF3366", +] + +export const CONDITION_LABELS: { + [key: string]: conditionLabelType +} = { + manual: "manual", + forward: "forward", + backward: "backward", + repeat: "repeat", + fallback: "fallback", + start: "start", + previous: "previous", +} + +export const conditionTypeIcons = { + python: , + custom: , + slot: , + button: , + llm: , +} + +export const responseTypeIcons = { + python: , + text: , + llm: +} + diff --git a/frontend/src/contexts/buildContext.tsx b/frontend/src/contexts/buildContext.tsx index 5f098368..a83433a8 100644 --- a/frontend/src/contexts/buildContext.tsx +++ b/frontend/src/contexts/buildContext.tsx @@ -1,6 +1,5 @@ /* eslint-disable react-refresh/only-export-components */ -import { createContext, useEffect, useState } from "react" -import toast from "react-hot-toast" +import { createContext, useContext, useEffect, useState } from "react" import { useSearchParams } from "react-router-dom" import { buildApiStatusType, @@ -12,6 +11,7 @@ import { get_builds, localBuildType, } from "../api/bot" +import { notificationsContext } from "./notificationsContext" type BuildContextType = { build: boolean @@ -53,6 +53,7 @@ export const BuildProvider = ({ children }: { children: React.ReactNode }) => { const [searchParams, setSearchParams] = useSearchParams() const [logsPage, setLogsPage] = useState(searchParams.get("logs_page") === "opened") const [builds, setBuilds] = useState([]) + const { notification: n } = useContext(notificationsContext) const setBuildsHandler = (builds: buildMinifyApiType[]) => { setBuilds(() => @@ -93,7 +94,11 @@ export const BuildProvider = ({ children }: { children: React.ReactNode }) => { if (timer > 15) { setBuild(() => false) setBuildStatus("failed") - toast.error("Build timeout!") + n.add({ + title: "Build timeout error!", + message: "", + type: "error", + }) await build_stop(start_res.build_id) return (flag = false) } @@ -113,11 +118,19 @@ export const BuildProvider = ({ children }: { children: React.ReactNode }) => { if (status === "completed") { setBuildStatus("completed") setBuild(() => true) - toast.success("Build successfully!") + n.add({ + title: "Build successfully!", + message: "", + type: "success", + }) } else if (status === "failed") { setBuildStatus("failed") setBuild(() => false) - toast.error("Build failed!") + n.add({ + title: "Build failed!", + message: "Unknown build error. Please check your script.", + type: "error", + }) } } await new Promise((resolve) => setTimeout(resolve, 1000)) diff --git a/frontend/src/contexts/chatContext.tsx b/frontend/src/contexts/chatContext.tsx index 39212789..146443a2 100644 --- a/frontend/src/contexts/chatContext.tsx +++ b/frontend/src/contexts/chatContext.tsx @@ -1,5 +1,6 @@ import { createContext, useState } from "react" import { useSearchParams } from "react-router-dom" +import useLocalStorage from "../hooks/useLocalStorage" export type messageType = { message: string @@ -21,7 +22,7 @@ export const ChatProvider = ({ children }: { children: React.ReactNode }) => { // eslint-disable-next-line @typescript-eslint/no-unused-vars const [searchParams, setSearchParams] = useSearchParams() const [chat, setChat] = useState(searchParams.get('chat') === 'opened') - const [messages, setMessages] = useState([]) + const [messages, setMessages] = useLocalStorage("chat_messages", []) return ( { const [tab, setTab] = useState(initialValue.tab) const { flowId } = useParams() const [flows, setFlows] = useState([]) + const { notification: n } = useContext(notificationsContext) useEffect(() => { setTab(flowId || "") @@ -105,7 +106,7 @@ export const FlowProvider = ({ children }: { children: React.ReactNode }) => { } } - useEffect(() => { + useEffect(() => { getFlows() }, []) @@ -118,13 +119,13 @@ export const FlowProvider = ({ children }: { children: React.ReactNode }) => { setTimeout(async () => { console.log("quiet save flows") await saveFlows(flows) - toast.success("DEBUG: Flows saved") + n.add({ message: "Flows saved", title: "DEBUG", type: "debug" }) }, 100) } - const getLocaleFlows = () => { + const getLocaleFlows = useCallback(() => { return flows - } + }, [flows]) const deleteFlow = useCallback( (flow: FlowType) => { @@ -144,13 +145,14 @@ export const FlowProvider = ({ children }: { children: React.ReactNode }) => { [flows] ) - const deleteNode = (id: string) => { + const deleteNode = useCallback((id: string) => { const flow = flows.find((flow) => flow.data.nodes.some((node) => node.id === id)) if (!flow) return -1 const deleted_node: NodeType = flow.data.nodes.find((node) => node.id === id) as NodeType - if (deleted_node?.data.flags?.includes("start")) return toast.error("Can't delete start node") - if (deleted_node?.id?.includes("LOCAL")) return toast.error("Can't delete local node") - if (deleted_node?.id?.includes("GLOBAL")) return toast.error("Can't delete global node") + if (deleted_node?.data.flags?.includes("start")) + return n.add({ title: "Warning!", message: "Can't delete start node", type: "warning" }) + if (deleted_node?.id?.includes("LOCAL")) return n.add({ title: "Warning!", message: "Can't delete local node", type: "warning" }) + if (deleted_node?.id?.includes("GLOBAL")) return n.add({ title: "Warning!", message: "Can't delete global node", type: "warning" }) if (deleted_node?.data.flags?.includes("fallback")) { console.log( flow.data.nodes @@ -170,9 +172,9 @@ export const FlowProvider = ({ children }: { children: React.ReactNode }) => { ) saveFlows(newFlows) setFlows(newFlows) - } + }, [flowId, flows, n]) - const deleteEdge = (id: string) => { + const deleteEdge = useCallback((id: string) => { const flow = flows.find((flow) => flow.data.edges.some((edge) => edge.id === id)) if (!flow) return -1 const newEdges = flow.data.edges.filter((edge) => edge.id !== id) @@ -182,15 +184,15 @@ export const FlowProvider = ({ children }: { children: React.ReactNode }) => { ) ) setFlows((flows) => flows.map((flow) => ({ ...flow, data: { ...flow.data, edges: newEdges } }))) - } + }, [flowId, flows]) - const deleteObject = (id: string) => { + const deleteObject = useCallback((id: string) => { const flow_node = flows.find((flow) => flow.data.nodes.some((node) => node.id === id)) const flow_edge = flows.find((flow) => flow.data.edges.some((edge) => edge.id === id)) if (!flow_node && !flow_edge) return -1 if (flow_node) deleteNode(id) if (flow_edge) deleteEdge(id) - } + }, [deleteEdge, deleteNode, flows]) return ( - - - - - - - - {children} - - - - - - - + + + + + + + + + {children} + + + + + + + + ) } diff --git a/frontend/src/contexts/notificationsContext.tsx b/frontend/src/contexts/notificationsContext.tsx new file mode 100644 index 00000000..f02b9964 --- /dev/null +++ b/frontend/src/contexts/notificationsContext.tsx @@ -0,0 +1,166 @@ +import classNames from "classnames" +import { AlertOctagon, AlertTriangle, Bug, CheckCircle2, Info } from "lucide-react" +import { createContext } from "react" +import toast from "react-hot-toast" +import useLocalStorage from "../hooks/useLocalStorage" + +export type notificationTypeType = "success" | "warning" | "error" | "info" | "debug" + +export type notificationType = { + title: string + message: string + type: notificationTypeType + duration: number + timestamp: number + stack: number +} + +export type createNotificationType = { + title: string + message: string + type?: "success" | "warning" | "error" | "info" | "debug" + duration?: number + timestamp?: number + stack?: number +} + +type notificationsContextType = { + notifications: notificationType[] + notification: { + add: (notification: createNotificationType) => void + delete: (timestamp: number) => void + clear: () => void + set: (notifications: notificationType[]) => void + } +} + +export const notificationsContext = createContext({ + notifications: [], + notification: { + add: () => {}, + delete: () => {}, + clear: () => {}, + set: () => {}, + }, +}) + +const NotificationsProvider = ({ children }: { children: React.ReactNode }) => { + const [notifications, setNotifications] = useLocalStorage("notifications", []) + + const notificationTypeColor = (type: string) => { + switch (type) { + case "success": + return "bg-[#ebf9f5] border-green-500" + case "warning": + return "bg-[#fff5ea] border-yellow-500" + case "error": + return "bg-[#ffebeb] border-red-500" + case "info": + return "bg-[#ebf4fa] border-blue-500" + case "debug": + return "bg-[#f5f5f5] border-neutral-500" + } + } + + const notificationHeaderColor = (type: string) => { + switch (type) { + case "success": + return "text-black" + case "warning": + return "text-black" + case "error": + return "text-[#B20000]" + case "info": + return "text-black" + case "debug": + return "text-neutral-500" + } + } + const notificationTypeIcon = (type: string) => { + switch (type) { + case "success": + return + case "warning": + return + case "error": + return + case "info": + return + case "debug": + return + } + } + + const addNotification = ({ + message, + title, + type = "info", + duration = 5000, + timestamp = Date.now(), + stack = 1, + }: createNotificationType) => { + const color = notificationTypeColor(type) + const notification = { title, message, type, timestamp, stack, duration } + setNotifications((prevNotifications) => [...prevNotifications, notification]) + toast.custom( + (t) => ( +
+
+
+ {notificationTypeIcon(notification.type)} +

+ {notification.title} +

+
+ {notification.message && ( +

{notification.message}

+ )} +
+
+ ), + { + id: message, + } + ) + } + + const deleteNotification = (timestamp: number) => { + + setNotifications((prevNotifications) => + prevNotifications.filter((notification) => notification.timestamp !== timestamp) + ) + } + + const clearNotifications = () => { + setNotifications([]) + } + + const notification = { + add: addNotification, + delete: deleteNotification, + clear: clearNotifications, + set: setNotifications, + } + + return ( + + {children} + + ) +} + +export default NotificationsProvider diff --git a/frontend/src/contexts/runContext.tsx b/frontend/src/contexts/runContext.tsx index 7d787035..fb49a0c0 100644 --- a/frontend/src/contexts/runContext.tsx +++ b/frontend/src/contexts/runContext.tsx @@ -1,5 +1,4 @@ import { createContext, useContext, useEffect, useState } from "react" -import toast from "react-hot-toast" import { buildApiStatusType, buildPresetType, @@ -12,6 +11,7 @@ import { run_stop, } from "../api/bot" import { buildContext } from "./buildContext" +import { notificationsContext } from "./notificationsContext" export type runApiType = { id: number @@ -58,6 +58,7 @@ export const RunProvider = ({ children }: { children: React.ReactNode }) => { const [runStatus, setRunStatus] = useState("stopped") const [runs, setRuns] = useState([]) const { setBuildsHandler, builds: context_builds } = useContext(buildContext) + const { notification: n } = useContext(notificationsContext) const setRunsHandler = (runs: runMinifyApiType[]) => { setRuns(runs.map((run) => ({ ...run, type: "run" }))) @@ -70,7 +71,7 @@ export const RunProvider = ({ children }: { children: React.ReactNode }) => { return { ...run, type: "run" } }) setRuns(_runs) - if (_runs[_runs.length - 1].status === "running") { + if (_runs[_runs.length - 1].status === "alive") { setRun(_runs[_runs.length - 1]) setRunStatus("alive") } @@ -108,7 +109,11 @@ export const RunProvider = ({ children }: { children: React.ReactNode }) => { if (timer > 9999) { setRunPending(() => false) setRunStatus("failed") - toast.error("Run timeout error!") + n.add({ + title: "Run timeout error!", + message: "", + type: "error", + }) return (flag = false) } const { status } = await run_status(started_run.id) @@ -117,11 +122,15 @@ export const RunProvider = ({ children }: { children: React.ReactNode }) => { setRunPending(false) setRunStatus("alive") } - if (status === 'failed') { + if (status === "failed") { flag = false setRunPending(false) setRunStatus("failed") - toast.error("Run failed!") + n.add({ + message: "Unknown run error. Please check your script.", + title: "Run failed!", + type: "error", + }) } await new Promise((resolve) => setTimeout(resolve, 500)) } @@ -146,7 +155,11 @@ export const RunProvider = ({ children }: { children: React.ReactNode }) => { setRunsHandler(runs) setRunStatus("stopped") setRunPending(() => false) - toast.success("Run stopped!") + n.add({ + message: "", + title: "Run stopped!", + type: "info", + }) }, 500) } } catch (error) { diff --git a/frontend/src/contexts/themeContext.tsx b/frontend/src/contexts/themeContext.tsx index 88acfc2e..8a1ef4f0 100644 --- a/frontend/src/contexts/themeContext.tsx +++ b/frontend/src/contexts/themeContext.tsx @@ -21,8 +21,8 @@ export const ThemeProvider = ({ children }: { children: React.ReactNode }) => { useEffect(() => { const _theme = localStorage.getItem("theme") as themeType - setTheme(_theme) - document.body.classList.add(_theme) + setTheme(_theme ?? "dark") + document.body.classList.add(_theme ?? "dark") }, []) const toggleTheme = useCallback(() => { diff --git a/frontend/src/contexts/workspaceContext.tsx b/frontend/src/contexts/workspaceContext.tsx index 1661f0c3..41381b6e 100644 --- a/frontend/src/contexts/workspaceContext.tsx +++ b/frontend/src/contexts/workspaceContext.tsx @@ -1,10 +1,11 @@ /* eslint-disable react-refresh/only-export-components */ -import { createContext, useContext, useEffect, useState } from "react" +import { createContext, useCallback, useContext, useEffect, useState } from "react" import { useSearchParams } from "react-router-dom" import { Node } from "reactflow" import { FlowType } from "../types/FlowTypes" import { NodeDataType } from "../types/NodeTypes" import { flowContext } from "./flowContext" +import { notificationsContext } from "./notificationsContext" type WorkspaceContextType = { workspaceMode: boolean @@ -60,11 +61,12 @@ export const WorkspaceProvider = ({ children }: { children: React.ReactNode }) = const [nodesLayoutMode, setNodesLayoutMode] = useState(false) const [managerMode, setManagerMode] = useState(false) const [searchParams, setSearchParams] = useSearchParams() - const [settingsPage, setSettingsPage] = useState(searchParams.get('settings') === 'opened') + const [settingsPage, setSettingsPage] = useState(searchParams.get("settings") === "opened") const [selectedNode, setSelectedNode] = useState("") const { updateFlow, flows, tab, quietSaveFlows, setFlows } = useContext(flowContext) const [mouseOnPane, setMouseOnPane] = useState(true) const [modalsOpened, setModalsOpened] = useState(0) + const { notification: n } = useContext(notificationsContext) useEffect(() => { console.log(modalsOpened) @@ -80,24 +82,39 @@ export const WorkspaceProvider = ({ children }: { children: React.ReactNode }) = setModalsOpened(0) } }, [modalsOpened]) - + useEffect(() => console.log(mouseOnPane), [mouseOnPane]) const flow = flows.find((flow) => flow.name === tab) - const toggleWorkspaceMode = () => { + const toggleWorkspaceMode = useCallback(() => { setWorkspaceMode(() => !workspaceMode) - } + n.add({ + message: `Workspace mode is now ${workspaceMode ? "fixed" : "free"}.`, + title: "Workspace mode changed!", + type: "info", + }) + }, [n, workspaceMode]) - const toggleNodesLayoutMode = () => { + const toggleNodesLayoutMode = useCallback(() => { setNodesLayoutMode(() => !nodesLayoutMode) - } + n.add({ + message: `Nodes layout mode is now ${!nodesLayoutMode ? "on" : "off"}.`, + title: "Layout mode changed!", + type: "info", + }) + }, [n, nodesLayoutMode]) - const toggleManagerMode = () => { + const toggleManagerMode = useCallback(() => { setManagerMode(() => !managerMode) - } + n.add({ + message: `Manager mode is now ${!managerMode ? "on" : "off"}.`, + title: "Mode changed!", + type: "info", + }) + }, [managerMode, n]) - const handleNodeFlags = (e: React.MouseEvent) => { + const handleNodeFlags = useCallback((e: React.MouseEvent) => { const nodes = flows.flatMap((flow) => flow.data.nodes) console.log(nodes) const new_nds = nodes.map((nd: Node) => { @@ -127,23 +144,22 @@ export const WorkspaceProvider = ({ children }: { children: React.ReactNode }) = }, } }) - setFlows(new_flows) - - if (flow) { - updateFlow(flow) - } + setFlows(() => new_flows) + // if (flow) { + // updateFlow(flow) + // } quietSaveFlows() - } + }, [flows, quietSaveFlows, selectedNode, setFlows]) - const onModalOpen = (onOpen: () => void) => { + const onModalOpen = useCallback((onOpen: () => void) => { setMouseOnPane(false) onOpen() - } + }, []) - const onModalClose = (onClose: () => void) => { + const onModalClose = useCallback((onClose: () => void) => { setMouseOnPane(true) onClose() - } + }, []) return ( { return ( + height='31' + viewBox='0 0 360 317' + fill='none' + xmlns='http://www.w3.org/2000/svg'> - - - - - - - ) diff --git a/frontend/src/icons/nodes/conditions/ButtonConditionIcon.tsx b/frontend/src/icons/nodes/conditions/ButtonConditionIcon.tsx new file mode 100644 index 00000000..5c29e853 --- /dev/null +++ b/frontend/src/icons/nodes/conditions/ButtonConditionIcon.tsx @@ -0,0 +1,23 @@ +import React from "react" + +const ButtonConditionIcon = ({className, stroke="var(--foreground)"}: React.SVGAttributes) => { + return ( + + + + ) +} + +export default ButtonConditionIcon diff --git a/frontend/src/icons/nodes/conditions/CodeConditionIcon.tsx b/frontend/src/icons/nodes/conditions/CodeConditionIcon.tsx new file mode 100644 index 00000000..933c473a --- /dev/null +++ b/frontend/src/icons/nodes/conditions/CodeConditionIcon.tsx @@ -0,0 +1,37 @@ +import React from "react" + +const CodeConditionIcon = ({className, fill="var(--foreground)"}: React.SVGAttributes) => { + return ( + + + + + + ) +} + +export default CodeConditionIcon diff --git a/frontend/src/icons/nodes/conditions/CustomConditionIcon.tsx b/frontend/src/icons/nodes/conditions/CustomConditionIcon.tsx new file mode 100644 index 00000000..e3c7c005 --- /dev/null +++ b/frontend/src/icons/nodes/conditions/CustomConditionIcon.tsx @@ -0,0 +1,24 @@ +import React from "react" + +const CustomConditionIcon = ({ className, fill, stroke="var(--foreground)" }: React.SVGAttributes) => { + return ( + + + + ) +} + +export default CustomConditionIcon diff --git a/frontend/src/icons/nodes/conditions/LLMConditionIcon.tsx b/frontend/src/icons/nodes/conditions/LLMConditionIcon.tsx new file mode 100644 index 00000000..7b981cc6 --- /dev/null +++ b/frontend/src/icons/nodes/conditions/LLMConditionIcon.tsx @@ -0,0 +1,23 @@ +import React from "react" + +const LLMConditionIcon = ({className, fill="var(--foreground)"}: React.SVGAttributes) => { + return ( + + + + ) +} + +export default LLMConditionIcon diff --git a/frontend/src/icons/nodes/conditions/SlotsConditionIcon.tsx b/frontend/src/icons/nodes/conditions/SlotsConditionIcon.tsx new file mode 100644 index 00000000..e15bc4ee --- /dev/null +++ b/frontend/src/icons/nodes/conditions/SlotsConditionIcon.tsx @@ -0,0 +1,26 @@ +import React from "react" + +const SlotsConditionIcon = ({ + className, + stroke = "black", +}: React.SVGAttributes) => { + return ( + + + + ) +} + +export default SlotsConditionIcon diff --git a/frontend/src/index.css b/frontend/src/index.css index 16066da0..86430117 100644 --- a/frontend/src/index.css +++ b/frontend/src/index.css @@ -3,14 +3,14 @@ @tailwind utilities; @font-face { - font-family: 'Inter'; + font-family: "Inter"; src: url(../public/Inter-VariableFont.ttf); } @layer base { :root { font-family: - 'Inter', + "Inter", system-ui, -apple-system, BlinkMacSystemFont, @@ -34,13 +34,13 @@ --background: #fff; --foreground: #191919; - --bg-secondary: #f8fafc; + --bg-secondary: #F9FAFC; --fg-secondary: #333; --chat-background: #ebf5fa; --node: #fff; - --node-header: #f8fafc; + --node-header: #F9FAFC; --border: #e4e8ee; --border-node-selected: #94a3b8; @@ -86,8 +86,20 @@ --status-green: #00cc9a; --status-blue: #2563eb; - .dark { + --condition-test-success: #00cc9933; + --condition-test-error: #ff333333; + + --node-start-label-bg: #e5faf5; + --node-start-label-fg: #00cc99; + + --node-fallback-label-bg: #fde9e9; + --node-fallback-label-fg: #ff3333; + + --logo-red: #E86C4A; + --logo-yellow: #FFC247; + --logo-blue: #00A3FF; + .dark { color: #ebebeb; --background: #212121; @@ -128,6 +140,20 @@ --info-background: #172554; --condition-default: rgba(51, 153, 204, 1); + + --node-start-label-bg: #00cc99; + --node-start-label-fg: #e5faf5; + + --node-fallback-label-bg: #ff3333; + --node-fallback-label-fg: #fde9e9; + + /* --condition-test-success: #00cc99; + --condition-test-error: #ff3333; */ + + --logo-red: #E55934; + --logo-yellow: #FFBB33; + --logo-blue: #018BD9; + } } } @@ -138,7 +164,7 @@ margin: 0; color: var(--text); font-family: - 'Inter', + "Inter", system-ui, -apple-system, BlinkMacSystemFont, @@ -188,6 +214,16 @@ body { @apply border-node-selected; } +.border-error { + /* --tw-border-opacity: 1; + border-color: rgb(239 68 68 / var(--tw-border-opacity)) !important; */ + @apply border-red-500 +} + +.selected .border-error { + @apply border-red-800; +} + .header-service-btn { @apply w-[34px] h-[34px] min-w-[34px] min-h-[34px] bg-transparent border border-transparent hover:bg-header-btn-hover hover:border-border rounded; } diff --git a/frontend/src/main.tsx b/frontend/src/main.tsx index 6f6eb50d..6c63518f 100644 --- a/frontend/src/main.tsx +++ b/frontend/src/main.tsx @@ -1,5 +1,10 @@ +import { StrictMode } from "react" import ReactDOM from "react-dom/client" import App from "./App.tsx" import "./index.css" -ReactDOM.createRoot(document.getElementById("root")!).render() +ReactDOM.createRoot(document.getElementById("root")!).render( + + + +) diff --git a/frontend/src/modals/ConditionModal/ConditionModal.tsx b/frontend/src/modals/ConditionModal/ConditionModal.tsx index e364d286..649e6204 100644 --- a/frontend/src/modals/ConditionModal/ConditionModal.tsx +++ b/frontend/src/modals/ConditionModal/ConditionModal.tsx @@ -12,12 +12,12 @@ import { import classNames from "classnames" import { HelpCircle, TrashIcon } from "lucide-react" import { useContext, useEffect, useMemo, useState } from "react" -import toast from "react-hot-toast" import { useParams } from "react-router-dom" import { useReactFlow } from "reactflow" import { lint_service } from "../../api/services" import ModalComponent from "../../components/ModalComponent" import { flowContext } from "../../contexts/flowContext" +import { notificationsContext } from "../../contexts/notificationsContext" import { conditionType, conditionTypeType } from "../../types/ConditionTypes" import { NodeDataType, NodeType } from "../../types/NodeTypes" import { generateNewConditionBase } from "../../utils" @@ -62,6 +62,7 @@ const ConditionModal = ({ } const { getNode, setNodes, getNodes } = useReactFlow() + const { notification: n } = useContext(notificationsContext) const { updateFlow, flows, quietSaveFlows } = useContext(flowContext) const { flowId } = useParams() @@ -284,7 +285,11 @@ const ConditionModal = ({ ) } else { if (!validate_name.status) { - toast.error(`Condition name is not valid: \n ${validate_name.reason}`) + n.add({ + title: "Saving error!", + message: `Condition name is not valid: \n ${validate_name.reason}`, + type: "error", + }) } } } @@ -344,8 +349,9 @@ const ConditionModal = ({ )} -
+
setCurrentCondition({ ...currentCondition, name: e.target.value })} /> + setCurrentCondition({ ...currentCondition, data: { + ...currentCondition.data, + priority: parseInt(e.target.value) + } })} + />
{bodyItems[selected]} @@ -366,7 +384,7 @@ const ConditionModal = ({

{lintStatus?.status == "ok" ? "Condition test passed!" : lintStatus?.message}

diff --git a/frontend/src/modals/FlowModal/CreateFlowModal.tsx b/frontend/src/modals/FlowModal/CreateFlowModal.tsx index 199753c3..d167bd76 100644 --- a/frontend/src/modals/FlowModal/CreateFlowModal.tsx +++ b/frontend/src/modals/FlowModal/CreateFlowModal.tsx @@ -7,14 +7,14 @@ import { ModalFooter, ModalHeader, Select, - SelectItem + SelectItem, } from "@nextui-org/react" import { HelpCircle } from "lucide-react" import { useContext, useState } from "react" -import toast from "react-hot-toast" import ModalComponent from "../../components/ModalComponent" import { FLOW_COLORS } from "../../consts" import { flowContext } from "../../contexts/flowContext" +import { notificationsContext } from "../../contexts/notificationsContext" import { ModalType } from "../../types/ModalTypes" import { generateNewFlow, validateFlowName } from "../../utils" @@ -29,6 +29,7 @@ export type CreateFlowType = { const CreateFlowModal = ({ isOpen, onClose, size = "3xl" }: CreateFlowModalProps) => { const { flows, setFlows, saveFlows } = useContext(flowContext) + const { notification: n } = useContext(notificationsContext) const [flow, setFlow] = useState({ name: "", description: "", @@ -45,7 +46,14 @@ const CreateFlowModal = ({ isOpen, onClose, size = "3xl" }: CreateFlowModalProps } const onFlowSave = () => { - if (validateFlowName(flow.name, flows) && flow.color && flow.subflow) { + if (!validateFlowName(flow.name, flows)) { + return n.add({ + title: "Warning!", + message: "Flow name is not valid.", + type: "warning", + }) + } + if (flow.color && flow.subflow) { const newFlow = generateNewFlow(flow) setFlows([...flows, newFlow]) saveFlows([...flows, newFlow]) @@ -58,7 +66,11 @@ const CreateFlowModal = ({ isOpen, onClose, size = "3xl" }: CreateFlowModalProps setIsSubFlow(false) onClose() } else { - toast.error("Please fill all the fields correctly!") + n.add({ + title: "Creating error!", + message: "Please fill all the fields correctly.", + type: "error", + }) } } @@ -81,6 +93,7 @@ const CreateFlowModal = ({ isOpen, onClose, size = "3xl" }: CreateFlowModalProps name='name' onChange={onFlowChange} value={flow.name} + min={2} /> { const { flows, setFlows, saveFlows } = useContext(flowContext) + const { notification: n } = useContext(notificationsContext) const [newFlows, setNewFlows] = useState([...flows] ?? []) const { flowId } = useParams() const [flow, setFlow] = useState( newFlows.find((_flow) => _flow.name === flowId) ?? [][0] ) + const [newFlow, setNewFlow] = useState(flow) + + // eslint-disable-next-line @typescript-eslint/no-unused-vars const [isSubFlow, setIsSubFlow] = useState(false) const [isGlobal, setIsGlobal] = useState(false) @@ -45,17 +49,23 @@ const ManageFlowsModal = ({ isOpen, onClose, size = "3xl" }: CreateFlowModalProp useEffect(() => { setFlow(() => newFlows.find((_flow) => _flow.name === flowId) ?? [][0]) + setNewFlow(() => newFlows.find((_flow) => _flow.name === flowId) ?? [][0]) + if (flowId === "Global") { setIsGlobal(true) } }, [flowId, newFlows, isOpen]) const onFlowSelect = (e: React.MouseEvent) => { - setFlow(newFlows.find((_flow) => _flow.name === e.currentTarget.name) ?? [][0]) + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore + setFlow(() => newFlows.find((_flow) => _flow.name === e.target.name) ?? [][0]) } + useEffect(() => { if (flow) { + setNewFlow(flow) if (flow.name === "Global") { setIsGlobal(true) } else { @@ -65,20 +75,34 @@ const ManageFlowsModal = ({ isOpen, onClose, size = "3xl" }: CreateFlowModalProp }, [flow]) const onFlowChange = (e: React.ChangeEvent) => { - setFlow({ - ...flow, + setNewFlow({ + ...newFlow, [e.target.name]: e.target.value, }) } const onFlowSave = () => { - if (validateFlowName(flow.name, newFlows) && flow.color && flow.subflow) { - setFlows([...newFlows.map((_flow) => (_flow.id === flow.id ? flow : _flow))]) - saveFlows([...newFlows.map((_flow) => (_flow.id === flow.id ? flow : _flow))]) + if (!validateFlowName(newFlow.name, newFlows) || newFlow.name === flow.name) { + return n.add({ + title: "Warning!", + message: "Flow name is not valid.", + type: "warning", + }) + } + if ( + newFlow.color && + newFlow.subflow + ) { + setFlows([...newFlows.map((_flow) => (_flow.id === flow.id ? newFlow : _flow))]) + saveFlows([...newFlows.map((_flow) => (_flow.id === flow.id ? newFlow : _flow))]) setIsSubFlow(false) onClose() } else { - toast.error("Please fill all the fields correct!") + n.add({ + title: "Warning!", + message: "Please fill all the fields correctly.", + type: "warning", + }) } } @@ -118,7 +142,8 @@ const ManageFlowsModal = ({ isOpen, onClose, size = "3xl" }: CreateFlowModalProp placeholder="Enter flow's name here" name='name' onChange={onFlowChange} - value={flow.name} + value={newFlow.name} + min={2} />
@@ -137,11 +162,11 @@ const ManageFlowsModal = ({ isOpen, onClose, size = "3xl" }: CreateFlowModalProp ))}
@@ -174,12 +199,12 @@ const ManageFlowsModal = ({ isOpen, onClose, size = "3xl" }: CreateFlowModalProp name='subflow' onChange={(e) => { if (e.target.value !== "") { - setFlow({ ...flow, subflow: e.target.value }) + setNewFlow({ ...newFlow, subflow: e.target.value }) } else { - setFlow({ ...flow, subflow: "Global" }) + setNewFlow({ ...newFlow, subflow: "Global" }) } }} - selectedKeys={flow.subflow ? [flow.subflow] : []}> + selectedKeys={newFlow.subflow ? [newFlow.subflow] : []}> {(flow) => {flow.name}}
diff --git a/frontend/src/modals/NodeModal/NodeModal.tsx b/frontend/src/modals/NodeModal/NodeModal.tsx index f4721ddc..9149feed 100644 --- a/frontend/src/modals/NodeModal/NodeModal.tsx +++ b/frontend/src/modals/NodeModal/NodeModal.tsx @@ -6,16 +6,14 @@ import { ModalFooter, ModalHeader, ModalProps, - useDisclosure, } from "@nextui-org/react" import { HelpCircle, TrashIcon } from "lucide-react" -import React, { useCallback, useContext, useEffect, useState } from "react" +import React, { useCallback, useContext, useEffect } from "react" import { useReactFlow } from "reactflow" import ModalComponent from "../../components/ModalComponent" import { flowContext } from "../../contexts/flowContext" import EditPenIcon from "../../icons/EditPenIcon" import { NodeDataType } from "../../types/NodeTypes" -import ResponseModal from "../ResponseModal/ResponseModal" import ConditionRow from "./components/ConditionRow" type NodeModalProps = { @@ -23,18 +21,22 @@ type NodeModalProps = { size?: ModalProps["size"] isOpen: boolean onClose: () => void + onResponseModalOpen: () => void + nodeDataState: NodeDataType + setNodeDataState: React.Dispatch> } -const NodeModal = ({ data, isOpen, onClose, size = "3xl" }: NodeModalProps) => { +const NodeModal = ({ + data, + isOpen, + onClose, + size = "3xl", + onResponseModalOpen, + nodeDataState, + setNodeDataState, +}: NodeModalProps) => { const { getNodes, setNodes } = useReactFlow() const { quietSaveFlows } = useContext(flowContext) - const [nodeDataState, setNodeDataState] = useState(data) - - const { - isOpen: isResponseModalOpen, - onOpen: onResponseModalOpen, - onClose: onResponseModalClose, - } = useDisclosure() useEffect(() => { setNodeDataState(getNodes().find((node) => node.data.id === data.id)?.data ?? data) @@ -49,7 +51,14 @@ const NodeModal = ({ data, isOpen, onClose, size = "3xl" }: NodeModalProps) => { ) const setTextResponseValue = (e: React.ChangeEvent) => { - setNodeDataState({ ...nodeDataState, response: { ...nodeDataState.response!, type: 'text', data: [{ text: e.target.value, priority: 1 }] } }) + setNodeDataState({ + ...nodeDataState, + response: { + ...nodeDataState.response!, + type: "text", + data: [{ text: e.target.value, priority: 1 }], + }, + }) } const onNodeSave = () => { @@ -85,7 +94,6 @@ const NodeModal = ({ data, isOpen, onClose, size = "3xl" }: NodeModalProps) => { } } - return ( <> {
- ) diff --git a/frontend/src/modals/ResponseModal/ResponseModal.tsx b/frontend/src/modals/ResponseModal/ResponseModal.tsx index d4dff5a1..3e55efc7 100644 --- a/frontend/src/modals/ResponseModal/ResponseModal.tsx +++ b/frontend/src/modals/ResponseModal/ResponseModal.tsx @@ -10,11 +10,11 @@ import { Tabs, } from "@nextui-org/react" import { useContext, useMemo, useState } from "react" -import toast from "react-hot-toast" import { useParams } from "react-router-dom" import { useReactFlow } from "reactflow" import ModalComponent from "../../components/ModalComponent" import { flowContext } from "../../contexts/flowContext" +import { notificationsContext } from "../../contexts/notificationsContext" import { NodeDataType } from "../../types/NodeTypes" import { responseType, responseTypeType } from "../../types/ResponseTypes" import PythonResponse from "./components/PythonResponse" @@ -49,6 +49,7 @@ const ResponseModal = ({ setCurrentResponse({ ...currentResponse, type: key }) setSelected(key) } + const { notification: n } = useContext(notificationsContext) const tabItems: { title: ResponseModalTab @@ -94,10 +95,18 @@ const ResponseModal = ({ const saveResponse = () => { if (!currentResponse.name) { - return toast.error("Response name is required!") + return n.add({ + title: "Saving error!", + message: "Response name is required!", + type: "error", + }) } if (flows.some((flow) => flow.data.nodes.some((node) => (node.data.response.name === currentResponse.name && node.id !== data.id)))) { - return toast.error("Response name must be unique!") + return n.add({ + title: "Saving error!", + message: "Response name must be unique!", + type: "error", + }) } else { const nodes = getNodes() const node = getNode(data.id) @@ -112,7 +121,10 @@ const ResponseModal = ({ } const new_nodes = nodes.map((node) => (node.id === data.id ? new_node : node)) setNodes(() => new_nodes) - setData(new_node.data) + setData({ + ...data, + response: new_node.data.response + }) // currentFlow.data.nodes = nodes.map((node) => (node.id === data.id ? new_node : node)) // updateFlow(currentFlow) quietSaveFlows() diff --git a/frontend/src/pages/Flow.tsx b/frontend/src/pages/Flow.tsx index 4f9fa367..ebfb7367 100644 --- a/frontend/src/pages/Flow.tsx +++ b/frontend/src/pages/Flow.tsx @@ -1,9 +1,8 @@ -import React, { useCallback, useContext, useEffect, useRef, useState } from "react" +import React, { useCallback, useContext, useEffect, useMemo, useRef, useState } from "react" import ReactFlow, { Background, BackgroundVariant, Connection, - Controls, Edge, HandleType, Node, @@ -17,25 +16,27 @@ import ReactFlow, { } from "reactflow" import { a, useTransition } from "@react-spring/web" -import toast from "react-hot-toast" import { useParams } from "react-router-dom" import "reactflow/dist/style.css" import { v4 } from "uuid" import Chat from "../components/chat/Chat" +import CustomEdge from "../components/edges/ButtonEdge/ButtonEdge" import FootBar from "../components/footbar/FootBar" import DefaultNode from "../components/nodes/DefaultNode" import FallbackNode from "../components/nodes/FallbackNode" import LinkNode from "../components/nodes/LinkNode" import StartNode from "../components/nodes/StartNode" import SideBar from "../components/sidebar/SideBar" -import { NODES } from "../consts" +import { NODES, NODE_NAMES } from "../consts" import { flowContext } from "../contexts/flowContext" +import { notificationsContext } from "../contexts/notificationsContext" import { undoRedoContext } from "../contexts/undoRedoContext" import { workspaceContext } from "../contexts/workspaceContext" import "../index.css" import { FlowType } from "../types/FlowTypes" import { NodeDataType, NodeType, NodesTypes } from "../types/NodeTypes" import { responseType } from "../types/ResponseTypes" +import Fallback from "./Fallback" import Logs from "./Logs" import NodesLayout from "./NodesLayout" import Settings from "./Settings" @@ -47,6 +48,12 @@ const nodeTypes = { link_node: LinkNode, } +const edgeTypes = { + default: CustomEdge, +} + +const untrackedFields = ["position", "positionAbsolute", "targetPosition", "sourcePosition"] + // export const addNodeToGraph = (node: NodeType, graph: FlowType[]) => {} export default function Flow() { @@ -70,10 +77,12 @@ export default function Flow() { const [nodes, setNodes, onNodesChange] = useNodesState(flow?.data.nodes || []) const [edges, setEdges, onEdgesChange] = useEdgesState(flow?.data.edges || []) + const [reactFlowInstance, setReactFlowInstance] = useState() const [selection, setSelection] = useState() const [selected, setSelected] = useState() const isEdgeUpdateSuccess = useRef(false) + const { notification: n } = useContext(notificationsContext) // const { // isOpen: isLinkModalOpen, @@ -81,14 +90,55 @@ export default function Flow() { // onClose: onLinkModalClose, // } = useDisclosure() - useEffect(() => { - if (flow && reactFlowInstance) { + const handleUpdateFlowData = useCallback(() => { + if (reactFlowInstance && flow && flow.name === flowId) { + // const _node = reactFlowInstance.getNodes()[0] + // if (_node && _node.id === flow.data.nodes[0].id) { flow.data = reactFlowInstance.toObject() updateFlow(flow) - console.log("update flow") + // } + } + }, [flow, flowId, reactFlowInstance, updateFlow]) + + const handleFullUpdateFlowData = useCallback(() => { + if (reactFlowInstance && flow && flow.name === flowId) { + const _node = reactFlowInstance.getNodes()[0] + if (_node && _node.id === flow.data.nodes[0].id) { + flow.data = reactFlowInstance.toObject() + updateFlow(flow) + // const links: Node[] = flow.data.nodes.filter( + // (node) => node.type === "link_node" + // ) + // links.forEach((link) => { + // if ( + // !flows.find((fl) => link.data.transition.target_flow === fl.name) || + // !flows.find((fl) => + // fl.data.nodes.some((node) => node.id === link.data.transition.target_node) + // ) + // ) { + // n.add({ + // message: `Link ${link.data.id} is broken! Please configure it again.`, + // title: "Link error", + // type: "error", + // }) + // } + // }) + } } + }, [flow, flowId, flows, reactFlowInstance, updateFlow]) + + const filteredNodes = useMemo(() => { + return nodes.map((obj) => { + return Object.fromEntries( + Object.entries(obj).filter(([key]) => !untrackedFields.includes(key)) + ) + }) + }, [nodes]) + + useEffect(() => { + handleUpdateFlowData() // eslint-disable-next-line react-hooks/exhaustive-deps - }, [nodes, edges]) + }, [edges, nodes.length]) useEffect(() => { if (reactFlowInstance && flow?.name === flowId) { @@ -121,15 +171,17 @@ export default function Flow() { if (nd.type === "select") { if (nd.selected) { setSelectedNode(nd.id) + setSelected(nd.id) } else { setSelectedNode("") + setSelected("") } } }) } onNodesChange(nds) }, - [onNodesChange, selectedNode, setSelectedNode] + [onNodesChange, setSelectedNode] ) const onEdgeUpdateStart = useCallback(() => { @@ -161,8 +213,9 @@ export default function Flow() { (event: React.MouseEvent, node: Node) => { const node_ = node as NodeType setSelected(node_.id) + setSelectedNode(node_.id) }, - [setSelected] + [setSelected, setSelectedNode] ) const onEdgeClick = useCallback( @@ -175,7 +228,7 @@ export default function Flow() { const onConnect = useCallback( (params: Edge | Connection) => { takeSnapshot() - setEdges((eds) => addEdge(params, eds)) + setEdges((eds) => addEdge({ ...params, type: "default" }, eds)) }, // eslint-disable-next-line react-hooks/exhaustive-deps [setEdges] @@ -231,9 +284,14 @@ export default function Flow() { id: newId, type, position, + dragHandle: NODES[type].dragHandle, data: { id: newId, - name: NODES[type].name, + name: + type === "default_node" + ? NODE_NAMES.find((name) => !nodes.some((node) => node.data.name === name)) ?? + "Empty names array" + : NODES[type].name, flags: START_FALLBACK_FLAGS, conditions: NODES[type].conditions, global_conditions: [], @@ -247,7 +305,7 @@ export default function Flow() { } setNodes((nds) => nds.concat(newNode)) }, - [takeSnapshot, reactFlowInstance, setNodes, flow] + [takeSnapshot, reactFlowInstance, flows, setNodes] ) useEffect(() => { @@ -256,17 +314,16 @@ export default function Flow() { e.preventDefault() if (reactFlowInstance && flow && flow.name === flowId) { saveFlows(flows) - toast.success("Saved!") + n.add({ message: "", title: "Saved!", type: "success" }) } } if ((e.ctrlKey || e.metaKey) && e.key === "h") { e.preventDefault() toggleWorkspaceMode() - toast.success("Workspace mode: " + (workspaceMode ? "Fixed" : "Free")) } - if (e.key === "Backspace" && mouseOnPane) { + if ((e.key === "Backspace" || e.key === "Delete") && mouseOnPane) { e.preventDefault() if (selected) { takeSnapshot() @@ -286,6 +343,7 @@ export default function Flow() { flowId, flows, mouseOnPane, + n, reactFlowInstance, saveFlows, selected, @@ -303,6 +361,8 @@ export default function Flow() { exitBeforeEnter: true, }) + if (!flow) return ; + return (
{ - if (reactFlowInstance && flow && flow.name === flowId) { - const _node = reactFlowInstance.getNodes()[0] - if (_node && _node.id === flow.data.nodes[0].id) { - flow.data = reactFlowInstance.toObject() - updateFlow(flow) - } - } - }} + onMoveStart={handleFullUpdateFlowData} + onMoveEnd={handleFullUpdateFlowData} + panOnScroll={true} + panOnScrollSpeed={1.5} onSelectionChange={onSelectionChange} onDragOver={onDragOver} onDrop={onDrop} @@ -345,6 +401,7 @@ export default function Flow() { onEdgeUpdateStart={onEdgeUpdateStart} onEdgeUpdateEnd={onEdgeUpdateEnd} onNodeDragStart={() => takeSnapshot()} + onNodeDragStop={handleFullUpdateFlowData} onConnect={onConnect} nodesConnectable={!managerMode} nodesDraggable={!managerMode} @@ -353,13 +410,6 @@ export default function Flow() { edgesFocusable={!managerMode} snapGrid={workspaceMode ? [24, 24] : [96, 96]} snapToGrid={!workspaceMode}> - - {/* */} { } }, [navigate, pathname]) - const toastOptions: ToastOptions = useMemo( () => theme === "light" ? { style: { backgroundColor: "#fff", - color: "#333", }, position: "bottom-right", } : { style: { backgroundColor: "#333", - color: "#fff", }, position: "bottom-right", }, [theme] ) + return (
diff --git a/frontend/src/pages/Logs.tsx b/frontend/src/pages/Logs.tsx index 47e97ec2..d6e1c479 100644 --- a/frontend/src/pages/Logs.tsx +++ b/frontend/src/pages/Logs.tsx @@ -1,13 +1,13 @@ import { Accordion, AccordionItem, Divider, Spinner } from "@nextui-org/react" import { CheckCircle2, CircleSlash, Slash, X } from "lucide-react" -import { useContext, useEffect, useState } from "react" +import { memo, useContext, useEffect, useState } from "react" import { useSearchParams } from "react-router-dom" import { localBuildType, localRunType } from "../api/bot" import { buildContext } from "../contexts/buildContext" import { runContext } from "../contexts/runContext" import { parseSearchParams } from "../utils" -const Logs = () => { +const Logs = memo(() => { const { builds, logsPage } = useContext(buildContext) const { runs } = useContext(runContext) const [searchParams, setSearchParams] = useSearchParams() @@ -283,6 +283,6 @@ const Logs = () => {
*/}
) -} +}) export default Logs diff --git a/frontend/src/pages/Settings.tsx b/frontend/src/pages/Settings.tsx index 66758a53..0e9245b2 100644 --- a/frontend/src/pages/Settings.tsx +++ b/frontend/src/pages/Settings.tsx @@ -1,10 +1,10 @@ -import React, { useContext } from "react" -import { workspaceContext } from "../contexts/workspaceContext" import { Button, Divider, Select, SelectItem, Switch } from "@nextui-org/react" import { MoonIcon, SunIcon } from "lucide-react" +import { memo, useContext } from "react" import { themeContext } from "../contexts/themeContext" +import { workspaceContext } from "../contexts/workspaceContext" -const Settings = () => { +const Settings = memo(() => { const { settingsPage } = useContext(workspaceContext) const { theme, toggleTheme } = useContext(themeContext) @@ -68,7 +68,13 @@ const Settings = () => {

Language

- @@ -86,6 +92,6 @@ const Settings = () => {
) -} +}) export default Settings diff --git a/frontend/src/types/NodeTypes.ts b/frontend/src/types/NodeTypes.ts index f4ed6d6f..a8e2dfd5 100644 --- a/frontend/src/types/NodeTypes.ts +++ b/frontend/src/types/NodeTypes.ts @@ -6,6 +6,7 @@ export type NodesTypes = 'default_node' | 'link_node' export type NodeType = { id: string type: string + dragHandle?: string data: NodeDataType position: { x: number diff --git a/frontend/src/utils.ts b/frontend/src/utils.ts index 451ea568..5b4ac861 100644 --- a/frontend/src/utils.ts +++ b/frontend/src/utils.ts @@ -32,7 +32,7 @@ export const generateNewFlow = (flow: CreateFlowType) => { } export const validateFlowName = (name: string, flows: FlowType[]) => { - return !flows.some((flow) => flow.name === name) && name.length > 2 + return !flows.some((flow) => flow.name === name) && name.length >= 2 } export const parseSearchParams = ( From efaed141c6ebbeed82fd979b9d1a4ebfaa0eeee1 Mon Sep 17 00:00:00 2001 From: Maks <90211175+MXerFix@users.noreply.github.com> Date: Thu, 29 Aug 2024 14:27:41 +0300 Subject: [PATCH 02/10] chore: Do more front optimization (#76) * fix: overflow scroll y * fix: modal types fix && delete unused node components * rename: favicon change * fix: header btns color fix * feat: add screen preloader logic * fix: return controls * fix: manage flows name save fix * feat: add copy/paste fn --- frontend/public/favicon.ico | Bin 52946 -> 173925 bytes frontend/src/UI/Preloader/preloader.css | 104 +------ frontend/src/components/chat/Chat.tsx | 2 +- frontend/src/components/footbar/FootBar.tsx | 20 +- frontend/src/components/header/BuildMenu.tsx | 6 +- frontend/src/components/header/Header.tsx | 12 +- frontend/src/components/nodes/DefaultNode.tsx | 4 +- .../src/components/nodes/FallbackNode.tsx | 46 --- frontend/src/components/nodes/StartNode.tsx | 84 ------ frontend/src/contexts/flowContext.tsx | 138 +++++---- frontend/src/contexts/metaContext.tsx | 48 ++- frontend/src/contexts/undoRedoContext.tsx | 274 +++++++++++++++++- ...alStogareIcon.tsx => LocalStorageIcon.tsx} | 4 +- .../src/modals/FlowModal/ManageFlowsModal.tsx | 9 +- frontend/src/pages/Flow.tsx | 60 +++- frontend/src/utils.ts | 6 + 16 files changed, 487 insertions(+), 330 deletions(-) delete mode 100644 frontend/src/components/nodes/FallbackNode.tsx delete mode 100644 frontend/src/components/nodes/StartNode.tsx rename frontend/src/icons/footbar/{LocalStogareIcon.tsx => LocalStorageIcon.tsx} (90%) diff --git a/frontend/public/favicon.ico b/frontend/public/favicon.ico index 2833de99c8d273e654402b9af3617e22a75c33d7..ca0e3469d00eee6bf28fd250ef336300a4e174e4 100644 GIT binary patch literal 173925 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 diff --git a/frontend/src/UI/Preloader/preloader.css b/frontend/src/UI/Preloader/preloader.css index 9ece2d71..8b63a157 100644 --- a/frontend/src/UI/Preloader/preloader.css +++ b/frontend/src/UI/Preloader/preloader.css @@ -1,113 +1,17 @@ #preloader-wrapper { background-color: hsl(var(--background)); position: absolute; + top: 0; + left: 0; z-index: 99; - width: 100%; - height: 100%; + width: 100vw; + height: 100vh; display: flex; flex-direction: column; align-items: center; justify-content: center; } -/* #preloader { - position: fixed; - top: 0; - left: 0; - width: 100%; - height: 100%; - scale: 0.5; -} -#loader { - display: block; - position: relative; - left: 50%; - top: 50%; - width: 150px; - height: 150px; - margin: -75px 0 0 -75px; - border-radius: 50%; - border: 6px solid transparent; - border-top-color: #70db97; - -webkit-animation: spin 2s cubic-bezier(0.785, 0.135, 0.15, 0.86) infinite; - animation: spin 2s cubic-bezier(0.785, 0.135, 0.15, 0.86) infinite; -} -#loader:before { - content: ""; - position: absolute; - top: 5px; - left: 5px; - right: 5px; - bottom: 5px; - border-radius: 50%; - border: 6px solid transparent; - border-top-color: #2036ff; - -webkit-animation: spin 3s cubic-bezier(0.785, 0.135, 0.15, 0.86) infinite; - animation: spin 3s cubic-bezier(0.785, 0.135, 0.15, 0.86) infinite; -} -#loader:after { - content: ""; - position: absolute; - top: 15px; - left: 15px; - right: 15px; - bottom: 15px; - border-radius: 50%; - border: 6px solid transparent; - border-top-color: #077ac9; - -webkit-animation: spin 1.5s cubic-bezier(0.785, 0.135, 0.15, 0.86) infinite; - animation: spin 1.5s cubic-bezier(0.785, 0.135, 0.15, 0.86) infinite; -} -@-webkit-keyframes spin { - 0% { - -webkit-transform: rotate(0deg); - -ms-transform: rotate(0deg); - transform: rotate(0deg); - } - 100% { - -webkit-transform: rotate(360deg); - -ms-transform: rotate(360deg); - transform: rotate(360deg); - } -} -@keyframes spin { - 0% { - -webkit-transform: rotate(0deg); - -ms-transform: rotate(0deg); - transform: rotate(0deg); - } - 100% { - -webkit-transform: rotate(360deg); - -ms-transform: rotate(360deg); - transform: rotate(360deg); - } -} - -@-webkit-keyframes spin-reverse { - 0% { - -webkit-transform: rotate(0deg); - -ms-transform: rotate(0deg); - transform: rotate(0deg); - } - 100% { - -webkit-transform: rotate(-360deg); - -ms-transform: rotate(-360deg); - transform: rotate(-360deg); - } -} -@keyframes spin-reverse { - 0% { - -webkit-transform: rotate(0deg); - -ms-transform: rotate(0deg); - transform: rotate(0deg); - } - 100% { - -webkit-transform: rotate(-360deg); - -ms-transform: rotate(-360deg); - transform: rotate(-360deg); - } -} */ - .loader { position: relative; width: 120px; diff --git a/frontend/src/components/chat/Chat.tsx b/frontend/src/components/chat/Chat.tsx index 355282d4..c25e53e4 100644 --- a/frontend/src/components/chat/Chat.tsx +++ b/frontend/src/components/chat/Chat.tsx @@ -148,7 +148,7 @@ const Chat = memo(() => { return (
diff --git a/frontend/src/components/footbar/FootBar.tsx b/frontend/src/components/footbar/FootBar.tsx index c9a48664..41f637ac 100644 --- a/frontend/src/components/footbar/FootBar.tsx +++ b/frontend/src/components/footbar/FootBar.tsx @@ -9,16 +9,16 @@ import { notificationsContext } from "../../contexts/notificationsContext" import { workspaceContext } from "../../contexts/workspaceContext" import { Logo } from "../../icons/Logo" import MonitorIcon from "../../icons/buildmenu/MonitorIcon" -import LocalStogareIcon from "../../icons/footbar/LocalStogareIcon" +import LocalStorageIcon from "../../icons/footbar/LocalStorageIcon" import LocalStorage from "../../modals/LocalStorage/LocalStorage" import { parseSearchParams } from "../../utils" import { NotificationsWindow } from "../notifications/NotificationsWindow" const FootBar = memo(() => { const { - isOpen: isLocalStogareOpen, - onOpen: onLocalStogareOpen, - onClose: onLocalStogareClose, + isOpen: isLocalStorageOpen, + onOpen: onLocalStorageOpen, + onClose: onLocalStorageClose, } = useDisclosure() const { version } = useContext(MetaContext) @@ -73,7 +73,7 @@ const FootBar = memo(() => {
-
+
{
{
) diff --git a/frontend/src/components/header/BuildMenu.tsx b/frontend/src/components/header/BuildMenu.tsx index a1517923..f28b66d3 100644 --- a/frontend/src/components/header/BuildMenu.tsx +++ b/frontend/src/components/header/BuildMenu.tsx @@ -51,7 +51,7 @@ const BuildMenu = () => { /> } className={classNames( - "bg-overlay hover:bg-background border border-border rounded-small", + "bg-background hover:bg-overlay border border-border rounded-small", runStatus === "alive" ? "border-emerald-500" : runStatus === "stopped" @@ -115,8 +115,8 @@ const BuildMenu = () => { isIconOnly style={{}} className={classNames( - "bg-overlay hover:bg-background border border-border rounded-small", - chat ? "bg-background border-border-darker" : "" + "bg-background hover:bg-overlay border border-border rounded-small", + chat ? "bg-overlay border-border-darker" : "" )}> diff --git a/frontend/src/components/header/Header.tsx b/frontend/src/components/header/Header.tsx index bbff4a1f..9723fc89 100644 --- a/frontend/src/components/header/Header.tsx +++ b/frontend/src/components/header/Header.tsx @@ -53,8 +53,8 @@ const Header = memo(() => { isIconOnly onClick={toggleManagerMode} className={classNames( - " bg-overlay hover:bg-background border border-border rounded-small", - managerMode ? "bg-background border-border-darker" : "" + " bg-background hover:bg-overlay border border-border rounded-small", + managerMode ? "bg-overlay border-border-darker" : "" )}> @@ -66,8 +66,8 @@ const Header = memo(() => { onClick={toggleWorkspaceMode} isIconOnly className={classNames( - " bg-overlay hover:bg-background border border-border rounded-small", - workspaceMode ? "bg-background border-border-darker" : "" + " bg-background hover:bg-overlay border border-border rounded-small", + workspaceMode ? "bg-overlay border-border-darker" : "" )}> @@ -79,8 +79,8 @@ const Header = memo(() => { onClick={toggleNodesLayoutMode} isIconOnly className={classNames( - " bg-overlay hover:bg-background border border-border rounded-small", - nodesLayoutMode ? "bg-background border-border-darker" : "" + " bg-background hover:bg-overlay border border-border rounded-small", + nodesLayoutMode ? "bg-overlay border-border-darker" : "" )}> {/* {nodesLayoutMode ? "Canvas Mode" : "List mode"} */} diff --git a/frontend/src/components/nodes/DefaultNode.tsx b/frontend/src/components/nodes/DefaultNode.tsx index 1a59b54d..fffd94e7 100644 --- a/frontend/src/components/nodes/DefaultNode.tsx +++ b/frontend/src/components/nodes/DefaultNode.tsx @@ -103,7 +103,9 @@ const DefaultNode = memo(({ data }: { data: NodeDataType }) => {
-
+
diff --git a/frontend/src/components/nodes/FallbackNode.tsx b/frontend/src/components/nodes/FallbackNode.tsx deleted file mode 100644 index 21154350..00000000 --- a/frontend/src/components/nodes/FallbackNode.tsx +++ /dev/null @@ -1,46 +0,0 @@ -import { memo } from "react" -import { NodeDataType } from "../../types/NodeTypes" -import { Handle, Position } from "reactflow" -import "reactflow/dist/style.css" -import "../../index.css" -import Response from "./responses/Response" - -const FallbackNode = memo(({ data }: { data: NodeDataType }) => { - return ( -
-
- -

{data.name}

-
-
- - {/*
- {data.conditions.map((condition) => ( - - ))} -
*/} -
-
- ) -}) - -export default FallbackNode diff --git a/frontend/src/components/nodes/StartNode.tsx b/frontend/src/components/nodes/StartNode.tsx deleted file mode 100644 index 8edcf3ac..00000000 --- a/frontend/src/components/nodes/StartNode.tsx +++ /dev/null @@ -1,84 +0,0 @@ -import React, { memo } from "react" -import { NodeDataType } from "../../types/NodeTypes" -import { Handle, Position } from "reactflow" -import "reactflow/dist/style.css" -import "../../index.css" -import Condition from "./conditions/Condition" -import Response from "./responses/Response" -import { PlusIcon } from "lucide-react" -import { useDisclosure } from "@nextui-org/react" -import ConditionModal from "../../modals/ConditionModal/ConditionModal" -import NodeModal from "../../modals/NodeModal/NodeModal" -import EditNodeIcon from "../../icons/nodes/EditNodeIcon" - -const StartNode = memo(({ data }: { data: NodeDataType }) => { - const { - onOpen: onConditionOpen, - onClose: onConditionClose, - isOpen: isConditionOpen, - } = useDisclosure() - const { onOpen: onNodeOpen, onClose: onNodeClose, isOpen: isNodeOpen } = useDisclosure() - - return ( - <> -
-
-
- -

{data.name}

-
- -
-
- -
- {data.conditions?.map((condition) => ( - - ))} -
- -
-
- - - - ) -}) - -export default StartNode diff --git a/frontend/src/contexts/flowContext.tsx b/frontend/src/contexts/flowContext.tsx index a808746c..9ae0aa78 100644 --- a/frontend/src/contexts/flowContext.tsx +++ b/frontend/src/contexts/flowContext.tsx @@ -1,12 +1,13 @@ /* eslint-disable react-refresh/only-export-components */ import React, { createContext, useCallback, useContext, useEffect, useState } from "react" import { useParams } from "react-router-dom" -import { Edge } from "reactflow" +import { Edge, ReactFlowInstance } from "reactflow" import { v4 } from "uuid" import { get_flows, save_flows } from "../api/flows" import { FLOW_COLORS } from "../consts" import { FlowType } from "../types/FlowTypes" import { NodeType } from "../types/NodeTypes" +import { MetaContext } from "./metaContext" import { notificationsContext } from "./notificationsContext" // import { v4 } from "uuid" @@ -48,6 +49,8 @@ const globalFlow: FlowType = { } type TabContextType = { + reactFlowInstance: ReactFlowInstance | null + setReactFlowInstance: React.Dispatch> tab: string setTab: React.Dispatch> flows: FlowType[] @@ -64,6 +67,8 @@ type TabContextType = { } const initialValue: TabContextType = { + reactFlowInstance: null, + setReactFlowInstance: () => {}, tab: "", setTab: () => {}, flows: [], @@ -84,25 +89,35 @@ const initialValue: TabContextType = { export const flowContext = createContext(initialValue) export const FlowProvider = ({ children }: { children: React.ReactNode }) => { + + const [reactFlowInstance, setReactFlowInstance] = useState(null) const [tab, setTab] = useState(initialValue.tab) const { flowId } = useParams() const [flows, setFlows] = useState([]) const { notification: n } = useContext(notificationsContext) + const { screenLoading } = useContext(MetaContext) useEffect(() => { setTab(flowId || "") }, [flowId]) const getFlows = async () => { - const { data } = await get_flows() - if (data.flows) { - if (data.flows.some((flow) => flow.name === "Global")) { - setFlows(data.flows) + screenLoading.addScreenLoading() + try { + const { data } = await get_flows() + if (data.flows) { + if (data.flows.some((flow) => flow.name === "Global")) { + setFlows(data.flows) + } else { + setFlows([globalFlow, ...data.flows]) + } } else { - setFlows([globalFlow, ...data.flows]) + setFlows([globalFlow]) } - } else { - setFlows([globalFlow]) + } catch (error) { + console.error(error) + } finally { + screenLoading.removeScreenLoading() } } @@ -145,58 +160,73 @@ export const FlowProvider = ({ children }: { children: React.ReactNode }) => { [flows] ) - const deleteNode = useCallback((id: string) => { - const flow = flows.find((flow) => flow.data.nodes.some((node) => node.id === id)) - if (!flow) return -1 - const deleted_node: NodeType = flow.data.nodes.find((node) => node.id === id) as NodeType - if (deleted_node?.data.flags?.includes("start")) - return n.add({ title: "Warning!", message: "Can't delete start node", type: "warning" }) - if (deleted_node?.id?.includes("LOCAL")) return n.add({ title: "Warning!", message: "Can't delete local node", type: "warning" }) - if (deleted_node?.id?.includes("GLOBAL")) return n.add({ title: "Warning!", message: "Can't delete global node", type: "warning" }) - if (deleted_node?.data.flags?.includes("fallback")) { - console.log( - flow.data.nodes - .find((node) => node.id !== id && !node.data.id.includes("LOCAL")) - ?.data.flags.push("fallback") + const deleteNode = useCallback( + (id: string) => { + const flow = flows.find((flow) => flow.data.nodes.some((node) => node.id === id)) + if (!flow) return -1 + const deleted_node: NodeType = flow.data.nodes.find((node) => node.id === id) as NodeType + if (deleted_node?.data.flags?.includes("start")) + return n.add({ title: "Warning!", message: "Can't delete start node", type: "warning" }) + if (deleted_node?.id?.includes("LOCAL")) + return n.add({ title: "Warning!", message: "Can't delete local node", type: "warning" }) + if (deleted_node?.id?.includes("GLOBAL")) + return n.add({ title: "Warning!", message: "Can't delete global node", type: "warning" }) + if (deleted_node?.data.flags?.includes("fallback")) { + console.log( + flow.data.nodes + .find((node) => node.id !== id && !node.data.id.includes("LOCAL")) + ?.data.flags.push("fallback") + ) + // any_node.data.flags?.push("fallback") + } + const newNodes = flow.data.nodes.filter((node) => node.id !== id) + const newEdges = flow.data.edges.filter( + (edge: Edge) => edge.source !== id && edge.target !== id ) - // any_node.data.flags?.push("fallback") - } - const newNodes = flow.data.nodes.filter((node) => node.id !== id) - const newEdges = flow.data.edges.filter( - (edge: Edge) => edge.source !== id && edge.target !== id - ) - const newFlows = flows.map((flow) => - flow.name === flowId - ? { ...flow, data: { ...flow.data, nodes: newNodes, edges: newEdges } } - : flow - ) - saveFlows(newFlows) - setFlows(newFlows) - }, [flowId, flows, n]) - - const deleteEdge = useCallback((id: string) => { - const flow = flows.find((flow) => flow.data.edges.some((edge) => edge.id === id)) - if (!flow) return -1 - const newEdges = flow.data.edges.filter((edge) => edge.id !== id) - saveFlows( - flows.map((flow) => - flow.name === flowId ? { ...flow, data: { ...flow.data, edges: newEdges } } : flow + const newFlows = flows.map((flow) => + flow.name === flowId + ? { ...flow, data: { ...flow.data, nodes: newNodes, edges: newEdges } } + : flow + ) + saveFlows(newFlows) + setFlows(newFlows) + }, + [flowId, flows, n] + ) + + const deleteEdge = useCallback( + (id: string) => { + const flow = flows.find((flow) => flow.data.edges.some((edge) => edge.id === id)) + if (!flow) return -1 + const newEdges = flow.data.edges.filter((edge) => edge.id !== id) + saveFlows( + flows.map((flow) => + flow.name === flowId ? { ...flow, data: { ...flow.data, edges: newEdges } } : flow + ) ) - ) - setFlows((flows) => flows.map((flow) => ({ ...flow, data: { ...flow.data, edges: newEdges } }))) - }, [flowId, flows]) - - const deleteObject = useCallback((id: string) => { - const flow_node = flows.find((flow) => flow.data.nodes.some((node) => node.id === id)) - const flow_edge = flows.find((flow) => flow.data.edges.some((edge) => edge.id === id)) - if (!flow_node && !flow_edge) return -1 - if (flow_node) deleteNode(id) - if (flow_edge) deleteEdge(id) - }, [deleteEdge, deleteNode, flows]) + setFlows((flows) => + flows.map((flow) => ({ ...flow, data: { ...flow.data, edges: newEdges } })) + ) + }, + [flowId, flows] + ) + + const deleteObject = useCallback( + (id: string) => { + const flow_node = flows.find((flow) => flow.data.nodes.some((node) => node.id === id)) + const flow_edge = flows.find((flow) => flow.data.edges.some((edge) => edge.id === id)) + if (!flow_node && !flow_edge) return -1 + if (flow_node) deleteNode(id) + if (flow_edge) deleteEdge(id) + }, + [deleteEdge, deleteNode, flows] + ) return ( > + setScreenLoading: React.Dispatch> + screenLoading: { + addScreenLoading: () => void + removeScreenLoading: () => void + value: number + } } export const MetaContext = createContext({ version: "", setVersion: () => {}, + screenLoading: { + addScreenLoading: () => {}, + removeScreenLoading: () => {}, + value: 0, + }, + setScreenLoading: () => {}, }) interface MetaProviderProps { @@ -18,18 +30,48 @@ interface MetaProviderProps { } const MetaProvider = ({ children }: MetaProviderProps) => { + const [screenLoading, setScreenLoading] = useState(0) const [version, setVersion] = useState("") + const addScreenLoading = () => { + setScreenLoading((prev) => prev + 1) + } + + const removeScreenLoading = () => { + setScreenLoading((prev) => prev - 1) + } + const getVersion = async () => { - const version_data = await get_config_version() - setVersion(version_data) + addScreenLoading() + try { + const version_data = await get_config_version() + setVersion(version_data) + } catch (error) { + console.error(error) + } finally { + removeScreenLoading() + } } useEffect(() => { getVersion() }, []) - return {children} + return ( + + {children} + + ) } export default MetaProvider diff --git a/frontend/src/contexts/undoRedoContext.tsx b/frontend/src/contexts/undoRedoContext.tsx index 442c9ec2..a8d40e5f 100644 --- a/frontend/src/contexts/undoRedoContext.tsx +++ b/frontend/src/contexts/undoRedoContext.tsx @@ -1,12 +1,21 @@ import { cloneDeep } from "lodash" import { createContext, useCallback, useContext, useEffect, useState } from "react" -import { Edge, Node, useReactFlow } from "reactflow" +import { addEdge, Edge, Node, OnSelectionChangeParams, useReactFlow } from "reactflow" +import { v4 } from "uuid" +import { NodeDataType } from "../types/NodeTypes" import { flowContext } from "./flowContext" +import { notificationsContext } from "./notificationsContext" type undoRedoContextType = { undo: () => void redo: () => void takeSnapshot: () => void + copy: (selection: OnSelectionChangeParams) => void + paste: ( + selectionInstance: OnSelectionChangeParams, + position: { x: number; y: number; paneX?: number; paneY?: number } + ) => void + copiedSelection: OnSelectionChangeParams | null } type UseUndoRedoOptions = { @@ -31,6 +40,9 @@ const initialValue = { undo: () => {}, redo: () => {}, takeSnapshot: () => {}, + copy: () => {}, + paste: () => {}, + copiedSelection: null, } const defaultOptions: UseUndoRedoOptions = { @@ -43,6 +55,7 @@ export const undoRedoContext = createContext(initialValue) export function UndoRedoProvider({ children }: { children: React.ReactNode }) { const { tab, flows } = useContext(flowContext) + const { notification: n } = useContext(notificationsContext) const [past, setPast] = useState(flows.map(() => [])) const [future, setFuture] = useState(flows.map(() => [])) @@ -77,7 +90,7 @@ export function UndoRedoProvider({ children }: { children: React.ReactNode }) { return newFuture }) } - // eslint-disable-next-line react-hooks/exhaustive-deps + // eslint-disable-next-line react-hooks/exhaustive-deps }, [getNodes, getEdges, past, future, flows, tab, setPast, setFuture, tabIndex]) const undo = useCallback(() => { @@ -102,7 +115,7 @@ export function UndoRedoProvider({ children }: { children: React.ReactNode }) { setNodes(pastState.nodes) setEdges(pastState.edges) } - // eslint-disable-next-line react-hooks/exhaustive-deps + // eslint-disable-next-line react-hooks/exhaustive-deps }, [setNodes, setEdges, getNodes, getEdges, future, past, setFuture, setPast, tabIndex]) const redo = useCallback(() => { @@ -123,7 +136,7 @@ export function UndoRedoProvider({ children }: { children: React.ReactNode }) { setNodes(futureState.nodes) setEdges(futureState.edges) } - // eslint-disable-next-line react-hooks/exhaustive-deps + // eslint-disable-next-line react-hooks/exhaustive-deps }, [future, past, setFuture, setPast, setNodes, setEdges, getNodes, getEdges, future, tabIndex]) useEffect(() => { @@ -149,12 +162,265 @@ export function UndoRedoProvider({ children }: { children: React.ReactNode }) { document.removeEventListener("keydown", keyDownHandler) } }, [undo, redo]) + + const { reactFlowInstance } = useContext(flowContext) + const [copiedSelection, setCopiedSelection] = useState(null) + + const copy = (selection: OnSelectionChangeParams) => { + if (selection && (selection.nodes.length || selection.edges.length)) { + setCopiedSelection(cloneDeep(selection)) + n.add({ + title: "Copied!", + message: ` + Copied ${selection.nodes.length} nodes and ${selection.edges.length} edges. + `, + type: "success", + }) + } else { + n.add({ + title: "Nothing to copy!", + message: "", + type: "warning", + }) + } + } + + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const paste = ( + selectionInstance: OnSelectionChangeParams, + position: { x: number; y: number; paneX?: number; paneY?: number } + ) => { + if (!reactFlowInstance) { + return n.add({ + title: "Fatal error!", + message: "React flow instance not found!", + type: "error", + }) + } + if (!selectionInstance.edges.length && !selectionInstance.nodes.length) { + return n.add({ + title: "Nothing to paste!", + message: "", + type: "warning", + }) + } + const nodes: Node[] = reactFlowInstance.getNodes() + let edges: Edge[] = reactFlowInstance.getEdges() + let minimumX = Infinity + let minimumY = Infinity + const idsMap: { [id: string]: string } = {} + const sourceHandlesMap: { [id: string]: string } = {} + + selectionInstance.nodes.forEach((n) => { + if (n.position.y < minimumY) { + minimumY = n.position.y + } + if (n.position.x < minimumX) { + minimumX = n.position.x + } + }) + + const insidePosition = + position.paneX && position.paneY + ? { x: position.paneX + position.x, y: position.paneY + position.y } + : reactFlowInstance.project({ x: position.x, y: position.y }) + + const resultNodes: Node[] = [] + + selectionInstance.nodes.forEach((n: Node) => { + // Generate a unique node ID + const newId = v4() + idsMap[n.id] = newId + const newConditions = n.data.conditions?.map((c) => { + const newCondId = v4() + sourceHandlesMap[c.id] = newCondId + return { ...c, id: newCondId } + }) + const newResponse = n.data.response + ? { + ...n.data.response, + id: v4(), + } + : undefined + + // Create a new node object + const newNode: Node = { + id: newId, + type: n.type, + position: { + x: insidePosition.x + n.position.x - minimumX, + y: insidePosition.y + n.position.y - minimumY, + }, + data: { + ...cloneDeep(n.data), + conditions: newConditions, + response: newResponse, + flags: [], + id: newId, + }, + } + + resultNodes.push({ ...newNode, selected: true }) + }) + + if (resultNodes.length < selectionInstance.nodes.length) { + return + } + + const newNodes = [ + ...nodes.map((e: Node) => ({ ...e, selected: false })), + ...resultNodes, + ] + + console.log(selectionInstance.edges) + + selectionInstance.edges.forEach((e) => { + const source = idsMap[e.source] + const target = idsMap[e.target] + if (e.sourceHandle) { + const sourceHandle = sourceHandlesMap[e.sourceHandle] + const id = v4() + edges = addEdge( + { + source, + target, + sourceHandle, + targetHandle: null, + id, + selected: false, + }, + edges.map((e) => ({ ...e, selected: false })) + ) + } + }) + takeSnapshot() + reactFlowInstance.setNodes(newNodes) + reactFlowInstance.setEdges(edges) + } + + // function paste( + // selectionInstance, + // position: { x: number; y: number; paneX?: number; paneY?: number } + // ) { + // let minimumX = Infinity; + // let minimumY = Infinity; + // let idsMap = {}; + // let nodes = reactFlowInstance.getNodes(); + // let edges = reactFlowInstance.getEdges(); + // selectionInstance.nodes.forEach((n) => { + // if (n.position.y < minimumY) { + // minimumY = n.position.y; + // } + // if (n.position.x < minimumX) { + // minimumX = n.position.x; + // } + // }); + + // const insidePosition = position.paneX + // ? { x: position.paneX + position.x, y: position.paneY + position.y } + // : reactFlowInstance.project({ x: position.x, y: position.y }); + + // const resultNodes: any[] = [] + + // selectionInstance.nodes.forEach((n: NodeType) => { + // // Generate a unique node ID + // let newId = getNodeId(n.data.type); + // idsMap[n.id] = newId; + + // const positionX = insidePosition.x + n.position.x - minimumX + // const positionY = insidePosition.y + n.position.y - minimumY + + // // Create a new node object + // const newNode: NodeType = { + // id: newId, + // type: "genericNode", + // position: { + // x: insidePosition.x + n.position.x - minimumX, + // y: insidePosition.y + n.position.y - minimumY, + // }, + // data: { + // ..._.cloneDeep(n.data), + // id: newId, + // }, + // }; + + // // FIXME: CHECK WORK >>>>>>> + // // check for intersections before paste + // if (nodes.some(({ position, id, width, height }) => { + // const xIntersect = ((positionX > position.x - width) && (positionX < (position.x + width))) + // const yIntersect = ((positionY > position.y - height) && (positionY < (position.y + height))) + // const result = xIntersect && yIntersect + // // console.log({id: id, xIntersect: xIntersect, yIntersect: yIntersect, result: result}) + // return result + // })) { + // return setErrorData({ title: "Invalid place! Nodes can't intersect!" }) + // } + // // FIXME: CHECK WORK >>>>>>>> + + // resultNodes.push({ ...newNode, selected: true }) + + // }); + + // if (resultNodes.length < selectionInstance.nodes.length) { + // return + // } + + // // Add the new node to the list of nodes in state + // nodes = nodes + // .map((e) => ({ ...e, selected: false })) + // .concat(resultNodes); + // reactFlowInstance.setNodes(nodes); + + // selectionInstance.edges.forEach((e) => { + // let source = idsMap[e.source]; + // let target = idsMap[e.target]; + // let sourceHandleSplitted = e.sourceHandle.split("|"); + // let sourceHandle = + // source + + // "|" + + // sourceHandleSplitted[1] + + // "|" + + // source + // let targetHandleSplitted = e.targetHandle.split("|"); + // let targetHandle = + // targetHandleSplitted.slice(0, -1).join("|") + target; + // let id = + // "reactflow__edge-" + + // source + + // sourceHandle + + // "-" + + // target + + // targetHandle; + // edges = addEdge( + // { + // source, + // target, + // sourceHandle, + // targetHandle, + // id, + // style: { stroke: "inherit" }, + // className: + // targetHandle.split("|")[0] === "Text" + // ? "stroke-foreground " + // : "stroke-foreground ", + // animated: targetHandle.split("|")[0] === "Text", + // selected: false, + // }, + // edges.map((e) => ({ ...e, selected: false })) + // ); + // }); + // reactFlowInstance.setEdges(edges); + // } + return ( {children} diff --git a/frontend/src/icons/footbar/LocalStogareIcon.tsx b/frontend/src/icons/footbar/LocalStorageIcon.tsx similarity index 90% rename from frontend/src/icons/footbar/LocalStogareIcon.tsx rename to frontend/src/icons/footbar/LocalStorageIcon.tsx index 9a81c7a6..23a984c6 100644 --- a/frontend/src/icons/footbar/LocalStogareIcon.tsx +++ b/frontend/src/icons/footbar/LocalStorageIcon.tsx @@ -1,6 +1,6 @@ import React from "react" -const LocalStogareIcon = ({ stroke='var(--foreground)' }: React.SVGAttributes) => { +const LocalStorageIcon = ({ stroke='var(--foreground)' }: React.SVGAttributes) => { return ( (flow) - // eslint-disable-next-line @typescript-eslint/no-unused-vars const [isSubFlow, setIsSubFlow] = useState(false) const [isGlobal, setIsGlobal] = useState(false) @@ -61,7 +60,6 @@ const ManageFlowsModal = ({ isOpen, onClose, size = "3xl" }: CreateFlowModalProp // @ts-ignore setFlow(() => newFlows.find((_flow) => _flow.name === e.target.name) ?? [][0]) } - useEffect(() => { if (flow) { @@ -82,17 +80,14 @@ const ManageFlowsModal = ({ isOpen, onClose, size = "3xl" }: CreateFlowModalProp } const onFlowSave = () => { - if (!validateFlowName(newFlow.name, newFlows) || newFlow.name === flow.name) { + if (!validateFlowName(newFlow.name, newFlows) && newFlow.name !== flow.name) { return n.add({ title: "Warning!", message: "Flow name is not valid.", type: "warning", }) } - if ( - newFlow.color && - newFlow.subflow - ) { + if (newFlow.color && newFlow.subflow) { setFlows([...newFlows.map((_flow) => (_flow.id === flow.id ? newFlow : _flow))]) saveFlows([...newFlows.map((_flow) => (_flow.id === flow.id ? newFlow : _flow))]) setIsSubFlow(false) diff --git a/frontend/src/pages/Flow.tsx b/frontend/src/pages/Flow.tsx index ebfb7367..56c91c9e 100644 --- a/frontend/src/pages/Flow.tsx +++ b/frontend/src/pages/Flow.tsx @@ -3,12 +3,14 @@ import ReactFlow, { Background, BackgroundVariant, Connection, + Controls, Edge, HandleType, Node, NodeChange, OnSelectionChangeParams, ReactFlowInstance, + ReactFlowRefType, addEdge, updateEdge, useEdgesState, @@ -23,12 +25,11 @@ import Chat from "../components/chat/Chat" import CustomEdge from "../components/edges/ButtonEdge/ButtonEdge" import FootBar from "../components/footbar/FootBar" import DefaultNode from "../components/nodes/DefaultNode" -import FallbackNode from "../components/nodes/FallbackNode" import LinkNode from "../components/nodes/LinkNode" -import StartNode from "../components/nodes/StartNode" import SideBar from "../components/sidebar/SideBar" import { NODES, NODE_NAMES } from "../consts" import { flowContext } from "../contexts/flowContext" +import { MetaContext } from "../contexts/metaContext" import { notificationsContext } from "../contexts/notificationsContext" import { undoRedoContext } from "../contexts/undoRedoContext" import { workspaceContext } from "../contexts/workspaceContext" @@ -36,6 +37,7 @@ import "../index.css" import { FlowType } from "../types/FlowTypes" import { NodeDataType, NodeType, NodesTypes } from "../types/NodeTypes" import { responseType } from "../types/ResponseTypes" +import { Preloader } from "../UI/Preloader/Preloader" import Fallback from "./Fallback" import Logs from "./Logs" import NodesLayout from "./NodesLayout" @@ -43,8 +45,6 @@ import Settings from "./Settings" const nodeTypes = { default_node: DefaultNode, - start_node: StartNode, - fallback_node: FallbackNode, link_node: LinkNode, } @@ -57,9 +57,10 @@ const untrackedFields = ["position", "positionAbsolute", "targetPosition", "sour // export const addNodeToGraph = (node: NodeType, graph: FlowType[]) => {} export default function Flow() { - const reactFlowWrapper = useRef(null) + const reactFlowWrapper = useRef(null) - const { flows, updateFlow, saveFlows, deleteObject } = useContext(flowContext) + const { flows, updateFlow, saveFlows, deleteObject, reactFlowInstance, setReactFlowInstance } = + useContext(flowContext) const { toggleWorkspaceMode, workspaceMode, @@ -69,7 +70,8 @@ export default function Flow() { mouseOnPane, managerMode, } = useContext(workspaceContext) - const { takeSnapshot, undo } = useContext(undoRedoContext) + const { screenLoading } = useContext(MetaContext) + const { takeSnapshot, undo, copy, paste, copiedSelection } = useContext(undoRedoContext) const { flowId } = useParams() @@ -78,7 +80,7 @@ export default function Flow() { const [nodes, setNodes, onNodesChange] = useNodesState(flow?.data.nodes || []) const [edges, setEdges, onEdgesChange] = useEdgesState(flow?.data.edges || []) - const [reactFlowInstance, setReactFlowInstance] = useState() + // const [reactFlowInstance, setReactFlowInstance] = useState() const [selection, setSelection] = useState() const [selected, setSelected] = useState() const isEdgeUpdateSuccess = useRef(false) @@ -308,8 +310,29 @@ export default function Flow() { [takeSnapshot, reactFlowInstance, flows, setNodes] ) + const [mousePos, setMousePos] = useState({ x: 0, y: 0 }) + useEffect(() => { const kbdHandler = (e: KeyboardEvent) => { + if ((e.ctrlKey || e.metaKey) && e.key === "c") { + e.preventDefault() + if (selection) { + copy(selection) + } + } + if ((e.ctrlKey || e.metaKey) && e.key === "v") { + e.preventDefault() + if ( + reactFlowInstance && + flow && + flow.name === flowId && + copiedSelection && + reactFlowWrapper.current + ) { + const bounds = reactFlowWrapper.current.getBoundingClientRect() + paste(copiedSelection, { x: mousePos.x - bounds.left, y: mousePos.y - bounds.top }) + } + } if ((e.ctrlKey || e.metaKey) && e.key === "s") { e.preventDefault() if (reactFlowInstance && flow && flow.name === flowId) { @@ -332,22 +355,34 @@ export default function Flow() { } } + const mouseMoveHandler = (e: MouseEvent) => { + setMousePos({ x: e.clientX, y: e.clientY }) + } + document.addEventListener("keydown", kbdHandler) + document.addEventListener("mousemove", mouseMoveHandler) return () => { document.removeEventListener("keydown", kbdHandler) + document.removeEventListener("mousemove", mouseMoveHandler) } }, [ + copiedSelection, + copy, deleteObject, flow, flowId, flows, mouseOnPane, + mousePos.x, + mousePos.y, n, + paste, reactFlowInstance, saveFlows, selected, selectedNode, + selection, takeSnapshot, toggleWorkspaceMode, workspaceMode, @@ -361,7 +396,10 @@ export default function Flow() { exitBeforeEnter: true, }) - if (!flow) return ; + if (screenLoading.value) return + else if (flows.length && !flow && !screenLoading.value) { + return + } return (
+ ))} diff --git a/frontend/src/utils.ts b/frontend/src/utils.ts index 5b4ac861..0bbf908b 100644 --- a/frontend/src/utils.ts +++ b/frontend/src/utils.ts @@ -65,3 +65,9 @@ export const isNodeDeletionValid = (nodes: NodeType[], id: string) => { if (!node) return false return !node.data.flags?.includes("start") } + +export function delay(ms: number) { + return new Promise((resolve, reject) => { + setTimeout(resolve, ms); + }); +} From 76e591905b56ac12d7f5ca98e02be7d96346eb5c Mon Sep 17 00:00:00 2001 From: Rami <54779216+Ramimashkouk@users.noreply.github.com> Date: Fri, 30 Aug 2024 06:37:36 +0300 Subject: [PATCH 03/10] refactor: Rename dflowd to chatsky-ui (#72) * fix: Delete poetry usage from code * refactor: Rename app package to dflowd * chore: Rename in tests & ci & docs * chore: Include lower py versions * ci: Automate testing against many py versions * test: Kill process after timeout * doc: Add cli commands desciption * ci: Delete testing on windows * refactor: Rename dflowd to chatsky-ui * refactor: Fix tests workflow * fix: Enable project-dir use in cli cmds (#74) * fix: Update front with new structure * fix: Install ui into user proj * fix: Enable project-dir in all cli cmds * refactor: Add new configs * chore: fix string f-format * fix makefile * chore: Fix logs repetition * refactor: Install whl with pip in dockerfile * chore: Raise ValueError for same name nodes * chore: Delete temp_conf.yaml * fix: Enter project-dir when run_app in makefile * fix: Use immutable args & clean resources * fix: Use poetry for production in makefile * chore: Link to the template main branch --- .github/workflows/backend_check.yml | 39 ++-- .../workflows/build_and_upload_release.yml | 8 +- .github/workflows/docker_check.yml | 10 +- .github/workflows/e2e_test.yml | 27 +-- .gitignore | 8 +- CONTRIBUTING.md | 4 +- Dockerfile | 33 +--- Makefile | 66 ++++--- README.md | 13 +- backend/Dockerfile | 46 ----- backend/{df_designer => }/README.md | 0 backend/chatsky_ui/__init__.py | 3 + .../app => chatsky_ui}/api/__init_.py | 0 .../app => chatsky_ui/api/api_v1}/__init__.py | 0 .../app => chatsky_ui}/api/api_v1/api.py | 4 +- .../api/api_v1/endpoints}/__init__.py | 0 .../api/api_v1/endpoints/auth.py | 0 .../api/api_v1/endpoints/bot.py | 61 +++--- .../chatsky_ui/api/api_v1/endpoints/config.py | 10 + .../api/api_v1/endpoints/dff_services.py | 23 +-- .../api/api_v1/endpoints/flows.py | 13 +- backend/chatsky_ui/api/deps.py | 36 ++++ backend/chatsky_ui/cli.py | 184 ++++++++++++++++++ .../clients}/__init__.py | 0 .../app => chatsky_ui}/clients/dff_client.py | 4 +- .../clients => chatsky_ui/core}/__init__.py | 0 .../app => chatsky_ui}/core/auth.py | 0 backend/chatsky_ui/core/config.py | 120 ++++++++++++ .../app => chatsky_ui}/core/logger_config.py | 27 +-- .../app => chatsky_ui}/core/security.py | 0 .../app/core => chatsky_ui/db}/__init__.py | 0 .../app => chatsky_ui}/db/base.py | 6 +- .../app => chatsky_ui}/db/base_class.py | 0 .../app/db => chatsky_ui/db/crud}/__init__.py | 0 .../app => chatsky_ui}/db/crud/base.py | 0 .../app => chatsky_ui}/db/crud/crud_bot.py | 0 .../app => chatsky_ui}/db/init_db.py | 0 .../crud => chatsky_ui/db/models}/__init__.py | 0 .../app => chatsky_ui}/db/models/bot.py | 0 .../app => chatsky_ui}/db/session.py | 0 .../app => chatsky_ui}/initial_data.py | 0 .../{df_designer/app => chatsky_ui}/main.py | 13 +- .../models => chatsky_ui/schemas}/__init__.py | 0 .../schemas/code_snippet.py | 0 .../app => chatsky_ui}/schemas/pagination.py | 0 .../app => chatsky_ui}/schemas/preset.py | 0 .../schemas/process_status.py | 0 .../services}/__init__.py | 0 .../app => chatsky_ui}/services/index.py | 27 ++- .../services/json_converter.py | 40 ++-- .../app => chatsky_ui}/services/process.py | 8 +- .../services/process_manager.py | 56 ++++-- .../services/websocket_manager.py | 28 ++- .../app => chatsky_ui}/static/.gitkeep | 0 .../services => chatsky_ui/tests}/__init__.py | 0 .../tests/api}/__init__.py | 0 .../app => chatsky_ui}/tests/api/test_bot.py | 33 ++-- .../tests/api/test_flows.py | 6 +- .../app => chatsky_ui}/tests/conftest.py | 20 +- .../api => chatsky_ui/tests/e2e}/__init__.py | 0 .../app => chatsky_ui}/tests/e2e/test_e2e.py | 11 +- .../tests/integration}/__init__.py | 0 .../tests/integration/test_api_integration.py | 23 ++- .../tests/services}/__init__.py | 0 .../tests/services/test_process.py | 19 +- .../tests/services/test_process_manager.py | 21 +- .../tests/services/test_websocket_manager.py | 0 .../services => chatsky_ui/utils}/__init__.py | 0 .../app => chatsky_ui}/utils/ast_utils.py | 0 .../app/api/api_v1/endpoints/config.py | 14 -- backend/df_designer/app/api/deps.py | 30 --- backend/df_designer/app/cli.py | 113 ----------- backend/df_designer/app/core/config.py | 58 ------ backend/df_designer/app/utils/__init__.py | 0 backend/{df_designer => }/poetry.lock | 22 +-- backend/{df_designer => }/pyproject.toml | 17 +- backend/{df_designer => }/run.sh | 2 +- bin/add_ui_to_toml.sh | 7 + compose.yaml | 8 +- docs/appref/app/api/api_v1/endpoints.rst | 26 --- docs/appref/app/services.rst | 42 ---- docs/appref/app/tests/api.rst | 18 -- docs/appref/app/tests/e2e.rst | 10 - docs/appref/app/tests/integration.rst | 10 - docs/appref/app/tests/services.rst | 26 --- docs/appref/{app.rst => chatsky_ui.rst} | 8 +- docs/appref/{app => chatsky_ui}/api.rst | 4 +- .../appref/{app => chatsky_ui}/api/api_v1.rst | 6 +- .../chatsky_ui/api/api_v1/endpoints.rst | 26 +++ docs/appref/{app => chatsky_ui}/clients.rst | 6 +- docs/appref/{app => chatsky_ui}/core.rst | 10 +- docs/appref/{app => chatsky_ui}/db.rst | 6 +- docs/appref/{app => chatsky_ui}/schemas.rst | 14 +- docs/appref/chatsky_ui/services.rst | 42 ++++ docs/appref/{app => chatsky_ui}/tests.rst | 6 +- docs/appref/chatsky_ui/tests/api.rst | 18 ++ docs/appref/chatsky_ui/tests/e2e.rst | 10 + docs/appref/chatsky_ui/tests/integration.rst | 10 + docs/appref/chatsky_ui/tests/services.rst | 26 +++ docs/conf.py | 4 +- docs/index.rst | 4 +- frontend/dockerfile | 9 - frontend/src/pages/Logs.tsx | 4 +- 103 files changed, 903 insertions(+), 763 deletions(-) delete mode 100644 backend/Dockerfile rename backend/{df_designer => }/README.md (100%) create mode 100644 backend/chatsky_ui/__init__.py rename backend/{df_designer/app => chatsky_ui}/api/__init_.py (100%) rename backend/{df_designer/app => chatsky_ui/api/api_v1}/__init__.py (100%) rename backend/{df_designer/app => chatsky_ui}/api/api_v1/api.py (80%) rename backend/{df_designer/app/api/api_v1 => chatsky_ui/api/api_v1/endpoints}/__init__.py (100%) rename backend/{df_designer/app => chatsky_ui}/api/api_v1/endpoints/auth.py (100%) rename backend/{df_designer/app => chatsky_ui}/api/api_v1/endpoints/bot.py (85%) create mode 100644 backend/chatsky_ui/api/api_v1/endpoints/config.py rename backend/{df_designer/app => chatsky_ui}/api/api_v1/endpoints/dff_services.py (75%) rename backend/{df_designer/app => chatsky_ui}/api/api_v1/endpoints/flows.py (65%) create mode 100644 backend/chatsky_ui/api/deps.py create mode 100644 backend/chatsky_ui/cli.py rename backend/{df_designer/app/api/api_v1/endpoints => chatsky_ui/clients}/__init__.py (100%) rename backend/{df_designer/app => chatsky_ui}/clients/dff_client.py (95%) rename backend/{df_designer/app/clients => chatsky_ui/core}/__init__.py (100%) rename backend/{df_designer/app => chatsky_ui}/core/auth.py (100%) create mode 100644 backend/chatsky_ui/core/config.py rename backend/{df_designer/app => chatsky_ui}/core/logger_config.py (65%) rename backend/{df_designer/app => chatsky_ui}/core/security.py (100%) rename backend/{df_designer/app/core => chatsky_ui/db}/__init__.py (100%) rename backend/{df_designer/app => chatsky_ui}/db/base.py (83%) rename backend/{df_designer/app => chatsky_ui}/db/base_class.py (100%) rename backend/{df_designer/app/db => chatsky_ui/db/crud}/__init__.py (100%) rename backend/{df_designer/app => chatsky_ui}/db/crud/base.py (100%) rename backend/{df_designer/app => chatsky_ui}/db/crud/crud_bot.py (100%) rename backend/{df_designer/app => chatsky_ui}/db/init_db.py (100%) rename backend/{df_designer/app/db/crud => chatsky_ui/db/models}/__init__.py (100%) rename backend/{df_designer/app => chatsky_ui}/db/models/bot.py (100%) rename backend/{df_designer/app => chatsky_ui}/db/session.py (100%) rename backend/{df_designer/app => chatsky_ui}/initial_data.py (100%) rename backend/{df_designer/app => chatsky_ui}/main.py (82%) rename backend/{df_designer/app/db/models => chatsky_ui/schemas}/__init__.py (100%) rename backend/{df_designer/app => chatsky_ui}/schemas/code_snippet.py (100%) rename backend/{df_designer/app => chatsky_ui}/schemas/pagination.py (100%) rename backend/{df_designer/app => chatsky_ui}/schemas/preset.py (100%) rename backend/{df_designer/app => chatsky_ui}/schemas/process_status.py (100%) rename backend/{df_designer/app/schemas => chatsky_ui/services}/__init__.py (100%) rename backend/{df_designer/app => chatsky_ui}/services/index.py (90%) rename backend/{df_designer/app => chatsky_ui}/services/json_converter.py (90%) rename backend/{df_designer/app => chatsky_ui}/services/process.py (98%) rename backend/{df_designer/app => chatsky_ui}/services/process_manager.py (83%) rename backend/{df_designer/app => chatsky_ui}/services/websocket_manager.py (75%) rename backend/{df_designer/app => chatsky_ui}/static/.gitkeep (100%) rename backend/{df_designer/app/services => chatsky_ui/tests}/__init__.py (100%) rename backend/{df_designer/app/tests => chatsky_ui/tests/api}/__init__.py (100%) rename backend/{df_designer/app => chatsky_ui}/tests/api/test_bot.py (83%) rename backend/{df_designer/app => chatsky_ui}/tests/api/test_flows.py (59%) rename backend/{df_designer/app => chatsky_ui}/tests/conftest.py (78%) rename backend/{df_designer/app/tests/api => chatsky_ui/tests/e2e}/__init__.py (100%) rename backend/{df_designer/app => chatsky_ui}/tests/e2e/test_e2e.py (88%) rename backend/{df_designer/app/tests/e2e => chatsky_ui/tests/integration}/__init__.py (100%) rename backend/{df_designer/app => chatsky_ui}/tests/integration/test_api_integration.py (90%) rename backend/{df_designer/app/tests/integration => chatsky_ui/tests/services}/__init__.py (100%) rename backend/{df_designer/app => chatsky_ui}/tests/services/test_process.py (65%) rename backend/{df_designer/app => chatsky_ui}/tests/services/test_process_manager.py (82%) rename backend/{df_designer/app => chatsky_ui}/tests/services/test_websocket_manager.py (100%) rename backend/{df_designer/app/tests/services => chatsky_ui/utils}/__init__.py (100%) rename backend/{df_designer/app => chatsky_ui}/utils/ast_utils.py (100%) delete mode 100644 backend/df_designer/app/api/api_v1/endpoints/config.py delete mode 100644 backend/df_designer/app/api/deps.py delete mode 100644 backend/df_designer/app/cli.py delete mode 100644 backend/df_designer/app/core/config.py delete mode 100644 backend/df_designer/app/utils/__init__.py rename backend/{df_designer => }/poetry.lock (99%) rename backend/{df_designer => }/pyproject.toml (64%) rename backend/{df_designer => }/run.sh (71%) create mode 100755 bin/add_ui_to_toml.sh delete mode 100644 docs/appref/app/api/api_v1/endpoints.rst delete mode 100644 docs/appref/app/services.rst delete mode 100644 docs/appref/app/tests/api.rst delete mode 100644 docs/appref/app/tests/e2e.rst delete mode 100644 docs/appref/app/tests/integration.rst delete mode 100644 docs/appref/app/tests/services.rst rename docs/appref/{app.rst => chatsky_ui.rst} (71%) rename docs/appref/{app => chatsky_ui}/api.rst (74%) rename docs/appref/{app => chatsky_ui}/api/api_v1.rst (63%) create mode 100644 docs/appref/chatsky_ui/api/api_v1/endpoints.rst rename docs/appref/{app => chatsky_ui}/clients.rst (50%) rename docs/appref/{app => chatsky_ui}/core.rst (50%) rename docs/appref/{app => chatsky_ui}/db.rst (52%) rename docs/appref/{app => chatsky_ui}/schemas.rst (50%) create mode 100644 docs/appref/chatsky_ui/services.rst rename docs/appref/{app => chatsky_ui}/tests.rst (64%) create mode 100644 docs/appref/chatsky_ui/tests/api.rst create mode 100644 docs/appref/chatsky_ui/tests/e2e.rst create mode 100644 docs/appref/chatsky_ui/tests/integration.rst create mode 100644 docs/appref/chatsky_ui/tests/services.rst delete mode 100644 frontend/dockerfile diff --git a/.github/workflows/backend_check.yml b/.github/workflows/backend_check.yml index 13fa5abd..ce2b77ea 100644 --- a/.github/workflows/backend_check.yml +++ b/.github/workflows/backend_check.yml @@ -25,52 +25,63 @@ jobs: python -m pip install --upgrade pip poetry python -m poetry lock --no-update python -m poetry install --with lint --no-interaction - working-directory: backend/df_designer + working-directory: backend - name: run black codestyle run: | python -m poetry run black --line-length=120 --check . - working-directory: backend/df_designer + working-directory: backend - name: run flake8 codestyle run: | python -m poetry run flake8 --max-line-length 120 --ignore=E203 . - working-directory: backend/df_designer + working-directory: backend - name: run isort codestyle run: | python -m poetry run isort --line-length=120 --diff . - working-directory: backend/df_designer + working-directory: backend test_backend: - runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + python-version: ["3.8", "3.9", "3.10", "3.11"] + os: [macOS-latest, ubuntu-latest] + + runs-on: ${{ matrix.os }} steps: - uses: actions/checkout@v4 - - name: set up python 3.10 + - name: set up python ${{ matrix.python-version }} uses: actions/setup-python@v5 with: - python-version: '3.10' + python-version: ${{ matrix.python-version }} - name: setup poetry and install dependencies run: | python -m pip install --upgrade pip poetry python -m poetry lock --no-update python -m poetry install --with lint --no-interaction - working-directory: backend/df_designer + working-directory: backend - name: build wheel run: | python -m poetry build - working-directory: backend/df_designer + working-directory: backend - name: Create new project run: | - python -m poetry run dflowd init --destination ../../ --no-input --overwrite-if-exists - working-directory: backend/df_designer + python -m poetry run chatsky.ui init --destination ../ --no-input --overwrite-if-exists + working-directory: backend + + - name: Install chatsky-ui into new project poetry environment + run: | + ../bin/add_ui_to_toml.sh + working-directory: my_project - name: run tests run: | - python -m poetry install - python -m poetry run pytest ../backend/df_designer/app/tests/ --verbose - working-directory: df_designer_project + python -m poetry install --no-root + python -m poetry run pytest ../backend/chatsky_ui/tests/ --verbose + working-directory: my_project diff --git a/.github/workflows/build_and_upload_release.yml b/.github/workflows/build_and_upload_release.yml index 4bf6af25..27898a77 100644 --- a/.github/workflows/build_and_upload_release.yml +++ b/.github/workflows/build_and_upload_release.yml @@ -27,14 +27,14 @@ jobs: run: | bun install bun run build - cp -r ./dist/* ../backend/df_designer/app/static + cp -r ./dist/* ../backend/chatsky_ui/static working-directory: frontend - name: build wheels and test uploading to pypi if: startsWith(github.ref, 'refs/tags/v') != true run: | python -m poetry --build publish --dry-run - working-directory: backend/df_designer + working-directory: backend - name: build wheels and upload to pypi if: startsWith(github.ref, 'refs/tags/v') @@ -42,14 +42,14 @@ jobs: POETRY_PYPI_TOKEN_PYPI: ${{ secrets.PYPI_TOKEN }} run: | python -m poetry --build publish - working-directory: backend/df_designer + working-directory: backend - name: upload binaries into release if: startsWith(github.ref, 'refs/tags/v') uses: svenstaro/upload-release-action@v2 with: repo_token: ${{ secrets.GITHUB_TOKEN }} - file: backend/df_designer/dist/* + file: backend/dist/* tag: ${{ github.ref }} overwrite: true file_glob: true diff --git a/.github/workflows/docker_check.yml b/.github/workflows/docker_check.yml index ac7c5795..4276fba6 100644 --- a/.github/workflows/docker_check.yml +++ b/.github/workflows/docker_check.yml @@ -20,14 +20,14 @@ jobs: python -m pip install --upgrade pip poetry python -m poetry lock --no-update python -m poetry install --with lint --no-ansi --no-interaction - working-directory: backend/df_designer + working-directory: backend - name: Create new project - run: python -m poetry run dflowd init --destination ../../ --no-input --overwrite-if-exists - working-directory: backend/df_designer + run: python -m poetry run chatsky.ui init --destination ../ --no-input --overwrite-if-exists + working-directory: backend - name: Build Frontend - run: docker build -f Dockerfile --build-arg PROJECT_DIR=df_designer_project --target=frontend-builder . + run: docker build -f Dockerfile --build-arg PROJECT_DIR=my_project --target=frontend-builder . - name: Build backend & run app - run: docker build -f Dockerfile --build-arg PROJECT_DIR=df_designer_project --target=runtime . + run: docker build -f Dockerfile --build-arg PROJECT_DIR=my_project --target=runtime . diff --git a/.github/workflows/e2e_test.yml b/.github/workflows/e2e_test.yml index 73c99a35..a0ae24bb 100644 --- a/.github/workflows/e2e_test.yml +++ b/.github/workflows/e2e_test.yml @@ -1,3 +1,4 @@ +#TODO: work with pip to install the package as the end-user would # name: test app # on: @@ -53,7 +54,7 @@ # - name: copy static files # run: | -# cp -r frontend/dist/. backend/df_designer/app/static/ +# cp -r frontend/dist/. backend/chatsky_ui/static/ # - name: set up python 3.10 # uses: actions/setup-python@v5 @@ -65,17 +66,17 @@ # python -m pip install --upgrade pip poetry # python -m poetry lock --no-update # python -m poetry install --with lint --no-interaction -# working-directory: backend/df_designer +# working-directory: backend # - name: build wheel # run: python -m poetry build -# working-directory: backend/df_designer +# working-directory: backend # - name: Archive backend dist # uses: actions/upload-artifact@v4 # with: # name: backend-dist -# path: backend/df_designer/dist +# path: backend/dist # run_app: @@ -89,39 +90,39 @@ # with: # python-version: '3.10' -# - name: setup dflowd poetry and install dependencies +# - name: setup chatsky-ui poetry and install dependencies # run: | # python -m pip install --upgrade pip poetry # python -m poetry lock --no-update # python -m poetry install --with lint --no-interaction -# working-directory: backend/df_designer +# working-directory: backend # - name: Create new project # run: | -# python -m poetry run dflowd init --destination ../../ --no-input --overwrite-if-exists -# working-directory: backend/df_designer +# python -m poetry run chatsky.ui init --destination ../ --no-input --overwrite-if-exists +# working-directory: backend # - name: Create dist directory -# run: mkdir -p backend/df_designer/dist +# run: mkdir -p backend/dist # - name: Download backend dist # uses: actions/download-artifact@v4 # with: # name: backend-dist -# path: backend/df_designer/dist +# path: backend/dist # - name: setup project poetry and install dependencies # run: | # python -m pip install --upgrade pip poetry # python -m poetry lock --no-update # python -m poetry install --no-interaction -# working-directory: df_designer_project +# working-directory: my_project # - name: Run back & front # run: | -# python -m poetry run dflowd run_app & +# python -m poetry run chatsky.ui run_app & # sleep 10 -# working-directory: df_designer_project +# working-directory: my_project # - name: Install bun # run: npm install -g bun diff --git a/.gitignore b/.gitignore index 7752fb9b..4af2bc7a 100644 --- a/.gitignore +++ b/.gitignore @@ -245,9 +245,6 @@ cython_debug/ #.idea/ .vscode -/df_designer_front/node_modules -/df_designer_front/vscode -/df_designer_front/.vscode ./flows.json *.sqlite @@ -256,4 +253,7 @@ my_project /playwright-report/ /blob-report/ /playwright/.cache/ -df_designer_project + +temp_conf.yaml + +my_project diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index df287470..ac05b8c8 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -3,7 +3,7 @@ We have almost finished the main functionality. Nevertheless, we will be glad to We will be glad if you contribute to Dialog Flow Designer. ## Rules for submitting a PR -All PRs are reviewed by DflowD developers team. In order to make the reviewer job easier and increase the chance that your PR will be accepted, please add a short description with information about why this PR is needed and what changes will be made. +All PRs are reviewed by Chatsky-UI developers team. In order to make the reviewer job easier and increase the chance that your PR will be accepted, please add a short description with information about why this PR is needed and what changes will be made. ## Development We use poetry as a handy dependency management and packaging tool, which reads pyproject.toml to get specification for commands. poetry is a tool for command running automatization. If your environment does not support poetry, it can be installed as a python package with `pipx install poetry`. However, It's recommended to install isolated from the global Python environment, which prevents potential conflicts with other packages ([Installation on the official site](https://python-poetry.org/docs/#installing-with-the-official-installer:~:text=its%20own%20environment.-,Install%20Poetry,-The%20installer%20script)). @@ -14,7 +14,7 @@ We use poetry as a handy dependency management and packaging tool, which reads p ```bash python3 -m venv poetry-venv \ # create virtual env and install poetry && poetry-venv/bin/pip install poetry==1.8.2 -cd backend/df_designer \ # using poetry, install DflowD package +cd backend \ # using poetry, install Chatsky-UI package && poetry install \ && poetry shell \ && cd ../../ diff --git a/Dockerfile b/Dockerfile index 40b97713..bc162f58 100644 --- a/Dockerfile +++ b/Dockerfile @@ -15,14 +15,10 @@ RUN bun run build #--------------------------------------------------------- -# Use a slim variant to reduce image size where possible FROM python:3.10-slim as backend-builder WORKDIR /temp -ARG PROJECT_DIR -# ENV PROJECT_DIR ${PROJECT_DIR} - ENV POETRY_VERSION=1.8.2 \ POETRY_HOME=/poetry \ POETRY_VENV=/poetry-venv @@ -34,38 +30,29 @@ RUN python3 -m venv $POETRY_VENV \ ENV PATH="${PATH}:${POETRY_VENV}/bin" -COPY ./backend/df_designer /temp/backend/df_designer -COPY --from=frontend-builder /temp/frontend/dist /temp/backend/df_designer/app/static +COPY ./backend /temp/backend +COPY --from=frontend-builder /temp/frontend/dist /temp/backend/chatsky_ui/static -COPY ./${PROJECT_DIR} /temp/${PROJECT_DIR} # Build the wheel -WORKDIR /temp/backend/df_designer +WORKDIR /temp/backend RUN poetry build #--------------------------------------------------------- -#TODO: create something like src named e.g. runtime/ - FROM python:3.10-slim as runtime ARG PROJECT_DIR -COPY --from=backend-builder /poetry-venv /poetry-venv - -# Set environment variable to use the virtualenv -ENV PATH="/poetry-venv/bin:$PATH" +# Install pip and upgrade +RUN pip install --upgrade pip # Copy only the necessary files -COPY --from=backend-builder /temp/backend/df_designer /src2/backend/df_designer -COPY ./${PROJECT_DIR} /src2/project_dir +COPY --from=backend-builder /temp/backend/dist /src/dist +COPY ./${PROJECT_DIR} /src/project_dir # Install the wheel -WORKDIR /src2/project_dir -RUN poetry lock --no-update \ - && poetry install - -CMD ["poetry", "run", "dflowd", "run_app"] - +WORKDIR /src/project_dir +RUN pip install ../dist/*.whl -# #TODO: change scr to app (maybe) \ No newline at end of file +CMD ["chatsky.ui", "run_app"] diff --git a/Makefile b/Makefile index b983d78c..b474d072 100644 --- a/Makefile +++ b/Makefile @@ -1,8 +1,9 @@ SHELL = /bin/bash PYTHON = python3 -FRONTEND_DIR = ./frontend -BACKEND_DIR = ./backend/df_designer +FRONTEND_DIR = frontend +BACKEND_DIR = backend +PROJECT_NAME = my_project .PHONY: help @@ -46,7 +47,7 @@ install_backend_env: ## Installs backend dependencies using poetry .PHONY: clean_backend_env clean_backend_env: ## Removes backend dependencies using poetry - cd ${BACKEND_DIR} && poetry install_env remove --all + cd ${BACKEND_DIR} && poetry env remove --all .PHONY: build_backend build_backend: install_backend_env ## Builds the backend wheel @@ -61,47 +62,52 @@ check_project_arg: .PHONY: run_backend run_backend: check_project_arg ## Runs backend using the built dist. NEEDS arg: PROJECT_NAME - cd ${PROJECT_NAME} && poetry install && poetry run dflowd run_app --conf-reload="False" - + @set -a && . $(CURDIR)/.env && \ + cd ${PROJECT_NAME} && \ + poetry add $(CURDIR)/${BACKEND_DIR}/dist/*.whl && \ + poetry install && \ + . `poetry env info --path`/bin/activate && \ + chatsky.ui run_app + +# This environment activation method was used to avoid the issue of not being able to run the app in the same shell .PHONY: run_dev_backend run_dev_backend: check_project_arg install_backend_env ## Runs backend in dev mode. NEEDS arg: PROJECT_NAME - cd ${BACKEND_DIR} && poetry run dflowd run_app --project-dir ../../${PROJECT_NAME} + cd ${BACKEND_DIR} && \ + . `poetry env info --path`/bin/activate && \ + chatsky.ui run_app --project-dir ../${PROJECT_NAME} --conf-reload # backend tests .PHONY: unit_tests unit_tests: ## Runs all backend unit tests - if [ ! -d "./df_designer_project" ]; then \ - cd "${BACKEND_DIR}" && \ - poetry run dflowd init --destination ../../ --no-input --overwrite-if-exists; \ - fi - - cd df_designer_project && \ - poetry install && \ - poetry run pytest ../${BACKEND_DIR}/app/tests/api ../${BACKEND_DIR}/app/tests/services + cd ${BACKEND_DIR} && \ + . `poetry env info --path`/bin/activate && \ + pytest ../${BACKEND_DIR}/chatsky_ui/tests/api ../${BACKEND_DIR}/chatsky_ui/tests/services .PHONY: integration_tests integration_tests: ## Runs all backend integration tests - if [ ! -d "./df_designer_project" ]; then \ + if [ ! -d "${PROJECT_NAME}" ]; then \ cd "${BACKEND_DIR}" && \ - poetry run dflowd init --destination ../../ --no-input --overwrite-if-exists; \ + poetry run chatsky.ui init --destination ../ --no-input --overwrite-if-exists; \ fi - cd df_designer_project && \ - poetry install && \ - poetry run pytest ../${BACKEND_DIR}/app/tests/integration + cd ${BACKEND_DIR} && \ + . `poetry env info --path`/bin/activate && \ + cd ../${PROJECT_NAME} && \ + pytest ../${BACKEND_DIR}/chatsky_ui/tests/integration .PHONY: backend_e2e_test backend_e2e_test: ## Runs e2e backend test - if [ ! -d "./df_designer_project" ]; then \ + if [ ! -d "${PROJECT_NAME}" ]; then \ cd "${BACKEND_DIR}" && \ - poetry run dflowd init --destination ../../ --no-input --overwrite-if-exists; \ + poetry run chatsky.ui init --destination ../ --no-input --overwrite-if-exists; \ fi - cd df_designer_project && \ - poetry install && \ - poetry run pytest ../${BACKEND_DIR}/app/tests/e2e + cd ${BACKEND_DIR} && \ + . `poetry env info --path`/bin/activate && \ + cd ../${PROJECT_NAME} && \ + pytest ../${BACKEND_DIR}/chatsky_ui/tests/e2e .PHONY: backend_tests @@ -128,8 +134,8 @@ build: install_env ## Builds both frontend & backend make build_backend .PHONY: run_app -run_app: check_project_arg install_env build_frontend ## Builds frontend and backend then runs the app. NEEDS arg: PROJECT_NAME - cp ${FRONTEND_DIR}/dist/* ${BACKEND_DIR}/app/static/ && \ +run_app: check_project_arg build_frontend ## Builds frontend and backend then runs the app. NEEDS arg: PROJECT_NAME + cp ${FRONTEND_DIR}/dist/* ${BACKEND_DIR}/chatsky_ui/static/ && \ make build_backend && \ make run_backend PROJECT_NAME=${PROJECT_NAME} @@ -141,10 +147,12 @@ run_dev: check_project_arg install_env ## Runs both backend and frontend in dev .PHONY: init_proj -init_proj: install_backend_env ## Initiates a new project using dflowd - cd ${BACKEND_DIR} && poetry run dflowd init --destination ../../ +init_proj: install_backend_env ## Initiates a new project using chatsky-ui + cd ${BACKEND_DIR} && poetry run chatsky.ui init --destination ../ .PHONY: build_docs build_docs: install_backend_env ## Builds the docs - cd docs && make html && cd ../ + cd ${BACKEND_DIR} && \ + . `poetry env info --path`/bin/activate && \ + cd ../docs && make html && cd ../ diff --git a/README.md b/README.md index cdbb514e..764f1309 100644 --- a/README.md +++ b/README.md @@ -1,14 +1,14 @@ # Quick Start ## System Requirements -Ensure you have Python version 3.10 or higher installed. +Ensure you have Python version 3.8 or higher installed. ## Installation To install the necessary package, run the following command: ```bash -pip install dflowd --pre +pip install chatsky-ui ``` -## Configuring the dflowd app +## Configuring the chatsky-ui app You may add a `.env` file in the root directory and configure any of following environment variables. The values shown below are the default ones. ```.env HOST=0.0.0.0 @@ -27,13 +27,12 @@ RUN_RUNNING_TIMEOUT=5 ## Project Initiation Initialize your project by running: ```bash -dflowd init -cd # enter the slug you choose for your project with the help of the previous command +chatsky.ui init ``` -The `dflowd init` command will start an interactive `cookiecutter` process to create a project based on a predefined template. The resulting project will be a simple example template that you can customize to suit your needs. +The `chatsky.ui init` command will start an interactive `cookiecutter` process to create a project based on a predefined template. The resulting project will be a simple example template that you can customize to suit your needs. ## Running Your Project To run your project, use the following command: ```bash -dflowd run_backend +chatsky.ui run_app --project-dir # enter the slug you choose for your project with the help of the previous command ``` diff --git a/backend/Dockerfile b/backend/Dockerfile deleted file mode 100644 index 52816148..00000000 --- a/backend/Dockerfile +++ /dev/null @@ -1,46 +0,0 @@ -# Use a slim variant to reduce image size where possible -FROM python:3.10-slim as builder - -WORKDIR /src - -ARG PROJECT_DIR -# ENV PROJECT_DIR ${PROJECT_DIR} - -ENV POETRY_VERSION=1.8.2 \ - POETRY_HOME=/poetry \ - POETRY_VENV=/poetry-venv - -# Install Poetry in a virtual environment -RUN python3 -m venv $POETRY_VENV \ - && $POETRY_VENV/bin/pip install -U pip setuptools \ - && $POETRY_VENV/bin/pip install poetry==$POETRY_VERSION - -ENV PATH="${PATH}:${POETRY_VENV}/bin" - -COPY ./df_designer /src/df_designer -COPY ./${PROJECT_DIR} /src/${PROJECT_DIR} - -# Build the wheel -WORKDIR /src/df_designer -RUN poetry build - - -FROM python:3.10-slim as runtime - -ARG PROJECT_DIR - -COPY --from=builder /poetry-venv /poetry-venv - -# Set environment variable to use the virtualenv -ENV PATH="/poetry-venv/bin:$PATH" - -# Copy only the necessary files -COPY --from=builder /src/df_designer /df_designer -COPY --from=builder /src/${PROJECT_DIR} /${PROJECT_DIR} - -# Install the wheel -WORKDIR /${PROJECT_DIR} -RUN poetry lock --no-update \ - && poetry install - -CMD ["poetry", "run", "dflowd", "run_app"] diff --git a/backend/df_designer/README.md b/backend/README.md similarity index 100% rename from backend/df_designer/README.md rename to backend/README.md diff --git a/backend/chatsky_ui/__init__.py b/backend/chatsky_ui/__init__.py new file mode 100644 index 00000000..9dd81be4 --- /dev/null +++ b/backend/chatsky_ui/__init__.py @@ -0,0 +1,3 @@ +from importlib.metadata import version + +__version__ = version(__name__) diff --git a/backend/df_designer/app/api/__init_.py b/backend/chatsky_ui/api/__init_.py similarity index 100% rename from backend/df_designer/app/api/__init_.py rename to backend/chatsky_ui/api/__init_.py diff --git a/backend/df_designer/app/__init__.py b/backend/chatsky_ui/api/api_v1/__init__.py similarity index 100% rename from backend/df_designer/app/__init__.py rename to backend/chatsky_ui/api/api_v1/__init__.py diff --git a/backend/df_designer/app/api/api_v1/api.py b/backend/chatsky_ui/api/api_v1/api.py similarity index 80% rename from backend/df_designer/app/api/api_v1/api.py rename to backend/chatsky_ui/api/api_v1/api.py index 44ed909d..19eb4d16 100644 --- a/backend/df_designer/app/api/api_v1/api.py +++ b/backend/chatsky_ui/api/api_v1/api.py @@ -1,7 +1,7 @@ from fastapi import APIRouter -from app.api.api_v1.endpoints import bot, dff_services, flows, config -from app.core.config import settings +from chatsky_ui.api.api_v1.endpoints import bot, config, dff_services, flows +from chatsky_ui.core.config import settings api_router = APIRouter() diff --git a/backend/df_designer/app/api/api_v1/__init__.py b/backend/chatsky_ui/api/api_v1/endpoints/__init__.py similarity index 100% rename from backend/df_designer/app/api/api_v1/__init__.py rename to backend/chatsky_ui/api/api_v1/endpoints/__init__.py diff --git a/backend/df_designer/app/api/api_v1/endpoints/auth.py b/backend/chatsky_ui/api/api_v1/endpoints/auth.py similarity index 100% rename from backend/df_designer/app/api/api_v1/endpoints/auth.py rename to backend/chatsky_ui/api/api_v1/endpoints/auth.py diff --git a/backend/df_designer/app/api/api_v1/endpoints/bot.py b/backend/chatsky_ui/api/api_v1/endpoints/bot.py similarity index 85% rename from backend/df_designer/app/api/api_v1/endpoints/bot.py rename to backend/chatsky_ui/api/api_v1/endpoints/bot.py index a37eb760..283fd820 100644 --- a/backend/df_designer/app/api/api_v1/endpoints/bot.py +++ b/backend/chatsky_ui/api/api_v1/endpoints/bot.py @@ -1,22 +1,19 @@ import asyncio -from typing import Any, Optional +from typing import Any, Dict, List, Optional, Union from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException, WebSocket, WebSocketException, status -from app.api import deps -from app.core.logger_config import get_logger -from app.schemas.pagination import Pagination -from app.schemas.preset import Preset -from app.services.index import Index -from app.services.process_manager import BuildManager, ProcessManager, RunManager -from app.services.websocket_manager import WebSocketManager +from chatsky_ui.api import deps +from chatsky_ui.schemas.pagination import Pagination +from chatsky_ui.schemas.preset import Preset +from chatsky_ui.services.index import Index +from chatsky_ui.services.process_manager import BuildManager, ProcessManager, RunManager +from chatsky_ui.services.websocket_manager import WebSocketManager router = APIRouter() -logger = get_logger(__name__) - -async def _stop_process(id_: int, process_manager: ProcessManager, process="run") -> dict[str, str]: +async def _stop_process(id_: int, process_manager: ProcessManager, process="run") -> Dict[str, str]: """Stops a `build` or `run` process with the given id.""" try: @@ -27,11 +24,11 @@ async def _stop_process(id_: int, process_manager: ProcessManager, process="run" detail="Process not found. It may have already exited or not started yet. Please check logs.", ) from e - logger.info("%s process '%s' has stopped", process.capitalize(), id_) + process_manager.logger.info("%s process '%s' has stopped", process.capitalize(), id_) return {"status": "ok"} -async def _check_process_status(id_: int, process_manager: ProcessManager) -> dict[str, str]: +async def _check_process_status(id_: int, process_manager: ProcessManager) -> Dict[str, str]: """Checks the status of a `build` or `run` process with the given id.""" if id_ not in process_manager.processes: raise HTTPException( @@ -48,7 +45,7 @@ async def start_build( background_tasks: BackgroundTasks, build_manager: BuildManager = Depends(deps.get_build_manager), index: Index = Depends(deps.get_index), -) -> dict[str, str | int]: +) -> Dict[str, Union[str, int]]: """Starts a `build` process with the given preset. @@ -64,12 +61,12 @@ async def start_build( await asyncio.sleep(preset.wait_time) build_id = await build_manager.start(preset) background_tasks.add_task(build_manager.check_status, build_id, index) - logger.info("Build process '%s' has started", build_id) + build_manager.logger.info("Build process '%s' has started", build_id) return {"status": "ok", "build_id": build_id} @router.get("/build/stop/{build_id}", status_code=200) -async def stop_build(*, build_id: int, build_manager: BuildManager = Depends(deps.get_build_manager)) -> dict[str, str]: +async def stop_build(*, build_id: int, build_manager: BuildManager = Depends(deps.get_build_manager)) -> Dict[str, str]: """Stops a `build` process with the given id. Args: @@ -88,7 +85,7 @@ async def stop_build(*, build_id: int, build_manager: BuildManager = Depends(dep @router.get("/build/status/{build_id}", status_code=200) async def check_build_status( *, build_id: int, build_manager: BuildManager = Depends(deps.get_build_manager) -) -> dict[str, str]: +) -> Dict[str, str]: """Checks the status of a `build` process with the given id. Args: @@ -107,13 +104,13 @@ async def check_build_status( return await _check_process_status(build_id, build_manager) -@router.get("/builds", response_model=Optional[list | dict], status_code=200) +@router.get("/builds", response_model=Optional[Union[list, dict]], status_code=200) async def check_build_processes( build_id: Optional[int] = None, build_manager: BuildManager = Depends(deps.get_build_manager), run_manager: RunManager = Depends(deps.get_run_manager), pagination: Pagination = Depends(), -) -> Optional[dict[str, Any]] | list[dict[str, Any]]: +) -> Optional[Union[Dict[str, Any], List[Dict[str, Any]]]]: """Checks the status of all `build` processes and returns them along with their runs info. The offset and limit parameters can be used to paginate the results. @@ -132,7 +129,7 @@ async def check_build_processes( @router.get("/builds/logs/{build_id}", response_model=Optional[list], status_code=200) async def get_build_logs( build_id: int, build_manager: BuildManager = Depends(deps.get_build_manager), pagination: Pagination = Depends() -) -> Optional[list[str]]: +) -> Optional[List[str]]: """Gets the logs of a specific `build` process. The offset and limit parameters can be used to paginate the results. @@ -148,7 +145,7 @@ async def start_run( preset: Preset, background_tasks: BackgroundTasks, run_manager: RunManager = Depends(deps.get_run_manager) -) -> dict[str, str | int]: +) -> Dict[str, Union[str, int]]: """Starts a `run` process with the given preset. This runs a background task to check the status of the process every 2 seconds. @@ -164,12 +161,12 @@ async def start_run( await asyncio.sleep(preset.wait_time) run_id = await run_manager.start(build_id, preset) background_tasks.add_task(run_manager.check_status, run_id) - logger.info("Run process '%s' has started", run_id) + run_manager.logger.info("Run process '%s' has started", run_id) return {"status": "ok", "run_id": run_id} @router.get("/run/stop/{run_id}", status_code=200) -async def stop_run(*, run_id: int, run_manager: RunManager = Depends(deps.get_run_manager)) -> dict[str, str]: +async def stop_run(*, run_id: int, run_manager: RunManager = Depends(deps.get_run_manager)) -> Dict[str, str]: """Stops a `run` process with the given id. Args: @@ -187,7 +184,7 @@ async def stop_run(*, run_id: int, run_manager: RunManager = Depends(deps.get_ru @router.get("/run/status/{run_id}", status_code=200) -async def check_run_status(*, run_id: int, run_manager: RunManager = Depends(deps.get_run_manager)) -> dict[str, Any]: +async def check_run_status(*, run_id: int, run_manager: RunManager = Depends(deps.get_run_manager)) -> Dict[str, Any]: """Checks the status of a `run` process with the given id. Args: @@ -206,12 +203,12 @@ async def check_run_status(*, run_id: int, run_manager: RunManager = Depends(dep return await _check_process_status(run_id, run_manager) -@router.get("/runs", response_model=Optional[list | dict], status_code=200) +@router.get("/runs", response_model=Optional[Union[list, dict]], status_code=200) async def check_run_processes( run_id: Optional[int] = None, run_manager: RunManager = Depends(deps.get_run_manager), pagination: Pagination = Depends(), -) -> Optional[dict[str, Any]] | list[dict[str, Any]]: +) -> Optional[Union[Dict[str, Any], List[Dict[str, Any]]]]: """Checks the status of all `run` processes and returns them. The offset and limit parameters can be used to paginate the results. @@ -229,7 +226,7 @@ async def check_run_processes( @router.get("/runs/logs/{run_id}", response_model=Optional[list], status_code=200) async def get_run_logs( run_id: int, run_manager: RunManager = Depends(deps.get_run_manager), pagination: Pagination = Depends() -) -> Optional[list[str]]: +) -> Optional[List[str]]: """Gets the logs of a specific `run` process. The offset and limit parameters can be used to paginate the results. @@ -249,23 +246,23 @@ async def connect( The WebSocket URL should adhere to the format: /bot/run/connect?run_id=. """ - logger.debug("Connecting to websocket") + run_manager.logger.debug("Connecting to websocket") run_id = websocket.query_params.get("run_id") # Validate run_id if run_id is None: - logger.error("No run_id provided") + run_manager.logger.error("No run_id provided") raise WebSocketException(code=status.WS_1008_POLICY_VIOLATION) if not run_id.isdigit(): - logger.error("A non-digit run run_id provided") + run_manager.logger.error("A non-digit run run_id provided") raise WebSocketException(code=status.WS_1003_UNSUPPORTED_DATA) run_id = int(run_id) if run_id not in run_manager.processes: - logger.error("process with run_id '%s' exited or never existed", run_id) + run_manager.logger.error("process with run_id '%s' exited or never existed", run_id) raise WebSocketException(code=status.WS_1014_BAD_GATEWAY) await websocket_manager.connect(websocket) - logger.info("Websocket for run process '%s' has been opened", run_id) + run_manager.logger.info("Websocket for run process '%s' has been opened", run_id) await websocket.send_text("Start chatting") diff --git a/backend/chatsky_ui/api/api_v1/endpoints/config.py b/backend/chatsky_ui/api/api_v1/endpoints/config.py new file mode 100644 index 00000000..dde42f64 --- /dev/null +++ b/backend/chatsky_ui/api/api_v1/endpoints/config.py @@ -0,0 +1,10 @@ +from fastapi import APIRouter + +from chatsky_ui import __version__ + +router = APIRouter() + + +@router.get("/version") +async def get_version(): + return __version__ diff --git a/backend/df_designer/app/api/api_v1/endpoints/dff_services.py b/backend/chatsky_ui/api/api_v1/endpoints/dff_services.py similarity index 75% rename from backend/df_designer/app/api/api_v1/endpoints/dff_services.py rename to backend/chatsky_ui/api/api_v1/endpoints/dff_services.py index 6b6376c3..eb67ded6 100644 --- a/backend/df_designer/app/api/api_v1/endpoints/dff_services.py +++ b/backend/chatsky_ui/api/api_v1/endpoints/dff_services.py @@ -1,27 +1,24 @@ import re from io import StringIO -from typing import Optional +from typing import Dict, Optional, Union import aiofiles from fastapi import APIRouter, Depends from pylint.lint import Run, pylinter from pylint.reporters.text import TextReporter -from app.api.deps import get_index -from app.clients.dff_client import get_dff_conditions -from app.core.config import settings -from app.core.logger_config import get_logger -from app.schemas.code_snippet import CodeSnippet -from app.services.index import Index -from app.utils.ast_utils import get_imports_from_file +from chatsky_ui.api.deps import get_index +from chatsky_ui.clients.dff_client import get_dff_conditions +from chatsky_ui.core.config import settings +from chatsky_ui.schemas.code_snippet import CodeSnippet +from chatsky_ui.services.index import Index +from chatsky_ui.utils.ast_utils import get_imports_from_file router = APIRouter() -logger = get_logger(__name__) - @router.get("/search/{service_name}", status_code=200) -async def search_service(service_name: str, index: Index = Depends(get_index)) -> dict[str, str | Optional[list]]: +async def search_service(service_name: str, index: Index = Depends(get_index)) -> Dict[str, Optional[Union[str, list]]]: """Searches for a custom service by name and returns its code. A service could be a condition, reponse, or pre/postservice. @@ -31,7 +28,7 @@ async def search_service(service_name: str, index: Index = Depends(get_index)) - @router.post("/lint_snippet", status_code=200) -async def lint_snippet(snippet: CodeSnippet) -> dict[str, str]: +async def lint_snippet(snippet: CodeSnippet) -> Dict[str, str]: """Lints a snippet with Pylint. This endpoint Joins the snippet with all imports existing in the conditions.py file and then runs Pylint on it. @@ -58,6 +55,6 @@ async def lint_snippet(snippet: CodeSnippet) -> dict[str, str]: @router.get("/get_conditions", status_code=200) -async def get_conditions() -> dict[str, str | list]: +async def get_conditions() -> Dict[str, Union[str, list]]: """Gets the dff's out-of-the-box conditions.""" return {"status": "ok", "data": get_dff_conditions()} diff --git a/backend/df_designer/app/api/api_v1/endpoints/flows.py b/backend/chatsky_ui/api/api_v1/endpoints/flows.py similarity index 65% rename from backend/df_designer/app/api/api_v1/endpoints/flows.py rename to backend/chatsky_ui/api/api_v1/endpoints/flows.py index ae6bf60b..c7168e84 100644 --- a/backend/df_designer/app/api/api_v1/endpoints/flows.py +++ b/backend/chatsky_ui/api/api_v1/endpoints/flows.py @@ -1,17 +1,16 @@ +from typing import Dict, Union + from fastapi import APIRouter from omegaconf import OmegaConf -from app.core.config import settings -from app.core.logger_config import get_logger -from app.db.base import read_conf, write_conf +from chatsky_ui.core.config import settings +from chatsky_ui.db.base import read_conf, write_conf router = APIRouter() -logger = get_logger(__name__) - @router.get("/") -async def flows_get() -> dict[str, str | dict[str, list]]: +async def flows_get() -> Dict[str, Union[str, Dict[str, list]]]: """Get the flows by reading the frontend_flows.yaml file.""" omega_flows = await read_conf(settings.frontend_flows_path) dict_flows = OmegaConf.to_container(omega_flows, resolve=True) @@ -19,7 +18,7 @@ async def flows_get() -> dict[str, str | dict[str, list]]: @router.post("/") -async def flows_post(flows: dict[str, list]) -> dict[str, str]: +async def flows_post(flows: Dict[str, list]) -> Dict[str, str]: """Write the flows to the frontend_flows.yaml file.""" await write_conf(flows, settings.frontend_flows_path) return {"status": "ok"} diff --git a/backend/chatsky_ui/api/deps.py b/backend/chatsky_ui/api/deps.py new file mode 100644 index 00000000..7317d638 --- /dev/null +++ b/backend/chatsky_ui/api/deps.py @@ -0,0 +1,36 @@ +from chatsky_ui.core.config import settings +from chatsky_ui.services.index import Index +from chatsky_ui.services.process_manager import BuildManager, RunManager +from chatsky_ui.services.websocket_manager import WebSocketManager + +build_manager = BuildManager() + + +def get_build_manager() -> BuildManager: + build_manager.set_logger() + return build_manager + + +run_manager = RunManager() + + +def get_run_manager() -> RunManager: + run_manager.set_logger() + return run_manager + + +websocket_manager = WebSocketManager() + + +def get_websocket_manager() -> WebSocketManager: + websocket_manager.set_logger() + return websocket_manager + + +index = Index() + + +def get_index() -> Index: + index.set_logger() + index.set_path(settings.index_path) + return index diff --git a/backend/chatsky_ui/cli.py b/backend/chatsky_ui/cli.py new file mode 100644 index 00000000..f322fea7 --- /dev/null +++ b/backend/chatsky_ui/cli.py @@ -0,0 +1,184 @@ +import asyncio +import json +import os +import string +import sys +from pathlib import Path + +import nest_asyncio +import typer +from cookiecutter.main import cookiecutter +from typing_extensions import Annotated + +# Patch nest_asyncio before importing DFF +nest_asyncio.apply = lambda: None + +from chatsky_ui.core.config import app_runner, settings # noqa: E402 +from chatsky_ui.core.logger_config import get_logger # noqa: E402 + +cli = typer.Typer( + help="🚀 Welcome to Chatsky-UI!\n\n" + "To get started, use the following commands:\n\n" + "1. `init` - Initializes a new Chatsky-UI project.\n\n" + "2. `run_app` - Runs the UI for your project.\n" +) + + +async def _execute_command(command_to_run): + logger = get_logger(__name__) + try: + process = await asyncio.create_subprocess_exec(*command_to_run.split()) + + # Check the return code to determine success + if process.returncode == 0: + logger.info("Command '%s' executed successfully.", command_to_run) + elif process.returncode is None: + logger.info("Process by command '%s' is running.", command_to_run) + await process.wait() + logger.info("Process ended with return code: %d.", process.returncode) + sys.exit(process.returncode) + else: + logger.error("Command '%s' failed with return code: %d", command_to_run, process.returncode) + sys.exit(process.returncode) + + except Exception as e: + logger.error("Error executing '%s': %s", command_to_run, str(e)) + sys.exit(1) + + +def _execute_command_file(build_id: int, project_dir: Path, command_file: str, preset: str): + logger = get_logger(__name__) + + presets_build_path = settings.presets / command_file + with open(presets_build_path, encoding="UTF-8") as file: + file_content = file.read() + + template = string.Template(file_content) + substituted_content = template.substitute(work_directory=project_dir, build_id=build_id) + + presets_build_file = json.loads(substituted_content) + if preset in presets_build_file: + command_to_run = presets_build_file[preset]["cmd"] + logger.debug("Executing command for preset '%s': %s", preset, command_to_run) + + asyncio.run(_execute_command(command_to_run)) + else: + raise ValueError(f"Invalid preset '{preset}'. Preset must be one of {list(presets_build_file.keys())}") + + +@cli.command("build_bot") +def build_bot( + build_id: Annotated[int, typer.Option(help="Id to save the build with")] = None, + project_dir: Path = None, + preset: Annotated[str, typer.Option(help="Could be one of: success, failure, loop")] = "success", +): + """Builds the bot with one of three various presets.""" + project_dir = project_dir or settings.work_directory + + if not project_dir.is_dir(): + raise NotADirectoryError(f"Directory {project_dir} doesn't exist") + settings.set_config(work_directory=project_dir) + + _execute_command_file(build_id, project_dir, "build.json", preset) + + +@cli.command("build_scenario") +def build_scenario( + build_id: Annotated[int, typer.Argument(help="Id to save the build with")], + project_dir: Annotated[Path, typer.Option(help="Your Chatsky-UI project directory")] = ".", + # TODO: add custom_dir - maybe the same way like project_dir +): + """Builds the bot with preset `success`""" + if not project_dir.is_dir(): + raise NotADirectoryError(f"Directory {project_dir} doesn't exist") + settings.set_config(work_directory=project_dir) + + from chatsky_ui.services.json_converter import converter # pylint: disable=C0415 + + asyncio.run(converter(build_id=build_id)) + + +@cli.command("run_bot") +def run_bot( + build_id: Annotated[int, typer.Option(help="Id of the build to run")] = None, + project_dir: Annotated[Path, typer.Option(help="Your Chatsky-UI project directory")] = None, + preset: Annotated[str, typer.Option(help="Could be one of: success, failure, loop")] = "success", +): + """Runs the bot with one of three various presets.""" + project_dir = project_dir or settings.work_directory + + if not project_dir.is_dir(): + raise NotADirectoryError(f"Directory {project_dir} doesn't exist") + settings.set_config(work_directory=project_dir) + + _execute_command_file(build_id, project_dir, "run.json", preset) + + +@cli.command("run_scenario") +def run_scenario( + build_id: Annotated[int, typer.Argument(help="Id of the build to run")], + project_dir: Annotated[Path, typer.Option(help="Your Chatsky-UI project directory")] = ".", +): + """Runs the bot with preset `success`""" + if not project_dir.is_dir(): + raise NotADirectoryError(f"Directory {project_dir} doesn't exist") + settings.set_config(work_directory=project_dir) + script_path = settings.scripts_dir / f"build_{build_id}.yaml" + + command_to_run = f"python {project_dir}/app.py --script-path {script_path}" + asyncio.run(_execute_command(command_to_run)) + + +@cli.command("run_app") +def run_app( + host: str = None, + port: int = None, + log_level: str = None, + conf_reload: Annotated[bool, typer.Option(help="True for dev-mode, False otherwise")] = None, + project_dir: Annotated[Path, typer.Option(help="Your Chatsky-UI project directory")] = Path("."), +) -> None: + """Runs the UI for your `project_dir` on `host:port`.""" + host = host or settings.host + port = port or settings.port + log_level = log_level or settings.log_level + conf_reload = conf_reload or settings.conf_reload + + if not project_dir.is_dir(): + raise NotADirectoryError(f"Directory {project_dir} doesn't exist") + + settings.set_config( + host=host, + port=port, + log_level=log_level, + conf_reload=str(conf_reload).lower() in ["true", "yes", "t", "y", "1"], + work_directory=project_dir, + ) + if conf_reload: + settings.save_config() # this is for the sake of maintaining the state of the settings + + app_runner.set_settings(settings) + app_runner.run() + + +@cli.command("init") +def init( + destination: Annotated[Path, typer.Option(help="Path where you want to create your project")] = None, + no_input: Annotated[bool, typer.Option(help="True for quick and easy initialization using default values")] = False, + overwrite_if_exists: Annotated[ + bool, + typer.Option(help="True for replacing any project named as `my_project`)"), + ] = True, +): + """Initializes a new Chatsky-UI project using an off-the-shelf template.""" + destination = destination or settings.work_directory + + original_dir = os.getcwd() + try: + os.chdir(destination) + cookiecutter( + "https://github.com/Ramimashkouk/df_d_template.git", + no_input=no_input, + overwrite_if_exists=overwrite_if_exists, + ) + finally: + os.chdir(original_dir) diff --git a/backend/df_designer/app/api/api_v1/endpoints/__init__.py b/backend/chatsky_ui/clients/__init__.py similarity index 100% rename from backend/df_designer/app/api/api_v1/endpoints/__init__.py rename to backend/chatsky_ui/clients/__init__.py diff --git a/backend/df_designer/app/clients/dff_client.py b/backend/chatsky_ui/clients/dff_client.py similarity index 95% rename from backend/df_designer/app/clients/dff_client.py rename to backend/chatsky_ui/clients/dff_client.py index 09317168..9163f619 100644 --- a/backend/df_designer/app/clients/dff_client.py +++ b/backend/chatsky_ui/clients/dff_client.py @@ -1,3 +1,5 @@ +from typing import List + import dff.script.conditions as cnd from dff.pipeline.pipeline import script_parsing @@ -9,7 +11,7 @@ } -def get_dff_conditions() -> list[dict]: +def get_dff_conditions() -> List[dict]: """Gets the DFF's out-of-the-box conditions. Returns: diff --git a/backend/df_designer/app/clients/__init__.py b/backend/chatsky_ui/core/__init__.py similarity index 100% rename from backend/df_designer/app/clients/__init__.py rename to backend/chatsky_ui/core/__init__.py diff --git a/backend/df_designer/app/core/auth.py b/backend/chatsky_ui/core/auth.py similarity index 100% rename from backend/df_designer/app/core/auth.py rename to backend/chatsky_ui/core/auth.py diff --git a/backend/chatsky_ui/core/config.py b/backend/chatsky_ui/core/config.py new file mode 100644 index 00000000..30cd10a9 --- /dev/null +++ b/backend/chatsky_ui/core/config.py @@ -0,0 +1,120 @@ +import os +from pathlib import Path + +import uvicorn +from dotenv import load_dotenv +from omegaconf import DictConfig, OmegaConf + +load_dotenv() + + +class Settings: + def __init__(self): + self.API_V1_STR = "/api/v1" + self.APP = "chatsky_ui.main:app" + + self.config_file_path = Path(__file__).absolute() + self.static_files = self.config_file_path.parent.with_name("static") + self.start_page = self.static_files / "index.html" + self.package_dir = self.config_file_path.parents[2] + self.temp_conf = self.config_file_path.with_name("temp_conf.yaml") + + self.set_config( + host=os.getenv("HOST", "0.0.0.0"), + port=os.getenv("PORT", "8000"), + log_level=os.getenv("LOG_LEVEL", "info"), + conf_reload=os.getenv("CONF_RELOAD", "false"), + work_directory=".", + ) + + def set_config(self, **kwargs): + for key, value in kwargs.items(): + if key == "work_directory": + value = Path(value) + elif key == "conf_reload": + value = str(value).lower() in ["true", "yes", "t", "y", "1"] + elif key == "port": + value = int(value) + setattr(self, key, value) + + if "work_directory" in kwargs: + self._set_user_proj_paths() + + def _set_user_proj_paths(self): + self.builds_path = self.work_directory / "chatsky_ui/app_data/builds.yaml" + self.runs_path = self.work_directory / "chatsky_ui/app_data/runs.yaml" + self.frontend_flows_path = self.work_directory / "chatsky_ui/app_data/frontend_flows.yaml" + self.dir_logs = self.work_directory / "chatsky_ui/logs" + self.presets = self.work_directory / "chatsky_ui/presets" + self.snippet2lint_path = self.work_directory / "chatsky_ui/.snippet2lint.py" + + self.custom_dir = self.work_directory / "bot/custom" + self.index_path = self.custom_dir / ".services_index.yaml" + self.conditions_path = self.custom_dir / "conditions.py" + self.responses_path = self.custom_dir / "responses.py" + self.scripts_dir = self.work_directory / "bot/scripts" + + def save_config(self): + if not self.temp_conf.exists(): + self.temp_conf.touch() + OmegaConf.save( + OmegaConf.create( + { + "work_directory": str(self.work_directory), + "host": self.host, + "port": self.port, + "log_level": self.log_level, + "conf_reload": self.conf_reload, + } + ), # type: ignore + self.temp_conf, + ) + + def _load_temp_config(self) -> DictConfig: + if not self.temp_conf.exists(): + raise FileNotFoundError(f"{self.temp_conf} not found.") + + return OmegaConf.load(self.temp_conf) # type: ignore + + def refresh_work_dir(self): + config = self._load_temp_config() + self.set_config(**config) + + +class AppRunner: + def __init__(self): + self._settings = None + + @property + def settings(self): + if self._settings is None: + raise ValueError("Settings has not been configured. Call set_logger() first.") + return self._settings + + def set_settings(self, app_settings: Settings): + self._settings = app_settings + + def run(self): + if reload := self.settings.conf_reload: + reload_conf = { + "reload": reload, + "reload_dirs": [str(self.settings.package_dir)], + "reload_excludes": [ + f"./{self.settings.work_directory}/*", + f"./{self.settings.work_directory}/*/*", + f"./{self.settings.work_directory}/*/*/*", + ], + } + else: + reload_conf = {"reload": reload} + uvicorn.run( + self.settings.APP, + host=self.settings.host, + port=self.settings.port, + log_level=self.settings.log_level, + **reload_conf, + ) + + +settings = Settings() +app_runner = AppRunner() diff --git a/backend/df_designer/app/core/logger_config.py b/backend/chatsky_ui/core/logger_config.py similarity index 65% rename from backend/df_designer/app/core/logger_config.py rename to backend/chatsky_ui/core/logger_config.py index 56d252a1..8fd0f345 100644 --- a/backend/df_designer/app/core/logger_config.py +++ b/backend/chatsky_ui/core/logger_config.py @@ -1,11 +1,11 @@ import logging from datetime import datetime from pathlib import Path -from typing import Literal, Optional +from typing import Dict, Literal, Optional -from app.core.config import settings +from chatsky_ui.core.config import settings -LOG_LEVELS: dict[str, int] = { +LOG_LEVELS: Dict[str, int] = { "critical": logging.CRITICAL, "error": logging.ERROR, "warning": logging.WARNING, @@ -43,17 +43,18 @@ def get_logger(name, file_handler_path: Optional[Path] = None): logger.propagate = False logger.setLevel(LOG_LEVELS[settings.log_level]) - c_handler = logging.StreamHandler() - f_handler = logging.FileHandler(file_handler_path) - c_handler.setLevel(LOG_LEVELS[settings.log_level]) - f_handler.setLevel(LOG_LEVELS[settings.log_level]) + if not logger.hasHandlers(): + c_handler = logging.StreamHandler() + f_handler = logging.FileHandler(file_handler_path) + c_handler.setLevel(LOG_LEVELS[settings.log_level]) + f_handler.setLevel(LOG_LEVELS[settings.log_level]) - c_format = logging.Formatter("%(name)s - %(levelname)s - %(message)s") - f_format = logging.Formatter("%(asctime)s - %(name)s - %(levelname)s - %(message)s") - c_handler.setFormatter(c_format) - f_handler.setFormatter(f_format) + c_format = logging.Formatter("%(name)s - %(levelname)s - %(message)s") + f_format = logging.Formatter("%(asctime)s - %(name)s - %(levelname)s - %(message)s") + c_handler.setFormatter(c_format) + f_handler.setFormatter(f_format) - logger.addHandler(c_handler) - logger.addHandler(f_handler) + logger.addHandler(c_handler) + logger.addHandler(f_handler) return logger diff --git a/backend/df_designer/app/core/security.py b/backend/chatsky_ui/core/security.py similarity index 100% rename from backend/df_designer/app/core/security.py rename to backend/chatsky_ui/core/security.py diff --git a/backend/df_designer/app/core/__init__.py b/backend/chatsky_ui/db/__init__.py similarity index 100% rename from backend/df_designer/app/core/__init__.py rename to backend/chatsky_ui/db/__init__.py diff --git a/backend/df_designer/app/db/base.py b/backend/chatsky_ui/db/base.py similarity index 83% rename from backend/df_designer/app/db/base.py rename to backend/chatsky_ui/db/base.py index 3faa5825..6ea872a7 100644 --- a/backend/df_designer/app/db/base.py +++ b/backend/chatsky_ui/db/base.py @@ -1,6 +1,6 @@ from asyncio import Lock from pathlib import Path -from typing import List +from typing import List, Union import aiofiles from omegaconf import OmegaConf @@ -10,7 +10,7 @@ file_lock = Lock() -async def read_conf(path: Path) -> DictConfig | ListConfig: +async def read_conf(path: Path) -> Union[DictConfig, ListConfig]: async with file_lock: async with aiofiles.open(path, "r", encoding="UTF-8") as file: data = await file.read() @@ -18,7 +18,7 @@ async def read_conf(path: Path) -> DictConfig | ListConfig: return omega_data -async def write_conf(data: DictConfig | ListConfig | dict | list, path: Path) -> None: +async def write_conf(data: Union[DictConfig, ListConfig, dict, list], path: Path) -> None: yaml_conf = OmegaConf.to_yaml(data) async with file_lock: async with aiofiles.open(path, "w", encoding="UTF-8") as file: # TODO: change to "a" for append diff --git a/backend/df_designer/app/db/base_class.py b/backend/chatsky_ui/db/base_class.py similarity index 100% rename from backend/df_designer/app/db/base_class.py rename to backend/chatsky_ui/db/base_class.py diff --git a/backend/df_designer/app/db/__init__.py b/backend/chatsky_ui/db/crud/__init__.py similarity index 100% rename from backend/df_designer/app/db/__init__.py rename to backend/chatsky_ui/db/crud/__init__.py diff --git a/backend/df_designer/app/db/crud/base.py b/backend/chatsky_ui/db/crud/base.py similarity index 100% rename from backend/df_designer/app/db/crud/base.py rename to backend/chatsky_ui/db/crud/base.py diff --git a/backend/df_designer/app/db/crud/crud_bot.py b/backend/chatsky_ui/db/crud/crud_bot.py similarity index 100% rename from backend/df_designer/app/db/crud/crud_bot.py rename to backend/chatsky_ui/db/crud/crud_bot.py diff --git a/backend/df_designer/app/db/init_db.py b/backend/chatsky_ui/db/init_db.py similarity index 100% rename from backend/df_designer/app/db/init_db.py rename to backend/chatsky_ui/db/init_db.py diff --git a/backend/df_designer/app/db/crud/__init__.py b/backend/chatsky_ui/db/models/__init__.py similarity index 100% rename from backend/df_designer/app/db/crud/__init__.py rename to backend/chatsky_ui/db/models/__init__.py diff --git a/backend/df_designer/app/db/models/bot.py b/backend/chatsky_ui/db/models/bot.py similarity index 100% rename from backend/df_designer/app/db/models/bot.py rename to backend/chatsky_ui/db/models/bot.py diff --git a/backend/df_designer/app/db/session.py b/backend/chatsky_ui/db/session.py similarity index 100% rename from backend/df_designer/app/db/session.py rename to backend/chatsky_ui/db/session.py diff --git a/backend/df_designer/app/initial_data.py b/backend/chatsky_ui/initial_data.py similarity index 100% rename from backend/df_designer/app/initial_data.py rename to backend/chatsky_ui/initial_data.py diff --git a/backend/df_designer/app/main.py b/backend/chatsky_ui/main.py similarity index 82% rename from backend/df_designer/app/main.py rename to backend/chatsky_ui/main.py index f50dc5bf..05e890b2 100644 --- a/backend/df_designer/app/main.py +++ b/backend/chatsky_ui/main.py @@ -4,23 +4,28 @@ from fastapi.middleware.cors import CORSMiddleware from fastapi.responses import FileResponse, HTMLResponse, RedirectResponse -from app.api.api_v1.api import api_router -from app.api.deps import get_index -from app.core.config import settings +from chatsky_ui.api.api_v1.api import api_router +from chatsky_ui.api.deps import get_index +from chatsky_ui.core.config import settings index_dict = {} @asynccontextmanager async def lifespan(app: FastAPI): + if settings.temp_conf.exists(): + settings.refresh_work_dir() + index_dict["instance"] = get_index() await index_dict["instance"].load() yield - # Clean up and release the resources + + settings.temp_conf.unlink(missing_ok=True) app = FastAPI(title="DF Designer", lifespan=lifespan) + app.add_middleware( CORSMiddleware, allow_origins=["*"], diff --git a/backend/df_designer/app/db/models/__init__.py b/backend/chatsky_ui/schemas/__init__.py similarity index 100% rename from backend/df_designer/app/db/models/__init__.py rename to backend/chatsky_ui/schemas/__init__.py diff --git a/backend/df_designer/app/schemas/code_snippet.py b/backend/chatsky_ui/schemas/code_snippet.py similarity index 100% rename from backend/df_designer/app/schemas/code_snippet.py rename to backend/chatsky_ui/schemas/code_snippet.py diff --git a/backend/df_designer/app/schemas/pagination.py b/backend/chatsky_ui/schemas/pagination.py similarity index 100% rename from backend/df_designer/app/schemas/pagination.py rename to backend/chatsky_ui/schemas/pagination.py diff --git a/backend/df_designer/app/schemas/preset.py b/backend/chatsky_ui/schemas/preset.py similarity index 100% rename from backend/df_designer/app/schemas/preset.py rename to backend/chatsky_ui/schemas/preset.py diff --git a/backend/df_designer/app/schemas/process_status.py b/backend/chatsky_ui/schemas/process_status.py similarity index 100% rename from backend/df_designer/app/schemas/process_status.py rename to backend/chatsky_ui/schemas/process_status.py diff --git a/backend/df_designer/app/schemas/__init__.py b/backend/chatsky_ui/services/__init__.py similarity index 100% rename from backend/df_designer/app/schemas/__init__.py rename to backend/chatsky_ui/services/__init__.py diff --git a/backend/df_designer/app/services/index.py b/backend/chatsky_ui/services/index.py similarity index 90% rename from backend/df_designer/app/services/index.py rename to backend/chatsky_ui/services/index.py index 04f0749c..683ae9b3 100644 --- a/backend/df_designer/app/services/index.py +++ b/backend/chatsky_ui/services/index.py @@ -13,19 +13,36 @@ from omegaconf import OmegaConf from omegaconf.dictconfig import DictConfig -from app.core.config import settings -from app.core.logger_config import get_logger -from app.db.base import read_conf, read_logs, write_conf +from chatsky_ui.core.logger_config import get_logger +from chatsky_ui.db.base import read_conf, read_logs, write_conf class Index: def __init__(self): - self.path: Path = settings.index_path self.index: dict = {} self.conditions: List[str] = [] self.responses: List[str] = [] self.services: List[str] = [] - self.logger = get_logger(__name__) + self._logger = None + self._path = None + + @property + def logger(self): + if self._logger is None: + raise ValueError("Logger has not been configured. Call set_logger() first.") + return self._logger + + def set_logger(self): + self._logger = get_logger(__name__) + + @property + def path(self): + if self._path is None: + raise ValueError("Path has not been configured. Call set_path() first.") + return self._path + + def set_path(self, path: Path): + self._path = path async def _load_index(self) -> None: """Load indexed conditions, responses and services from disk.""" diff --git a/backend/df_designer/app/services/json_converter.py b/backend/chatsky_ui/services/json_converter.py similarity index 90% rename from backend/df_designer/app/services/json_converter.py rename to backend/chatsky_ui/services/json_converter.py index 8331beb1..d0d7a77a 100644 --- a/backend/df_designer/app/services/json_converter.py +++ b/backend/chatsky_ui/services/json_converter.py @@ -5,24 +5,25 @@ Converts a user project's frontend graph to a script understandable by DFF json-importer. """ from pathlib import Path -from typing import Tuple +from typing import List, Optional, Tuple -from app.api.deps import get_index -from app.core.logger_config import get_logger -from app.db.base import read_conf, write_conf -from app.services.index import Index from omegaconf.dictconfig import DictConfig +from chatsky_ui.api.deps import get_index +from chatsky_ui.core.logger_config import get_logger +from chatsky_ui.core.config import settings +from chatsky_ui.db.base import read_conf, write_conf +from chatsky_ui.services.index import Index + logger = get_logger(__name__) -def _get_db_paths(build_id: int, project_dir: Path, custom_dir: str) -> Tuple[Path, Path, Path, Path]: +def _get_db_paths(build_id: int) -> Tuple[Path, Path, Path, Path]: """Get paths to frontend graph, dff script, and dff custom conditions files.""" - - frontend_graph_path = project_dir / "df_designer" / "frontend_flows.yaml" - custom_conditions_file = project_dir / "bot" / custom_dir / "conditions.py" - custom_responses_file = project_dir / "bot" / custom_dir / "responses.py" - script_path = project_dir / "bot" / "scripts" / f"build_{build_id}.yaml" + frontend_graph_path = settings.frontend_flows_path + custom_conditions_file = settings.conditions_path + custom_responses_file = settings.responses_path + script_path = settings.scripts_dir / f"build_{build_id}.yaml" if not frontend_graph_path.exists(): raise FileNotFoundError(f"File {frontend_graph_path} doesn't exist") @@ -40,6 +41,7 @@ def _get_db_paths(build_id: int, project_dir: Path, custom_dir: str) -> Tuple[Pa def _organize_graph_according_to_nodes(flow_graph: DictConfig, script: dict) -> dict: nodes = {} for flow in flow_graph["flows"]: + node_names_in_one_flow = [] for node in flow.data.nodes: if "flags" in node.data: if "start" in node.data.flags: @@ -50,13 +52,17 @@ def _organize_graph_according_to_nodes(flow_graph: DictConfig, script: dict) -> if "fallback_label" in script["CONFIG"]: raise ValueError("There are more than one fallback node in the script") script["CONFIG"]["fallback_label"] = [flow.name, node.data.name] + + if node.data.name in node_names_in_one_flow: + raise ValueError(f"There is more than one node with the name '{node.data.name}' in the same flow.") + node_names_in_one_flow.append(node.data.name) nodes[node.id] = {"info": node} nodes[node.id]["flow"] = flow.name nodes[node.id]["TRANSITIONS"] = [] return nodes -def _get_condition(nodes: dict, edge: DictConfig) -> DictConfig | None: +def _get_condition(nodes: dict, edge: DictConfig) -> Optional[DictConfig]: """Get node's condition from `nodes` according to `edge` info.""" return next( (condition for condition in nodes[edge.source]["info"].data.conditions if condition["id"] == edge.sourceHandle), @@ -179,7 +185,7 @@ async def _replace(service: DictConfig, services_lines: list, cnd_strt_lineno: i return all_lines -async def update_responses_lines(nodes: dict, responses_lines: list, index: Index) -> tuple[dict, list[str]]: +async def update_responses_lines(nodes: dict, responses_lines: list, index: Index) -> Tuple[dict, List[str]]: """Organizes the responses in nodes in a format that json-importer accepts. If the response type is "python", its function will be added to responses_lines to be written @@ -220,18 +226,16 @@ async def update_responses_lines(nodes: dict, responses_lines: list, index: Inde return nodes, responses_lines -async def converter(build_id: int, project_dir: str, custom_dir: str = "custom") -> None: +async def converter(build_id: int) -> None: """Translate frontend flow script into dff script.""" index = get_index() await index.load() index.logger.debug("Loaded index '%s'", index.index) - frontend_graph_path, script_path, custom_conditions_file, custom_responses_file = _get_db_paths( - build_id, Path(project_dir), custom_dir - ) + frontend_graph_path, script_path, custom_conditions_file, custom_responses_file = _get_db_paths(build_id) script = { - "CONFIG": {"custom_dir": "/".join(["..", custom_dir])}, + "CONFIG": {"custom_dir": str("/" / settings.custom_dir)}, } flow_graph: DictConfig = await read_conf(frontend_graph_path) # type: ignore diff --git a/backend/df_designer/app/services/process.py b/backend/chatsky_ui/services/process.py similarity index 98% rename from backend/df_designer/app/services/process.py rename to backend/chatsky_ui/services/process.py index ffa033ce..9ad18522 100644 --- a/backend/df_designer/app/services/process.py +++ b/backend/chatsky_ui/services/process.py @@ -14,10 +14,10 @@ from dotenv import load_dotenv -from app.core.config import settings -from app.core.logger_config import get_logger, setup_logging -from app.db.base import read_conf, write_conf -from app.schemas.process_status import Status +from chatsky_ui.core.config import settings +from chatsky_ui.core.logger_config import get_logger, setup_logging +from chatsky_ui.db.base import read_conf, write_conf +from chatsky_ui.schemas.process_status import Status load_dotenv() diff --git a/backend/df_designer/app/services/process_manager.py b/backend/chatsky_ui/services/process_manager.py similarity index 83% rename from backend/df_designer/app/services/process_manager.py rename to backend/chatsky_ui/services/process_manager.py index eb8fbb9b..bb9e1d13 100644 --- a/backend/df_designer/app/services/process_manager.py +++ b/backend/chatsky_ui/services/process_manager.py @@ -7,26 +7,34 @@ are stored in the `processes` dictionary of process managers. """ from pathlib import Path -from typing import Any, Dict, List, Optional +from typing import Any, Dict, List, Optional, Union from omegaconf import OmegaConf -from app.core.config import settings -from app.core.logger_config import get_logger -from app.db.base import read_conf, read_logs -from app.schemas.preset import Preset -from app.schemas.process_status import Status -from app.services.process import BuildProcess, RunProcess - -logger = get_logger(__name__) +from chatsky_ui.core.config import settings +from chatsky_ui.core.logger_config import get_logger +from chatsky_ui.db.base import read_conf, read_logs +from chatsky_ui.schemas.preset import Preset +from chatsky_ui.schemas.process_status import Status +from chatsky_ui.services.process import BuildProcess, RunProcess class ProcessManager: """Base for build and run process managers.""" def __init__(self): - self.processes: Dict[int, BuildProcess | RunProcess] = {} + self.processes: Dict[int, Union[BuildProcess, RunProcess]] = {} self.last_id: int + self._logger = None + + @property + def logger(self): + if self._logger is None: + raise ValueError("Logger has not been configured. Call set_logger() first.") + return self._logger + + def set_logger(self): + self._logger = get_logger(__name__) def get_last_id(self): """Gets the maximum id among processes of type BuildProcess or RunProcess.""" @@ -40,7 +48,7 @@ async def stop(self, id_: int) -> None: RuntimeError: If the process has not started yet. """ if id_ not in self.processes: - logger.error("Process with id '%s' not found in recent running processes", id_) + self.logger.error("Process with id '%s' not found in recent running processes", id_) raise ProcessLookupError try: await self.processes[id_].stop() @@ -76,7 +84,7 @@ async def fetch_process_logs(self, id_: int, offset: int, limit: int, path: Path """Returns the logs of one process according to its id. If the process is not found, returns None.""" process_info = await self.get_process_info(id_, path) if process_info is None: - logger.error("Id '%s' not found", id_) + self.logger.error("Id '%s' not found", id_) return None log_file = Path(process_info["log_path"]) @@ -84,14 +92,14 @@ async def fetch_process_logs(self, id_: int, offset: int, limit: int, path: Path logs = await read_logs(log_file) logs = [log for log in logs if log.strip()] except FileNotFoundError: - logger.error("Log file '%s' not found", log_file) + self.logger.error("Log file '%s' not found", log_file) return None if offset > len(logs): - logger.info("Offset '%s' is out of bounds ('%s' logs found)", offset, len(logs)) + self.logger.info("Offset '%s' is out of bounds ('%s' logs found)", offset, len(logs)) return None # TODO: raise error! - logger.info("Returning %s logs", len(logs)) + self.logger.info("Returning %s logs", len(logs)) return logs[offset : offset + limit] @@ -111,7 +119,11 @@ async def start(self, build_id: int, preset: Preset) -> int: Returns: int: the id of the new started process """ - cmd_to_run = f"dflowd run_bot {build_id} --preset {preset.end_status}" + cmd_to_run = ( + f"chatsky.ui run_bot --build-id {build_id} " + f"--preset {preset.end_status} " + f"--project-dir {settings.work_directory}" + ) self.last_id = max([run["id"] for run in await self.get_full_info(0, 10000)]) self.last_id += 1 id_ = self.last_id @@ -126,8 +138,9 @@ async def get_run_info(self, id_: int) -> Optional[Dict[str, Any]]: """Returns metadata of a specific run process identified by its unique ID.""" return await super().get_process_info(id_, settings.runs_path) - async def get_full_info(self, offset: int, limit: int, path: Path = settings.runs_path) -> List[Dict[str, Any]]: + async def get_full_info(self, offset: int, limit: int, path: Path = None) -> List[Dict[str, Any]]: """Returns metadata of ``limit`` number of run processes, starting from the ``offset``th process.""" + path = path or settings.runs_path return await super().get_full_info(offset, limit, path) async def fetch_run_logs(self, run_id: int, offset: int, limit: int) -> Optional[List[str]]: @@ -157,7 +170,11 @@ async def start(self, preset: Preset) -> int: self.last_id += 1 id_ = self.last_id process = BuildProcess(id_, preset.end_status) - cmd_to_run = f"dflowd build_bot {id_} --preset {preset.end_status}" + cmd_to_run = ( + f"chatsky.ui build_bot --build-id {id_} " + f"--preset {preset.end_status} " + f"--project-dir {settings.work_directory}" + ) await process.start(cmd_to_run) self.processes[id_] = process @@ -183,8 +200,9 @@ async def get_build_info(self, id_: int, run_manager: RunManager) -> Optional[Di builds_info = await self.get_full_info_with_runs_info(run_manager, offset=0, limit=10**5) return next((build for build in builds_info if build["id"] == id_), None) - async def get_full_info(self, offset: int, limit: int, path: Path = settings.builds_path) -> List[Dict[str, Any]]: + async def get_full_info(self, offset: int, limit: int, path: Path = None) -> List[Dict[str, Any]]: """Returns metadata of ``limit`` number of processes, starting from the ``offset`` process.""" + path = path or settings.builds_path return await super().get_full_info(offset, limit, path) async def get_full_info_with_runs_info( diff --git a/backend/df_designer/app/services/websocket_manager.py b/backend/chatsky_ui/services/websocket_manager.py similarity index 75% rename from backend/df_designer/app/services/websocket_manager.py rename to backend/chatsky_ui/services/websocket_manager.py index 4bedbaa2..e1229c6a 100644 --- a/backend/df_designer/app/services/websocket_manager.py +++ b/backend/chatsky_ui/services/websocket_manager.py @@ -3,14 +3,12 @@ """ import asyncio from asyncio.tasks import Task -from typing import Dict, Set +from typing import Dict, List, Set from fastapi import WebSocket, WebSocketDisconnect -from app.core.logger_config import get_logger -from app.services.process_manager import ProcessManager - -logger = get_logger(__name__) +from chatsky_ui.core.logger_config import get_logger +from chatsky_ui.services.process_manager import ProcessManager class WebSocketManager: @@ -18,7 +16,17 @@ class WebSocketManager: def __init__(self): self.pending_tasks: Dict[WebSocket, Set[Task]] = dict() - self.active_connections: list[WebSocket] = [] + self.active_connections: List[WebSocket] = [] + self._logger = None + + @property + def logger(self): + if self._logger is None: + raise ValueError("Logger has not been configured. Call set_logger() first.") + return self._logger + + def set_logger(self): + self._logger = get_logger(__name__) async def connect(self, websocket: WebSocket): """Accepts the websocket connection and marks it as active connection.""" @@ -29,7 +37,7 @@ def disconnect(self, websocket: WebSocket): """Cancels pending tasks of the open websocket process and removes it from active connections.""" # TODO: await websocket.close() if websocket in self.pending_tasks: - logger.info("Cancelling pending tasks") + self.logger.info("Cancelling pending tasks") for task in self.pending_tasks[websocket]: task.cancel() del self.pending_tasks[websocket] @@ -50,7 +58,7 @@ async def send_process_output_to_websocket( break await websocket.send_text(response.decode().strip()) except WebSocketDisconnect: - logger.info("Websocket connection is closed by client") + self.logger.info("Websocket connection is closed by client") except RuntimeError: raise @@ -65,8 +73,8 @@ async def forward_websocket_messages_to_process( break await process_manager.processes[run_id].write_stdin(user_message.encode() + b"\n") except asyncio.CancelledError: - logger.info("Websocket connection is closed") + self.logger.info("Websocket connection is closed") except WebSocketDisconnect: - logger.info("Websocket connection is closed by client") + self.logger.info("Websocket connection is closed by client") except RuntimeError: raise diff --git a/backend/df_designer/app/static/.gitkeep b/backend/chatsky_ui/static/.gitkeep similarity index 100% rename from backend/df_designer/app/static/.gitkeep rename to backend/chatsky_ui/static/.gitkeep diff --git a/backend/df_designer/app/services/__init__.py b/backend/chatsky_ui/tests/__init__.py similarity index 100% rename from backend/df_designer/app/services/__init__.py rename to backend/chatsky_ui/tests/__init__.py diff --git a/backend/df_designer/app/tests/__init__.py b/backend/chatsky_ui/tests/api/__init__.py similarity index 100% rename from backend/df_designer/app/tests/__init__.py rename to backend/chatsky_ui/tests/api/__init__.py diff --git a/backend/df_designer/app/tests/api/test_bot.py b/backend/chatsky_ui/tests/api/test_bot.py similarity index 83% rename from backend/df_designer/app/tests/api/test_bot.py rename to backend/chatsky_ui/tests/api/test_bot.py index 1235d172..164e27d9 100644 --- a/backend/df_designer/app/tests/api/test_bot.py +++ b/backend/chatsky_ui/tests/api/test_bot.py @@ -1,7 +1,7 @@ import pytest from fastapi import BackgroundTasks, HTTPException -from app.api.api_v1.endpoints.bot import ( +from chatsky_ui.api.api_v1.endpoints.bot import ( _check_process_status, _stop_process, check_build_processes, @@ -12,25 +12,24 @@ start_build, start_run, ) -from app.schemas.process_status import Status -from app.services.process_manager import BuildManager, RunManager +from chatsky_ui.schemas.process_status import Status +from chatsky_ui.services.process_manager import RunManager PROCESS_ID = 0 RUN_ID = 42 BUILD_ID = 43 -@pytest.mark.parametrize("process_type, process_manager", [("build", BuildManager), ("run", RunManager)]) @pytest.mark.asyncio -async def test_stop_process_success(mocker, process_type, process_manager): - mock_stop = mocker.AsyncMock() - mocker.patch.object(process_manager, "stop", mock_stop) +async def test_stop_process_success(mocker): + process_manager = mocker.MagicMock() + process_manager.stop = mocker.AsyncMock() # Call the function under test - await _stop_process(PROCESS_ID, process_manager(), process_type) + await _stop_process(PROCESS_ID, process_manager) # Assert the stop method was called once with the correct id - mock_stop.assert_awaited_once_with(PROCESS_ID) + process_manager.stop.assert_awaited_once_with(PROCESS_ID) # TODO: take into consideration the errors when process type is build @@ -79,8 +78,8 @@ async def test_start_build(mocker): @pytest.mark.asyncio async def test_check_build_processes_some_info(mocker, pagination): - build_manager = mocker.MagicMock(spec=BuildManager()) - run_manager = mocker.MagicMock(spec=RunManager()) + build_manager = mocker.AsyncMock() + run_manager = mocker.AsyncMock() await check_build_processes(BUILD_ID, build_manager, run_manager, pagination) @@ -90,8 +89,8 @@ async def test_check_build_processes_some_info(mocker, pagination): @pytest.mark.asyncio async def test_check_build_processes_all_info(mocker, pagination): build_id = None - build_manager = mocker.MagicMock(spec=BuildManager()) - run_manager = mocker.MagicMock(spec=RunManager()) + build_manager = mocker.AsyncMock() + run_manager = mocker.AsyncMock() await check_build_processes(build_id, build_manager, run_manager, pagination) @@ -102,7 +101,7 @@ async def test_check_build_processes_all_info(mocker, pagination): @pytest.mark.asyncio async def test_get_build_logs(mocker, pagination): - build_manager = mocker.MagicMock(spec=BuildManager()) + build_manager = mocker.AsyncMock() await get_build_logs(BUILD_ID, build_manager, pagination) @@ -127,7 +126,7 @@ async def test_start_run(mocker): @pytest.mark.asyncio async def test_check_run_processes_some_info(mocker, pagination): - run_manager = mocker.MagicMock(spec=RunManager()) + run_manager = mocker.AsyncMock() await check_run_processes(RUN_ID, run_manager, pagination) @@ -137,7 +136,7 @@ async def test_check_run_processes_some_info(mocker, pagination): @pytest.mark.asyncio async def test_check_run_processes_all_info(mocker, pagination): run_id = None - run_manager = mocker.MagicMock(spec=RunManager()) + run_manager = mocker.AsyncMock() await check_run_processes(run_id, run_manager, pagination) @@ -146,7 +145,7 @@ async def test_check_run_processes_all_info(mocker, pagination): @pytest.mark.asyncio async def test_get_run_logs(mocker, pagination): - run_manager = mocker.MagicMock(spec=RunManager()) + run_manager = mocker.AsyncMock() await get_run_logs(RUN_ID, run_manager, pagination) diff --git a/backend/df_designer/app/tests/api/test_flows.py b/backend/chatsky_ui/tests/api/test_flows.py similarity index 59% rename from backend/df_designer/app/tests/api/test_flows.py rename to backend/chatsky_ui/tests/api/test_flows.py index ba4ada64..30a631cf 100644 --- a/backend/df_designer/app/tests/api/test_flows.py +++ b/backend/chatsky_ui/tests/api/test_flows.py @@ -2,12 +2,12 @@ import pytest from omegaconf import OmegaConf -from app.api.api_v1.endpoints.flows import flows_get, flows_post +from chatsky_ui.api.api_v1.endpoints.flows import flows_get, flows_post @pytest.mark.asyncio async def test_flows_get(mocker): - mocker.patch("app.api.api_v1.endpoints.flows.read_conf", return_value=OmegaConf.create({"foo": "bar"})) + mocker.patch("chatsky_ui.api.api_v1.endpoints.flows.read_conf", return_value=OmegaConf.create({"foo": "bar"})) response = await flows_get() assert response["status"] == "ok" assert response["data"] == {"foo": "bar"} @@ -15,6 +15,6 @@ async def test_flows_get(mocker): @pytest.mark.asyncio async def test_flows_post(mocker): - mocker.patch("app.api.api_v1.endpoints.flows.write_conf", return_value={}) + mocker.patch("chatsky_ui.api.api_v1.endpoints.flows.write_conf", return_value={}) response = await flows_post({"foo": "bar"}) assert response["status"] == "ok" diff --git a/backend/df_designer/app/tests/conftest.py b/backend/chatsky_ui/tests/conftest.py similarity index 78% rename from backend/df_designer/app/tests/conftest.py rename to backend/chatsky_ui/tests/conftest.py index 0fee92e2..662ec4a2 100644 --- a/backend/df_designer/app/tests/conftest.py +++ b/backend/chatsky_ui/tests/conftest.py @@ -12,12 +12,12 @@ nest_asyncio.apply = lambda: None -from app.main import app -from app.schemas.pagination import Pagination -from app.schemas.preset import Preset -from app.services.process import RunProcess -from app.services.process_manager import BuildManager, RunManager -from app.services.websocket_manager import WebSocketManager +from chatsky_ui.main import app +from chatsky_ui.schemas.pagination import Pagination +from chatsky_ui.schemas.preset import Preset +from chatsky_ui.services.process import RunProcess +from chatsky_ui.services.process_manager import BuildManager, RunManager +from chatsky_ui.services.websocket_manager import WebSocketManager DUMMY_BUILD_ID = -1 @@ -74,7 +74,9 @@ async def _run_process(cmd_to_run): @pytest.fixture() def run_manager(): - return RunManager() + manager = RunManager() + manager.set_logger() + return manager @pytest.fixture() @@ -84,4 +86,6 @@ def build_manager(): @pytest.fixture def websocket_manager(): - return WebSocketManager() + manager = WebSocketManager() + manager.set_logger() + return manager diff --git a/backend/df_designer/app/tests/api/__init__.py b/backend/chatsky_ui/tests/e2e/__init__.py similarity index 100% rename from backend/df_designer/app/tests/api/__init__.py rename to backend/chatsky_ui/tests/e2e/__init__.py diff --git a/backend/df_designer/app/tests/e2e/test_e2e.py b/backend/chatsky_ui/tests/e2e/test_e2e.py similarity index 88% rename from backend/df_designer/app/tests/e2e/test_e2e.py rename to backend/chatsky_ui/tests/e2e/test_e2e.py index 88daf4ce..38ccd983 100644 --- a/backend/df_designer/app/tests/e2e/test_e2e.py +++ b/backend/chatsky_ui/tests/e2e/test_e2e.py @@ -5,13 +5,10 @@ from httpx_ws import aconnect_ws from httpx_ws.transport import ASGIWebSocketTransport -from app.api.deps import get_build_manager, get_run_manager -from app.core.logger_config import get_logger -from app.main import app -from app.schemas.process_status import Status -from app.tests.conftest import override_dependency, start_process - -logger = get_logger(__name__) +from chatsky_ui.api.deps import get_build_manager, get_run_manager +from chatsky_ui.main import app +from chatsky_ui.schemas.process_status import Status +from chatsky_ui.tests.conftest import override_dependency, start_process async def _assert_process_status(response, process_manager): diff --git a/backend/df_designer/app/tests/e2e/__init__.py b/backend/chatsky_ui/tests/integration/__init__.py similarity index 100% rename from backend/df_designer/app/tests/e2e/__init__.py rename to backend/chatsky_ui/tests/integration/__init__.py diff --git a/backend/df_designer/app/tests/integration/test_api_integration.py b/backend/chatsky_ui/tests/integration/test_api_integration.py similarity index 90% rename from backend/df_designer/app/tests/integration/test_api_integration.py rename to backend/chatsky_ui/tests/integration/test_api_integration.py index 53239526..81a1aa69 100644 --- a/backend/df_designer/app/tests/integration/test_api_integration.py +++ b/backend/chatsky_ui/tests/integration/test_api_integration.py @@ -9,11 +9,11 @@ from httpx_ws import aconnect_ws from httpx_ws.transport import ASGIWebSocketTransport -from app.api.deps import get_build_manager, get_run_manager -from app.core.logger_config import get_logger -from app.main import app -from app.schemas.process_status import Status -from app.tests.conftest import override_dependency, start_process +from chatsky_ui.api.deps import get_build_manager, get_run_manager +from chatsky_ui.core.logger_config import get_logger +from chatsky_ui.main import app +from chatsky_ui.schemas.process_status import Status +from chatsky_ui.tests.conftest import override_dependency, start_process load_dotenv() @@ -54,8 +54,15 @@ async def _test_start_process(mocker_obj, get_manager_func, endpoint, preset_end current_status = await _assert_process_status(response, process_manager, expected_end_status, timeout) if current_status == Status.RUNNING: - process_manager.processes[process_manager.last_id].process.terminate() - await process_manager.processes[process_manager.last_id].process.wait() + process = process_manager.processes[process_manager.last_id].process + process.terminate() + try: + await asyncio.wait_for(process.wait(), timeout=timeout) + logger.debug("The test process was gracefully terminated.") + except asyncio.TimeoutError: + process.kill() + await process.wait() + logger.debug("The test process was forcefully killed.") async def _test_stop_process(mocker, get_manager_func, start_endpoint, stop_endpoint): @@ -186,7 +193,7 @@ async def test_connect_to_ws(mocker): # Start a process start_response = await start_process( client, - endpoint=f"http://localhost:8000/api/v1/bot/run/start/{build_id}", + endpoint=f"http://localhost:8007/api/v1/bot/run/start/{build_id}", preset_end_status="success", ) assert start_response.status_code == 201 diff --git a/backend/df_designer/app/tests/integration/__init__.py b/backend/chatsky_ui/tests/services/__init__.py similarity index 100% rename from backend/df_designer/app/tests/integration/__init__.py rename to backend/chatsky_ui/tests/services/__init__.py diff --git a/backend/df_designer/app/tests/services/test_process.py b/backend/chatsky_ui/tests/services/test_process.py similarity index 65% rename from backend/df_designer/app/tests/services/test_process.py rename to backend/chatsky_ui/tests/services/test_process.py index e6bdd273..c0c958db 100644 --- a/backend/df_designer/app/tests/services/test_process.py +++ b/backend/chatsky_ui/tests/services/test_process.py @@ -2,24 +2,17 @@ import pytest -from app.core.logger_config import get_logger -from app.schemas.process_status import Status - -logger = get_logger(__name__) +from chatsky_ui.schemas.process_status import Status class TestRunProcess: - # def test_update_db_info(self, run_process): - # process = await run_process("echo 'Hello df_designer'") - # process.update_db_info() - @pytest.mark.asyncio @pytest.mark.parametrize( "cmd_to_run, status", [ ("sleep 10000", Status.RUNNING), ("false", Status.FAILED), - ("echo Hello df_designer", Status.COMPLETED), + ("echo Hello", Status.COMPLETED), ], ) async def test_check_status(self, run_process, cmd_to_run, status): @@ -39,16 +32,16 @@ async def test_stop(self, run_process): @pytest.mark.asyncio async def test_read_stdout(self, run_process): - process = await run_process("echo Hello df_designer") + process = await run_process("echo Hello") output = await process.read_stdout() - assert output.strip().decode() == "Hello df_designer" + assert output.strip().decode() == "Hello" @pytest.mark.asyncio async def test_write_stdout(self, run_process): process = await run_process("cat") - await process.write_stdin(b"DF_Designer team welcome you.\n") + await process.write_stdin(b"Chatsky-UI team welcome you.\n") output = await process.process.stdout.readline() - assert output.decode().strip() == "DF_Designer team welcome you." + assert output.decode().strip() == "Chatsky-UI team welcome you." # class TestBuildProcess: diff --git a/backend/df_designer/app/tests/services/test_process_manager.py b/backend/chatsky_ui/tests/services/test_process_manager.py similarity index 82% rename from backend/df_designer/app/tests/services/test_process_manager.py rename to backend/chatsky_ui/tests/services/test_process_manager.py index 86b2a414..11799169 100644 --- a/backend/df_designer/app/tests/services/test_process_manager.py +++ b/backend/chatsky_ui/tests/services/test_process_manager.py @@ -1,10 +1,7 @@ -import pytest from pathlib import Path -from omegaconf import OmegaConf - -from app.core.logger_config import get_logger -logger = get_logger(__name__) +import pytest +from omegaconf import OmegaConf RUN_ID = 42 BUILD_ID = 43 @@ -15,7 +12,7 @@ class TestRunManager: async def test_start(self, mocker, preset, run_manager): # noqa: F811 # Mock the RunProcess constructor whereever it's called in # the process_manager file within the scope of this test function - run_process = mocker.patch("app.services.process_manager.RunProcess") + run_process = mocker.patch("chatsky_ui.services.process_manager.RunProcess") run_process_instance = run_process.return_value run_process_instance.start = mocker.AsyncMock() run_manager.get_full_info = mocker.AsyncMock(return_value=[{"id": RUN_ID}]) @@ -23,7 +20,9 @@ async def test_start(self, mocker, preset, run_manager): # noqa: F811 await run_manager.start(build_id=BUILD_ID, preset=preset) run_process.assert_called_once_with(run_manager.last_id, BUILD_ID, preset.end_status) - run_process_instance.start.assert_awaited_once_with(f"dflowd run_bot {BUILD_ID} --preset {preset.end_status}") + run_process_instance.start.assert_awaited_once_with( + f"chatsky.ui run_bot --build-id {BUILD_ID} " f"--preset {preset.end_status} " f"--project-dir ." + ) assert run_manager.processes[run_manager.last_id] is run_process_instance @@ -56,7 +55,7 @@ async def test_get_process_info(self, mocker, run_manager): "status": "stopped", } - read_conf = mocker.patch("app.services.process_manager.read_conf") + read_conf = mocker.patch("chatsky_ui.services.process_manager.read_conf") read_conf.return_value = df_conf run_info = await run_manager.get_run_info(RUN_ID) @@ -77,7 +76,7 @@ async def test_get_full_info(self, mocker, run_manager): "status": "stopped", } - read_conf = mocker.patch("app.services.process_manager.read_conf") + read_conf = mocker.patch("chatsky_ui.services.process_manager.read_conf") read_conf.return_value = df_conf run_info = await run_manager.get_full_info(0, 1) @@ -85,10 +84,10 @@ async def test_get_full_info(self, mocker, run_manager): @pytest.mark.asyncio async def test_fetch_run_logs(self, mocker, run_manager): - LOG_PATH = Path("df_designer/logs/runs/20240425/42_211545.log") + LOG_PATH = Path("path/to/log") run_manager.get_process_info = mocker.AsyncMock(return_value={"id": RUN_ID, "log_path": LOG_PATH}) - read_logs = mocker.patch("app.services.process_manager.read_logs", return_value=["log1", "log2"]) + read_logs = mocker.patch("chatsky_ui.services.process_manager.read_logs", return_value=["log1", "log2"]) logs = await run_manager.fetch_run_logs(RUN_ID, 0, 1) diff --git a/backend/df_designer/app/tests/services/test_websocket_manager.py b/backend/chatsky_ui/tests/services/test_websocket_manager.py similarity index 100% rename from backend/df_designer/app/tests/services/test_websocket_manager.py rename to backend/chatsky_ui/tests/services/test_websocket_manager.py diff --git a/backend/df_designer/app/tests/services/__init__.py b/backend/chatsky_ui/utils/__init__.py similarity index 100% rename from backend/df_designer/app/tests/services/__init__.py rename to backend/chatsky_ui/utils/__init__.py diff --git a/backend/df_designer/app/utils/ast_utils.py b/backend/chatsky_ui/utils/ast_utils.py similarity index 100% rename from backend/df_designer/app/utils/ast_utils.py rename to backend/chatsky_ui/utils/ast_utils.py diff --git a/backend/df_designer/app/api/api_v1/endpoints/config.py b/backend/df_designer/app/api/api_v1/endpoints/config.py deleted file mode 100644 index 97ad716b..00000000 --- a/backend/df_designer/app/api/api_v1/endpoints/config.py +++ /dev/null @@ -1,14 +0,0 @@ -from fastapi import APIRouter - -import toml - -from app.core.config import settings - - -router = APIRouter() - - -@router.get("/version") -async def get_version(): - pyproject = toml.load(settings.pyproject_path) - return pyproject["tool"]["poetry"]["version"] diff --git a/backend/df_designer/app/api/deps.py b/backend/df_designer/app/api/deps.py deleted file mode 100644 index 934e5cd9..00000000 --- a/backend/df_designer/app/api/deps.py +++ /dev/null @@ -1,30 +0,0 @@ -from app.services.index import Index -from app.services.process_manager import BuildManager, RunManager -from app.services.websocket_manager import WebSocketManager - -build_manager = BuildManager() - - -def get_build_manager() -> BuildManager: - return build_manager - - -run_manager = RunManager() - - -def get_run_manager() -> RunManager: - return run_manager - - -websocket_manager = WebSocketManager() - - -def get_websocket_manager() -> WebSocketManager: - return websocket_manager - - -index = Index() - - -def get_index() -> Index: - return index diff --git a/backend/df_designer/app/cli.py b/backend/df_designer/app/cli.py deleted file mode 100644 index 699e4ba5..00000000 --- a/backend/df_designer/app/cli.py +++ /dev/null @@ -1,113 +0,0 @@ -import asyncio -import json -import os -import sys -from pathlib import Path - -import nest_asyncio -import typer -from cookiecutter.main import cookiecutter - -# Patch nest_asyncio before importing DFF -nest_asyncio.apply = lambda: None - -from app.core.config import app_runner, settings # noqa: E402 -from app.core.logger_config import get_logger # noqa: E402 - -cli = typer.Typer() - - -async def _execute_command(command_to_run): - logger = get_logger(__name__) - try: - process = await asyncio.create_subprocess_exec(*command_to_run.split()) - - # Check the return code to determine success - if process.returncode == 0: - logger.info("Command '%s' executed successfully.", command_to_run) - elif process.returncode is None: - logger.info("Process by command '%s' is running.", command_to_run) - await process.wait() - logger.info("Process ended with return code: %d.", process.returncode) - sys.exit(process.returncode) - else: - logger.error("Command '%s' failed with return code: %d", command_to_run, process.returncode) - sys.exit(process.returncode) - - except Exception as e: - logger.error("Error executing '%s': %s", command_to_run, str(e)) - sys.exit(1) - - -def _execute_command_file(build_id: int, project_dir: str, command_file: str, preset: str): - logger = get_logger(__name__) - presets_build_path = Path(project_dir) / "df_designer" / "presets" / command_file - with open(presets_build_path) as file: - presets_build_file = json.load(file) - - if preset in presets_build_file: - command_to_run = presets_build_file[preset]["cmd"] - if preset == "success": - command_to_run += f" {build_id}" - logger.debug("Executing command for preset '%s': %s", preset, command_to_run) - - asyncio.run(_execute_command(command_to_run)) - else: - raise ValueError(f"Invalid preset '{preset}'. Preset must be one of {list(presets_build_file.keys())}") - - -@cli.command("build_bot") -def build_bot(build_id: int, project_dir: str = settings.work_directory, preset: str = "success"): - _execute_command_file(build_id, project_dir, "build.json", preset) - - -@cli.command("build_scenario") -def build_scenario(build_id: int, project_dir: str = "."): - from app.services.json_converter import converter # pylint: disable=C0415 - - asyncio.run(converter(build_id=build_id, project_dir=project_dir)) - - -@cli.command("run_bot") -def run_bot(build_id: int, project_dir: str = settings.work_directory, preset: str = "success"): - _execute_command_file(build_id, project_dir, "run.json", preset) - - -@cli.command("run_scenario") -def run_scenario(build_id: int, project_dir: str = "."): - script_path = Path(project_dir) / "bot" / "scripts" / f"build_{build_id}.yaml" - if not script_path.exists(): - raise FileNotFoundError(f"File {script_path} doesn't exist") - command_to_run = f"poetry run python {project_dir}/app.py --script-path {script_path}" - asyncio.run(_execute_command(command_to_run)) - - -@cli.command("run_app") -def run_app( - ip_address: str = settings.host, - port: int = settings.port, - conf_reload: str = str(settings.conf_reload), - project_dir: str = settings.work_directory, -) -> None: - """Run the backend.""" - settings.host = ip_address - settings.port = port - settings.conf_reload = conf_reload.lower() in ["true", "yes", "t", "y", "1"] - settings.work_directory = project_dir - - app_runner.run() - - -@cli.command("init") -def init(destination: str = settings.work_directory, no_input: bool = False, overwrite_if_exists: bool = True): - original_dir = os.getcwd() - try: - os.chdir(destination) - cookiecutter( - "https://github.com/Ramimashkouk/df_d_template.git", - no_input=no_input, - overwrite_if_exists=overwrite_if_exists, - extra_context={"dflowd_version": "0.1.0b4"}, - ) - finally: - os.chdir(original_dir) diff --git a/backend/df_designer/app/core/config.py b/backend/df_designer/app/core/config.py deleted file mode 100644 index 254f9a1a..00000000 --- a/backend/df_designer/app/core/config.py +++ /dev/null @@ -1,58 +0,0 @@ -import os -from pathlib import Path - -import uvicorn -from dotenv import load_dotenv -from pydantic_settings import BaseSettings - -load_dotenv() - - -class Settings(BaseSettings): - API_V1_STR: str = "/api/v1" - APP: str = "app.main:app" - - work_directory: str = "." - config_file_path: Path = Path(__file__).absolute() - static_files: Path = config_file_path.parent.with_name("static") - start_page: Path = static_files.joinpath("index.html") - package_dir: Path = config_file_path.parents[3] - pyproject_path: Path = package_dir / "df_designer" / "pyproject.toml" - - host: str = os.getenv("HOST", "0.0.0.0") - port: int = int(os.getenv("PORT", 8000)) - log_level: str = os.getenv("LOG_LEVEL", "info") - conf_reload: bool = os.getenv("CONF_RELOAD", "true").lower() in ["true", "1", "t", "y", "yes"] - - builds_path: Path = Path(f"{work_directory}/df_designer/builds.yaml") - runs_path: Path = Path(f"{work_directory}/df_designer/runs.yaml") - dir_logs: Path = Path(f"{work_directory}/df_designer/logs") - frontend_flows_path: Path = Path(f"{work_directory}/df_designer/frontend_flows.yaml") - index_path: Path = Path(f"{work_directory}/bot/custom/.services_index.yaml") - snippet2lint_path: Path = Path(f"{work_directory}/bot/custom/.snippet2lint.py") - - -class AppRunner: - def __init__(self, settings: Settings): - self.settings = settings - - def run(self): - if reload := self.settings.conf_reload: - reload_conf = { - "reload": reload, - "reload_dirs": [self.settings.work_directory, str(self.settings.package_dir)], - } - else: - reload_conf = {"reload": reload} - - uvicorn.run( - self.settings.APP, - host=self.settings.host, - port=self.settings.port, - log_level=self.settings.log_level, - **reload_conf, - ) - - -settings = Settings() -app_runner = AppRunner(settings) diff --git a/backend/df_designer/app/utils/__init__.py b/backend/df_designer/app/utils/__init__.py deleted file mode 100644 index e69de29b..00000000 diff --git a/backend/df_designer/poetry.lock b/backend/poetry.lock similarity index 99% rename from backend/df_designer/poetry.lock rename to backend/poetry.lock index 12c0be0f..d9412bf7 100644 --- a/backend/df_designer/poetry.lock +++ b/backend/poetry.lock @@ -33,6 +33,9 @@ files = [ {file = "annotated_types-0.6.0.tar.gz", hash = "sha256:563339e807e53ffd9c267e99fc6d9ea23eb8443c08f112651963e24e22f84a5d"}, ] +[package.dependencies] +typing-extensions = {version = ">=4.0.0", markers = "python_version < \"3.9\""} + [[package]] name = "antlr4-python3-runtime" version = "4.9.3" @@ -153,6 +156,7 @@ mypy-extensions = ">=0.4.3" pathspec = ">=0.9.0" platformdirs = ">=2" tomli = {version = ">=1.1.0", markers = "python_full_version < \"3.11.0a7\""} +typing-extensions = {version = ">=3.10.0.0", markers = "python_version < \"3.10\""} [package.extras] colorama = ["colorama (>=0.4.3)"] @@ -1012,6 +1016,7 @@ mccabe = ">=0.6,<0.8" platformdirs = ">=2.2.0" tomli = {version = ">=1.1.0", markers = "python_version < \"3.11\""} tomlkit = ">=0.10.1" +typing-extensions = {version = ">=3.10.0", markers = "python_version < \"3.10\""} [package.extras] spelling = ["pyenchant (>=3.2,<4.0)"] @@ -1214,6 +1219,7 @@ files = [ [package.dependencies] markdown-it-py = ">=2.2.0" pygments = ">=2.13.0,<3.0.0" +typing-extensions = {version = ">=4.0.0,<5.0", markers = "python_version < \"3.9\""} [package.extras] jupyter = ["ipywidgets (>=7.5.1,<9)"] @@ -1426,6 +1432,7 @@ files = [ [package.dependencies] anyio = ">=3.4.0,<5" +typing-extensions = {version = ">=3.10.0", markers = "python_version < \"3.10\""} [package.extras] full = ["httpx (>=0.22.0)", "itsdangerous", "jinja2", "python-multipart (>=0.0.7)", "pyyaml"] @@ -1441,17 +1448,6 @@ files = [ {file = "text_unidecode-1.3-py2.py3-none-any.whl", hash = "sha256:1311f10e8b895935241623731c2ba64f4c455287888b18189350b67134a822e8"}, ] -[[package]] -name = "toml" -version = "0.10.2" -description = "Python Library for Tom's Obvious, Minimal Language" -optional = false -python-versions = ">=2.6, !=3.0.*, !=3.1.*, !=3.2.*" -files = [ - {file = "toml-0.10.2-py2.py3-none-any.whl", hash = "sha256:806143ae5bfb6a3c6e736a764057db0e6a0e05e338b5630894a5f779cabb4f9b"}, - {file = "toml-0.10.2.tar.gz", hash = "sha256:b3bda1d108d5dd99f4a20d24d9c348e91c4db7ab1b749200bded2f839ccbe68f"}, -] - [[package]] name = "tomli" version = "2.0.1" @@ -1867,5 +1863,5 @@ h11 = ">=0.9.0,<1" [metadata] lock-version = "2.0" -python-versions = "^3.10" -content-hash = "c00f7a8ff2d1dabc5a7cfa165e191ecbbc82198eead23946a4e7e76b78015728" +python-versions = "^3.8.1" +content-hash = "eacc9c09bcf6fc53ade1a2613baf756e347fb17a27296fce3ea76b498c7d3211" diff --git a/backend/df_designer/pyproject.toml b/backend/pyproject.toml similarity index 64% rename from backend/df_designer/pyproject.toml rename to backend/pyproject.toml index fca26ac6..acb2af8d 100644 --- a/backend/df_designer/pyproject.toml +++ b/backend/pyproject.toml @@ -1,7 +1,7 @@ [tool.poetry] -name = "dflowd" -version = "0.1.0b4" -description = "Dialog Flow Designer" +name = "chatsky-ui" +version = "0.2.0" +description = "Chatsky-UI is GUI for Chatsky Framework, that is a free and open-source software stack for creating chatbots, released under the terms of Apache License 2.0." license = "Apache-2.0" authors = [ "Denis Kuznetsov ", @@ -9,10 +9,10 @@ authors = [ "Rami Mashkouk ", ] readme = "README.md" -packages = [{include = "app"}] +packages = [{include = "chatsky_ui"}] [tool.poetry.dependencies] -python = "^3.10" +python = "^3.8.1" fastapi = "^0.110.0" uvicorn = {extras = ["standard"], version = "^0.28.0"} pydantic = "^2.6.3" @@ -28,12 +28,11 @@ pytest-mock = "^3.14.0" httpx = "^0.27.0" httpx-ws = "^0.6.0" pylint = "^3.2.3" -sphinx = "^7.3.7" -sphinx-rtd-theme = "^2.0.0" -toml = "^0.10.2" +sphinx = { version = "*", python = "^3.10"} +sphinx-rtd-theme = { version = "*", python = "^3.10"} [tool.poetry.scripts] -dflowd = "app.cli:cli" +"chatsky.ui" = "chatsky_ui.cli:cli" [tool.poetry.group.lint] optional = true diff --git a/backend/df_designer/run.sh b/backend/run.sh similarity index 71% rename from backend/df_designer/run.sh rename to backend/run.sh index a85b4608..272e1b74 100644 --- a/backend/df_designer/run.sh +++ b/backend/run.sh @@ -1,6 +1,6 @@ #!/bin/sh -export APP_MODULE=${APP_MODULE-app.main:app} +export APP_MODULE=${APP_MODULE-chatsky_ui.main:app} export HOST=${HOST:-0.0.0.0} export PORT=${PORT:-8001} diff --git a/bin/add_ui_to_toml.sh b/bin/add_ui_to_toml.sh new file mode 100755 index 00000000..a0be2353 --- /dev/null +++ b/bin/add_ui_to_toml.sh @@ -0,0 +1,7 @@ +#!/bin/bash + +# Find the latest version of the wheel file +VERSION=$(basename $(ls ../backend/dist/chatsky_ui-*.whl) | sed -E 's/chatsky_ui-([^-]+)-.*/\1/' | head -n 1) + +# Add the specific version to my project +poetry add ../backend/dist/chatsky_ui-$VERSION-py3-none-any.whl diff --git a/compose.yaml b/compose.yaml index 9a4b6c76..a1295ea9 100644 --- a/compose.yaml +++ b/compose.yaml @@ -1,15 +1,17 @@ volumes: - project_data: + ui_data: + bot_data: services: backend: build: args: - PROJECT_DIR: df_designer_project + PROJECT_DIR: my_project context: ./ dockerfile: Dockerfile ports: - 8000:8000 volumes: - - project_data:/src2/df_designer_project + - ui_data:/src/project_dir/chatsky_ui/app_data + - bot_data:/src/project_dir/bot version: '3.8' diff --git a/docs/appref/app/api/api_v1/endpoints.rst b/docs/appref/app/api/api_v1/endpoints.rst deleted file mode 100644 index 09e45e10..00000000 --- a/docs/appref/app/api/api_v1/endpoints.rst +++ /dev/null @@ -1,26 +0,0 @@ -app.api.api\_v1.endpoints package -========================= - -app.api.api\_v1.endpoints.bot module ----------------------------- - -.. automodule:: app.api.api_v1.endpoints.bot - :members: - :undoc-members: - :show-inheritance: - -app.api.api\_v1.endpoints.dff\_services module --------------------------------------- - -.. automodule:: app.api.api_v1.endpoints.dff_services - :members: - :undoc-members: - :show-inheritance: - -app.api.api\_v1.endpoints.flows module ------------------------------- - -.. automodule:: app.api.api_v1.endpoints.flows - :members: - :undoc-members: - :show-inheritance: diff --git a/docs/appref/app/services.rst b/docs/appref/app/services.rst deleted file mode 100644 index 6fef525f..00000000 --- a/docs/appref/app/services.rst +++ /dev/null @@ -1,42 +0,0 @@ -app.services package -==================== - -app.services.index module -------------------------- - -.. automodule:: app.services.index - :members: - :undoc-members: - :show-inheritance: - -app.services.json\_converter module ------------------------------------ - -.. automodule:: app.services.json_converter - :members: - :undoc-members: - :show-inheritance: - -app.services.process module ---------------------------- - -.. automodule:: app.services.process - :members: - :undoc-members: - :show-inheritance: - -app.services.process\_manager module ------------------------------------- - -.. automodule:: app.services.process_manager - :members: - :undoc-members: - :show-inheritance: - -app.services.websocket\_manager module --------------------------------------- - -.. automodule:: app.services.websocket_manager - :members: - :undoc-members: - :show-inheritance: diff --git a/docs/appref/app/tests/api.rst b/docs/appref/app/tests/api.rst deleted file mode 100644 index fffb1a50..00000000 --- a/docs/appref/app/tests/api.rst +++ /dev/null @@ -1,18 +0,0 @@ -app.tests.api package -===================== - -app.tests.api.test\_bot module ------------------------------- - -.. automodule:: app.tests.api.test_bot - :members: - :undoc-members: - :show-inheritance: - -app.tests.api.test\_flows module --------------------------------- - -.. automodule:: app.tests.api.test_flows - :members: - :undoc-members: - :show-inheritance: diff --git a/docs/appref/app/tests/e2e.rst b/docs/appref/app/tests/e2e.rst deleted file mode 100644 index 7f703ae7..00000000 --- a/docs/appref/app/tests/e2e.rst +++ /dev/null @@ -1,10 +0,0 @@ -app.tests.e2e package -===================== - -app.tests.e2e.test\_e2e module ------------------------------- - -.. automodule:: app.tests.e2e.test_e2e - :members: - :undoc-members: - :show-inheritance: diff --git a/docs/appref/app/tests/integration.rst b/docs/appref/app/tests/integration.rst deleted file mode 100644 index 915763d1..00000000 --- a/docs/appref/app/tests/integration.rst +++ /dev/null @@ -1,10 +0,0 @@ -app.tests.integration package -============================= - -app.tests.integration.test\_api\_integration module ---------------------------------------------------- - -.. automodule:: app.tests.integration.test_api_integration - :members: - :undoc-members: - :show-inheritance: diff --git a/docs/appref/app/tests/services.rst b/docs/appref/app/tests/services.rst deleted file mode 100644 index 047db33b..00000000 --- a/docs/appref/app/tests/services.rst +++ /dev/null @@ -1,26 +0,0 @@ -app.tests.services package -========================== - -app.tests.services.test\_process module ---------------------------------------- - -.. automodule:: app.tests.services.test_process - :members: - :undoc-members: - :show-inheritance: - -app.tests.services.test\_process\_manager module ------------------------------------------------- - -.. automodule:: app.tests.services.test_process_manager - :members: - :undoc-members: - :show-inheritance: - -app.tests.services.test\_websocket\_manager module --------------------------------------------------- - -.. automodule:: app.tests.services.test_websocket_manager - :members: - :undoc-members: - :show-inheritance: diff --git a/docs/appref/app.rst b/docs/appref/chatsky_ui.rst similarity index 71% rename from docs/appref/app.rst rename to docs/appref/chatsky_ui.rst index 90b85e2a..95807256 100644 --- a/docs/appref/app.rst +++ b/docs/appref/chatsky_ui.rst @@ -1,4 +1,4 @@ -app package +chatsky_ui package =========== Subpackages @@ -8,13 +8,13 @@ Subpackages :glob: :maxdepth: 4 - app/* + chatsky_ui/* cli module -------------- -.. automodule:: app.cli +.. automodule:: chatsky_ui.cli :members: :undoc-members: :show-inheritance: @@ -22,7 +22,7 @@ cli module main module --------------- -.. automodule:: app.main +.. automodule:: chatsky_ui.main :members: :undoc-members: :show-inheritance: diff --git a/docs/appref/app/api.rst b/docs/appref/chatsky_ui/api.rst similarity index 74% rename from docs/appref/app/api.rst rename to docs/appref/chatsky_ui/api.rst index 34d1dae1..51bc2fea 100644 --- a/docs/appref/app/api.rst +++ b/docs/appref/chatsky_ui/api.rst @@ -11,10 +11,10 @@ Subpackages api/* -app.api.deps module +chatsky_ui.api.deps module ------------------ -.. automodule:: app.api.deps +.. automodule:: chatsky_ui.api.deps :members: :undoc-members: :show-inheritance: diff --git a/docs/appref/app/api/api_v1.rst b/docs/appref/chatsky_ui/api/api_v1.rst similarity index 63% rename from docs/appref/app/api/api_v1.rst rename to docs/appref/chatsky_ui/api/api_v1.rst index 72e21f51..19aac900 100644 --- a/docs/appref/app/api/api_v1.rst +++ b/docs/appref/chatsky_ui/api/api_v1.rst @@ -1,4 +1,4 @@ -app.api.api\_v1 package +chatsky_ui.api.api\_v1 package ========================= Subpackages @@ -11,10 +11,10 @@ Subpackages api_v1/* -app.api.api\_v1.api module +chatsky_ui.api.api\_v1.api module ---------------------------- -.. automodule:: app.api.api_v1.api +.. automodule:: chatsky_ui.api.api_v1.api :members: :undoc-members: :show-inheritance: diff --git a/docs/appref/chatsky_ui/api/api_v1/endpoints.rst b/docs/appref/chatsky_ui/api/api_v1/endpoints.rst new file mode 100644 index 00000000..f450bd5e --- /dev/null +++ b/docs/appref/chatsky_ui/api/api_v1/endpoints.rst @@ -0,0 +1,26 @@ +chatsky_ui.api.api\_v1.endpoints package +========================= + +chatsky_ui.api.api\_v1.endpoints.bot module +---------------------------- + +.. automodule:: chatsky_ui.api.api_v1.endpoints.bot + :members: + :undoc-members: + :show-inheritance: + +chatsky_ui.api.api\_v1.endpoints.dff\_services module +-------------------------------------- + +.. automodule:: chatsky_ui.api.api_v1.endpoints.dff_services + :members: + :undoc-members: + :show-inheritance: + +chatsky_ui.api.api\_v1.endpoints.flows module +------------------------------ + +.. automodule:: chatsky_ui.api.api_v1.endpoints.flows + :members: + :undoc-members: + :show-inheritance: diff --git a/docs/appref/app/clients.rst b/docs/appref/chatsky_ui/clients.rst similarity index 50% rename from docs/appref/app/clients.rst rename to docs/appref/chatsky_ui/clients.rst index bc25a2de..0a4b419d 100644 --- a/docs/appref/app/clients.rst +++ b/docs/appref/chatsky_ui/clients.rst @@ -1,10 +1,10 @@ -app.clients package +chatsky_ui.clients package =================== -app.clients.dff module +chatsky_ui.clients.dff module ---------------------- -.. automodule:: app.clients.dff +.. automodule:: chatsky_ui.clients.dff :members: :undoc-members: :show-inheritance: diff --git a/docs/appref/app/core.rst b/docs/appref/chatsky_ui/core.rst similarity index 50% rename from docs/appref/app/core.rst rename to docs/appref/chatsky_ui/core.rst index 57ae4092..b3b64803 100644 --- a/docs/appref/app/core.rst +++ b/docs/appref/chatsky_ui/core.rst @@ -1,18 +1,18 @@ -app.core package +chatsky_ui.core package ================ -app.core.config module +chatsky_ui.core.config module ---------------------- -.. automodule:: app.core.config +.. automodule:: chatsky_ui.core.config :members: :undoc-members: :show-inheritance: -app.core.logger\_config module +chatsky_ui.core.logger\_config module ------------------------------ -.. automodule:: app.core.logger_config +.. automodule:: chatsky_ui.core.logger_config :members: :undoc-members: :show-inheritance: diff --git a/docs/appref/app/db.rst b/docs/appref/chatsky_ui/db.rst similarity index 52% rename from docs/appref/app/db.rst rename to docs/appref/chatsky_ui/db.rst index 653573b8..e8bc4174 100644 --- a/docs/appref/app/db.rst +++ b/docs/appref/chatsky_ui/db.rst @@ -1,10 +1,10 @@ -app.db package +chatsky_ui.db package ============== -app.db.base module +chatsky_ui.db.base module ------------------ -.. automodule:: app.db.base +.. automodule:: chatsky_ui.db.base :members: :undoc-members: :show-inheritance: diff --git a/docs/appref/app/schemas.rst b/docs/appref/chatsky_ui/schemas.rst similarity index 50% rename from docs/appref/app/schemas.rst rename to docs/appref/chatsky_ui/schemas.rst index 300c2a23..c20a0874 100644 --- a/docs/appref/app/schemas.rst +++ b/docs/appref/chatsky_ui/schemas.rst @@ -1,26 +1,26 @@ -app.schemas package +chatsky_ui.schemas package =================== -app.schemas.pagination module +chatsky_ui.schemas.pagination module ----------------------------- -.. automodule:: app.schemas.pagination +.. automodule:: chatsky_ui.schemas.pagination :members: :undoc-members: :show-inheritance: -app.schemas.preset module +chatsky_ui.schemas.preset module ------------------------- -.. automodule:: app.schemas.preset +.. automodule:: chatsky_ui.schemas.preset :members: :undoc-members: :show-inheritance: -app.schemas.process\_status module +chatsky_ui.schemas.process\_status module ---------------------------------- -.. automodule:: app.schemas.process_status +.. automodule:: chatsky_ui.schemas.process_status :members: :undoc-members: :show-inheritance: diff --git a/docs/appref/chatsky_ui/services.rst b/docs/appref/chatsky_ui/services.rst new file mode 100644 index 00000000..3be7e826 --- /dev/null +++ b/docs/appref/chatsky_ui/services.rst @@ -0,0 +1,42 @@ +chatsky_ui.services package +==================== + +chatsky_ui.services.index module +------------------------- + +.. automodule:: chatsky_ui.services.index + :members: + :undoc-members: + :show-inheritance: + +chatsky_ui.services.json\_converter module +----------------------------------- + +.. automodule:: chatsky_ui.services.json_converter + :members: + :undoc-members: + :show-inheritance: + +chatsky_ui.services.process module +--------------------------- + +.. automodule:: chatsky_ui.services.process + :members: + :undoc-members: + :show-inheritance: + +chatsky_ui.services.process\_manager module +------------------------------------ + +.. automodule:: chatsky_ui.services.process_manager + :members: + :undoc-members: + :show-inheritance: + +chatsky_ui.services.websocket\_manager module +-------------------------------------- + +.. automodule:: chatsky_ui.services.websocket_manager + :members: + :undoc-members: + :show-inheritance: diff --git a/docs/appref/app/tests.rst b/docs/appref/chatsky_ui/tests.rst similarity index 64% rename from docs/appref/app/tests.rst rename to docs/appref/chatsky_ui/tests.rst index a960dcbb..fe6b4a95 100644 --- a/docs/appref/app/tests.rst +++ b/docs/appref/chatsky_ui/tests.rst @@ -1,4 +1,4 @@ -app.tests package +chatsky_ui.tests package ================= Subpackages @@ -11,10 +11,10 @@ Subpackages tests/* -app.tests.conftest module +chatsky_ui.tests.conftest module ------------------------- -.. automodule:: app.tests.conftest +.. automodule:: chatsky_ui.tests.conftest :members: :undoc-members: :show-inheritance: diff --git a/docs/appref/chatsky_ui/tests/api.rst b/docs/appref/chatsky_ui/tests/api.rst new file mode 100644 index 00000000..c123e1fe --- /dev/null +++ b/docs/appref/chatsky_ui/tests/api.rst @@ -0,0 +1,18 @@ +chatsky_ui.tests.api package +===================== + +chatsky_ui.tests.api.test\_bot module +------------------------------ + +.. automodule:: chatsky_ui.tests.api.test_bot + :members: + :undoc-members: + :show-inheritance: + +chatsky_ui.tests.api.test\_flows module +-------------------------------- + +.. automodule:: chatsky_ui.tests.api.test_flows + :members: + :undoc-members: + :show-inheritance: diff --git a/docs/appref/chatsky_ui/tests/e2e.rst b/docs/appref/chatsky_ui/tests/e2e.rst new file mode 100644 index 00000000..946e388f --- /dev/null +++ b/docs/appref/chatsky_ui/tests/e2e.rst @@ -0,0 +1,10 @@ +chatsky_ui.tests.e2e package +===================== + +chatsky_ui.tests.e2e.test\_e2e module +------------------------------ + +.. automodule:: chatsky_ui.tests.e2e.test_e2e + :members: + :undoc-members: + :show-inheritance: diff --git a/docs/appref/chatsky_ui/tests/integration.rst b/docs/appref/chatsky_ui/tests/integration.rst new file mode 100644 index 00000000..21146a99 --- /dev/null +++ b/docs/appref/chatsky_ui/tests/integration.rst @@ -0,0 +1,10 @@ +chatsky_ui.tests.integration package +============================= + +chatsky_ui.tests.integration.test\_api\_integration module +--------------------------------------------------- + +.. automodule:: chatsky_ui.tests.integration.test_api_integration + :members: + :undoc-members: + :show-inheritance: diff --git a/docs/appref/chatsky_ui/tests/services.rst b/docs/appref/chatsky_ui/tests/services.rst new file mode 100644 index 00000000..1de9f52c --- /dev/null +++ b/docs/appref/chatsky_ui/tests/services.rst @@ -0,0 +1,26 @@ +chatsky_ui.tests.services package +========================== + +chatsky_ui.tests.services.test\_process module +--------------------------------------- + +.. automodule:: chatsky_ui.tests.services.test_process + :members: + :undoc-members: + :show-inheritance: + +chatsky_ui.tests.services.test\_process\_manager module +------------------------------------------------ + +.. automodule:: chatsky_ui.tests.services.test_process_manager + :members: + :undoc-members: + :show-inheritance: + +chatsky_ui.tests.services.test\_websocket\_manager module +-------------------------------------------------- + +.. automodule:: chatsky_ui.tests.services.test_websocket_manager + :members: + :undoc-members: + :show-inheritance: diff --git a/docs/conf.py b/docs/conf.py index cc8a1c7c..7fc365ab 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -10,10 +10,10 @@ # -- Project information ----------------------------------------------------- # https://www.sphinx-doc.org/en/master/usage/configuration.html#project-information -project = 'DflowD' +project = 'Chatsky-UI' copyright = '2024, Denis Kuznetsov, Maks Rogatkin, Rami Mashkouk' author = 'Denis Kuznetsov, Maks Rogatkin, Rami Mashkouk' -release = '0.1.0-beta.1' +release = '0.2.0' # -- General configuration --------------------------------------------------- # https://www.sphinx-doc.org/en/maste r/usage/configuration.html#general-configuration diff --git a/docs/index.rst b/docs/index.rst index 98657bb0..61d1b181 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -1,9 +1,9 @@ -.. DflowD documentation master file, created by +.. Chatsky-UI documentation master file, created by sphinx-quickstart on Tue Jun 18 10:41:20 2024. You can adapt this file completely to your liking, but it should at least contain the root `toctree` directive. -Welcome to DflowD's documentation! +Welcome to Chatsky-UI's documentation! ================================== .. toctree:: diff --git a/frontend/dockerfile b/frontend/dockerfile deleted file mode 100644 index 7c13de21..00000000 --- a/frontend/dockerfile +++ /dev/null @@ -1,9 +0,0 @@ -FROM oven/bun:1 as base -WORKDIR /app - -COPY package*.json ./ -COPY bun.lockb ./ - -RUN bun install - -COPY ./ ./ \ No newline at end of file diff --git a/frontend/src/pages/Logs.tsx b/frontend/src/pages/Logs.tsx index d6e1c479..6b216920 100644 --- a/frontend/src/pages/Logs.tsx +++ b/frontend/src/pages/Logs.tsx @@ -197,7 +197,7 @@ const Logs = memo(() => { Logs file path: {currentItem.log_path} @@ -248,7 +248,7 @@ const Logs = memo(() => { Logs file path: {currentItem.log_path} From 4305070273ee9405e10150fea759c650a63d41b8 Mon Sep 17 00:00:00 2001 From: Rami <54779216+Ramimashkouk@users.noreply.github.com> Date: Fri, 30 Aug 2024 16:16:28 +0300 Subject: [PATCH 04/10] ci: Add workflow for doc publishing (#80) * ci: Add workflow for doc publishing * ci: Fix doc workflow --- .github/workflows/build_and_publish_docs.yml | 43 ++++++ backend/poetry.lock | 137 +++++++++++++------ backend/pyproject.toml | 4 +- 3 files changed, 137 insertions(+), 47 deletions(-) create mode 100644 .github/workflows/build_and_publish_docs.yml diff --git a/.github/workflows/build_and_publish_docs.yml b/.github/workflows/build_and_publish_docs.yml new file mode 100644 index 00000000..8c76d3b7 --- /dev/null +++ b/.github/workflows/build_and_publish_docs.yml @@ -0,0 +1,43 @@ +name: build_and_publish_docs + +on: + workflow_dispatch: + +permissions: + contents: write + +concurrency: + group: "pages" + cancel-in-progress: false + +jobs: + publish: + name: build and publish docs + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: set up python 3.9 + uses: actions/setup-python@v5 + with: + python-version: 3.9 + + - name: setup poetry + run: | + python -m pip install --upgrade pip poetry + + - name: install dependencies + run: poetry install + + - name: build documentation + run: poetry run make -C docs html + + - name: remove jekyll theming + run: touch docs/_build/html/.nojekyll + + - name: deploy website + uses: JamesIves/github-pages-deploy-action@v4 + with: + branch: gh-pages + folder: docs/_build/html + single-commit: True diff --git a/backend/poetry.lock b/backend/poetry.lock index d9412bf7..84ec8d6d 100644 --- a/backend/poetry.lock +++ b/backend/poetry.lock @@ -13,13 +13,13 @@ files = [ [[package]] name = "alabaster" -version = "0.7.16" -description = "A light, configurable Sphinx theme" +version = "0.7.13" +description = "A configurable sidebar-enabled Sphinx theme" optional = false -python-versions = ">=3.9" +python-versions = ">=3.6" files = [ - {file = "alabaster-0.7.16-py3-none-any.whl", hash = "sha256:b46733c07dce03ae4e150330b975c75737fa60f0a7c591b6c8bf4928a28e2c92"}, - {file = "alabaster-0.7.16.tar.gz", hash = "sha256:75a8b99c28a5dad50dd7f8ccdd447a121ddb3892da9e53d1ca5cca3106d58d65"}, + {file = "alabaster-0.7.13-py3-none-any.whl", hash = "sha256:1ee19aca801bbabb5ba3f5f258e4422dfa86f82f3e9cefb0859b283cdd7f62a3"}, + {file = "alabaster-0.7.13.tar.gz", hash = "sha256:a27a4a084d5e690e16e01e03ad2b2e552c61a65469419b907243193de1a84ae2"}, ] [[package]] @@ -112,6 +112,9 @@ files = [ {file = "babel-2.15.0.tar.gz", hash = "sha256:8daf0e265d05768bc6c7a314cf1321e9a123afc328cc635c18622a2f30a04413"}, ] +[package.dependencies] +pytz = {version = ">=2015.7", markers = "python_version < \"3.9\""} + [package.extras] dev = ["freezegun (>=1.0,<2.0)", "pytest (>=6.0)", "pytest-cov"] @@ -580,6 +583,25 @@ files = [ {file = "imagesize-1.4.1.tar.gz", hash = "sha256:69150444affb9cb0d5cc5a92b3676f0b2fb7cd9ae39e947a5e11a36b4497cd4a"}, ] +[[package]] +name = "importlib-metadata" +version = "8.4.0" +description = "Read metadata from Python packages" +optional = false +python-versions = ">=3.8" +files = [ + {file = "importlib_metadata-8.4.0-py3-none-any.whl", hash = "sha256:66f342cc6ac9818fc6ff340576acd24d65ba0b3efabb2b4ac08b598965a4a2f1"}, + {file = "importlib_metadata-8.4.0.tar.gz", hash = "sha256:9a547d3bc3608b025f93d403fdd1aae741c24fbb8314df4b155675742ce303c5"}, +] + +[package.dependencies] +zipp = ">=0.5" + +[package.extras] +doc = ["furo", "jaraco.packaging (>=9.3)", "jaraco.tidelift (>=1.4)", "rst.linker (>=1.9)", "sphinx (>=3.5)", "sphinx-lint"] +perf = ["ipython"] +test = ["flufl.flake8", "importlib-resources (>=1.3)", "jaraco.test (>=5.4)", "packaging", "pyfakefs", "pytest (>=6,!=8.1.*)", "pytest-checkdocs (>=2.4)", "pytest-cov", "pytest-enabler (>=2.2)", "pytest-mypy", "pytest-perf (>=0.9.2)", "pytest-ruff (>=0.2.1)"] + [[package]] name = "iniconfig" version = "2.0.0" @@ -1124,6 +1146,17 @@ text-unidecode = ">=1.3" [package.extras] unidecode = ["Unidecode (>=1.1.1)"] +[[package]] +name = "pytz" +version = "2024.1" +description = "World timezone definitions, modern and historical" +optional = false +python-versions = "*" +files = [ + {file = "pytz-2024.1-py2.py3-none-any.whl", hash = "sha256:328171f4e3623139da4983451950b28e95ac706e13f3f2630a879749e7a8b319"}, + {file = "pytz-2024.1.tar.gz", hash = "sha256:2a29735ea9c18baf14b448846bde5a48030ed267578472d8955cd0e7443a9812"}, +] + [[package]] name = "pyyaml" version = "6.0.1" @@ -1259,24 +1292,25 @@ files = [ [[package]] name = "sphinx" -version = "7.3.7" +version = "7.1.2" description = "Python documentation generator" optional = false -python-versions = ">=3.9" +python-versions = ">=3.8" files = [ - {file = "sphinx-7.3.7-py3-none-any.whl", hash = "sha256:413f75440be4cacf328f580b4274ada4565fb2187d696a84970c23f77b64d8c3"}, - {file = "sphinx-7.3.7.tar.gz", hash = "sha256:a4a7db75ed37531c05002d56ed6948d4c42f473a36f46e1382b0bd76ca9627bc"}, + {file = "sphinx-7.1.2-py3-none-any.whl", hash = "sha256:d170a81825b2fcacb6dfd5a0d7f578a053e45d3f2b153fecc948c37344eb4cbe"}, + {file = "sphinx-7.1.2.tar.gz", hash = "sha256:780f4d32f1d7d1126576e0e5ecc19dc32ab76cd24e950228dcf7b1f6d3d9e22f"}, ] [package.dependencies] -alabaster = ">=0.7.14,<0.8.0" +alabaster = ">=0.7,<0.8" babel = ">=2.9" colorama = {version = ">=0.4.5", markers = "sys_platform == \"win32\""} -docutils = ">=0.18.1,<0.22" +docutils = ">=0.18.1,<0.21" imagesize = ">=1.3" +importlib-metadata = {version = ">=4.8", markers = "python_version < \"3.10\""} Jinja2 = ">=3.0" packaging = ">=21.0" -Pygments = ">=2.14" +Pygments = ">=2.13" requests = ">=2.25.0" snowballstemmer = ">=2.0" sphinxcontrib-applehelp = "*" @@ -1284,13 +1318,12 @@ sphinxcontrib-devhelp = "*" sphinxcontrib-htmlhelp = ">=2.0.0" sphinxcontrib-jsmath = "*" sphinxcontrib-qthelp = "*" -sphinxcontrib-serializinghtml = ">=1.1.9" -tomli = {version = ">=2", markers = "python_version < \"3.11\""} +sphinxcontrib-serializinghtml = ">=1.1.5" [package.extras] docs = ["sphinxcontrib-websupport"] -lint = ["flake8 (>=3.5.0)", "importlib_metadata", "mypy (==1.9.0)", "pytest (>=6.0)", "ruff (==0.3.7)", "sphinx-lint", "tomli", "types-docutils", "types-requests"] -test = ["cython (>=3.0)", "defusedxml (>=0.7.1)", "pytest (>=6.0)", "setuptools (>=67.0)"] +lint = ["docutils-stubs", "flake8 (>=3.5.0)", "flake8-simplify", "isort", "mypy (>=0.990)", "ruff", "sphinx-lint", "types-requests"] +test = ["cython", "filelock", "html5lib", "pytest (>=4.6)"] [[package]] name = "sphinx-rtd-theme" @@ -1313,50 +1346,47 @@ dev = ["bump2version", "sphinxcontrib-httpdomain", "transifex-client", "wheel"] [[package]] name = "sphinxcontrib-applehelp" -version = "1.0.8" +version = "1.0.4" description = "sphinxcontrib-applehelp is a Sphinx extension which outputs Apple help books" optional = false -python-versions = ">=3.9" +python-versions = ">=3.8" files = [ - {file = "sphinxcontrib_applehelp-1.0.8-py3-none-any.whl", hash = "sha256:cb61eb0ec1b61f349e5cc36b2028e9e7ca765be05e49641c97241274753067b4"}, - {file = "sphinxcontrib_applehelp-1.0.8.tar.gz", hash = "sha256:c40a4f96f3776c4393d933412053962fac2b84f4c99a7982ba42e09576a70619"}, + {file = "sphinxcontrib-applehelp-1.0.4.tar.gz", hash = "sha256:828f867945bbe39817c210a1abfd1bc4895c8b73fcaade56d45357a348a07d7e"}, + {file = "sphinxcontrib_applehelp-1.0.4-py3-none-any.whl", hash = "sha256:29d341f67fb0f6f586b23ad80e072c8e6ad0b48417db2bde114a4c9746feb228"}, ] [package.extras] lint = ["docutils-stubs", "flake8", "mypy"] -standalone = ["Sphinx (>=5)"] test = ["pytest"] [[package]] name = "sphinxcontrib-devhelp" -version = "1.0.6" -description = "sphinxcontrib-devhelp is a sphinx extension which outputs Devhelp documents" +version = "1.0.2" +description = "sphinxcontrib-devhelp is a sphinx extension which outputs Devhelp document." optional = false -python-versions = ">=3.9" +python-versions = ">=3.5" files = [ - {file = "sphinxcontrib_devhelp-1.0.6-py3-none-any.whl", hash = "sha256:6485d09629944511c893fa11355bda18b742b83a2b181f9a009f7e500595c90f"}, - {file = "sphinxcontrib_devhelp-1.0.6.tar.gz", hash = "sha256:9893fd3f90506bc4b97bdb977ceb8fbd823989f4316b28c3841ec128544372d3"}, + {file = "sphinxcontrib-devhelp-1.0.2.tar.gz", hash = "sha256:ff7f1afa7b9642e7060379360a67e9c41e8f3121f2ce9164266f61b9f4b338e4"}, + {file = "sphinxcontrib_devhelp-1.0.2-py2.py3-none-any.whl", hash = "sha256:8165223f9a335cc1af7ffe1ed31d2871f325254c0423bc0c4c7cd1c1e4734a2e"}, ] [package.extras] lint = ["docutils-stubs", "flake8", "mypy"] -standalone = ["Sphinx (>=5)"] test = ["pytest"] [[package]] name = "sphinxcontrib-htmlhelp" -version = "2.0.5" +version = "2.0.1" description = "sphinxcontrib-htmlhelp is a sphinx extension which renders HTML help files" optional = false -python-versions = ">=3.9" +python-versions = ">=3.8" files = [ - {file = "sphinxcontrib_htmlhelp-2.0.5-py3-none-any.whl", hash = "sha256:393f04f112b4d2f53d93448d4bce35842f62b307ccdc549ec1585e950bc35e04"}, - {file = "sphinxcontrib_htmlhelp-2.0.5.tar.gz", hash = "sha256:0dc87637d5de53dd5eec3a6a01753b1ccf99494bd756aafecd74b4fa9e729015"}, + {file = "sphinxcontrib-htmlhelp-2.0.1.tar.gz", hash = "sha256:0cbdd302815330058422b98a113195c9249825d681e18f11e8b1f78a2f11efff"}, + {file = "sphinxcontrib_htmlhelp-2.0.1-py3-none-any.whl", hash = "sha256:c38cb46dccf316c79de6e5515e1770414b797162b23cd3d06e67020e1d2a6903"}, ] [package.extras] lint = ["docutils-stubs", "flake8", "mypy"] -standalone = ["Sphinx (>=5)"] test = ["html5lib", "pytest"] [[package]] @@ -1389,34 +1419,32 @@ test = ["flake8", "mypy", "pytest"] [[package]] name = "sphinxcontrib-qthelp" -version = "1.0.7" -description = "sphinxcontrib-qthelp is a sphinx extension which outputs QtHelp documents" +version = "1.0.3" +description = "sphinxcontrib-qthelp is a sphinx extension which outputs QtHelp document." optional = false -python-versions = ">=3.9" +python-versions = ">=3.5" files = [ - {file = "sphinxcontrib_qthelp-1.0.7-py3-none-any.whl", hash = "sha256:e2ae3b5c492d58fcbd73281fbd27e34b8393ec34a073c792642cd8e529288182"}, - {file = "sphinxcontrib_qthelp-1.0.7.tar.gz", hash = "sha256:053dedc38823a80a7209a80860b16b722e9e0209e32fea98c90e4e6624588ed6"}, + {file = "sphinxcontrib-qthelp-1.0.3.tar.gz", hash = "sha256:4c33767ee058b70dba89a6fc5c1892c0d57a54be67ddd3e7875a18d14cba5a72"}, + {file = "sphinxcontrib_qthelp-1.0.3-py2.py3-none-any.whl", hash = "sha256:bd9fc24bcb748a8d51fd4ecaade681350aa63009a347a8c14e637895444dfab6"}, ] [package.extras] lint = ["docutils-stubs", "flake8", "mypy"] -standalone = ["Sphinx (>=5)"] test = ["pytest"] [[package]] name = "sphinxcontrib-serializinghtml" -version = "1.1.10" -description = "sphinxcontrib-serializinghtml is a sphinx extension which outputs \"serialized\" HTML files (json and pickle)" +version = "1.1.5" +description = "sphinxcontrib-serializinghtml is a sphinx extension which outputs \"serialized\" HTML files (json and pickle)." optional = false -python-versions = ">=3.9" +python-versions = ">=3.5" files = [ - {file = "sphinxcontrib_serializinghtml-1.1.10-py3-none-any.whl", hash = "sha256:326369b8df80a7d2d8d7f99aa5ac577f51ea51556ed974e7716cfd4fca3f6cb7"}, - {file = "sphinxcontrib_serializinghtml-1.1.10.tar.gz", hash = "sha256:93f3f5dc458b91b192fe10c397e324f262cf163d79f3282c158e8436a2c4511f"}, + {file = "sphinxcontrib-serializinghtml-1.1.5.tar.gz", hash = "sha256:aa5f6de5dfdf809ef505c4895e51ef5c9eac17d0f287933eb49ec495280b6952"}, + {file = "sphinxcontrib_serializinghtml-1.1.5-py2.py3-none-any.whl", hash = "sha256:352a9a00ae864471d3a7ead8d7d79f5fc0b57e8b3f95e9867eb9eb28999b92fd"}, ] [package.extras] lint = ["docutils-stubs", "flake8", "mypy"] -standalone = ["Sphinx (>=5)"] test = ["pytest"] [[package]] @@ -1861,7 +1889,26 @@ files = [ [package.dependencies] h11 = ">=0.9.0,<1" +[[package]] +name = "zipp" +version = "3.20.1" +description = "Backport of pathlib-compatible object wrapper for zip files" +optional = false +python-versions = ">=3.8" +files = [ + {file = "zipp-3.20.1-py3-none-any.whl", hash = "sha256:9960cd8967c8f85a56f920d5d507274e74f9ff813a0ab8889a5b5be2daf44064"}, + {file = "zipp-3.20.1.tar.gz", hash = "sha256:c22b14cc4763c5a5b04134207736c107db42e9d3ef2d9779d465f5f1bcba572b"}, +] + +[package.extras] +check = ["pytest-checkdocs (>=2.4)", "pytest-ruff (>=0.2.1)"] +cover = ["pytest-cov"] +doc = ["furo", "jaraco.packaging (>=9.3)", "jaraco.tidelift (>=1.4)", "rst.linker (>=1.9)", "sphinx (>=3.5)", "sphinx-lint"] +enabler = ["pytest-enabler (>=2.2)"] +test = ["big-O", "importlib-resources", "jaraco.functools", "jaraco.itertools", "jaraco.test", "more-itertools", "pytest (>=6,!=8.1.*)", "pytest-ignore-flaky"] +type = ["pytest-mypy"] + [metadata] lock-version = "2.0" python-versions = "^3.8.1" -content-hash = "eacc9c09bcf6fc53ade1a2613baf756e347fb17a27296fce3ea76b498c7d3211" +content-hash = "f80671aa36a35cf0f6a92e876da44666b09ff84dbe0fe8f022c74f420fc63ac9" diff --git a/backend/pyproject.toml b/backend/pyproject.toml index acb2af8d..4b952148 100644 --- a/backend/pyproject.toml +++ b/backend/pyproject.toml @@ -28,8 +28,8 @@ pytest-mock = "^3.14.0" httpx = "^0.27.0" httpx-ws = "^0.6.0" pylint = "^3.2.3" -sphinx = { version = "*", python = "^3.10"} -sphinx-rtd-theme = { version = "*", python = "^3.10"} +sphinx = "*" +sphinx-rtd-theme = "*" [tool.poetry.scripts] "chatsky.ui" = "chatsky_ui.cli:cli" From 3a05e3746349fe631cc471ac733e9a4f4473dfd0 Mon Sep 17 00:00:00 2001 From: Maks <90211175+MXerFix@users.noreply.github.com> Date: Mon, 2 Sep 2024 03:49:07 +0300 Subject: [PATCH 05/10] chore: Migrate to xyflow and apply multiple fixes (#79) * migrate/feat: migration to xyflow + update delete function * fix: home ui fixes & add info home header btn * fix: fix flow clear bug & add chatsky info * fix: add disableCopyPaste parameter * fix: clear clg * chore: add snapshot --- frontend/bun.lockb | Bin 470188 -> 457390 bytes frontend/package.json | 2 +- frontend/src/App.tsx | 2 +- frontend/src/UI/Preloader/preloader.css | 2 +- frontend/src/api/index.ts | 2 - frontend/src/assets/react.svg | 1 - frontend/src/components/ReactFlowCustom.tsx | 12 + frontend/src/components/chat/Chat.tsx | 6 +- .../edges/ButtonEdge/ButtonEdge.tsx | 8 +- frontend/src/components/footbar/FootBar.tsx | 6 +- frontend/src/components/header/BuildMenu.tsx | 21 +- frontend/src/components/header/Header.tsx | 50 +++- .../header/components/NodeInstruments.tsx | 15 +- frontend/src/components/nodes/DefaultNode.tsx | 16 +- frontend/src/components/nodes/LinkNode.tsx | 23 +- .../components/nodes/conditions/Condition.tsx | 6 +- .../components/nodes/responses/Response.tsx | 2 +- .../notifications/NotificationsWindow.tsx | 7 +- .../components/NotificationComponent.tsx | 6 +- .../src/components/sidebar/DragListItem.tsx | 2 +- frontend/src/consts.tsx | 23 +- frontend/src/contexts/buildContext.tsx | 4 +- frontend/src/contexts/flowContext.tsx | 83 ++++-- frontend/src/contexts/metaContext.tsx | 1 + .../src/contexts/notificationsContext.tsx | 6 +- frontend/src/contexts/runContext.tsx | 4 +- frontend/src/contexts/undoRedoContext.tsx | 219 +++++----------- frontend/src/contexts/workspaceContext.tsx | 91 +++---- .../nodes/conditions/CodeConditionIcon.tsx | 18 +- .../nodes/conditions/LLMConditionIcon.tsx | 6 +- .../modals/ConditionModal/ConditionModal.tsx | 37 ++- .../src/modals/FlowModal/CreateFlowModal.tsx | 4 +- .../src/modals/FlowModal/ManageFlowsModal.tsx | 24 +- frontend/src/modals/LinkModal/LinkModal.tsx | 8 +- frontend/src/modals/NodeModal/NodeModal.tsx | 26 +- .../modals/ResponseModal/ResponseModal.tsx | 14 +- frontend/src/pages/Flow.tsx | 247 +++++++----------- frontend/src/pages/NodesLayout.tsx | 8 +- frontend/src/types/FlowTypes.ts | 5 +- frontend/src/types/NodeTypes.ts | 36 ++- frontend/src/types/ReactFlowTypes.ts | 8 + frontend/src/utils.ts | 102 ++++++-- 42 files changed, 618 insertions(+), 545 deletions(-) mode change 100644 => 100755 frontend/src/api/index.ts delete mode 100644 frontend/src/assets/react.svg create mode 100644 frontend/src/components/ReactFlowCustom.tsx mode change 100644 => 100755 frontend/src/components/header/Header.tsx mode change 100644 => 100755 frontend/src/components/nodes/LinkNode.tsx mode change 100644 => 100755 frontend/src/pages/Flow.tsx create mode 100644 frontend/src/types/ReactFlowTypes.ts diff --git a/frontend/bun.lockb b/frontend/bun.lockb index 6f9b69402cea419a54eabbb63263d537cd47a6cb..78d333fe07c163424e343384dfdc4c167a9ccf66 100755 GIT binary patch delta 84205 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 WK-!?ZF7PZrS^Sdj)N}di*Z%{r%>a`C delta 91417 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 {} -// const baseURL = VITE_BASE_API_URL ?? "http://localhost:8000/api/v1" const baseURL = DEV ? VITE_BASE_API_URL : window.location.protocol + "//" + window.location.host + "/api/v1" diff --git a/frontend/src/assets/react.svg b/frontend/src/assets/react.svg deleted file mode 100644 index 6c87de9b..00000000 --- a/frontend/src/assets/react.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/frontend/src/components/ReactFlowCustom.tsx b/frontend/src/components/ReactFlowCustom.tsx new file mode 100644 index 00000000..36c1259c --- /dev/null +++ b/frontend/src/components/ReactFlowCustom.tsx @@ -0,0 +1,12 @@ +import { Edge, ReactFlow, ReactFlowProps } from '@xyflow/react' +import { AppNode } from '../types/NodeTypes' + +const ReactFlowCustom = ({ ...props }: ReactFlowProps) => { + return ( + + {props.children} + + ) +} + +export default ReactFlowCustom \ No newline at end of file diff --git a/frontend/src/components/chat/Chat.tsx b/frontend/src/components/chat/Chat.tsx index c25e53e4..f973ae4d 100644 --- a/frontend/src/components/chat/Chat.tsx +++ b/frontend/src/components/chat/Chat.tsx @@ -5,7 +5,7 @@ import { Paperclip, RefreshCcw, Send, Smile, X } from "lucide-react" import { memo, useContext, useEffect, useRef, useState } from "react" import { useSearchParams } from "react-router-dom" import { chatContext } from "../../contexts/chatContext" -import { notificationsContext } from "../../contexts/notificationsContext" +import { NotificationsContext } from "../../contexts/notificationsContext" import { runContext } from "../../contexts/runContext" import { workspaceContext } from "../../contexts/workspaceContext" import { DEV } from "../../env.consts" @@ -19,7 +19,7 @@ const Chat = memo(() => { const [searchParams, setSearchParams] = useSearchParams() const ws = useRef(null) const { setMouseOnPane } = useContext(workspaceContext) - const { notification: n } = useContext(notificationsContext) + const { notification: n } = useContext(NotificationsContext) const [isEmoji, setIsEmoji] = useState(false) @@ -122,7 +122,7 @@ const Chat = memo(() => { const socket = new WebSocket( `ws://${DEV ? "localhost:8000" : window.location.host}/api/v1/bot/run/connect?run_id=${run.id}` ) - socket.onopen = (e) => { + socket.onopen = () => { n.add({ message: "Chat was successfully connected!", title: "Success", type: "success" }) } socket.onmessage = (event: MessageEvent) => { diff --git a/frontend/src/components/edges/ButtonEdge/ButtonEdge.tsx b/frontend/src/components/edges/ButtonEdge/ButtonEdge.tsx index 4aa018b8..703326f4 100644 --- a/frontend/src/components/edges/ButtonEdge/ButtonEdge.tsx +++ b/frontend/src/components/edges/ButtonEdge/ButtonEdge.tsx @@ -1,4 +1,3 @@ -import { X } from "lucide-react" import { BaseEdge, Edge, @@ -6,7 +5,8 @@ import { EdgeProps, getBezierPath, useReactFlow, -} from "reactflow" +} from "@xyflow/react" +import { X } from "lucide-react" import "./buttonedge.css" export default function CustomEdge({ @@ -53,9 +53,9 @@ export default function CustomEdge({ }} className='nodrag nopan'>

diff --git a/frontend/src/components/footbar/FootBar.tsx b/frontend/src/components/footbar/FootBar.tsx index 41f637ac..eb3f09fb 100644 --- a/frontend/src/components/footbar/FootBar.tsx +++ b/frontend/src/components/footbar/FootBar.tsx @@ -5,11 +5,11 @@ import { Key, memo, useCallback, useContext, useState } from "react" import { Link, useSearchParams } from "react-router-dom" import { buildContext } from "../../contexts/buildContext" import { MetaContext } from "../../contexts/metaContext" -import { notificationsContext } from "../../contexts/notificationsContext" +import { NotificationsContext } from "../../contexts/notificationsContext" import { workspaceContext } from "../../contexts/workspaceContext" -import { Logo } from "../../icons/Logo" import MonitorIcon from "../../icons/buildmenu/MonitorIcon" import LocalStorageIcon from "../../icons/footbar/LocalStorageIcon" +import { Logo } from "../../icons/Logo" import LocalStorage from "../../modals/LocalStorage/LocalStorage" import { parseSearchParams } from "../../utils" import { NotificationsWindow } from "../notifications/NotificationsWindow" @@ -26,7 +26,7 @@ const FootBar = memo(() => { const { logsPage, setLogsPage } = useContext(buildContext) const [searchParams, setSearchParams] = useSearchParams() const [isNotificationsOpen, setIsNotificationsOpen] = useState(false) - const { notifications } = useContext(notificationsContext) + const { notifications } = useContext(NotificationsContext) const onSelectionChange = useCallback( (key: Key) => { diff --git a/frontend/src/components/header/BuildMenu.tsx b/frontend/src/components/header/BuildMenu.tsx index f28b66d3..67452b26 100644 --- a/frontend/src/components/header/BuildMenu.tsx +++ b/frontend/src/components/header/BuildMenu.tsx @@ -1,6 +1,6 @@ import { Button, Spinner, Tooltip } from "@nextui-org/react" import classNames from "classnames" -import { useContext, useState } from "react" +import { useContext } from "react" import { useSearchParams } from "react-router-dom" import { buildContext } from "../../contexts/buildContext" import { chatContext } from "../../contexts/chatContext" @@ -11,10 +11,9 @@ import StopIcon from "../../icons/buildmenu/StopIcon" import { parseSearchParams } from "../../utils" const BuildMenu = () => { - const { buildStart, buildPending, buildStatus, setLogsPage, logsPage } = useContext(buildContext) + const { buildStart, buildPending } = useContext(buildContext) const { chat, setChat } = useContext(chatContext) const { runStart, runPending, runStatus, runStop, run } = useContext(runContext) - const [showBuildMenu, setShowBuildMenu] = useState(false) const [searchParams, setSearchParams] = useSearchParams() return ( @@ -30,7 +29,9 @@ const BuildMenu = () => { className={classNames("transition-all duration-300", showBuildMenu && "rotate-180")} /> */} - + */} - +
- - {/* */} + {location.pathname.includes("flow") && } + {location.pathname.includes("home") && ( + + + + + +

+ + Chatsky UI +

+
+

+ Version: {version} +

+ + +

GitHub

+
+ +

DeepPavlov.ai

+
+
+
+
+ )}
) diff --git a/frontend/src/components/header/components/NodeInstruments.tsx b/frontend/src/components/header/components/NodeInstruments.tsx index 66f402b5..f6fd6091 100644 --- a/frontend/src/components/header/components/NodeInstruments.tsx +++ b/frontend/src/components/header/components/NodeInstruments.tsx @@ -1,30 +1,29 @@ import { Button, Tooltip } from "@nextui-org/react" +import { Edge, useReactFlow } from "@xyflow/react" import classNames from "classnames" -import { useContext, useMemo } from "react" -import { Node, useReactFlow } from "reactflow" +import { useContext } from "react" import { flowContext } from "../../../contexts/flowContext" import { workspaceContext } from "../../../contexts/workspaceContext" import FallbackNodeIcon from "../../../icons/nodes/FallbackNodeIcon" import StartNodeIcon from "../../../icons/nodes/StartNodeIcon" import { FlowType } from "../../../types/FlowTypes" -import { NodeDataType } from "../../../types/NodeTypes" +import { AppNode } from "../../../types/NodeTypes" const NodeInstruments = ({ flow }: { flow: FlowType }) => { - const { setNodes } = useReactFlow() + const { setNodes } = useReactFlow() const { handleNodeFlags, selectedNode } = useContext(workspaceContext) const { deleteNode } = useContext(flowContext) - const selectedNodeData: Node | null = + const selectedNodeData: AppNode | null = flow?.data.nodes.find((node) => node.id === selectedNode) ?? null - const is_node_default = useMemo(() => selectedNodeData?.type === "default_node", [selectedNodeData]) - + // eslint-disable-next-line @typescript-eslint/no-unused-vars const deleteSelectedNodeHandler = () => { setNodes((nds) => nds.filter((node) => node.id !== selectedNode)) deleteNode(selectedNode) } - if (!is_node_default) return <> + if (selectedNodeData?.type !== 'default_node') return <> return (
diff --git a/frontend/src/components/nodes/DefaultNode.tsx b/frontend/src/components/nodes/DefaultNode.tsx index fffd94e7..5de21360 100644 --- a/frontend/src/components/nodes/DefaultNode.tsx +++ b/frontend/src/components/nodes/DefaultNode.tsx @@ -1,9 +1,9 @@ import { Button, useDisclosure } from "@nextui-org/react" +import { Handle, Position } from "@xyflow/react" +import "@xyflow/react/dist/style.css" import classNames from "classnames" import { PlusIcon } from "lucide-react" import { memo, useContext, useMemo, useState } from "react" -import { Handle, Position } from "reactflow" -import "reactflow/dist/style.css" import { workspaceContext } from "../../contexts/workspaceContext" import EditNodeIcon from "../../icons/nodes/EditNodeIcon" import FallbackNodeIcon from "../../icons/nodes/FallbackNodeIcon" @@ -14,11 +14,11 @@ import "../../index.css" import ConditionModal from "../../modals/ConditionModal/ConditionModal" import NodeModal from "../../modals/NodeModal/NodeModal" import ResponseModal from "../../modals/ResponseModal/ResponseModal" -import { NodeDataType } from "../../types/NodeTypes" +import { DefaultNodeDataType } from "../../types/NodeTypes" import Condition from "./conditions/Condition" import Response from "./responses/Response" -const DefaultNode = memo(({ data }: { data: NodeDataType }) => { +const DefaultNode = memo(({ data }: { data: DefaultNodeDataType }) => { const { onOpen: onConditionOpen, onClose: onConditionClose, @@ -27,7 +27,7 @@ const DefaultNode = memo(({ data }: { data: NodeDataType }) => { const { selectedNode } = useContext(workspaceContext) - const [nodeDataState, setNodeDataState] = useState(data) + const [nodeDataState, setNodeDataState] = useState(data) const { onOpen: onNodeOpen, onClose: onNodeClose, isOpen: isNodeOpen } = useDisclosure() const { @@ -75,7 +75,7 @@ const DefaultNode = memo(({ data }: { data: NodeDataType }) => { width: "0.7rem", height: "0.7rem", top: "1.875rem", - left: "-0.335rem", + left: "0rem", zIndex: 10, }} /> @@ -102,9 +102,9 @@ const DefaultNode = memo(({ data }: { data: NodeDataType }) => { />
-
+
diff --git a/frontend/src/components/nodes/LinkNode.tsx b/frontend/src/components/nodes/LinkNode.tsx old mode 100644 new mode 100755 index 21055407..3491faac --- a/frontend/src/components/nodes/LinkNode.tsx +++ b/frontend/src/components/nodes/LinkNode.tsx @@ -19,24 +19,25 @@ import { Tooltip, useDisclosure, } from "@nextui-org/react" +import { Handle, Position } from "@xyflow/react" +import "@xyflow/react/dist/style.css" import classNames from "classnames" import { AlertTriangle, Link2, Trash2 } from "lucide-react" import { memo, useContext, useEffect, useMemo, useState } from "react" -import { Handle, Node, Position } from "reactflow" -import "reactflow/dist/style.css" import { flowContext } from "../../contexts/flowContext" -import { notificationsContext } from "../../contexts/notificationsContext" +import { NotificationsContext } from "../../contexts/notificationsContext" import "../../index.css" import { FlowType } from "../../types/FlowTypes" -import { NodeDataType } from "../../types/NodeTypes" +import { AppNode, LinkNodeDataType } from "../../types/NodeTypes" -const LinkNode = memo(({ data }: { data: NodeDataType }) => { +const LinkNode = memo(({ data }: { data: LinkNodeDataType }) => { const { onOpen, onClose, isOpen } = useDisclosure() const { flows, deleteNode } = useContext(flowContext) const [toFlow, setToFlow] = useState() - const [toNode, setToNode] = useState>() + const [toNode, setToNode] = useState() const [error, setError] = useState(false) - const { notification: n } = useContext(notificationsContext) + const [r, setR] = useState(0) + const { notification: n } = useContext(NotificationsContext) // const { openPopUp } = useContext(PopUpContext) useEffect(() => { @@ -75,6 +76,9 @@ const LinkNode = memo(({ data }: { data: NodeDataType }) => { ) useEffect(() => { + if (r === 0) { + return setR((r) => r + 1) + } if (!TO_FLOW || !TO_NODE) { setError(true) n.add({ @@ -110,10 +114,7 @@ const LinkNode = memo(({ data }: { data: NodeDataType }) => { <>
+ className={classNames("default_node px-6 py-4", error && "border-error")}>
{ return (
-
+
{conditionTypeIcons[condition.type]} {condition.name} @@ -59,7 +59,7 @@ const Condition = ({ data, condition }: NodeComponentConditionType) => { borderStyle: "solid", width: "0.7rem", height: "0.7rem", - right: "-0.95rem", + right: "-0.7rem", zIndex: 10, }} /> diff --git a/frontend/src/components/nodes/responses/Response.tsx b/frontend/src/components/nodes/responses/Response.tsx index 2e4ea468..413bbcc8 100644 --- a/frontend/src/components/nodes/responses/Response.tsx +++ b/frontend/src/components/nodes/responses/Response.tsx @@ -5,7 +5,7 @@ const Response = ({ data }: NodeComponentType) => { return (
-

{data.response?.data[0]?.text ?? "No text response"}

+

{data.response.data[0]?.text ?? "No text response"}

) } diff --git a/frontend/src/components/notifications/NotificationsWindow.tsx b/frontend/src/components/notifications/NotificationsWindow.tsx index a947c854..31f1abb8 100644 --- a/frontend/src/components/notifications/NotificationsWindow.tsx +++ b/frontend/src/components/notifications/NotificationsWindow.tsx @@ -8,7 +8,7 @@ import { } from "@nextui-org/react" import { AlertOctagon, AlertTriangle, BugIcon, CheckCircle2, InfoIcon, Trash } from "lucide-react" import { useContext, useState } from "react" -import { notificationsContext } from "../../contexts/notificationsContext" +import { NotificationsContext } from "../../contexts/notificationsContext" import NotificationComponent from "./components/NotificationComponent" type NotificationsWindowProps = { @@ -30,7 +30,7 @@ type NotificationsWindowProps = { // } export const NotificationsWindow = ({ isOpen, setIsOpen }: NotificationsWindowProps) => { - const { notifications, notification } = useContext(notificationsContext) + const { notifications, notification } = useContext(NotificationsContext) const [notificationFilter, setNotificationFilter] = useState([]) const handleSelectionChange = (e: React.ChangeEvent) => { @@ -41,7 +41,6 @@ export const NotificationsWindow = ({ isOpen, setIsOpen }: NotificationsWindowPr } }; - console.log(notificationFilter) const renderNotifications = () => { const filtered_notifications = notifications.filter( @@ -54,13 +53,11 @@ export const NotificationsWindow = ({ isOpen, setIsOpen }: NotificationsWindowPr const stack = not.stack + 1 const next_not = time_sorted_notifications[idx + 1] if (next_not && not && next_not.message === not.message && not.stack !== 0) { - console.log(not, next_not) next_not.stack = stack not.stack = 0 } return not }) - console.log(stack_checked_notifications) const stack_filtered_notifications = stack_checked_notifications.filter((not) => not.stack > 0) if (stack_filtered_notifications.length === 0) { return ( diff --git a/frontend/src/components/notifications/components/NotificationComponent.tsx b/frontend/src/components/notifications/components/NotificationComponent.tsx index 53170a30..7377160b 100644 --- a/frontend/src/components/notifications/components/NotificationComponent.tsx +++ b/frontend/src/components/notifications/components/NotificationComponent.tsx @@ -2,7 +2,7 @@ import { Button } from "@nextui-org/react" import classNames from "classnames" import { AlertOctagon, AlertTriangle, Bug, CheckCircle2, Info, X } from "lucide-react" import { useContext, useMemo, useState } from "react" -import { notificationType, notificationsContext } from "../../../contexts/notificationsContext" +import { NotificationsContext, notificationType } from "../../../contexts/notificationsContext" type NotificationComponentType = { notification: notificationType & { @@ -11,7 +11,7 @@ type NotificationComponentType = { } const NotificationComponent = ({ notification }: NotificationComponentType) => { - const { notification: nt, notifications } = useContext(notificationsContext) + const { notification: nt, notifications } = useContext(NotificationsContext) const [isDelete, setIsDelete] = useState(false) const notificationTypeColor = (type: string) => { @@ -83,9 +83,7 @@ const NotificationComponent = ({ notification }: NotificationComponentType) => { if (notification.stack > 1) { const index = notifications.findIndex((n) => n.timestamp == notification.timestamp) for (let i = 0; i <= index; i++) { - console.log(notifications[i]) if (notifications[i].stack === 0 && notifications[i].message === notification.message) { - console.log("deletion") nt.delete(notifications[i].timestamp) } } diff --git a/frontend/src/components/sidebar/DragListItem.tsx b/frontend/src/components/sidebar/DragListItem.tsx index 864214ef..d49d2717 100644 --- a/frontend/src/components/sidebar/DragListItem.tsx +++ b/frontend/src/components/sidebar/DragListItem.tsx @@ -4,7 +4,7 @@ import React from "react"; const DragListItem = ({ item }: { item: { name: string; color: string; type: string } }) => { const onDragStart = (event: React.DragEvent, nodeType: string) => { // if (event.dataTransfer) { - event.dataTransfer.setData("application/reactflow", nodeType) + event.dataTransfer.setData("application/@xyflow/react", nodeType) event.dataTransfer.effectAllowed = "move" // } } diff --git a/frontend/src/consts.tsx b/frontend/src/consts.tsx index 0be0e0f0..6b9f5048 100644 --- a/frontend/src/consts.tsx +++ b/frontend/src/consts.tsx @@ -73,7 +73,7 @@ export const NODE_NAMES = [ "Science discussion", "Experience story", "Political discussion", - "Now plans", + "Now plans", "Sport talk", "Movie discussion", "Family story", @@ -108,7 +108,7 @@ export const NODE_NAMES = [ "Cooking discussion", "Technology story", "Music talk", - "Next year plans" + "Next year plans", ] export const START_FALLBACK_NODE_FLAGS = ["start", "fallback"] @@ -119,7 +119,7 @@ export const NODES = { default_node: { name: "Default Node", type: "default_node", - dragHandle: '.custom-drag-handle', + dragHandle: ".custom-drag-handle", conditions: [], global_conditions: [], local_conditions: [], @@ -148,15 +148,11 @@ export const NODES = { link_node: { name: "Link", type: "link_node", - dragHandle: '', - conditions: [], - global_conditions: [], - local_conditions: [], - response: { - name: "Link response", - type: "text", - data: [{ text: "Link response", priority: 1 }], - } + dragHandle: "", + transition: { + target_flow: "", + target_node: "", + }, }, } @@ -194,6 +190,5 @@ export const conditionTypeIcons = { export const responseTypeIcons = { python: , text: , - llm: + llm: , } - diff --git a/frontend/src/contexts/buildContext.tsx b/frontend/src/contexts/buildContext.tsx index a83433a8..07126512 100644 --- a/frontend/src/contexts/buildContext.tsx +++ b/frontend/src/contexts/buildContext.tsx @@ -11,7 +11,7 @@ import { get_builds, localBuildType, } from "../api/bot" -import { notificationsContext } from "./notificationsContext" +import { NotificationsContext } from "./notificationsContext" type BuildContextType = { build: boolean @@ -53,7 +53,7 @@ export const BuildProvider = ({ children }: { children: React.ReactNode }) => { const [searchParams, setSearchParams] = useSearchParams() const [logsPage, setLogsPage] = useState(searchParams.get("logs_page") === "opened") const [builds, setBuilds] = useState([]) - const { notification: n } = useContext(notificationsContext) + const { notification: n } = useContext(NotificationsContext) const setBuildsHandler = (builds: buildMinifyApiType[]) => { setBuilds(() => diff --git a/frontend/src/contexts/flowContext.tsx b/frontend/src/contexts/flowContext.tsx index 9ae0aa78..cb848f7a 100644 --- a/frontend/src/contexts/flowContext.tsx +++ b/frontend/src/contexts/flowContext.tsx @@ -1,14 +1,14 @@ /* eslint-disable react-refresh/only-export-components */ +import { Edge, OnBeforeDelete, ReactFlowInstance } from "@xyflow/react" import React, { createContext, useCallback, useContext, useEffect, useState } from "react" import { useParams } from "react-router-dom" -import { Edge, ReactFlowInstance } from "reactflow" import { v4 } from "uuid" import { get_flows, save_flows } from "../api/flows" import { FLOW_COLORS } from "../consts" import { FlowType } from "../types/FlowTypes" -import { NodeType } from "../types/NodeTypes" +import { AppNode } from "../types/NodeTypes" import { MetaContext } from "./metaContext" -import { notificationsContext } from "./notificationsContext" +import { NotificationsContext } from "./notificationsContext" // import { v4 } from "uuid" const globalFlow: FlowType = { @@ -22,12 +22,14 @@ const globalFlow: FlowType = { id: v4(), type: "default_node", data: { + id: "GLOBAL_NODE", flags: [], conditions: [], global_conditions: [], local_conditions: [], name: "Global node", response: { + id: "GLOBAL_NODE_RESPONSE", name: "global_response", type: "text", data: [{ text: "Global node response", priority: 1 }], @@ -48,9 +50,11 @@ const globalFlow: FlowType = { }, } +export type CustomReactFlowInstanceType = ReactFlowInstance + type TabContextType = { - reactFlowInstance: ReactFlowInstance | null - setReactFlowInstance: React.Dispatch> + reactFlowInstance: CustomReactFlowInstanceType | null + setReactFlowInstance: React.Dispatch> tab: string setTab: React.Dispatch> flows: FlowType[] @@ -64,6 +68,8 @@ type TabContextType = { deleteNode: (id: string) => void deleteEdge: (id: string) => void deleteObject: (id: string) => void + validateDeletion: OnBeforeDelete + validateNodeDeletion: (node: AppNode) => boolean } const initialValue: TabContextType = { @@ -84,20 +90,28 @@ const initialValue: TabContextType = { deleteNode: () => {}, deleteEdge: () => {}, deleteObject: () => {}, + validateDeletion: () => + new Promise(() => { + return false + }), + validateNodeDeletion: () => false, } export const flowContext = createContext(initialValue) export const FlowProvider = ({ children }: { children: React.ReactNode }) => { - - const [reactFlowInstance, setReactFlowInstance] = useState(null) + const [reactFlowInstance, setReactFlowInstance] = useState | null>(null) const [tab, setTab] = useState(initialValue.tab) const { flowId } = useParams() const [flows, setFlows] = useState([]) - const { notification: n } = useContext(notificationsContext) + const { notification: n } = useContext(NotificationsContext) const { screenLoading } = useContext(MetaContext) useEffect(() => { + setReactFlowInstance(null) setTab(flowId || "") }, [flowId]) @@ -123,10 +137,11 @@ export const FlowProvider = ({ children }: { children: React.ReactNode }) => { useEffect(() => { getFlows() + // eslint-disable-next-line react-hooks/exhaustive-deps }, []) const saveFlows = async (flows: FlowType[]) => { - const res = await save_flows(flows) + await save_flows(flows) setFlows(flows) } @@ -160,23 +175,55 @@ export const FlowProvider = ({ children }: { children: React.ReactNode }) => { [flows] ) + const validateDeletion = ({ nodes, edges }: { nodes: AppNode[]; edges: Edge[] }) => { + const is_nodes_valid = nodes.every((node) => { + if (node.type === "default_node" && node?.data.flags?.includes("start")) { + n.add({ title: "Warning!", message: "Can't delete start node", type: "warning" }) + return false + } + if (node?.id?.includes("LOCAL")) { + n.add({ title: "Warning!", message: "Can't delete local node", type: "warning" }) + return false + } + if (node?.id?.includes("GLOBAL")) { + n.add({ title: "Warning!", message: "Can't delete global node", type: "warning" }) + return false + } + return true + }) + return new Promise((resolve) => { + resolve(is_nodes_valid) + }) + } + + const validateNodeDeletion = (node: AppNode) => { + if (node.type === "default_node" && node.data.flags.includes("start")) { + n.add({ title: "Warning!", message: "Can't delete start node", type: "warning" }) + return false + } + if (node.id.includes("LOCAL")) { + n.add({ title: "Warning!", message: "Can't delete local node", type: "warning" }) + return false + } + if (node.id.includes("GLOBAL")) { + n.add({ title: "Warning!", message: "Can't delete global node", type: "warning" }) + return false + } + return true + } + const deleteNode = useCallback( (id: string) => { const flow = flows.find((flow) => flow.data.nodes.some((node) => node.id === id)) if (!flow) return -1 - const deleted_node: NodeType = flow.data.nodes.find((node) => node.id === id) as NodeType - if (deleted_node?.data.flags?.includes("start")) + const deleted_node: AppNode = flow.data.nodes.find((node) => node.id === id) as AppNode + if (deleted_node.type === "default_node" && deleted_node?.data.flags?.includes("start")) return n.add({ title: "Warning!", message: "Can't delete start node", type: "warning" }) if (deleted_node?.id?.includes("LOCAL")) return n.add({ title: "Warning!", message: "Can't delete local node", type: "warning" }) if (deleted_node?.id?.includes("GLOBAL")) return n.add({ title: "Warning!", message: "Can't delete global node", type: "warning" }) - if (deleted_node?.data.flags?.includes("fallback")) { - console.log( - flow.data.nodes - .find((node) => node.id !== id && !node.data.id.includes("LOCAL")) - ?.data.flags.push("fallback") - ) + if (deleted_node.type === "default_node" && deleted_node?.data.flags?.includes("fallback")) { // any_node.data.flags?.push("fallback") } const newNodes = flow.data.nodes.filter((node) => node.id !== id) @@ -240,6 +287,8 @@ export const FlowProvider = ({ children }: { children: React.ReactNode }) => { deleteNode, deleteEdge, deleteObject, + validateDeletion, + validateNodeDeletion, }}> {children} diff --git a/frontend/src/contexts/metaContext.tsx b/frontend/src/contexts/metaContext.tsx index 07533a2d..6c9fe84d 100644 --- a/frontend/src/contexts/metaContext.tsx +++ b/frontend/src/contexts/metaContext.tsx @@ -55,6 +55,7 @@ const MetaProvider = ({ children }: MetaProviderProps) => { useEffect(() => { getVersion() + // eslint-disable-next-line react-hooks/exhaustive-deps }, []) return ( diff --git a/frontend/src/contexts/notificationsContext.tsx b/frontend/src/contexts/notificationsContext.tsx index f02b9964..d2e3f259 100644 --- a/frontend/src/contexts/notificationsContext.tsx +++ b/frontend/src/contexts/notificationsContext.tsx @@ -34,7 +34,7 @@ type notificationsContextType = { } } -export const notificationsContext = createContext({ +export const NotificationsContext = createContext({ notifications: [], notification: { add: () => {}, @@ -153,13 +153,13 @@ const NotificationsProvider = ({ children }: { children: React.ReactNode }) => { } return ( - {children} - + ) } diff --git a/frontend/src/contexts/runContext.tsx b/frontend/src/contexts/runContext.tsx index fb49a0c0..96ccea4a 100644 --- a/frontend/src/contexts/runContext.tsx +++ b/frontend/src/contexts/runContext.tsx @@ -11,7 +11,7 @@ import { run_stop, } from "../api/bot" import { buildContext } from "./buildContext" -import { notificationsContext } from "./notificationsContext" +import { NotificationsContext } from "./notificationsContext" export type runApiType = { id: number @@ -58,7 +58,7 @@ export const RunProvider = ({ children }: { children: React.ReactNode }) => { const [runStatus, setRunStatus] = useState("stopped") const [runs, setRuns] = useState([]) const { setBuildsHandler, builds: context_builds } = useContext(buildContext) - const { notification: n } = useContext(notificationsContext) + const { notification: n } = useContext(NotificationsContext) const setRunsHandler = (runs: runMinifyApiType[]) => { setRuns(runs.map((run) => ({ ...run, type: "run" }))) diff --git a/frontend/src/contexts/undoRedoContext.tsx b/frontend/src/contexts/undoRedoContext.tsx index a8d40e5f..f6c78c21 100644 --- a/frontend/src/contexts/undoRedoContext.tsx +++ b/frontend/src/contexts/undoRedoContext.tsx @@ -1,10 +1,13 @@ +import { addEdge, Edge, Node, OnSelectionChangeParams, useReactFlow } from "@xyflow/react" import { cloneDeep } from "lodash" import { createContext, useCallback, useContext, useEffect, useState } from "react" -import { addEdge, Edge, Node, OnSelectionChangeParams, useReactFlow } from "reactflow" import { v4 } from "uuid" -import { NodeDataType } from "../types/NodeTypes" +import { AppNode } from "../types/NodeTypes" +import { OnSelectionChangeParamsCustom } from "../types/ReactFlowTypes" +import { generateNewNode } from "../utils" import { flowContext } from "./flowContext" -import { notificationsContext } from "./notificationsContext" +import { NotificationsContext } from "./notificationsContext" +import { workspaceContext } from "./workspaceContext" type undoRedoContextType = { undo: () => void @@ -16,6 +19,8 @@ type undoRedoContextType = { position: { x: number; y: number; paneX?: number; paneY?: number } ) => void copiedSelection: OnSelectionChangeParams | null + disableCopyPaste: boolean + setDisableCopyPaste: React.Dispatch> } type UseUndoRedoOptions = { @@ -43,6 +48,8 @@ const initialValue = { copy: () => {}, paste: () => {}, copiedSelection: null, + disableCopyPaste: false, + setDisableCopyPaste: () => {}, } const defaultOptions: UseUndoRedoOptions = { @@ -55,7 +62,8 @@ export const undoRedoContext = createContext(initialValue) export function UndoRedoProvider({ children }: { children: React.ReactNode }) { const { tab, flows } = useContext(flowContext) - const { notification: n } = useContext(notificationsContext) + const { notification: n } = useContext(NotificationsContext) + const { modalsOpened } = useContext(workspaceContext) const [past, setPast] = useState(flows.map(() => [])) const [future, setFuture] = useState(flows.map(() => [])) @@ -165,6 +173,15 @@ export function UndoRedoProvider({ children }: { children: React.ReactNode }) { const { reactFlowInstance } = useContext(flowContext) const [copiedSelection, setCopiedSelection] = useState(null) + const [disableCopyPaste, setDisableCopyPaste] = useState(false) + + useEffect(() => { + if (modalsOpened === 0) { + setDisableCopyPaste(false) + } else if (modalsOpened > 0) { + setDisableCopyPaste(true) + } + }, [modalsOpened]) const copy = (selection: OnSelectionChangeParams) => { if (selection && (selection.nodes.length || selection.edges.length)) { @@ -204,8 +221,9 @@ export function UndoRedoProvider({ children }: { children: React.ReactNode }) { type: "warning", }) } - const nodes: Node[] = reactFlowInstance.getNodes() - let edges: Edge[] = reactFlowInstance.getEdges() + const _selectionInstance = selectionInstance as OnSelectionChangeParamsCustom + const nodes = reactFlowInstance.getNodes() + let edges = reactFlowInstance.getEdges() let minimumX = Infinity let minimumY = Infinity const idsMap: { [id: string]: string } = {} @@ -223,30 +241,29 @@ export function UndoRedoProvider({ children }: { children: React.ReactNode }) { const insidePosition = position.paneX && position.paneY ? { x: position.paneX + position.x, y: position.paneY + position.y } - : reactFlowInstance.project({ x: position.x, y: position.y }) - - const resultNodes: Node[] = [] - - selectionInstance.nodes.forEach((n: Node) => { - // Generate a unique node ID - const newId = v4() - idsMap[n.id] = newId - const newConditions = n.data.conditions?.map((c) => { - const newCondId = v4() - sourceHandlesMap[c.id] = newCondId - return { ...c, id: newCondId } - }) - const newResponse = n.data.response - ? { - ...n.data.response, - id: v4(), - } - : undefined + : reactFlowInstance.screenToFlowPosition({ x: position.x, y: position.y }) + + const resultNodes: AppNode[] = [] + + _selectionInstance.nodes.forEach((n: AppNode) => { + let newConditions + let newResponse + if (n.type === "default_node") { + newConditions = n.data.conditions.map((c) => { + const newCondId = "condition_" + v4() + sourceHandlesMap[c.id] = newCondId + return { ...c, id: newCondId } + }) + newResponse = n.data.response + ? { + ...n.data.response, + id: "response_" + v4(), + } + : undefined + } // Create a new node object - const newNode: Node = { - id: newId, - type: n.type, + const newNode = generateNewNode(n.type, { position: { x: insidePosition.x + n.position.x - minimumX, y: insidePosition.y + n.position.y - minimumY, @@ -256,9 +273,25 @@ export function UndoRedoProvider({ children }: { children: React.ReactNode }) { conditions: newConditions, response: newResponse, flags: [], - id: newId, }, - } + }) + idsMap[n.id] = newNode.id + + // const newNode: AppNode = { + // id: newId, + // type: n.type, + // position: { + // x: insidePosition.x + n.position.x - minimumX, + // y: insidePosition.y + n.position.y - minimumY, + // }, + // data: { + // ...cloneDeep(n.data), + // conditions: newConditions, + // response: newResponse, + // flags: [], + // id: newId, + // }, + // } resultNodes.push({ ...newNode, selected: true }) }) @@ -267,26 +300,22 @@ export function UndoRedoProvider({ children }: { children: React.ReactNode }) { return } - const newNodes = [ - ...nodes.map((e: Node) => ({ ...e, selected: false })), - ...resultNodes, - ] + const newNodes = [...nodes.map((e: AppNode) => ({ ...e, selected: false })), ...resultNodes] - console.log(selectionInstance.edges) selectionInstance.edges.forEach((e) => { const source = idsMap[e.source] const target = idsMap[e.target] if (e.sourceHandle) { const sourceHandle = sourceHandlesMap[e.sourceHandle] - const id = v4() + const id = "reactflow__edge-" + v4() edges = addEdge( { source, target, sourceHandle, targetHandle: null, - id, + id: id, selected: false, }, edges.map((e) => ({ ...e, selected: false })) @@ -298,120 +327,6 @@ export function UndoRedoProvider({ children }: { children: React.ReactNode }) { reactFlowInstance.setEdges(edges) } - // function paste( - // selectionInstance, - // position: { x: number; y: number; paneX?: number; paneY?: number } - // ) { - // let minimumX = Infinity; - // let minimumY = Infinity; - // let idsMap = {}; - // let nodes = reactFlowInstance.getNodes(); - // let edges = reactFlowInstance.getEdges(); - // selectionInstance.nodes.forEach((n) => { - // if (n.position.y < minimumY) { - // minimumY = n.position.y; - // } - // if (n.position.x < minimumX) { - // minimumX = n.position.x; - // } - // }); - - // const insidePosition = position.paneX - // ? { x: position.paneX + position.x, y: position.paneY + position.y } - // : reactFlowInstance.project({ x: position.x, y: position.y }); - - // const resultNodes: any[] = [] - - // selectionInstance.nodes.forEach((n: NodeType) => { - // // Generate a unique node ID - // let newId = getNodeId(n.data.type); - // idsMap[n.id] = newId; - - // const positionX = insidePosition.x + n.position.x - minimumX - // const positionY = insidePosition.y + n.position.y - minimumY - - // // Create a new node object - // const newNode: NodeType = { - // id: newId, - // type: "genericNode", - // position: { - // x: insidePosition.x + n.position.x - minimumX, - // y: insidePosition.y + n.position.y - minimumY, - // }, - // data: { - // ..._.cloneDeep(n.data), - // id: newId, - // }, - // }; - - // // FIXME: CHECK WORK >>>>>>> - // // check for intersections before paste - // if (nodes.some(({ position, id, width, height }) => { - // const xIntersect = ((positionX > position.x - width) && (positionX < (position.x + width))) - // const yIntersect = ((positionY > position.y - height) && (positionY < (position.y + height))) - // const result = xIntersect && yIntersect - // // console.log({id: id, xIntersect: xIntersect, yIntersect: yIntersect, result: result}) - // return result - // })) { - // return setErrorData({ title: "Invalid place! Nodes can't intersect!" }) - // } - // // FIXME: CHECK WORK >>>>>>>> - - // resultNodes.push({ ...newNode, selected: true }) - - // }); - - // if (resultNodes.length < selectionInstance.nodes.length) { - // return - // } - - // // Add the new node to the list of nodes in state - // nodes = nodes - // .map((e) => ({ ...e, selected: false })) - // .concat(resultNodes); - // reactFlowInstance.setNodes(nodes); - - // selectionInstance.edges.forEach((e) => { - // let source = idsMap[e.source]; - // let target = idsMap[e.target]; - // let sourceHandleSplitted = e.sourceHandle.split("|"); - // let sourceHandle = - // source + - // "|" + - // sourceHandleSplitted[1] + - // "|" + - // source - // let targetHandleSplitted = e.targetHandle.split("|"); - // let targetHandle = - // targetHandleSplitted.slice(0, -1).join("|") + target; - // let id = - // "reactflow__edge-" + - // source + - // sourceHandle + - // "-" + - // target + - // targetHandle; - // edges = addEdge( - // { - // source, - // target, - // sourceHandle, - // targetHandle, - // id, - // style: { stroke: "inherit" }, - // className: - // targetHandle.split("|")[0] === "Text" - // ? "stroke-foreground " - // : "stroke-foreground ", - // animated: targetHandle.split("|")[0] === "Text", - // selected: false, - // }, - // edges.map((e) => ({ ...e, selected: false })) - // ); - // }); - // reactFlowInstance.setEdges(edges); - // } - return ( {children} diff --git a/frontend/src/contexts/workspaceContext.tsx b/frontend/src/contexts/workspaceContext.tsx index 41381b6e..fd40bffa 100644 --- a/frontend/src/contexts/workspaceContext.tsx +++ b/frontend/src/contexts/workspaceContext.tsx @@ -1,11 +1,10 @@ /* eslint-disable react-refresh/only-export-components */ import { createContext, useCallback, useContext, useEffect, useState } from "react" import { useSearchParams } from "react-router-dom" -import { Node } from "reactflow" import { FlowType } from "../types/FlowTypes" -import { NodeDataType } from "../types/NodeTypes" +import { AppNode } from "../types/NodeTypes" import { flowContext } from "./flowContext" -import { notificationsContext } from "./notificationsContext" +import { NotificationsContext } from "./notificationsContext" type WorkspaceContextType = { workspaceMode: boolean @@ -20,7 +19,7 @@ type WorkspaceContextType = { setSelectedNode: React.Dispatch> handleNodeFlags: ( e: React.MouseEvent, - setNodes: React.Dispatch[]>> + setNodes: React.Dispatch> ) => void mouseOnPane: boolean setMouseOnPane: React.Dispatch> @@ -60,17 +59,13 @@ export const WorkspaceProvider = ({ children }: { children: React.ReactNode }) = const [workspaceMode, setWorkspaceMode] = useState(false) const [nodesLayoutMode, setNodesLayoutMode] = useState(false) const [managerMode, setManagerMode] = useState(false) - const [searchParams, setSearchParams] = useSearchParams() + const [searchParams] = useSearchParams() const [settingsPage, setSettingsPage] = useState(searchParams.get("settings") === "opened") const [selectedNode, setSelectedNode] = useState("") - const { updateFlow, flows, tab, quietSaveFlows, setFlows } = useContext(flowContext) + const { flows, quietSaveFlows, setFlows } = useContext(flowContext) const [mouseOnPane, setMouseOnPane] = useState(true) const [modalsOpened, setModalsOpened] = useState(0) - const { notification: n } = useContext(notificationsContext) - - useEffect(() => { - console.log(modalsOpened) - }, [modalsOpened]) + const { notification: n } = useContext(NotificationsContext) useEffect(() => { if (modalsOpened === 0) { @@ -83,10 +78,6 @@ export const WorkspaceProvider = ({ children }: { children: React.ReactNode }) = } }, [modalsOpened]) - useEffect(() => console.log(mouseOnPane), [mouseOnPane]) - - const flow = flows.find((flow) => flow.name === tab) - const toggleWorkspaceMode = useCallback(() => { setWorkspaceMode(() => !workspaceMode) n.add({ @@ -114,42 +105,44 @@ export const WorkspaceProvider = ({ children }: { children: React.ReactNode }) = }) }, [managerMode, n]) - const handleNodeFlags = useCallback((e: React.MouseEvent) => { - const nodes = flows.flatMap((flow) => flow.data.nodes) - console.log(nodes) - const new_nds = nodes.map((nd: Node) => { - if (nd.data.flags?.includes(e.currentTarget.name)) { - nd.data.flags = nd.data.flags.filter((flag) => flag !== e.currentTarget.name) - } - if (nd.id === selectedNode) { - if (nd.data.flags?.includes(e.currentTarget.name)) { + const handleNodeFlags = useCallback( + (e: React.MouseEvent) => { + const nodes = flows.flatMap((flow) => flow.data.nodes) + const new_nds = nodes.map((nd: AppNode) => { + if (nd.type === "default_node" && nd.data.flags?.includes(e.currentTarget.name)) { nd.data.flags = nd.data.flags.filter((flag) => flag !== e.currentTarget.name) - } else { - if (!nd.data.flags) nd.data.flags = [e.currentTarget.name] - else nd.data.flags = [...nd.data.flags, e.currentTarget.name] } - } - return nd - }) - const new_flows: FlowType[] = flows.map((flow) => { - return { - ...flow, - data: { - ...flow.data, - nodes: flow.data.nodes.map((nd: Node) => { - const new_nd = new_nds.find((n) => n.id === nd.id) - if (new_nd) return new_nd - else return nd - }), - }, - } - }) - setFlows(() => new_flows) - // if (flow) { - // updateFlow(flow) - // } - quietSaveFlows() - }, [flows, quietSaveFlows, selectedNode, setFlows]) + if (nd.type === "default_node" && nd.id === selectedNode) { + if (nd.data.flags?.includes(e.currentTarget.name)) { + nd.data.flags = nd.data.flags.filter((flag) => flag !== e.currentTarget.name) + } else { + if (!nd.data.flags) nd.data.flags = [e.currentTarget.name] + else nd.data.flags = [...nd.data.flags, e.currentTarget.name] + } + } + return nd + }) + const new_flows: FlowType[] = flows.map((flow) => { + return { + ...flow, + data: { + ...flow.data, + nodes: flow.data.nodes.map((nd: AppNode) => { + const new_nd = new_nds.find((n) => n.id === nd.id) + if (new_nd) return new_nd + else return nd + }), + }, + } + }) + setFlows(() => new_flows) + // if (flow) { + // updateFlow(flow) + // } + quietSaveFlows() + }, + [flows, quietSaveFlows, selectedNode, setFlows] + ) const onModalOpen = useCallback((onOpen: () => void) => { setMouseOnPane(false) diff --git a/frontend/src/icons/nodes/conditions/CodeConditionIcon.tsx b/frontend/src/icons/nodes/conditions/CodeConditionIcon.tsx index 933c473a..7518c176 100644 --- a/frontend/src/icons/nodes/conditions/CodeConditionIcon.tsx +++ b/frontend/src/icons/nodes/conditions/CodeConditionIcon.tsx @@ -10,25 +10,25 @@ const CodeConditionIcon = ({className, fill="var(--foreground)"}: React.SVGAttri fill='none' xmlns='http://www.w3.org/2000/svg'> ) diff --git a/frontend/src/icons/nodes/conditions/LLMConditionIcon.tsx b/frontend/src/icons/nodes/conditions/LLMConditionIcon.tsx index 7b981cc6..792646f0 100644 --- a/frontend/src/icons/nodes/conditions/LLMConditionIcon.tsx +++ b/frontend/src/icons/nodes/conditions/LLMConditionIcon.tsx @@ -10,11 +10,11 @@ const LLMConditionIcon = ({className, fill="var(--foreground)"}: React.SVGAttrib fill='none' xmlns='http://www.w3.org/2000/svg'> ) diff --git a/frontend/src/modals/ConditionModal/ConditionModal.tsx b/frontend/src/modals/ConditionModal/ConditionModal.tsx index 649e6204..cd03f148 100644 --- a/frontend/src/modals/ConditionModal/ConditionModal.tsx +++ b/frontend/src/modals/ConditionModal/ConditionModal.tsx @@ -9,23 +9,23 @@ import { Tab, Tabs, } from "@nextui-org/react" +import { Edge, useReactFlow } from "@xyflow/react" import classNames from "classnames" import { HelpCircle, TrashIcon } from "lucide-react" import { useContext, useEffect, useMemo, useState } from "react" import { useParams } from "react-router-dom" -import { useReactFlow } from "reactflow" import { lint_service } from "../../api/services" import ModalComponent from "../../components/ModalComponent" import { flowContext } from "../../contexts/flowContext" -import { notificationsContext } from "../../contexts/notificationsContext" +import { NotificationsContext } from "../../contexts/notificationsContext" import { conditionType, conditionTypeType } from "../../types/ConditionTypes" -import { NodeDataType, NodeType } from "../../types/NodeTypes" +import { AppNode, DefaultNodeDataType, DefaultNodeType } from "../../types/NodeTypes" import { generateNewConditionBase } from "../../utils" import PythonCondition from "./components/PythonCondition" import UsingLLMConditionSection from "./components/UsingLLMCondition" type ConditionModalProps = { - data: NodeDataType + data: DefaultNodeDataType condition?: conditionType is_create?: boolean size?: ModalProps["size"] @@ -61,8 +61,8 @@ const ConditionModal = ({ setSelected(key) } - const { getNode, setNodes, getNodes } = useReactFlow() - const { notification: n } = useContext(notificationsContext) + const { getNode, setNodes, getNodes } = useReactFlow() + const { notification: n } = useContext(NotificationsContext) const { updateFlow, flows, quietSaveFlows } = useContext(flowContext) const { flowId } = useParams() @@ -71,10 +71,11 @@ const ConditionModal = ({ ) const validateConditionName = (is_create: boolean) => { - const nodes = getNodes() as NodeType[] + const nodes = getNodes() as AppNode[] if (!is_create) { - const is_name_valid = !nodes.some((node: NodeType) => - node.data.conditions?.some( + const is_name_valid = !nodes.some((node: AppNode) => + node.type === "default_node" && + node.data.conditions.some( (c) => c.name === currentCondition.name && c.id !== currentCondition.id ) ) @@ -90,7 +91,8 @@ const ConditionModal = ({ } } } else { - const is_name_valid = !nodes.some((node: NodeType) => + const is_name_valid = !nodes.some((node: AppNode) => + node.type === "default_node" && node.data.conditions?.some((c) => c.name === currentCondition.name) ) if (!is_name_valid) { @@ -206,16 +208,12 @@ const ConditionModal = ({ [currentCondition] ) - // useEffect(() => { - // console.log(currentCondition) - // }, [currentCondition]) const lintCondition = async () => { setLintStatus(null) if (currentCondition.type === "python") { try { const res = await lint_service(currentCondition.data.python?.action ?? "") - console.log(res) setLintStatus(res) return res } catch (error) { @@ -231,7 +229,6 @@ const ConditionModal = ({ if (currentCondition.type === "python") { const lint = await lintCondition() const validate_action = validateConditionAction() - console.log(lint) if (lint && validate_action.status) { setTestConditionPending(() => false) return true @@ -261,14 +258,14 @@ const ConditionModal = ({ const currentFlow = flows.find((flow) => flow.name === flowId) const validate_name: ValidateErrorType = validateConditionName(is_create) if (validate_name.status) { - if (node && currentFlow) { - const new_node = { + if (node && node.type === 'default_node' && currentFlow) { + const new_node: DefaultNodeType = { ...node, data: { ...node.data, conditions: is_create ? [...node.data.conditions, currentCondition] - : data.conditions?.map((condition) => + : data.conditions.map((condition) => condition.id === currentCondition.id ? currentCondition : condition ), }, @@ -298,8 +295,8 @@ const ConditionModal = ({ const nodes = getNodes() const node = getNode(data.id) const currentFlow = flows.find((flow) => flow.name === flowId) - if (node && currentFlow) { - const new_node = { + if (node && node.type === 'default_node' && currentFlow) { + const new_node: DefaultNodeType = { ...node, data: { ...node.data, diff --git a/frontend/src/modals/FlowModal/CreateFlowModal.tsx b/frontend/src/modals/FlowModal/CreateFlowModal.tsx index d167bd76..b369c1fd 100644 --- a/frontend/src/modals/FlowModal/CreateFlowModal.tsx +++ b/frontend/src/modals/FlowModal/CreateFlowModal.tsx @@ -14,7 +14,7 @@ import { useContext, useState } from "react" import ModalComponent from "../../components/ModalComponent" import { FLOW_COLORS } from "../../consts" import { flowContext } from "../../contexts/flowContext" -import { notificationsContext } from "../../contexts/notificationsContext" +import { NotificationsContext } from "../../contexts/notificationsContext" import { ModalType } from "../../types/ModalTypes" import { generateNewFlow, validateFlowName } from "../../utils" @@ -29,7 +29,7 @@ export type CreateFlowType = { const CreateFlowModal = ({ isOpen, onClose, size = "3xl" }: CreateFlowModalProps) => { const { flows, setFlows, saveFlows } = useContext(flowContext) - const { notification: n } = useContext(notificationsContext) + const { notification: n } = useContext(NotificationsContext) const [flow, setFlow] = useState({ name: "", description: "", diff --git a/frontend/src/modals/FlowModal/ManageFlowsModal.tsx b/frontend/src/modals/FlowModal/ManageFlowsModal.tsx index 285fc04f..e56b6fd9 100644 --- a/frontend/src/modals/FlowModal/ManageFlowsModal.tsx +++ b/frontend/src/modals/FlowModal/ManageFlowsModal.tsx @@ -10,11 +10,11 @@ import { } from "@nextui-org/react" import { HelpCircle, TrashIcon } from "lucide-react" import { useContext, useEffect, useState } from "react" -import { useParams } from "react-router-dom" +import { useNavigate, useParams } from "react-router-dom" import ModalComponent from "../../components/ModalComponent" import { FLOW_COLORS } from "../../consts" import { flowContext } from "../../contexts/flowContext" -import { notificationsContext } from "../../contexts/notificationsContext" +import { NotificationsContext } from "../../contexts/notificationsContext" import { FlowType } from "../../types/FlowTypes" import { ModalType } from "../../types/ModalTypes" import { validateFlowName } from "../../utils" @@ -30,12 +30,13 @@ export type CreateFlowType = { const ManageFlowsModal = ({ isOpen, onClose, size = "3xl" }: CreateFlowModalProps) => { const { flows, setFlows, saveFlows } = useContext(flowContext) - const { notification: n } = useContext(notificationsContext) + const { notification: n } = useContext(NotificationsContext) const [newFlows, setNewFlows] = useState([...flows] ?? []) const { flowId } = useParams() const [flow, setFlow] = useState( newFlows.find((_flow) => _flow.name === flowId) ?? [][0] ) + const navigate = useNavigate() const [newFlow, setNewFlow] = useState(flow) // eslint-disable-next-line @typescript-eslint/no-unused-vars @@ -101,6 +102,22 @@ const ManageFlowsModal = ({ isOpen, onClose, size = "3xl" }: CreateFlowModalProp } } + const onFlowDelete = () => { + if (newFlow.name === "Global") { + return n.add({ + title: "Warning!", + message: "Global flow cannot be deleted.", + type: "warning", + }) + } else { + if (flowId === newFlow.name) { + navigate('/app/home') + } + setFlows([...newFlows.filter((_flow) => _flow.name !== newFlow.name)]) + saveFlows([...newFlows.filter((_flow) => _flow.name !== newFlow.name)]) + } + } + return (
- {node.data.conditions && + {node.type === 'default_node' && node.data.conditions && node.data.conditions.map((condition) => ( <> {condition.data.transition_type !== "manual" && ( diff --git a/frontend/src/types/FlowTypes.ts b/frontend/src/types/FlowTypes.ts index 9760bfd2..92e28910 100644 --- a/frontend/src/types/FlowTypes.ts +++ b/frontend/src/types/FlowTypes.ts @@ -1,4 +1,5 @@ -import { ReactFlowJsonObject } from "reactflow" +import { Edge, ReactFlowJsonObject } from "@xyflow/react" +import { AppNode } from "./NodeTypes" export type FlowType = { id: string @@ -6,5 +7,5 @@ export type FlowType = { description?: string color?: string subflow?: string - data: ReactFlowJsonObject + data: ReactFlowJsonObject } diff --git a/frontend/src/types/NodeTypes.ts b/frontend/src/types/NodeTypes.ts index a8e2dfd5..31c102dd 100644 --- a/frontend/src/types/NodeTypes.ts +++ b/frontend/src/types/NodeTypes.ts @@ -1,19 +1,39 @@ +import { Node } from "@xyflow/react" import { conditionType } from "./ConditionTypes" import { responseType } from "./ResponseTypes" export type NodesTypes = 'default_node' | 'link_node' -export type NodeType = { +export type DefaultNodeType = Node +export type LinkNodeType = Node +export type AppNode = DefaultNodeType | LinkNodeType +export type AllowAppNode = DefaultNodeType & LinkNodeType + + +export type DefaultNodeDataType = { + id: string + name: string + response: responseType + conditions: conditionType[] + global_conditions?: string[] + local_conditions?: string[] + flags: string[] +} + +export type LinkNodeDataType = { id: string - type: string - dragHandle?: string - data: NodeDataType - position: { - x: number - y: number + name: string + transition: { + target_flow: string + target_node: string } } +export type PartialDefaultNodeDataType = Partial +export type PartialLinkNodeDataType = Partial + +export type AppNodeDataType = DefaultNodeDataType | LinkNodeDataType + export type NodeDataType = { id: string name: string @@ -29,7 +49,7 @@ export type NodeDataType = { } export type NodeComponentType = { - data: NodeDataType + data: DefaultNodeDataType } export interface NodeComponentConditionType extends NodeComponentType { diff --git a/frontend/src/types/ReactFlowTypes.ts b/frontend/src/types/ReactFlowTypes.ts new file mode 100644 index 00000000..21fed9ff --- /dev/null +++ b/frontend/src/types/ReactFlowTypes.ts @@ -0,0 +1,8 @@ +import { Edge } from "@xyflow/react"; +import { AppNode } from "./NodeTypes"; + + +export type OnSelectionChangeParamsCustom = { + nodes: AppNode[] + edges: Edge[] +} \ No newline at end of file diff --git a/frontend/src/utils.ts b/frontend/src/utils.ts index 0bbf908b..bbafbfec 100644 --- a/frontend/src/utils.ts +++ b/frontend/src/utils.ts @@ -2,25 +2,22 @@ import { v4 } from "uuid" import { CreateFlowType } from "./modals/FlowModal/CreateFlowModal" import { conditionType } from "./types/ConditionTypes" import { FlowType } from "./types/FlowTypes" -import { NodeType } from "./types/NodeTypes" +import { + AppNode, + DefaultNodeDataType, + DefaultNodeType, + LinkNodeDataType, + LinkNodeType, + NodesTypes, +} from "./types/NodeTypes" export const generateNewFlow = (flow: CreateFlowType) => { - const node_id = v4() - const node_2_id = v4() - const condition_id = v4() const newFlow: FlowType = { ...flow, - id: v4(), + id: "flow_" + v4(), data: { nodes: [], - edges: [ - { - id: v4(), - source: node_id, - sourceHandle: condition_id, - target: node_2_id, - }, - ], + edges: [], viewport: { x: 0, y: 0, @@ -50,7 +47,7 @@ export const parseSearchParams = ( export const generateNewConditionBase = (): conditionType => { return { - id: v4(), + id: "condition_" + v4(), name: "new_cnd", type: "python", data: { @@ -60,14 +57,83 @@ export const generateNewConditionBase = (): conditionType => { } } -export const isNodeDeletionValid = (nodes: NodeType[], id: string) => { +export const isNodeDeletionValid = (nodes: AppNode[], id: string) => { const node = nodes.find((n) => n.id === id) if (!node) return false - return !node.data.flags?.includes("start") + if (node.type === "link_node") return true + if (node.type === "default_node") return !node.data.flags?.includes("start") } export function delay(ms: number) { return new Promise((resolve, reject) => { - setTimeout(resolve, ms); - }); + setTimeout(resolve, ms) + }) +} + +export const generateNewNode = ( + type: NodesTypes | undefined, + template?: Partial< + (Omit & { + data: Partial + }) & + (Omit & { data: Partial }) + > +) => { + const id = type + "_" + v4() + switch (type) { + case "default_node": + return { + id, + type, + position: template?.position ?? { x: 0, y: 0 }, + data: { + id, + name: template?.data?.name ?? "New node", + response: template?.data?.response ?? { + id: "response_" + v4(), + name: "response", + type: "text", + data: [{ text: "New node response", priority: 1 }], + }, + flags: template?.data?.flags ?? [], + conditions: template?.data?.conditions ?? [], + global_conditions: template?.data?.global_conditions ?? [], + local_conditions: template?.data?.local_conditions ?? [], + }, + } + case "link_node": + return { + id, + type, + position: template?.position ?? { x: 0, y: 0 }, + data: { + id, + name: template?.data?.name ?? "Link", + transition: template?.data?.transition ?? { + target_flow: template?.data?.transition?.target_flow ?? "", + target_node: template?.data?.transition?.target_flow ?? "", + }, + }, + } + } + return { + id, + type, + position: template?.position ?? { x: 0, y: 0 }, + data: { + id, + name: template?.data?.name ?? "New node", + response: template?.data?.response ?? { + id: "response_" + v4(), + name: "response", + type: "text", + data: [{ text: "New node response", priority: 1 }], + }, + flags: template?.data?.flags ?? [], + conditions: template?.data?.conditions ?? [], + global_conditions: template?.data?.global_conditions ?? [], + local_conditions: template?.data?.local_conditions ?? [], + }, + } } + From 877ca342c006c5eb1af49c0b6c05dd00d7bbc59a Mon Sep 17 00:00:00 2001 From: Maks <90211175+MXerFix@users.noreply.github.com> Date: Mon, 2 Sep 2024 12:54:45 +0300 Subject: [PATCH 06/10] fix: Resolve multiple issues and update project configuration (#82) * fix: zoom optimization fix * fix: validate node trigger update fix * fix: node data update fix * fix: link error state fix * fix: fallback background fix * chore: consts update * chore: add functions documentation to current components * chore: lint warnings done * fix: typescript no build fix * chore: Update `bun.lockb` --- frontend/bun.lockb | Bin 457390 -> 457390 bytes frontend/cypress.config.ts | 2 +- frontend/src/components/ModalComponent.tsx | 3 +- frontend/src/components/nodes/DefaultNode.tsx | 3 +- frontend/src/components/nodes/LinkNode.tsx | 32 ++++--- .../notifications/NotificationsWindow.tsx | 14 +-- frontend/src/consts.tsx | 1 + frontend/src/contexts/flowContext.tsx | 50 +++++++++- .../src/contexts/notificationsContext.tsx | 28 +++++- frontend/src/contexts/runContext.tsx | 2 +- frontend/src/contexts/undoRedoContext.tsx | 27 ++++-- frontend/src/contexts/workspaceContext.tsx | 5 + frontend/src/icons/PlusFlagIcon.tsx | 7 +- .../nodes/conditions/CustomConditionIcon.tsx | 2 +- .../modals/ConditionModal/ConditionModal.tsx | 2 +- frontend/src/modals/NodeModal/NodeModal.tsx | 18 +--- .../modals/ResponseModal/ResponseModal.tsx | 3 +- frontend/src/pages/Fallback.tsx | 2 +- frontend/src/pages/Flow.tsx | 87 +++++++++++++----- frontend/src/types/NodeTypes.ts | 14 +-- frontend/src/utils.ts | 2 +- 21 files changed, 204 insertions(+), 100 deletions(-) diff --git a/frontend/bun.lockb b/frontend/bun.lockb index 78d333fe07c163424e343384dfdc4c167a9ccf66..5dda435e2fb2e60cb0c0b8457f70ffeb570cba52 100755 GIT binary patch delta 40 ucmZ4YReIf5>4p}@7N!>FEiBCu9E@>>MtVki2JPJuEI`b4p}@7N!>FEiBCu983%l(B2)v0>rG_yCc|?7XbhmA`ET- diff --git a/frontend/cypress.config.ts b/frontend/cypress.config.ts index 17161e32..e1c55815 100644 --- a/frontend/cypress.config.ts +++ b/frontend/cypress.config.ts @@ -2,7 +2,7 @@ import { defineConfig } from "cypress"; export default defineConfig({ e2e: { - setupNodeEvents(on, config) { + setupNodeEvents() { // implement node event listeners here }, }, diff --git a/frontend/src/components/ModalComponent.tsx b/frontend/src/components/ModalComponent.tsx index a1a5d9e1..33fa5386 100644 --- a/frontend/src/components/ModalComponent.tsx +++ b/frontend/src/components/ModalComponent.tsx @@ -3,7 +3,7 @@ import { useContext, useEffect, useState } from "react" import { workspaceContext } from "../contexts/workspaceContext" const ModalComponent = ({ ...props }: ModalProps) => { - const { setModalsOpened, modalsOpened } = useContext(workspaceContext) + const { setModalsOpened } = useContext(workspaceContext) const [isModalOpen, setIsModalOpen] = useState(false) @@ -24,6 +24,7 @@ const ModalComponent = ({ ...props }: ModalProps) => { setModalsOpened((prev) => prev + 1) } // Пустой массив зависимостей гарантирует, что этот эффект будет выполнен только один раз, после того как компонент был смонтирован + // eslint-disable-next-line react-hooks/exhaustive-deps }, []) return {props.children} diff --git a/frontend/src/components/nodes/DefaultNode.tsx b/frontend/src/components/nodes/DefaultNode.tsx index 5de21360..fc225ff7 100644 --- a/frontend/src/components/nodes/DefaultNode.tsx +++ b/frontend/src/components/nodes/DefaultNode.tsx @@ -25,6 +25,7 @@ const DefaultNode = memo(({ data }: { data: DefaultNodeDataType }) => { isOpen: isConditionOpen, } = useDisclosure() + // eslint-disable-next-line @typescript-eslint/no-unused-vars const { selectedNode } = useContext(workspaceContext) const [nodeDataState, setNodeDataState] = useState(data) @@ -36,7 +37,7 @@ const DefaultNode = memo(({ data }: { data: DefaultNodeDataType }) => { isOpen: isResponseOpen, } = useDisclosure() - const validate_node = useMemo(() => data.response?.data.length && data.conditions?.length, []) + const validate_node = useMemo(() => data.response?.data.length && data.conditions?.length, [data.conditions?.length, data.response?.data.length]) return ( <> diff --git a/frontend/src/components/nodes/LinkNode.tsx b/frontend/src/components/nodes/LinkNode.tsx index 3491faac..b17ebfc1 100755 --- a/frontend/src/components/nodes/LinkNode.tsx +++ b/frontend/src/components/nodes/LinkNode.tsx @@ -19,7 +19,7 @@ import { Tooltip, useDisclosure, } from "@nextui-org/react" -import { Handle, Position } from "@xyflow/react" +import { Edge, Handle, Position, useReactFlow } from "@xyflow/react" import "@xyflow/react/dist/style.css" import classNames from "classnames" import { AlertTriangle, Link2, Trash2 } from "lucide-react" @@ -31,12 +31,14 @@ import { FlowType } from "../../types/FlowTypes" import { AppNode, LinkNodeDataType } from "../../types/NodeTypes" const LinkNode = memo(({ data }: { data: LinkNodeDataType }) => { + const { updateNodeData } = useReactFlow() const { onOpen, onClose, isOpen } = useDisclosure() const { flows, deleteNode } = useContext(flowContext) const [toFlow, setToFlow] = useState() const [toNode, setToNode] = useState() const [error, setError] = useState(false) - const [r, setR] = useState(0) + const [isConfigured, setIsConfigured] = useState(data.transition.is_configured ?? false) + // const [r, setR] = useState(0) const { notification: n } = useContext(NotificationsContext) // const { openPopUp } = useContext(PopUpContext) @@ -56,6 +58,7 @@ const LinkNode = memo(({ data }: { data: LinkNodeDataType }) => { if (!data.transition.target_node) { onOpen() } + // eslint-disable-next-line react-hooks/exhaustive-deps }, [data.transition.target_node]) const handleFlowSelectionChange = (e: React.ChangeEvent) => { @@ -76,10 +79,7 @@ const LinkNode = memo(({ data }: { data: LinkNodeDataType }) => { ) useEffect(() => { - if (r === 0) { - return setR((r) => r + 1) - } - if (!TO_FLOW || !TO_NODE) { + if ((!TO_FLOW || !TO_NODE) && isConfigured) { setError(true) n.add({ message: `Link ${data.id} is broken! Please configure it again.`, @@ -89,6 +89,7 @@ const LinkNode = memo(({ data }: { data: LinkNodeDataType }) => { } else { setError(false) } + // eslint-disable-next-line react-hooks/exhaustive-deps }, [TO_FLOW, TO_NODE]) const onDismiss = () => { @@ -104,8 +105,15 @@ const LinkNode = memo(({ data }: { data: LinkNodeDataType }) => { const onSave = () => { if (toFlow && toNode) { - data.transition.target_flow = toFlow.name - data.transition.target_node = toNode.data.id + updateNodeData(data.id, { + ...data, + transition: { + target_flow: toFlow.name, + target_node: toNode.data.id, + is_configured: true + } + }) + setIsConfigured(true) onClose() } } @@ -114,7 +122,7 @@ const LinkNode = memo(({ data }: { data: LinkNodeDataType }) => { <>
+ className={classNames(`default_node px-6 py-4`, error && "border-error",)}>
{ ID: {data.id} - {(!toFlow || !toNode) && ( + {((!toFlow || !toNode) && isConfigured) && ( @@ -155,7 +163,7 @@ const LinkNode = memo(({ data }: { data: LinkNodeDataType }) => {
- {TO_FLOW ? TO_FLOW.name : "ERROR"} / {TO_NODE ? TO_NODE.data.name : "ERROR"} + {TO_FLOW ? TO_FLOW.name : isConfigured ? "ERROR" : ""} / {TO_NODE ? TO_NODE.data.name : isConfigured ? "ERROR" : ""}
@@ -176,7 +184,7 @@ const LinkNode = memo(({ data }: { data: LinkNodeDataType }) => { className='text-white' color='warning' radius='sm' - content='Link options is required to add a link'> + content='Link options is required for creating a link'> > } -// const getNotificationsStack = (notifications: notificationType[], index: number) => { -// const notification_instance: notificationType = notifications[index] -// let counter = 1 -// while ( -// notifications[index + counter]?.title === notification_instance?.title && -// notifications[index + counter]?.message === notification_instance?.message && -// notifications[index + counter]?.type === notification_instance?.type -// ) { -// counter += 1 -// } -// return counter -// } -export const NotificationsWindow = ({ isOpen, setIsOpen }: NotificationsWindowProps) => { +export const NotificationsWindow = ({ setIsOpen }: NotificationsWindowProps) => { const { notifications, notification } = useContext(NotificationsContext) const [notificationFilter, setNotificationFilter] = useState([]) diff --git a/frontend/src/consts.tsx b/frontend/src/consts.tsx index 6b9f5048..23174d3d 100644 --- a/frontend/src/consts.tsx +++ b/frontend/src/consts.tsx @@ -161,6 +161,7 @@ export const FLOW_COLORS = [ "#FF9500", "#FFCC00", "#00CC99", + "#00AAAA", "#3300FF", "#7000FF", "#CC66CC", diff --git a/frontend/src/contexts/flowContext.tsx b/frontend/src/contexts/flowContext.tsx index cb848f7a..ab18447f 100644 --- a/frontend/src/contexts/flowContext.tsx +++ b/frontend/src/contexts/flowContext.tsx @@ -14,7 +14,7 @@ import { NotificationsContext } from "./notificationsContext" const globalFlow: FlowType = { id: "GLOBAL", name: "Global", - description: "This is Global Flow", + description: "This is Global Flow. It will be able to use soon...", color: FLOW_COLORS[5], data: { nodes: [ @@ -111,10 +111,15 @@ export const FlowProvider = ({ children }: { children: React.ReactNode }) => { const { screenLoading } = useContext(MetaContext) useEffect(() => { + // set null reactFlowInstance before Init new flow setReactFlowInstance(null) setTab(flowId || "") }, [flowId]) + /** + * API flows get function + * @returns {FlowType[]} flows array + */ const getFlows = async () => { screenLoading.addScreenLoading() try { @@ -135,16 +140,24 @@ export const FlowProvider = ({ children }: { children: React.ReactNode }) => { } } + // initial get flows useEffect(() => { getFlows() // eslint-disable-next-line react-hooks/exhaustive-deps }, []) + /** + * + * @param {FlowType[]} flows flows to save array + */ const saveFlows = async (flows: FlowType[]) => { await save_flows(flows) setFlows(flows) } + /** + * quiet save flows function - saves flows automatically + */ const quietSaveFlows = async () => { setTimeout(async () => { console.log("quiet save flows") @@ -153,10 +166,18 @@ export const FlowProvider = ({ children }: { children: React.ReactNode }) => { }, 100) } + /** + * get updated flows function + */ const getLocaleFlows = useCallback(() => { return flows }, [flows]) + + /** + * Delete flow function + * @param {FlowType} flow delete this flow + */ const deleteFlow = useCallback( (flow: FlowType) => { const new_flows = flows.filter((f) => f.name !== flow.name) @@ -166,6 +187,10 @@ export const FlowProvider = ({ children }: { children: React.ReactNode }) => { [flows] ) + /** + * Update flow function + * @param {FlowType} flow updates this flow in local state + */ const updateFlow = useCallback( (flow: FlowType) => { const new_flows = flows.map((f) => (f.name === flow.name ? flow : f)) @@ -175,7 +200,14 @@ export const FlowProvider = ({ children }: { children: React.ReactNode }) => { [flows] ) - const validateDeletion = ({ nodes, edges }: { nodes: AppNode[]; edges: Edge[] }) => { + /** + * @async Validate object deletion function + * @param {AppNode[]} nodes nodes to check before deletion + * @param {Edge[]} edges edges to check before delete (unused) + * @returns {Promise} Promise(boolean is_deletion_valid value) + * ONLY FOR "OnBeforeDelete ReactFlow handler" + */ + const validateDeletion = ({ nodes }: { nodes: AppNode[]; edges: Edge[] }): Promise => { const is_nodes_valid = nodes.every((node) => { if (node.type === "default_node" && node?.data.flags?.includes("start")) { n.add({ title: "Warning!", message: "Can't delete start node", type: "warning" }) @@ -196,6 +228,11 @@ export const FlowProvider = ({ children }: { children: React.ReactNode }) => { }) } + /** + * Validate node deletion function + * @param {AppNode} node node to check before deletion + * @returns {boolean} boolean is_deletion_valid value + */ const validateNodeDeletion = (node: AppNode) => { if (node.type === "default_node" && node.data.flags.includes("start")) { n.add({ title: "Warning!", message: "Can't delete start node", type: "warning" }) @@ -212,6 +249,9 @@ export const FlowProvider = ({ children }: { children: React.ReactNode }) => { return true } + /** + * @deprecated + */ const deleteNode = useCallback( (id: string) => { const flow = flows.find((flow) => flow.data.nodes.some((node) => node.id === id)) @@ -241,6 +281,9 @@ export const FlowProvider = ({ children }: { children: React.ReactNode }) => { [flowId, flows, n] ) + /** + * @deprecated + */ const deleteEdge = useCallback( (id: string) => { const flow = flows.find((flow) => flow.data.edges.some((edge) => edge.id === id)) @@ -258,6 +301,9 @@ export const FlowProvider = ({ children }: { children: React.ReactNode }) => { [flowId, flows] ) + /** + * @deprecated + */ const deleteObject = useCallback( (id: string) => { const flow_node = flows.find((flow) => flow.data.nodes.some((node) => node.id === id)) diff --git a/frontend/src/contexts/notificationsContext.tsx b/frontend/src/contexts/notificationsContext.tsx index d2e3f259..b100856c 100644 --- a/frontend/src/contexts/notificationsContext.tsx +++ b/frontend/src/contexts/notificationsContext.tsx @@ -47,6 +47,11 @@ export const NotificationsContext = createContext({ const NotificationsProvider = ({ children }: { children: React.ReactNode }) => { const [notifications, setNotifications] = useLocalStorage("notifications", []) + /** + * This function returns notification toast classNames by notification type + * @param type notification type + * @returns notification toast classNames + */ const notificationTypeColor = (type: string) => { switch (type) { case "success": @@ -62,6 +67,11 @@ const NotificationsProvider = ({ children }: { children: React.ReactNode }) => { } } + /** + * This function returns notification header text color by notification type + * @param type notification type + * @returns notification header text color + */ const notificationHeaderColor = (type: string) => { switch (type) { case "success": @@ -76,6 +86,12 @@ const NotificationsProvider = ({ children }: { children: React.ReactNode }) => { return "text-neutral-500" } } + + /** + * This functions returns icon by notification type + * @param type notification type + * @returns icon by notification type + */ const notificationTypeIcon = (type: string) => { switch (type) { case "success": @@ -91,6 +107,11 @@ const NotificationsProvider = ({ children }: { children: React.ReactNode }) => { } } + /** + * Create new notification function + * @param {createNotificationType} notification_object message, title, type, duration, timestamp, stack of notifications + * Calls new notification + */ const addNotification = ({ message, title, @@ -134,8 +155,11 @@ const NotificationsProvider = ({ children }: { children: React.ReactNode }) => { ) } - const deleteNotification = (timestamp: number) => { - + /** + * Delete notification by timestamp (as id) function + * @param {number} timestamp as notification id + */ + const deleteNotification = (timestamp: number) => { setNotifications((prevNotifications) => prevNotifications.filter((notification) => notification.timestamp !== timestamp) ) diff --git a/frontend/src/contexts/runContext.tsx b/frontend/src/contexts/runContext.tsx index 96ccea4a..af20ab46 100644 --- a/frontend/src/contexts/runContext.tsx +++ b/frontend/src/contexts/runContext.tsx @@ -57,7 +57,7 @@ export const RunProvider = ({ children }: { children: React.ReactNode }) => { const [runPending, setRunPending] = useState(false) const [runStatus, setRunStatus] = useState("stopped") const [runs, setRuns] = useState([]) - const { setBuildsHandler, builds: context_builds } = useContext(buildContext) + const { setBuildsHandler } = useContext(buildContext) const { notification: n } = useContext(NotificationsContext) const setRunsHandler = (runs: runMinifyApiType[]) => { diff --git a/frontend/src/contexts/undoRedoContext.tsx b/frontend/src/contexts/undoRedoContext.tsx index f6c78c21..cd5b58d4 100644 --- a/frontend/src/contexts/undoRedoContext.tsx +++ b/frontend/src/contexts/undoRedoContext.tsx @@ -28,14 +28,6 @@ type UseUndoRedoOptions = { enableShortcuts: boolean } -// type UseUndoRedo = (options?: UseUndoRedoOptions) => { -// undo: () => void -// redo: () => void -// takeSnapshot: () => void -// canUndo: boolean -// canRedo: boolean -// } - type HistoryItem = { nodes: Node[] edges: Edge[] @@ -78,6 +70,9 @@ export function UndoRedoProvider({ children }: { children: React.ReactNode }) { const { setNodes, setEdges, getNodes, getEdges } = useReactFlow() + /** + * Take snapshot for undo/redo functions + */ const takeSnapshot = useCallback(() => { // push the current graph to the past state if (tabIndex > -1) { @@ -101,6 +96,9 @@ export function UndoRedoProvider({ children }: { children: React.ReactNode }) { // eslint-disable-next-line react-hooks/exhaustive-deps }, [getNodes, getEdges, past, future, flows, tab, setPast, setFuture, tabIndex]) + /** + * Undo function + */ const undo = useCallback(() => { // get the last state that we want to go back to const pastState = past[tabIndex][past[tabIndex].length - 1] @@ -126,6 +124,9 @@ export function UndoRedoProvider({ children }: { children: React.ReactNode }) { // eslint-disable-next-line react-hooks/exhaustive-deps }, [setNodes, setEdges, getNodes, getEdges, future, past, setFuture, setPast, tabIndex]) + /** + * Redo function + */ const redo = useCallback(() => { const futureState = future[tabIndex][future[tabIndex].length - 1] @@ -183,6 +184,10 @@ export function UndoRedoProvider({ children }: { children: React.ReactNode }) { } }, [modalsOpened]) + /** + * Copy function + * @param selection last selection to copy + */ const copy = (selection: OnSelectionChangeParams) => { if (selection && (selection.nodes.length || selection.edges.length)) { setCopiedSelection(cloneDeep(selection)) @@ -202,7 +207,11 @@ export function UndoRedoProvider({ children }: { children: React.ReactNode }) { } } - // eslint-disable-next-line @typescript-eslint/no-unused-vars + /** + * Paste function + * @param selectionInstance last selection of nodes&edges + * @param position position of pasting nodes&edges + */ const paste = ( selectionInstance: OnSelectionChangeParams, position: { x: number; y: number; paneX?: number; paneY?: number } diff --git a/frontend/src/contexts/workspaceContext.tsx b/frontend/src/contexts/workspaceContext.tsx index fd40bffa..c2e948e6 100644 --- a/frontend/src/contexts/workspaceContext.tsx +++ b/frontend/src/contexts/workspaceContext.tsx @@ -67,6 +67,10 @@ export const WorkspaceProvider = ({ children }: { children: React.ReactNode }) = const [modalsOpened, setModalsOpened] = useState(0) const { notification: n } = useContext(NotificationsContext) + + /** + * Count opened modals for correct shortcuts work + */ useEffect(() => { if (modalsOpened === 0) { setMouseOnPane(true) @@ -78,6 +82,7 @@ export const WorkspaceProvider = ({ children }: { children: React.ReactNode }) = } }, [modalsOpened]) + const toggleWorkspaceMode = useCallback(() => { setWorkspaceMode(() => !workspaceMode) n.add({ diff --git a/frontend/src/icons/PlusFlagIcon.tsx b/frontend/src/icons/PlusFlagIcon.tsx index bee5067f..127bdbdc 100644 --- a/frontend/src/icons/PlusFlagIcon.tsx +++ b/frontend/src/icons/PlusFlagIcon.tsx @@ -1,8 +1,13 @@ import React from "react" -export const PlusFlagIcon = ({ fill="#00CC99", stroke="#00CC99", className }: React.SVGAttributes) => { +export const PlusFlagIcon = ({ + fill = "#00CC99", + stroke = "#00CC99", + className, +}: React.SVGAttributes) => { return ( ) => { +const CustomConditionIcon = ({ className, stroke="var(--foreground)" }: React.SVGAttributes) => { return ( () const { notification: n } = useContext(NotificationsContext) - const { updateFlow, flows, quietSaveFlows } = useContext(flowContext) + const { flows, quietSaveFlows } = useContext(flowContext) const { flowId } = useParams() const [currentCondition, setCurrentCondition] = useState( diff --git a/frontend/src/modals/NodeModal/NodeModal.tsx b/frontend/src/modals/NodeModal/NodeModal.tsx index 62bd52b7..d21cf9b6 100644 --- a/frontend/src/modals/NodeModal/NodeModal.tsx +++ b/frontend/src/modals/NodeModal/NodeModal.tsx @@ -36,7 +36,7 @@ const NodeModal = ({ nodeDataState, setNodeDataState, }: NodeModalProps) => { - const { getNodes, setNodes } = useReactFlow() + const { getNodes, setNodes, updateNodeData } = useReactFlow() const { quietSaveFlows, validateNodeDeletion } = useContext(flowContext) const { takeSnapshot } = useContext(undoRedoContext) @@ -49,7 +49,7 @@ const NodeModal = ({ (e: React.ChangeEvent) => { setNodeDataState({ ...nodeDataState, [e.target.name]: e.target.value }) }, - [nodeDataState] + [nodeDataState, setNodeDataState] ) const setTextResponseValue = (e: React.ChangeEvent) => { @@ -64,18 +64,8 @@ const NodeModal = ({ } const onNodeSave = () => { - const nodes = getNodes() - const node = nodes.find((node) => node.data.id === data.id) - const new_nodes = nodes.map((node) => { - if (node.data.id === data.id) { - return { ...node, data: { ...node.data, ...nodeDataState } } - } - return node - }) - if (node) { - takeSnapshot() - setNodes(() => new_nodes) - } + takeSnapshot() + updateNodeData(data.id, { ...nodeDataState }) quietSaveFlows() onClose() } diff --git a/frontend/src/modals/ResponseModal/ResponseModal.tsx b/frontend/src/modals/ResponseModal/ResponseModal.tsx index 7041dc06..d2ab7e99 100644 --- a/frontend/src/modals/ResponseModal/ResponseModal.tsx +++ b/frontend/src/modals/ResponseModal/ResponseModal.tsx @@ -40,7 +40,7 @@ const ResponseModal = ({ size = "3xl", }: ResponseModalProps) => { const { getNode, setNodes, getNodes } = useReactFlow() - const { flows, quietSaveFlows, updateFlow } = useContext(flowContext) + const { flows, quietSaveFlows } = useContext(flowContext) const { flowId } = useParams() const [selected, setSelected] = useState(response.type ?? "python") // const [nodeDataState, setNodeDataState] = useState(data) @@ -89,7 +89,6 @@ const ResponseModal = ({ /> ), }), - // eslint-disable-next-line react-hooks/exhaustive-deps [currentResponse] ) diff --git a/frontend/src/pages/Fallback.tsx b/frontend/src/pages/Fallback.tsx index eb50bf0a..9ead175e 100644 --- a/frontend/src/pages/Fallback.tsx +++ b/frontend/src/pages/Fallback.tsx @@ -14,7 +14,7 @@ const Fallback = () => { return (
+ className='flex flex-col items-center justify-center w-screen h-screen bg-background'> flow.name === flowId) const [nodes, setNodes, onNodesChange] = useNodesState(flow?.data.nodes || []) @@ -81,16 +80,39 @@ export default function Flow() { const [selection, setSelection] = useState() const [selected, setSelected] = useState() - const isEdgeUpdateSuccess = useRef(false) const { notification: n } = useContext(NotificationsContext) - const handleUpdateFlowData = useCallback(() => { - if (reactFlowInstance && flow && flow.name === flowId) { - flow.data = reactFlowInstance.toObject() as ReactFlowJsonObject - updateFlow(flow) - } - }, [flow, flowId, reactFlowInstance, updateFlow]) + /** + * Function update flow data function translates reactFlowInstanceJSON to flow.data + * @param {AppNode[]} nodes optional changed nodes + * @param {Edge[]} edges optional changed edges + */ + const handleUpdateFlowData = useCallback( + (nodes?: AppNode[], edges?: Edge[]) => { + if (reactFlowInstance && flow && flow.name === flowId) { + flow.data = reactFlowInstance.toObject() as ReactFlowJsonObject + if (nodes) { + flow.data.nodes = flow.data.nodes.map((node) => { + const curr_node = nodes.find((nd) => nd.id === node.id) + return curr_node ?? node + }) + } + if (edges) { + flow.data.edges = flow.data.edges.map((edge) => { + const curr_edge = edges.find((ed) => ed.id === edge.id) + return curr_edge ?? edge + }) + } + updateFlow(flow) + } + }, + [flow, flowId, reactFlowInstance, updateFlow] + ) + /** + * Function update flow data function translates reactFlowInstanceJSON to flow.data + * With additional first node check + */ const handleFullUpdateFlowData = useCallback(() => { if (reactFlowInstance && flow && flow.name === flowId) { const _node = reactFlowInstance.getNodes()[0] @@ -101,6 +123,9 @@ export default function Flow() { } }, [flow, flowId, reactFlowInstance, updateFlow]) + /** + * update flow.data when edges or nodes.length changed (no check nodes array) + * */ useEffect(() => { handleUpdateFlowData() // eslint-disable-next-line react-hooks/exhaustive-deps @@ -118,6 +143,10 @@ export default function Flow() { } }, [flow, flowId, reactFlowInstance, setEdges, setNodes]) + /** + * Initiate new reactFlowInstance object + * @param {CustomReactFlowInstanceType} e new reactFlowInstance object value + */ const onInit = useCallback( (e: CustomReactFlowInstanceType) => { setReactFlowInstance(e) @@ -127,7 +156,21 @@ export default function Flow() { const onNodesChangeMod = useCallback( (nds: NodeChange[]) => { + console.log("nds change") if (nds) { + // only calls update flow data function when node change type = "replace" (no call when move) + if (nds.every((nd) => nd.type === "replace")) { + const update_nodes = nds + .filter((nd) => nd.type === "replace") + .map((nd) => { + if (nd.type === "replace") { + return nd.item + } + }) + if (update_nodes.every((nd) => nd !== undefined)) { + handleUpdateFlowData(update_nodes as AppNode[]) + } + } nds .sort((nd1: NodeChange, nd2: NodeChange) => { if (nd1.type === "select" && nd2.type === "select") { @@ -137,6 +180,7 @@ export default function Flow() { } }) .forEach((nd) => { + console.log(nd) if (nd.type === "select") { if (nd.selected) { setSelectedNode(nd.id) @@ -150,25 +194,22 @@ export default function Flow() { } onNodesChange(nds) }, - [onNodesChange, setSelectedNode] + [handleUpdateFlowData, onNodesChange, setSelectedNode] ) const onEdgeUpdateStart = useCallback(() => { takeSnapshot() - isEdgeUpdateSuccess.current = false }, [takeSnapshot]) const onEdgeUpdate = useCallback( (oldEdge: Edge, newConnection: Connection) => { setEdges((els) => reconnectEdge(oldEdge, newConnection, els)) - isEdgeUpdateSuccess.current = true }, - // eslint-disable-next-line react-hooks/exhaustive-deps [setEdges] ) const onNodeClick = useCallback( - (event: React.MouseEvent, node: AppNode) => { + (_event: React.MouseEvent, node: AppNode) => { const node_ = node as AppNode setSelected(node_.id) setSelectedNode(node_.id) @@ -177,7 +218,7 @@ export default function Flow() { ) const onEdgeClick = useCallback( - (event: React.MouseEvent, edge: Edge) => { + (_event: React.MouseEvent, edge: Edge) => { setSelected(edge.id) }, [setSelected] @@ -276,7 +317,10 @@ export default function Flow() { ) const [mousePos, setMousePos] = useState({ x: 0, y: 0 }) - + + /** + * Keyboard shortcuts handlers + */ useEffect(() => { const kbdHandler = (e: KeyboardEvent) => { if ((e.ctrlKey || e.metaKey) && e.key === "c" && !disableCopyPaste) { @@ -319,6 +363,7 @@ export default function Flow() { }, [ copiedSelection, copy, + disableCopyPaste, flow, flowId, flows, @@ -372,7 +417,6 @@ export default function Flow() { edgeTypes={edgeTypes} nodes={nodes} edges={edges} - onMoveStart={handleFullUpdateFlowData} onMoveEnd={handleFullUpdateFlowData} panOnScroll={true} panOnScrollSpeed={1.5} @@ -385,15 +429,10 @@ export default function Flow() { onEdgesChange={onEdgesChange} onReconnect={onEdgeUpdate} onReconnectStart={onEdgeUpdateStart} - // onReconnectEnd={onEdgeUpdateEnd} onNodeDragStart={() => takeSnapshot()} onNodeDragStop={handleFullUpdateFlowData} onConnect={onConnect} - // onNodesDelete={onNodesDelete} - // onEdgesDelete={onEdgesDelete} onBeforeDelete={validateDeletion} - // onPaneMouseEnter={() => setDisableCopyPaste(false)} - // onPaneMouseLeave={() => setDisableCopyPaste(true)} edgesReconnectable={!managerMode} nodesConnectable={!managerMode} nodesDraggable={!managerMode} diff --git a/frontend/src/types/NodeTypes.ts b/frontend/src/types/NodeTypes.ts index 31c102dd..00daa880 100644 --- a/frontend/src/types/NodeTypes.ts +++ b/frontend/src/types/NodeTypes.ts @@ -26,6 +26,7 @@ export type LinkNodeDataType = { transition: { target_flow: string target_node: string + is_configured?: boolean | undefined } } @@ -34,19 +35,6 @@ export type PartialLinkNodeDataType = Partial export type AppNodeDataType = DefaultNodeDataType | LinkNodeDataType -export type NodeDataType = { - id: string - name: string - flags?: string[] - conditions?: conditionType[] - global_conditions?: string[] - local_conditions?: string[] - transition: { - target_flow: string - target_node: string - } - response?: responseType -} export type NodeComponentType = { data: DefaultNodeDataType diff --git a/frontend/src/utils.ts b/frontend/src/utils.ts index bbafbfec..ca22bcc3 100644 --- a/frontend/src/utils.ts +++ b/frontend/src/utils.ts @@ -65,7 +65,7 @@ export const isNodeDeletionValid = (nodes: AppNode[], id: string) => { } export function delay(ms: number) { - return new Promise((resolve, reject) => { + return new Promise((resolve) => { setTimeout(resolve, ms) }) } From c573bd48c271b23eea2fa638502ecb38d0903eab Mon Sep 17 00:00:00 2001 From: Rami <54779216+Ramimashkouk@users.noreply.github.com> Date: Mon, 2 Sep 2024 19:06:25 +0300 Subject: [PATCH 07/10] chore: Update readme and link modal (#83) * chore: Provide make cmd to init_with_cc * update readme.md * chore: add name input to the link modal * chore: some link refactoring and functions documentation * fix: run status button color mini-fix --------- Co-authored-by: MXerFix --- Makefile | 5 +++ README.md | 13 ++++-- frontend/src/components/header/BuildMenu.tsx | 3 +- frontend/src/components/nodes/LinkNode.tsx | 46 +++++++++++++++----- 4 files changed, 51 insertions(+), 16 deletions(-) diff --git a/Makefile b/Makefile index b474d072..bff4b922 100644 --- a/Makefile +++ b/Makefile @@ -151,6 +151,11 @@ init_proj: install_backend_env ## Initiates a new project using chatsky-ui cd ${BACKEND_DIR} && poetry run chatsky.ui init --destination ../ +.PHONY: init_with_cc +init_with_cc: ## Initiates a new project using cookiecutter + cookiecutter https://github.com/Ramimashkouk/df_d_template.git + + .PHONY: build_docs build_docs: install_backend_env ## Builds the docs cd ${BACKEND_DIR} && \ diff --git a/README.md b/README.md index 764f1309..fce37cbc 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # Quick Start ## System Requirements -Ensure you have Python version 3.8 or higher installed. +Ensure you have Python version 3.8.1 or higher installed. ## Installation To install the necessary package, run the following command: @@ -13,7 +13,7 @@ You may add a `.env` file in the root directory and configure any of following e ```.env HOST=0.0.0.0 PORT=8000 -CONF_RELOAD=True +CONF_RELOAD=False LOG_LEVEL=info GRACEFUL_TERMINATION_TIMEOUT=2 # Waiting for process to stop @@ -25,6 +25,8 @@ RUN_RUNNING_TIMEOUT=5 ``` ## Project Initiation +💡 You are encouraged to run `chatsky.ui --help` to explore the available CLI options. + Initialize your project by running: ```bash chatsky.ui init @@ -32,7 +34,10 @@ chatsky.ui init The `chatsky.ui init` command will start an interactive `cookiecutter` process to create a project based on a predefined template. The resulting project will be a simple example template that you can customize to suit your needs. ## Running Your Project -To run your project, use the following command: +To start your project, use the following command: ```bash -chatsky.ui run_app --project-dir # enter the slug you choose for your project with the help of the previous command +chatsky.ui run_app --project-dir # Replace with the slug you specified during initialization ``` + +## Documentation +You can refer to the [documentaion](https://deeppavlov.github.io/chatsky-ui/) to dig into the application code understanding. diff --git a/frontend/src/components/header/BuildMenu.tsx b/frontend/src/components/header/BuildMenu.tsx index 67452b26..0793fa89 100644 --- a/frontend/src/components/header/BuildMenu.tsx +++ b/frontend/src/components/header/BuildMenu.tsx @@ -13,7 +13,7 @@ import { parseSearchParams } from "../../utils" const BuildMenu = () => { const { buildStart, buildPending } = useContext(buildContext) const { chat, setChat } = useContext(chatContext) - const { runStart, runPending, runStatus, runStop, run } = useContext(runContext) + const { runStart, runPending, runStatus, runStop, run, setRunStatus } = useContext(runContext) const [searchParams, setSearchParams] = useSearchParams() return ( @@ -38,6 +38,7 @@ const BuildMenu = () => { style={{}} onClick={async () => { if (runStatus !== "alive") { + setRunStatus(() => 'running') await buildStart({ wait_time: 1, end_status: "success" }) await runStart({ end_status: "success", wait_time: 0 }) } else if (runStatus === "alive" && run) { diff --git a/frontend/src/components/nodes/LinkNode.tsx b/frontend/src/components/nodes/LinkNode.tsx index b17ebfc1..19b88878 100755 --- a/frontend/src/components/nodes/LinkNode.tsx +++ b/frontend/src/components/nodes/LinkNode.tsx @@ -1,5 +1,6 @@ import { Button, + Input, Modal, ModalBody, ModalContent, @@ -34,16 +35,18 @@ const LinkNode = memo(({ data }: { data: LinkNodeDataType }) => { const { updateNodeData } = useReactFlow() const { onOpen, onClose, isOpen } = useDisclosure() const { flows, deleteNode } = useContext(flowContext) + const [name, setName] = useState(data.name ?? "") const [toFlow, setToFlow] = useState() const [toNode, setToNode] = useState() const [error, setError] = useState(false) const [isConfigured, setIsConfigured] = useState(data.transition.is_configured ?? false) - // const [r, setR] = useState(0) const { notification: n } = useContext(NotificationsContext) - // const { openPopUp } = useContext(PopUpContext) + /** + * This useEffect checks if link configured + */ useEffect(() => { - if (data.transition.target_node) { + if (data.transition.is_configured) { const to_flow = flows.find((flow) => flow.data.nodes.some((node) => node.data.id === data.transition.target_node) ) @@ -55,10 +58,10 @@ const LinkNode = memo(({ data }: { data: LinkNodeDataType }) => { setToNode(to_node) } } - if (!data.transition.target_node) { + if (!data.transition.is_configured) { onOpen() } - // eslint-disable-next-line react-hooks/exhaustive-deps + // eslint-disable-next-line react-hooks/exhaustive-deps }, [data.transition.target_node]) const handleFlowSelectionChange = (e: React.ChangeEvent) => { @@ -78,6 +81,9 @@ const LinkNode = memo(({ data }: { data: LinkNodeDataType }) => { [TO_FLOW?.data.nodes, data.transition.target_node] ) + /** + * This useEffect checks the TO_FLOW and TO_NODE values is correct, and calls error if not + */ useEffect(() => { if ((!TO_FLOW || !TO_NODE) && isConfigured) { setError(true) @@ -89,9 +95,12 @@ const LinkNode = memo(({ data }: { data: LinkNodeDataType }) => { } else { setError(false) } - // eslint-disable-next-line react-hooks/exhaustive-deps + // eslint-disable-next-line react-hooks/exhaustive-deps }, [TO_FLOW, TO_NODE]) + /** + * This function will delete current link if TO_FLOW and TO_NODE values wasn't defined + */ const onDismiss = () => { setToFlow(TO_FLOW) setToNode(TO_NODE) @@ -103,15 +112,19 @@ const LinkNode = memo(({ data }: { data: LinkNodeDataType }) => { } } + /** + * Link data save function + */ const onSave = () => { if (toFlow && toNode) { updateNodeData(data.id, { ...data, + name: name, transition: { target_flow: toFlow.name, target_node: toNode.data.id, - is_configured: true - } + is_configured: true, + }, }) setIsConfigured(true) onClose() @@ -122,7 +135,7 @@ const LinkNode = memo(({ data }: { data: LinkNodeDataType }) => { <>
+ className={classNames(`default_node px-6 py-4`, error && "border-error")}>
{ ID: {data.id} - {((!toFlow || !toNode) && isConfigured) && ( + {(!toFlow || !toNode) && isConfigured && ( @@ -163,7 +176,8 @@ const LinkNode = memo(({ data }: { data: LinkNodeDataType }) => {
- {TO_FLOW ? TO_FLOW.name : isConfigured ? "ERROR" : ""} / {TO_NODE ? TO_NODE.data.name : isConfigured ? "ERROR" : ""} + {TO_FLOW ? TO_FLOW.name : isConfigured ? "ERROR" : ""} /{" "} + {TO_NODE ? TO_NODE.data.name : isConfigured ? "ERROR" : ""}
@@ -195,6 +209,16 @@ const LinkNode = memo(({ data }: { data: LinkNodeDataType }) => {
+
+ +
From 2a90cd0dc535038e89bde92796342614a3b907a2 Mon Sep 17 00:00:00 2001 From: Ramimashkouk Date: Tue, 3 Sep 2024 00:41:54 +0800 Subject: [PATCH 08/10] ci: Fix docs workflow --- .github/workflows/build_and_publish_docs.yml | 9 +-------- 1 file changed, 1 insertion(+), 8 deletions(-) diff --git a/.github/workflows/build_and_publish_docs.yml b/.github/workflows/build_and_publish_docs.yml index 8c76d3b7..f27e03ab 100644 --- a/.github/workflows/build_and_publish_docs.yml +++ b/.github/workflows/build_and_publish_docs.yml @@ -22,15 +22,8 @@ jobs: with: python-version: 3.9 - - name: setup poetry - run: | - python -m pip install --upgrade pip poetry - - - name: install dependencies - run: poetry install - - name: build documentation - run: poetry run make -C docs html + run: make build_docs - name: remove jekyll theming run: touch docs/_build/html/.nojekyll From 7c7b7fcb5dbe0a6e545280c39f585f4633117994 Mon Sep 17 00:00:00 2001 From: Rami <54779216+Ramimashkouk@users.noreply.github.com> Date: Mon, 2 Sep 2024 20:08:22 +0300 Subject: [PATCH 09/10] fix: Bring doc workflow to work (#85) * ci: Install poetry before building docs * chore: Delete the push branch --- .github/workflows/build_and_publish_docs.yml | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/.github/workflows/build_and_publish_docs.yml b/.github/workflows/build_and_publish_docs.yml index f27e03ab..0598eca8 100644 --- a/.github/workflows/build_and_publish_docs.yml +++ b/.github/workflows/build_and_publish_docs.yml @@ -1,5 +1,4 @@ name: build_and_publish_docs - on: workflow_dispatch: @@ -22,6 +21,14 @@ jobs: with: python-version: 3.9 + - name: setup poetry + run: | + python -m pip install --upgrade pip poetry + + - name: install dependencies + run: poetry install + working-directory: backend + - name: build documentation run: make build_docs From 06d810b35fe7fde19fb3181822e7a03c38b1eb3a Mon Sep 17 00:00:00 2001 From: Rami <54779216+Ramimashkouk@users.noreply.github.com> Date: Tue, 3 Sep 2024 11:35:43 +0300 Subject: [PATCH 10/10] fix: Fix snippet linter (#87) --- Makefile | 2 +- backend/chatsky_ui/api/api_v1/endpoints/dff_services.py | 2 +- backend/chatsky_ui/services/index.py | 7 ++++--- 3 files changed, 6 insertions(+), 5 deletions(-) diff --git a/Makefile b/Makefile index bff4b922..05b58031 100644 --- a/Makefile +++ b/Makefile @@ -62,7 +62,7 @@ check_project_arg: .PHONY: run_backend run_backend: check_project_arg ## Runs backend using the built dist. NEEDS arg: PROJECT_NAME - @set -a && . $(CURDIR)/.env && \ + @if [ -f $(CURDIR)/.env ]; then set -a && . $(CURDIR)/.env; fi && \ cd ${PROJECT_NAME} && \ poetry add $(CURDIR)/${BACKEND_DIR}/dist/*.whl && \ poetry install && \ diff --git a/backend/chatsky_ui/api/api_v1/endpoints/dff_services.py b/backend/chatsky_ui/api/api_v1/endpoints/dff_services.py index eb67ded6..5d643ff1 100644 --- a/backend/chatsky_ui/api/api_v1/endpoints/dff_services.py +++ b/backend/chatsky_ui/api/api_v1/endpoints/dff_services.py @@ -35,7 +35,7 @@ async def lint_snippet(snippet: CodeSnippet) -> Dict[str, str]: """ code_snippet = snippet.code.replace(r"\n", "\n") - imports = get_imports_from_file(settings.snippet2lint_path.parent / "conditions.py") + imports = get_imports_from_file(settings.conditions_path) code_snippet = "\n\n".join([imports, code_snippet]) async with aiofiles.open(settings.snippet2lint_path, "wt", encoding="UTF-8") as file: diff --git a/backend/chatsky_ui/services/index.py b/backend/chatsky_ui/services/index.py index 683ae9b3..05427c41 100644 --- a/backend/chatsky_ui/services/index.py +++ b/backend/chatsky_ui/services/index.py @@ -13,6 +13,7 @@ from omegaconf import OmegaConf from omegaconf.dictconfig import DictConfig +from chatsky_ui.core.config import settings from chatsky_ui.core.logger_config import get_logger from chatsky_ui.db.base import read_conf, read_logs, write_conf @@ -53,7 +54,7 @@ async def _load_index(self) -> None: async def _load_conditions(self) -> None: """Load conditions from disk.""" - path = self.path.parent / "conditions.py" + path = settings.conditions_path if path.exists(): self.conditions = await read_logs(path) self.logger.debug("Conditions loaded") @@ -62,7 +63,7 @@ async def _load_conditions(self) -> None: async def _load_responses(self) -> None: """Load responses from disk.""" - path = self.path.parent / "responses.py" + path = settings.responses_path if path.exists(): self.responses = await read_logs(path) self.logger.debug("Responses loaded") @@ -71,7 +72,7 @@ async def _load_responses(self) -> None: async def _load_services(self) -> None: """Load services from disk.""" - path = self.path.parent / "services.py" + path = settings.responses_path.parent / "services.py" if path.exists(): self.services = await read_logs(path) self.logger.debug("Services loaded")