mirror of
https://github.com/dograh-hq/dograh.git
synced 2026-06-07 07:55:16 +02:00
feat: enable runtime transition of nodes
This commit is contained in:
parent
f1fdc41949
commit
dfee942f9a
22 changed files with 369 additions and 200 deletions
122
ui/package-lock.json
generated
122
ui/package-lock.json
generated
|
|
@ -26,7 +26,7 @@
|
|||
"@radix-ui/react-tooltip": "^1.2.8",
|
||||
"@sentry/nextjs": "^9.28.1",
|
||||
"@stackframe/stack": "^2.8.80",
|
||||
"@xyflow/react": "^12.9.2",
|
||||
"@xyflow/react": "^12.10.2",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"date-fns": "^4.1.0",
|
||||
|
|
@ -11227,12 +11227,12 @@
|
|||
"peer": true
|
||||
},
|
||||
"node_modules/@xyflow/react": {
|
||||
"version": "12.9.2",
|
||||
"resolved": "https://registry.npmjs.org/@xyflow/react/-/react-12.9.2.tgz",
|
||||
"integrity": "sha512-Xr+LFcysHCCoc5KRHaw+FwbqbWYxp9tWtk1mshNcqy25OAPuaKzXSdqIMNOA82TIXF/gFKo0Wgpa6PU7wUUVqw==",
|
||||
"version": "12.10.2",
|
||||
"resolved": "https://registry.npmjs.org/@xyflow/react/-/react-12.10.2.tgz",
|
||||
"integrity": "sha512-CgIi6HwlcHXwlkTpr0fxLv/0sRVNZ8IdwKLzzeCscaYBwpvfcH1QFOCeaTCuEn1FQEs/B8CjnTSjhs8udgmBgQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@xyflow/system": "0.0.72",
|
||||
"@xyflow/system": "0.0.76",
|
||||
"classcat": "^5.0.3",
|
||||
"zustand": "^4.4.0"
|
||||
},
|
||||
|
|
@ -11270,9 +11270,9 @@
|
|||
}
|
||||
},
|
||||
"node_modules/@xyflow/system": {
|
||||
"version": "0.0.72",
|
||||
"resolved": "https://registry.npmjs.org/@xyflow/system/-/system-0.0.72.tgz",
|
||||
"integrity": "sha512-WBI5Aau0fXTXwxHPzceLNS6QdXggSWnGjDtj/gG669crApN8+SCmEtkBth1m7r6pStNo/5fI9McEi7Dk0ymCLA==",
|
||||
"version": "0.0.76",
|
||||
"resolved": "https://registry.npmjs.org/@xyflow/system/-/system-0.0.76.tgz",
|
||||
"integrity": "sha512-hvwvnRS1B3REwVDlWexsq7YQaPZeG3/mKo1jv38UmnpWmxihp14bW6VtEOuHEwJX2FvzFw8k77LyKSk/wiZVNA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/d3-drag": "^3.0.7",
|
||||
|
|
@ -11661,12 +11661,6 @@
|
|||
"tslib": "^2.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/asynckit": {
|
||||
"version": "0.4.0",
|
||||
"resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz",
|
||||
"integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/atomic-sleep": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/atomic-sleep/-/atomic-sleep-1.0.0.tgz",
|
||||
|
|
@ -11702,17 +11696,6 @@
|
|||
"node": ">=4"
|
||||
}
|
||||
},
|
||||
"node_modules/axios": {
|
||||
"version": "1.13.5",
|
||||
"resolved": "https://registry.npmjs.org/axios/-/axios-1.13.5.tgz",
|
||||
"integrity": "sha512-cz4ur7Vb0xS4/KUN0tPWe44eqxrIu31me+fbang3ijiNscE129POzipJJA6zniq2C/Z6sJCjMimjS8Lc/GAs8Q==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"follow-redirects": "^1.15.11",
|
||||
"form-data": "^4.0.5",
|
||||
"proxy-from-env": "^1.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/axobject-query": {
|
||||
"version": "4.1.0",
|
||||
"resolved": "https://registry.npmjs.org/axobject-query/-/axobject-query-4.1.0.tgz",
|
||||
|
|
@ -11957,6 +11940,7 @@
|
|||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz",
|
||||
"integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"es-errors": "^1.3.0",
|
||||
|
|
@ -12243,18 +12227,6 @@
|
|||
"integrity": "sha512-IfEDxwoWIjkeXL1eXcDiow4UbKjhLdq6/EuSVR9GMN7KVH3r9gQ83e73hsz1Nd1T3ijd5xv1wcWRYO+D6kCI2w==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/combined-stream": {
|
||||
"version": "1.0.8",
|
||||
"resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz",
|
||||
"integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"delayed-stream": "~1.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/commander": {
|
||||
"version": "14.0.3",
|
||||
"resolved": "https://registry.npmjs.org/commander/-/commander-14.0.3.tgz",
|
||||
|
|
@ -12797,15 +12769,6 @@
|
|||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/delayed-stream": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz",
|
||||
"integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=0.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/destr": {
|
||||
"version": "2.0.5",
|
||||
"resolved": "https://registry.npmjs.org/destr/-/destr-2.0.5.tgz",
|
||||
|
|
@ -12875,6 +12838,7 @@
|
|||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
|
||||
"integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"call-bind-apply-helpers": "^1.0.1",
|
||||
|
|
@ -13022,6 +12986,7 @@
|
|||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz",
|
||||
"integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
|
|
@ -13031,6 +12996,7 @@
|
|||
"version": "1.3.0",
|
||||
"resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz",
|
||||
"integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
|
|
@ -13075,6 +13041,7 @@
|
|||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz",
|
||||
"integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"es-errors": "^1.3.0"
|
||||
|
|
@ -13087,6 +13054,7 @@
|
|||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz",
|
||||
"integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"es-errors": "^1.3.0",
|
||||
|
|
@ -13908,26 +13876,6 @@
|
|||
"dev": true,
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/follow-redirects": {
|
||||
"version": "1.15.11",
|
||||
"resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.11.tgz",
|
||||
"integrity": "sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "individual",
|
||||
"url": "https://github.com/sponsors/RubenVerborgh"
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=4.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"debug": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/for-each": {
|
||||
"version": "0.3.5",
|
||||
"resolved": "https://registry.npmjs.org/for-each/-/for-each-0.3.5.tgz",
|
||||
|
|
@ -13944,22 +13892,6 @@
|
|||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/form-data": {
|
||||
"version": "4.0.5",
|
||||
"resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz",
|
||||
"integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"asynckit": "^0.4.0",
|
||||
"combined-stream": "^1.0.8",
|
||||
"es-set-tostringtag": "^2.1.0",
|
||||
"hasown": "^2.0.2",
|
||||
"mime-types": "^2.1.12"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 6"
|
||||
}
|
||||
},
|
||||
"node_modules/forwarded-parse": {
|
||||
"version": "2.1.2",
|
||||
"resolved": "https://registry.npmjs.org/forwarded-parse/-/forwarded-parse-2.1.2.tgz",
|
||||
|
|
@ -14048,6 +13980,7 @@
|
|||
"version": "1.3.0",
|
||||
"resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz",
|
||||
"integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"call-bind-apply-helpers": "^1.0.2",
|
||||
|
|
@ -14081,6 +14014,7 @@
|
|||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz",
|
||||
"integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"dunder-proto": "^1.0.1",
|
||||
|
|
@ -14192,6 +14126,7 @@
|
|||
"version": "1.2.0",
|
||||
"resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz",
|
||||
"integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
|
|
@ -14268,6 +14203,7 @@
|
|||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz",
|
||||
"integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
|
|
@ -14280,6 +14216,7 @@
|
|||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz",
|
||||
"integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"has-symbols": "^1.0.3"
|
||||
|
|
@ -15084,12 +15021,6 @@
|
|||
"license": "MIT",
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/json-schema": {
|
||||
"version": "0.4.0",
|
||||
"resolved": "https://registry.npmjs.org/json-schema/-/json-schema-0.4.0.tgz",
|
||||
"integrity": "sha512-es94M3nTIfsEPisRafak+HDLfHXnKBhV3vU5eqPcS3flIWqcxJWgXHXiey3YrpaNsanY5ei1VoYEbOzijuq9BA==",
|
||||
"license": "(AFL-2.1 OR BSD-3-Clause)"
|
||||
},
|
||||
"node_modules/json-schema-traverse": {
|
||||
"version": "0.4.1",
|
||||
"resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz",
|
||||
|
|
@ -15499,6 +15430,7 @@
|
|||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
|
||||
"integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
|
|
@ -15547,6 +15479,7 @@
|
|||
"resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz",
|
||||
"integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
|
|
@ -15556,6 +15489,7 @@
|
|||
"resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz",
|
||||
"integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"mime-db": "1.52.0"
|
||||
},
|
||||
|
|
@ -18131,18 +18065,6 @@
|
|||
"node": ">= 0.8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/type-fest": {
|
||||
"version": "4.41.0",
|
||||
"resolved": "https://registry.npmjs.org/type-fest/-/type-fest-4.41.0.tgz",
|
||||
"integrity": "sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA==",
|
||||
"license": "(MIT OR CC0-1.0)",
|
||||
"engines": {
|
||||
"node": ">=16"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/typed-array-buffer": {
|
||||
"version": "1.0.3",
|
||||
"resolved": "https://registry.npmjs.org/typed-array-buffer/-/typed-array-buffer-1.0.3.tgz",
|
||||
|
|
|
|||
|
|
@ -30,7 +30,7 @@
|
|||
"@radix-ui/react-tooltip": "^1.2.8",
|
||||
"@sentry/nextjs": "^9.28.1",
|
||||
"@stackframe/stack": "^2.8.80",
|
||||
"@xyflow/react": "^12.9.2",
|
||||
"@xyflow/react": "^12.10.2",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"date-fns": "^4.1.0",
|
||||
|
|
|
|||
|
|
@ -17,6 +17,7 @@ import { FlowEdge, FlowNode, NodeType } from "@/components/flow/types";
|
|||
import { Button } from '@/components/ui/button';
|
||||
import { Sheet, SheetContent } from '@/components/ui/sheet';
|
||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
|
||||
import { useOnboarding } from '@/context/OnboardingContext';
|
||||
import { WorkflowConfigurations } from '@/types/workflow-configurations';
|
||||
|
||||
import AddNodePanel from "../../../components/flow/AddNodePanel";
|
||||
|
|
@ -24,6 +25,7 @@ import CustomEdge from "../../../components/flow/edges/CustomEdge";
|
|||
import { GenericNode } from "../../../components/flow/nodes/GenericNode";
|
||||
import { PhoneCallDialog } from './components/PhoneCallDialog';
|
||||
import { VersionHistoryPanel, WorkflowVersion } from './components/VersionHistoryPanel';
|
||||
import type { WorkflowRuntimeFocusMode, WorkflowRuntimeNodeTransition } from './components/workflow-tester/types';
|
||||
import { WorkflowEditorHeader } from "./components/WorkflowEditorHeader";
|
||||
import { WorkflowTesterPanel } from './components/WorkflowTesterPanel';
|
||||
import { WorkflowProvider } from "./contexts/WorkflowContext";
|
||||
|
|
@ -40,6 +42,8 @@ interface RenderWorkflowProps {
|
|||
initialWorkflowName: string;
|
||||
workflowId: number;
|
||||
workflowUuid?: string;
|
||||
initialTotalRuns?: number | null;
|
||||
openTesterOnLoad?: boolean;
|
||||
initialFlow?: {
|
||||
nodes: FlowNode[];
|
||||
edges: FlowEdge[];
|
||||
|
|
@ -56,18 +60,33 @@ interface RenderWorkflowProps {
|
|||
user: { id: string; email?: string };
|
||||
}
|
||||
|
||||
function RenderWorkflow({ initialWorkflowName, workflowId, workflowUuid, initialFlow, initialTemplateContextVariables, initialWorkflowConfigurations, initialVersionNumber, initialVersionStatus, user }: RenderWorkflowProps) {
|
||||
function RenderWorkflow({
|
||||
initialWorkflowName,
|
||||
workflowId,
|
||||
workflowUuid,
|
||||
initialTotalRuns,
|
||||
openTesterOnLoad = false,
|
||||
initialFlow,
|
||||
initialTemplateContextVariables,
|
||||
initialWorkflowConfigurations,
|
||||
initialVersionNumber,
|
||||
initialVersionStatus,
|
||||
user,
|
||||
}: RenderWorkflowProps) {
|
||||
const router = useRouter();
|
||||
const { specs } = useNodeSpecs();
|
||||
const { hasCompletedAction } = useOnboarding();
|
||||
const [isPhoneCallDialogOpen, setIsPhoneCallDialogOpen] = useState(false);
|
||||
const [isVersionPanelOpen, setIsVersionPanelOpen] = useState(false);
|
||||
const [isTesterRailOpen, setIsTesterRailOpen] = useState(true);
|
||||
const [isTesterSheetOpen, setIsTesterSheetOpen] = useState(false);
|
||||
const [isDesktopViewport, setIsDesktopViewport] = useState(false);
|
||||
const [versions, setVersions] = useState<WorkflowVersion[]>([]);
|
||||
const [versionsLoading, setVersionsLoading] = useState(false);
|
||||
const [versionsLoadingMore, setVersionsLoadingMore] = useState(false);
|
||||
const [versionsHasMore, setVersionsHasMore] = useState(false);
|
||||
const [activeVersionId, setActiveVersionId] = useState<number | null>(null);
|
||||
const hasAutoOpenedTester = useRef(false);
|
||||
// Version info that updates immediately from the GET/save/publish responses.
|
||||
const [currentVersionNumber, setCurrentVersionNumber] = useState<number | null>(initialVersionNumber ?? null);
|
||||
const [currentVersionStatus, setCurrentVersionStatus] = useState<string | null>(initialVersionStatus ?? null);
|
||||
|
|
@ -75,6 +94,9 @@ function RenderWorkflow({ initialWorkflowName, workflowId, workflowUuid, initial
|
|||
const [documents, setDocuments] = useState<DocumentResponseSchema[] | undefined>(undefined);
|
||||
const [tools, setTools] = useState<ToolResponse[] | undefined>(undefined);
|
||||
const [recordings, setRecordings] = useState<RecordingResponseSchema[]>([]);
|
||||
const [runtimeFocusMode, setRuntimeFocusMode] = useState<WorkflowRuntimeFocusMode>("follow");
|
||||
const [activeRuntimeNodeId, setActiveRuntimeNodeId] = useState<string | null>(null);
|
||||
const [runtimePulseNonce, setRuntimePulseNonce] = useState(0);
|
||||
|
||||
const {
|
||||
rfInstance,
|
||||
|
|
@ -208,6 +230,13 @@ function RenderWorkflow({ initialWorkflowName, workflowId, workflowUuid, initial
|
|||
return true;
|
||||
}, [activeVersionId, versions, hasDraft]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isViewingHistoricalVersion) {
|
||||
return;
|
||||
}
|
||||
setActiveRuntimeNodeId(null);
|
||||
}, [isViewingHistoricalVersion]);
|
||||
|
||||
// Return to the draft version, creating one from published if needed
|
||||
const handleBackToDraft = useCallback(async () => {
|
||||
const existingDraft = versions.find((v) => v.status === "draft");
|
||||
|
|
@ -283,6 +312,29 @@ function RenderWorkflow({ initialWorkflowName, workflowId, workflowUuid, initial
|
|||
setIsTesterSheetOpen(true);
|
||||
}, []);
|
||||
|
||||
const shouldShowWebCallOnboarding = useMemo(() => {
|
||||
return (initialTotalRuns ?? 0) === 0 && !hasCompletedAction('web_call_started');
|
||||
}, [hasCompletedAction, initialTotalRuns]);
|
||||
|
||||
useEffect(() => {
|
||||
const syncViewport = () => {
|
||||
setIsDesktopViewport(window.innerWidth >= 1280);
|
||||
};
|
||||
|
||||
syncViewport();
|
||||
window.addEventListener('resize', syncViewport);
|
||||
return () => window.removeEventListener('resize', syncViewport);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (hasAutoOpenedTester.current || !openTesterOnLoad || !shouldShowWebCallOnboarding || testerDisabledReason) {
|
||||
return;
|
||||
}
|
||||
|
||||
handleOpenTester();
|
||||
hasAutoOpenedTester.current = true;
|
||||
}, [handleOpenTester, openTesterOnLoad, shouldShowWebCallOnboarding, testerDisabledReason]);
|
||||
|
||||
// Fetch documents, tools, and recordings once for the entire workflow
|
||||
useEffect(() => {
|
||||
const fetchData = async () => {
|
||||
|
|
@ -326,6 +378,48 @@ function RenderWorkflow({ initialWorkflowName, workflowId, workflowUuid, initial
|
|||
type: "custom"
|
||||
}), []);
|
||||
|
||||
const displayNodes = useMemo(
|
||||
() =>
|
||||
nodes.map((node) =>
|
||||
node.id === activeRuntimeNodeId
|
||||
? {
|
||||
...node,
|
||||
data: {
|
||||
...node.data,
|
||||
runtime_active: true,
|
||||
runtime_pulse_nonce: runtimePulseNonce,
|
||||
},
|
||||
}
|
||||
: node,
|
||||
),
|
||||
[activeRuntimeNodeId, nodes, runtimePulseNonce],
|
||||
);
|
||||
|
||||
const handleRuntimeNodeTransition = useCallback(
|
||||
(transition: WorkflowRuntimeNodeTransition) => {
|
||||
const nodeId = transition.nodeId;
|
||||
const instance = rfInstance.current;
|
||||
if (!nodeId || !instance) {
|
||||
return;
|
||||
}
|
||||
|
||||
setActiveRuntimeNodeId(nodeId);
|
||||
setRuntimePulseNonce((value) => value + 1);
|
||||
|
||||
if (runtimeFocusMode !== "follow" || !instance.viewportInitialized) {
|
||||
return;
|
||||
}
|
||||
|
||||
void instance.fitView({
|
||||
nodes: [{ id: nodeId }],
|
||||
duration: 350,
|
||||
padding: 0.45,
|
||||
maxZoom: 0.9,
|
||||
});
|
||||
},
|
||||
[rfInstance, runtimeFocusMode],
|
||||
);
|
||||
|
||||
// Guard saveWorkflow so it's a no-op when viewing a historical version.
|
||||
// This is the single safety net that covers every save path: header button,
|
||||
// Cmd+S, node edit dialogs, stale doc/tool cleanup, etc.
|
||||
|
|
@ -418,7 +512,7 @@ function RenderWorkflow({ initialWorkflowName, workflowId, workflowUuid, initial
|
|||
<div className="relative min-w-0 flex-1">
|
||||
<ReactFlow
|
||||
key={activeVersionId ?? 'current'}
|
||||
nodes={nodes}
|
||||
nodes={displayNodes}
|
||||
edges={edges}
|
||||
onNodesChange={onNodesChange}
|
||||
onEdgesChange={onEdgesChange}
|
||||
|
|
@ -572,7 +666,12 @@ function RenderWorkflow({ initialWorkflowName, workflowId, workflowUuid, initial
|
|||
initialContextVariables={templateContextVariables}
|
||||
disabled={testerDisabledReason !== null}
|
||||
disabledReason={testerDisabledReason}
|
||||
showWebCallOnboarding={shouldShowWebCallOnboarding}
|
||||
isVisible={isDesktopViewport}
|
||||
onClose={() => setIsTesterRailOpen(false)}
|
||||
runtimeFocusMode={runtimeFocusMode}
|
||||
onRuntimeFocusModeChange={setRuntimeFocusMode}
|
||||
onRuntimeNodeTransition={handleRuntimeNodeTransition}
|
||||
/>
|
||||
</aside>
|
||||
)}
|
||||
|
|
@ -585,6 +684,11 @@ function RenderWorkflow({ initialWorkflowName, workflowId, workflowUuid, initial
|
|||
initialContextVariables={templateContextVariables}
|
||||
disabled={testerDisabledReason !== null}
|
||||
disabledReason={testerDisabledReason}
|
||||
showWebCallOnboarding={shouldShowWebCallOnboarding}
|
||||
isVisible={isTesterSheetOpen}
|
||||
runtimeFocusMode={runtimeFocusMode}
|
||||
onRuntimeFocusModeChange={setRuntimeFocusMode}
|
||||
onRuntimeNodeTransition={handleRuntimeNodeTransition}
|
||||
/>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
|
|
|
|||
|
|
@ -1,21 +1,26 @@
|
|||
"use client";
|
||||
|
||||
import { Loader2, MessageSquareText, Mic, Phone, RefreshCw, X } from "lucide-react";
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import posthog from "posthog-js";
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import { createWorkflowRunApiV1WorkflowWorkflowIdRunsPost } from "@/client/sdk.gen";
|
||||
import { OnboardingTooltip } from "@/components/onboarding/OnboardingTooltip";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { PostHogEvent } from "@/constants/posthog-events";
|
||||
import { WORKFLOW_RUN_MODES } from "@/constants/workflowRunModes";
|
||||
import { useOnboarding } from "@/context/OnboardingContext";
|
||||
import { useAuth } from "@/lib/auth";
|
||||
import { cn, getRandomId } from "@/lib/utils";
|
||||
|
||||
import { AiSimulatorPlaceholder } from "./workflow-tester/AiSimulatorPlaceholder";
|
||||
import { EmbeddedVoiceTester } from "./workflow-tester/EmbeddedVoiceTester";
|
||||
import { ManualTextChatPanel } from "./workflow-tester/ManualTextChatPanel";
|
||||
import { ChatModeToggle, DisabledNotice, EmptyState } from "./workflow-tester/shared";
|
||||
import { ChatModeToggle, DisabledNotice, EmptyState, RuntimeFocusToggle } from "./workflow-tester/shared";
|
||||
import type { WorkflowRuntimeFocusMode, WorkflowRuntimeNodeTransition } from "./workflow-tester/types";
|
||||
import { extractSdkErrorMessage, getErrorMessage } from "./workflow-tester/utils";
|
||||
|
||||
interface WorkflowTesterPanelProps {
|
||||
|
|
@ -23,8 +28,13 @@ interface WorkflowTesterPanelProps {
|
|||
initialContextVariables?: Record<string, string>;
|
||||
disabled: boolean;
|
||||
disabledReason: string | null;
|
||||
showWebCallOnboarding?: boolean;
|
||||
isVisible?: boolean;
|
||||
className?: string;
|
||||
onClose?: () => void;
|
||||
runtimeFocusMode: WorkflowRuntimeFocusMode;
|
||||
onRuntimeFocusModeChange: (mode: WorkflowRuntimeFocusMode) => void;
|
||||
onRuntimeNodeTransition?: (transition: WorkflowRuntimeNodeTransition) => void;
|
||||
}
|
||||
|
||||
export function WorkflowTesterPanel({
|
||||
|
|
@ -32,10 +42,16 @@ export function WorkflowTesterPanel({
|
|||
initialContextVariables,
|
||||
disabled,
|
||||
disabledReason,
|
||||
showWebCallOnboarding = false,
|
||||
isVisible = true,
|
||||
className,
|
||||
onClose,
|
||||
runtimeFocusMode,
|
||||
onRuntimeFocusModeChange,
|
||||
onRuntimeNodeTransition,
|
||||
}: WorkflowTesterPanelProps) {
|
||||
const auth = useAuth();
|
||||
const { hasSeenTooltip, markTooltipSeen, markActionCompleted } = useOnboarding();
|
||||
const { isAuthenticated, loading: authLoading, getAccessToken } = auth;
|
||||
const [accessToken, setAccessToken] = useState<string | null>(null);
|
||||
const [activeMode, setActiveMode] = useState<"audio" | "text">("audio");
|
||||
|
|
@ -45,6 +61,7 @@ export function WorkflowTesterPanel({
|
|||
const [voiceRunId, setVoiceRunId] = useState<number | null>(null);
|
||||
const [creatingVoiceRun, setCreatingVoiceRun] = useState(false);
|
||||
const [tokenReady, setTokenReady] = useState(false);
|
||||
const runTestButtonRef = useRef<HTMLButtonElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
let ignore = false;
|
||||
|
|
@ -99,6 +116,13 @@ export function WorkflowTesterPanel({
|
|||
throw new Error(extractSdkErrorMessage(response.error, "Failed to create browser test run"));
|
||||
}
|
||||
|
||||
markActionCompleted("web_call_started");
|
||||
markTooltipSeen("web_call");
|
||||
posthog.capture(PostHogEvent.WEB_CALL_INITIATED, {
|
||||
workflow_id: workflowId,
|
||||
workflow_run_id: response.data.id,
|
||||
source: "workflow_editor",
|
||||
});
|
||||
setVoiceRunId(response.data.id);
|
||||
setActiveMode("audio");
|
||||
} catch (error) {
|
||||
|
|
@ -106,13 +130,22 @@ export function WorkflowTesterPanel({
|
|||
} finally {
|
||||
setCreatingVoiceRun(false);
|
||||
}
|
||||
}, [accessToken, disabled, workflowId]);
|
||||
}, [accessToken, disabled, markActionCompleted, markTooltipSeen, workflowId]);
|
||||
|
||||
const authUnavailableReason = tokenReady && !accessToken
|
||||
? "Authentication is required before testing can start."
|
||||
: null;
|
||||
const effectiveDisabledReason = disabledReason ?? authUnavailableReason;
|
||||
const testerBlocked = disabled || authUnavailableReason !== null;
|
||||
const showRunTestTooltip =
|
||||
showWebCallOnboarding &&
|
||||
isVisible &&
|
||||
activeMode === "audio" &&
|
||||
!voiceRunId &&
|
||||
tokenReady &&
|
||||
!!accessToken &&
|
||||
!testerBlocked &&
|
||||
!hasSeenTooltip("web_call");
|
||||
|
||||
return (
|
||||
<div className={cn("flex h-full min-h-0 flex-col bg-background", className)}>
|
||||
|
|
@ -145,6 +178,13 @@ export function WorkflowTesterPanel({
|
|||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="mt-3 flex items-center justify-between gap-3">
|
||||
<p className="text-xs text-muted-foreground">Canvas sync</p>
|
||||
<RuntimeFocusToggle
|
||||
value={runtimeFocusMode}
|
||||
onChange={onRuntimeFocusModeChange}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<TabsContent value="audio" className="min-h-0 flex-1 px-4 py-4">
|
||||
|
|
@ -165,6 +205,7 @@ export function WorkflowTesterPanel({
|
|||
initialContextVariables={initialContextVariables}
|
||||
accessToken={accessToken}
|
||||
onReset={() => setVoiceRunId(null)}
|
||||
onNodeTransition={onRuntimeNodeTransition}
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
|
|
@ -174,7 +215,11 @@ export function WorkflowTesterPanel({
|
|||
title="Call this agent in the browser"
|
||||
description="Test the agent over a voice call. Some telephony-only tools, like call transfer, are not yet supported here."
|
||||
action={
|
||||
<Button onClick={createVoiceRun} disabled={creatingVoiceRun || testerBlocked}>
|
||||
<Button
|
||||
ref={runTestButtonRef}
|
||||
onClick={createVoiceRun}
|
||||
disabled={creatingVoiceRun || testerBlocked}
|
||||
>
|
||||
{creatingVoiceRun ? (
|
||||
<>
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
|
|
@ -221,6 +266,7 @@ export function WorkflowTesterPanel({
|
|||
disabled={testerBlocked}
|
||||
disabledReason={effectiveDisabledReason}
|
||||
onActiveChange={setChatActive}
|
||||
onNodeTransition={onRuntimeNodeTransition}
|
||||
/>
|
||||
) : (
|
||||
<AiSimulatorPlaceholder disabledReason={effectiveDisabledReason} />
|
||||
|
|
@ -228,6 +274,15 @@ export function WorkflowTesterPanel({
|
|||
</div>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
|
||||
<OnboardingTooltip
|
||||
targetRef={runTestButtonRef}
|
||||
title="Try Your First Web Call"
|
||||
message="Start a browser call here to hear the agent, inspect the transcript, and validate the workflow before you customize it further."
|
||||
onDismiss={() => markTooltipSeen("web_call")}
|
||||
showNext={false}
|
||||
isVisible={showRunTestTooltip}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ import { RealtimeFeedback } from "@/components/workflow/conversation";
|
|||
|
||||
import { ApiKeyErrorDialog, ConnectionStatus, WorkflowConfigErrorDialog } from "../../run/[runId]/components";
|
||||
import { useWebSocketRTC } from "../../run/[runId]/hooks";
|
||||
import type { WorkflowRuntimeNodeTransition } from "./types";
|
||||
|
||||
interface EmbeddedVoiceTesterProps {
|
||||
workflowId: number;
|
||||
|
|
@ -16,6 +17,7 @@ interface EmbeddedVoiceTesterProps {
|
|||
initialContextVariables?: Record<string, string>;
|
||||
accessToken: string;
|
||||
onReset: () => void;
|
||||
onNodeTransition?: (transition: WorkflowRuntimeNodeTransition) => void;
|
||||
}
|
||||
|
||||
export function EmbeddedVoiceTester({
|
||||
|
|
@ -24,6 +26,7 @@ export function EmbeddedVoiceTester({
|
|||
initialContextVariables,
|
||||
accessToken,
|
||||
onReset,
|
||||
onNodeTransition,
|
||||
}: EmbeddedVoiceTesterProps) {
|
||||
const router = useRouter();
|
||||
const {
|
||||
|
|
@ -48,6 +51,7 @@ export function EmbeddedVoiceTester({
|
|||
workflowRunId,
|
||||
accessToken,
|
||||
initialContextVariables,
|
||||
onNodeTransition,
|
||||
});
|
||||
const autoStartedRef = useRef(false);
|
||||
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ import { ConversationTimeline } from "@/components/workflow/conversation";
|
|||
import { ChatComposer } from "./ChatComposer";
|
||||
import { DisabledNotice, ManualChatEmptyState, TypingIndicator } from "./shared";
|
||||
import { TurnMessageActions } from "./TurnMessageActions";
|
||||
import type { WorkflowRuntimeNodeTransition } from "./types";
|
||||
import { useTextChatSession } from "./useTextChatSession";
|
||||
|
||||
interface ManualTextChatPanelProps {
|
||||
|
|
@ -16,6 +17,7 @@ interface ManualTextChatPanelProps {
|
|||
disabled: boolean;
|
||||
disabledReason: string | null;
|
||||
onActiveChange?: (active: boolean) => void;
|
||||
onNodeTransition?: (transition: WorkflowRuntimeNodeTransition) => void;
|
||||
}
|
||||
|
||||
export function ManualTextChatPanel({
|
||||
|
|
@ -25,6 +27,7 @@ export function ManualTextChatPanel({
|
|||
disabled,
|
||||
disabledReason,
|
||||
onActiveChange,
|
||||
onNodeTransition,
|
||||
}: ManualTextChatPanelProps) {
|
||||
const {
|
||||
session,
|
||||
|
|
@ -51,6 +54,7 @@ export function ManualTextChatPanel({
|
|||
initialContextVariables,
|
||||
disabled,
|
||||
onActiveChange,
|
||||
onNodeTransition,
|
||||
});
|
||||
|
||||
if (!started && !session) {
|
||||
|
|
@ -70,7 +74,7 @@ export function ManualTextChatPanel({
|
|||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="min-h-0 flex-1">
|
||||
<div className="flex min-h-0 flex-1 flex-col">
|
||||
{creatingSession && !session ? (
|
||||
<div className="space-y-3 py-1">
|
||||
<Skeleton className="ml-auto h-9 w-2/3 rounded-2xl" />
|
||||
|
|
|
|||
|
|
@ -81,6 +81,42 @@ export function ChatModeToggle({
|
|||
);
|
||||
}
|
||||
|
||||
export function RuntimeFocusToggle({
|
||||
value,
|
||||
onChange,
|
||||
}: {
|
||||
value: "pulse" | "follow";
|
||||
onChange: (next: "pulse" | "follow") => void;
|
||||
}) {
|
||||
const options: Array<{ id: "pulse" | "follow"; label: string }> = [
|
||||
{ id: "pulse", label: "Pulse" },
|
||||
{ id: "follow", label: "Follow" },
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="inline-flex items-center gap-0.5 rounded-md border border-border/70 bg-muted/40 p-0.5">
|
||||
{options.map((option) => {
|
||||
const active = option.id === value;
|
||||
return (
|
||||
<button
|
||||
key={option.id}
|
||||
type="button"
|
||||
onClick={() => onChange(option.id)}
|
||||
className={cn(
|
||||
"rounded-[5px] px-2.5 py-1 text-xs font-medium transition",
|
||||
active
|
||||
? "bg-background text-foreground shadow-xs"
|
||||
: "text-muted-foreground hover:text-foreground",
|
||||
)}
|
||||
>
|
||||
{option.label}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function TypingIndicator() {
|
||||
return (
|
||||
<div className="flex justify-start">
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import type { WorkflowRunTextSessionResponse } from "@/client/types.gen";
|
||||
import type { ConversationNodeTransitionItem } from "@/components/workflow/conversation";
|
||||
|
||||
export interface TextChatMessage {
|
||||
text: string;
|
||||
|
|
@ -46,6 +47,10 @@ export interface TurnActionState {
|
|||
type: "rewind" | "edit";
|
||||
}
|
||||
|
||||
export type WorkflowRuntimeFocusMode = "pulse" | "follow";
|
||||
|
||||
export type WorkflowRuntimeNodeTransition = ConversationNodeTransitionItem;
|
||||
|
||||
export const EMPTY_TEXT_CHAT_TURNS: TextChatTurn[] = [];
|
||||
|
||||
export function toTextChatSession(response: WorkflowRunTextSessionResponse): TextChatSession {
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import {
|
||||
|
|
@ -16,6 +16,7 @@ import {
|
|||
type TextChatTurn,
|
||||
toTextChatSession,
|
||||
type TurnActionState,
|
||||
type WorkflowRuntimeNodeTransition,
|
||||
} from "./types";
|
||||
import { extractSdkErrorMessage, getErrorMessage, getReplayCursorTurnId } from "./utils";
|
||||
|
||||
|
|
@ -25,6 +26,7 @@ interface UseTextChatSessionProps {
|
|||
initialContextVariables?: Record<string, string>;
|
||||
disabled: boolean;
|
||||
onActiveChange?: (active: boolean) => void;
|
||||
onNodeTransition?: (transition: WorkflowRuntimeNodeTransition) => void;
|
||||
}
|
||||
|
||||
export function useTextChatSession({
|
||||
|
|
@ -33,6 +35,7 @@ export function useTextChatSession({
|
|||
initialContextVariables,
|
||||
disabled,
|
||||
onActiveChange,
|
||||
onNodeTransition,
|
||||
}: UseTextChatSessionProps) {
|
||||
const [session, setSession] = useState<TextChatSession | null>(null);
|
||||
const [started, setStarted] = useState(false);
|
||||
|
|
@ -41,6 +44,7 @@ export function useTextChatSession({
|
|||
const [sendingMessage, setSendingMessage] = useState(false);
|
||||
const [editingTurnId, setEditingTurnId] = useState<string | null>(null);
|
||||
const [activeTurnAction, setActiveTurnAction] = useState<TurnActionState | null>(null);
|
||||
const lastNotifiedNodeTransitionIdRef = useRef<string | null>(null);
|
||||
|
||||
const turns = session?.session_data.turns ?? EMPTY_TEXT_CHAT_TURNS;
|
||||
const editingTurn = editingTurnId
|
||||
|
|
@ -91,6 +95,26 @@ export function useTextChatSession({
|
|||
onActiveChange?.(started);
|
||||
}, [onActiveChange, started]);
|
||||
|
||||
useEffect(() => {
|
||||
const latestNodeTransition = [...conversationItems]
|
||||
.reverse()
|
||||
.find(
|
||||
(item): item is WorkflowRuntimeNodeTransition =>
|
||||
item.kind === "node-transition" && !!item.nodeId,
|
||||
);
|
||||
|
||||
if (!latestNodeTransition?.nodeId) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (lastNotifiedNodeTransitionIdRef.current === latestNodeTransition.id) {
|
||||
return;
|
||||
}
|
||||
|
||||
lastNotifiedNodeTransitionIdRef.current = latestNodeTransition.id;
|
||||
onNodeTransition?.(latestNodeTransition);
|
||||
}, [conversationItems, onNodeTransition]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!editingTurnId) {
|
||||
return;
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
'use client';
|
||||
|
||||
import { useParams } from 'next/navigation';
|
||||
import { useParams, useSearchParams } from 'next/navigation';
|
||||
import posthog from 'posthog-js';
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
|
||||
|
|
@ -18,6 +18,7 @@ import WorkflowLayout from '../WorkflowLayout';
|
|||
|
||||
export default function WorkflowDetailPage() {
|
||||
const params = useParams();
|
||||
const searchParams = useSearchParams();
|
||||
const [workflow, setWorkflow] = useState<WorkflowResponse | undefined>(undefined);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
|
@ -59,6 +60,7 @@ export default function WorkflowDetailPage() {
|
|||
}, [params.workflowId, user]);
|
||||
|
||||
const stableUser = useMemo(() => user, [user]);
|
||||
const openTesterOnLoad = searchParams.get('onboarding') === 'web_call';
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
|
|
@ -82,6 +84,8 @@ export default function WorkflowDetailPage() {
|
|||
initialWorkflowName={workflow.name}
|
||||
workflowId={workflow.id}
|
||||
workflowUuid={workflow.workflow_uuid ?? undefined}
|
||||
initialTotalRuns={workflow.total_runs ?? 0}
|
||||
openTesterOnLoad={openTesterOnLoad}
|
||||
initialFlow={{
|
||||
nodes: workflow.workflow_definition.nodes as FlowNode[],
|
||||
edges: workflow.workflow_definition.edges as FlowEdge[],
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@ import { client } from "@/client/client.gen";
|
|||
import { getTurnCredentialsApiV1TurnCredentialsGet, validateUserConfigurationsApiV1UserConfigurationsUserValidateGet, validateWorkflowApiV1WorkflowWorkflowIdValidatePost } from "@/client/sdk.gen";
|
||||
import { TurnCredentialsResponse } from "@/client/types.gen";
|
||||
import { WorkflowValidationError } from "@/components/flow/types";
|
||||
import type { RealtimeFeedbackMessage as FeedbackMessage } from "@/components/workflow/conversation";
|
||||
import type { ConversationNodeTransitionItem, RealtimeFeedbackMessage as FeedbackMessage } from "@/components/workflow/conversation";
|
||||
import { useAppConfig } from "@/context/AppConfigContext";
|
||||
import logger from '@/lib/logger';
|
||||
|
||||
|
|
@ -16,9 +16,10 @@ interface UseWebSocketRTCProps {
|
|||
workflowRunId: number;
|
||||
accessToken: string | null;
|
||||
initialContextVariables?: Record<string, string> | null;
|
||||
onNodeTransition?: (transition: ConversationNodeTransitionItem) => void;
|
||||
}
|
||||
|
||||
export const useWebSocketRTC = ({ workflowId, workflowRunId, accessToken, initialContextVariables }: UseWebSocketRTCProps) => {
|
||||
export const useWebSocketRTC = ({ workflowId, workflowRunId, accessToken, initialContextVariables, onNodeTransition }: UseWebSocketRTCProps) => {
|
||||
const [connectionStatus, setConnectionStatus] = useState<'idle' | 'connecting' | 'connected' | 'failed'>('idle');
|
||||
const [connectionActive, setConnectionActive] = useState(false);
|
||||
const [isCompleted, setIsCompleted] = useState(false);
|
||||
|
|
@ -53,6 +54,11 @@ export const useWebSocketRTC = ({ workflowId, workflowRunId, accessToken, initia
|
|||
const pcRef = useRef<RTCPeerConnection | null>(null);
|
||||
const wsRef = useRef<WebSocket | null>(null);
|
||||
const timeStartRef = useRef<number | null>(null);
|
||||
const onNodeTransitionRef = useRef(onNodeTransition);
|
||||
|
||||
useEffect(() => {
|
||||
onNodeTransitionRef.current = onNodeTransition;
|
||||
}, [onNodeTransition]);
|
||||
|
||||
// Generate a cryptographically secure unique ID
|
||||
const generateSecureId = () => {
|
||||
|
|
@ -394,17 +400,37 @@ export const useWebSocketRTC = ({ workflowId, workflowRunId, accessToken, initia
|
|||
}
|
||||
|
||||
case 'rtf-node-transition': {
|
||||
const { node_name, previous_node_name, allow_interrupt } = message.payload;
|
||||
const {
|
||||
node_id,
|
||||
node_name,
|
||||
previous_node_id,
|
||||
previous_node_name,
|
||||
allow_interrupt,
|
||||
} = message.payload;
|
||||
currentAllowInterruptRef.current = allow_interrupt;
|
||||
setFeedbackMessages(prev => [...prev, {
|
||||
const transitionTimestamp = new Date().toISOString();
|
||||
const transition: ConversationNodeTransitionItem = {
|
||||
kind: 'node-transition',
|
||||
id: `node-${Date.now()}`,
|
||||
timestamp: transitionTimestamp,
|
||||
nodeId: node_id,
|
||||
nodeName: node_name ?? 'Node',
|
||||
previousNodeId: previous_node_id,
|
||||
previousNodeName: previous_node_name,
|
||||
allowInterrupt: allow_interrupt,
|
||||
};
|
||||
setFeedbackMessages(prev => [...prev, {
|
||||
id: transition.id,
|
||||
type: 'node-transition',
|
||||
text: node_name,
|
||||
nodeName: node_name,
|
||||
text: transition.nodeName,
|
||||
nodeId: transition.nodeId,
|
||||
nodeName: transition.nodeName,
|
||||
previousNodeId: transition.previousNodeId,
|
||||
previousNode: previous_node_name,
|
||||
allowInterrupt: allow_interrupt,
|
||||
timestamp: new Date().toISOString(),
|
||||
timestamp: transitionTimestamp,
|
||||
}]);
|
||||
onNodeTransitionRef.current?.(transition);
|
||||
break;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,17 +1,14 @@
|
|||
'use client';
|
||||
|
||||
import { Check, Copy, ExternalLink, FileText, LoaderCircle, Phone, Video } from 'lucide-react';
|
||||
import { Check, Copy, ExternalLink, FileText, Video } from 'lucide-react';
|
||||
import Link from 'next/link';
|
||||
import { useParams, useRouter } from 'next/navigation';
|
||||
import { useParams } from 'next/navigation';
|
||||
import posthog from 'posthog-js';
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
|
||||
import BrowserCall from '@/app/workflow/[workflowId]/run/[runId]/BrowserCall';
|
||||
import WorkflowLayout from '@/app/workflow/WorkflowLayout';
|
||||
import {
|
||||
createWorkflowRunApiV1WorkflowWorkflowIdRunsPost,
|
||||
getWorkflowRunApiV1WorkflowWorkflowIdRunsRunIdGet,
|
||||
} from '@/client/sdk.gen';
|
||||
import { getWorkflowRunApiV1WorkflowWorkflowIdRunsRunIdGet } from '@/client/sdk.gen';
|
||||
import { MediaPreviewButton, MediaPreviewDialog } from '@/components/MediaPreviewDialog';
|
||||
import { OnboardingTooltip } from '@/components/onboarding/OnboardingTooltip';
|
||||
import { Button } from '@/components/ui/button';
|
||||
|
|
@ -23,7 +20,6 @@ import { WORKFLOW_RUN_MODES } from '@/constants/workflowRunModes';
|
|||
import { useOnboarding } from '@/context/OnboardingContext';
|
||||
import { useAuth } from '@/lib/auth';
|
||||
import { downloadFile } from '@/lib/files';
|
||||
import { getRandomId } from '@/lib/utils';
|
||||
|
||||
interface WorkflowRunResponse {
|
||||
mode: string;
|
||||
|
|
@ -151,9 +147,7 @@ function ContextDisplay({ title, context }: { title: string; context: Record<str
|
|||
|
||||
export default function WorkflowRunPage() {
|
||||
const params = useParams();
|
||||
const router = useRouter();
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [startingCall, setStartingCall] = useState(false);
|
||||
const auth = useAuth();
|
||||
const [workflowRun, setWorkflowRun] = useState<WorkflowRunResponse | null>(null);
|
||||
const { hasSeenTooltip, markTooltipSeen } = useOnboarding();
|
||||
|
|
@ -205,24 +199,6 @@ export default function WorkflowRunPage() {
|
|||
fetchWorkflowRun();
|
||||
}, [params.workflowId, params.runId, auth]);
|
||||
|
||||
const handleTestAgain = async () => {
|
||||
if (startingCall) return;
|
||||
setStartingCall(true);
|
||||
try {
|
||||
const workflowId = Number(params.workflowId);
|
||||
const workflowRunName = `WR-${getRandomId()}`;
|
||||
const response = await createWorkflowRunApiV1WorkflowWorkflowIdRunsPost({
|
||||
path: { workflow_id: workflowId },
|
||||
body: { mode: WORKFLOW_RUN_MODES.SMALL_WEBRTC, name: workflowRunName },
|
||||
});
|
||||
if (response.data?.id) {
|
||||
router.push(`/workflow/${workflowId}/run/${response.data.id}`);
|
||||
}
|
||||
} finally {
|
||||
setStartingCall(false);
|
||||
}
|
||||
};
|
||||
|
||||
let returnValue = null;
|
||||
const isTextChatRun = workflowRun?.mode === WORKFLOW_RUN_MODES.TEXTCHAT;
|
||||
const showHistoricalRunView = Boolean(workflowRun?.is_completed || isTextChatRun);
|
||||
|
|
@ -271,21 +247,6 @@ export default function WorkflowRunPage() {
|
|||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{!isTextChatRun && (
|
||||
<Button
|
||||
onClick={handleTestAgain}
|
||||
disabled={startingCall}
|
||||
variant="outline"
|
||||
className="gap-2"
|
||||
>
|
||||
{startingCall ? (
|
||||
<LoaderCircle className="h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<Phone className="h-4 w-4" />
|
||||
)}
|
||||
{startingCall ? 'Starting...' : 'Test Again'}
|
||||
</Button>
|
||||
)}
|
||||
<Link href={`/workflow/${params.workflowId}`}>
|
||||
<Button
|
||||
ref={customizeButtonRef}
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@
|
|||
import { useRouter } from 'next/navigation';
|
||||
import { useState } from 'react';
|
||||
|
||||
import { createWorkflowFromTemplateApiV1WorkflowCreateTemplatePost, createWorkflowRunApiV1WorkflowWorkflowIdRunsPost } from '@/client/sdk.gen';
|
||||
import { createWorkflowFromTemplateApiV1WorkflowCreateTemplatePost } from '@/client/sdk.gen';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import {
|
||||
|
|
@ -18,10 +18,8 @@ import { Input } from '@/components/ui/input';
|
|||
import { Label } from '@/components/ui/label';
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import { WORKFLOW_RUN_MODES } from '@/constants/workflowRunModes';
|
||||
import { useAuth } from '@/lib/auth';
|
||||
import logger from '@/lib/logger';
|
||||
import { getRandomId } from '@/lib/utils';
|
||||
|
||||
export default function CreateWorkflowPage() {
|
||||
const router = useRouter();
|
||||
|
|
@ -76,36 +74,9 @@ export default function CreateWorkflowPage() {
|
|||
}
|
||||
};
|
||||
|
||||
const handleModalContinue = async () => {
|
||||
if (!workflowId || !user) return;
|
||||
|
||||
try {
|
||||
const accessToken = await getAccessToken();
|
||||
const workflowRunName = `WR-${getRandomId()}`;
|
||||
|
||||
// Create a workflow run
|
||||
const response = await createWorkflowRunApiV1WorkflowWorkflowIdRunsPost({
|
||||
path: {
|
||||
workflow_id: Number(workflowId),
|
||||
},
|
||||
body: {
|
||||
mode: WORKFLOW_RUN_MODES.SMALL_WEBRTC, // Same mode as "Web Call" button
|
||||
name: workflowRunName
|
||||
},
|
||||
headers: {
|
||||
'Authorization': `Bearer ${accessToken}`,
|
||||
},
|
||||
});
|
||||
|
||||
// Navigate to the workflow run page
|
||||
if (response.data?.id) {
|
||||
router.push(`/workflow/${workflowId}/run/${response.data.id}`);
|
||||
}
|
||||
} catch (err) {
|
||||
logger.error(`Error creating workflow run: ${err}`);
|
||||
// Fallback to workflow page if run creation fails
|
||||
router.push(`/workflow/${workflowId}`);
|
||||
}
|
||||
const handleModalContinue = () => {
|
||||
if (!workflowId) return;
|
||||
router.push(`/workflow/${workflowId}?onboarding=web_call`);
|
||||
};
|
||||
|
||||
return (
|
||||
|
|
@ -233,7 +204,7 @@ export default function CreateWorkflowPage() {
|
|||
The voice bot is pre-set to communicate in English with an American accent.
|
||||
</p>
|
||||
<p>
|
||||
Next steps would be to test the voice bot using web call, and then modify it to suit your use case.
|
||||
Next steps would be to test the voice bot in the editor, and then modify it to suit your use case.
|
||||
</p>
|
||||
</div>
|
||||
</DialogDescription>
|
||||
|
|
@ -243,7 +214,7 @@ export default function CreateWorkflowPage() {
|
|||
onClick={handleModalContinue}
|
||||
className="w-full"
|
||||
>
|
||||
Start Web Call
|
||||
Open and Test Agent
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
|
|
|
|||
|
|
@ -9,8 +9,10 @@ export const BaseNode = forwardRef<
|
|||
invalid?: boolean;
|
||||
selected_through_edge?: boolean;
|
||||
hovered_through_edge?: boolean;
|
||||
runtimeActive?: boolean;
|
||||
runtimePulseNonce?: number;
|
||||
}
|
||||
>(({ className, selected, invalid, selected_through_edge, hovered_through_edge, ...props }, ref) => (
|
||||
>(({ children, className, selected, invalid, selected_through_edge, hovered_through_edge, runtimeActive, runtimePulseNonce, ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn(
|
||||
|
|
@ -26,11 +28,22 @@ export const BaseNode = forwardRef<
|
|||
// Hovered through edge takes precedence over selected through edge
|
||||
hovered_through_edge ? "ring-2 ring-primary/60 shadow-[0_0_12px_rgba(96,165,250,0.3)]" : "",
|
||||
!hovered_through_edge && selected_through_edge ? "ring-1 ring-primary/50 shadow-[0_0_8px_rgba(59,130,246,0.2)]" : "",
|
||||
runtimeActive ? "ring-2 ring-sky-400/60 shadow-[0_0_0_1px_rgba(56,189,248,0.18),0_0_24px_rgba(14,165,233,0.18)]" : "",
|
||||
!selected_through_edge && !hovered_through_edge && "hover:border-muted-foreground/50",
|
||||
)}
|
||||
tabIndex={0}
|
||||
{...props}
|
||||
/>
|
||||
>
|
||||
{runtimeActive ? (
|
||||
<span
|
||||
key={`runtime-pulse-${runtimePulseNonce ?? 0}`}
|
||||
className="pointer-events-none absolute -inset-2 rounded-[18px] border-2 border-sky-400/55"
|
||||
aria-hidden="true"
|
||||
style={{ animation: "ping 900ms ease-out 2" }}
|
||||
/>
|
||||
) : null}
|
||||
{children}
|
||||
</div>
|
||||
));
|
||||
|
||||
BaseNode.displayName = "BaseNode";
|
||||
|
|
|
|||
|
|
@ -608,6 +608,8 @@ export const GenericNode = memo(({ data, selected, id, type }: GenericNodeProps)
|
|||
invalid={data.invalid}
|
||||
selected_through_edge={data.selected_through_edge}
|
||||
hovered_through_edge={data.hovered_through_edge}
|
||||
runtimeActive={data.runtime_active}
|
||||
runtimePulseNonce={data.runtime_pulse_nonce}
|
||||
title={data.name || fallbackTitle}
|
||||
icon={<Icon />}
|
||||
badgeLabel={badge.label}
|
||||
|
|
|
|||
|
|
@ -10,6 +10,8 @@ interface NodeContentProps {
|
|||
invalid?: boolean;
|
||||
selected_through_edge?: boolean;
|
||||
hovered_through_edge?: boolean;
|
||||
runtimeActive?: boolean;
|
||||
runtimePulseNonce?: number;
|
||||
title: string;
|
||||
icon: ReactNode;
|
||||
badgeLabel?: string;
|
||||
|
|
@ -31,6 +33,8 @@ export const NodeContent = ({
|
|||
invalid,
|
||||
selected_through_edge,
|
||||
hovered_through_edge,
|
||||
runtimeActive,
|
||||
runtimePulseNonce,
|
||||
title,
|
||||
icon,
|
||||
badgeLabel,
|
||||
|
|
@ -54,6 +58,8 @@ export const NodeContent = ({
|
|||
invalid={invalid}
|
||||
selected_through_edge={selected_through_edge}
|
||||
hovered_through_edge={hovered_through_edge}
|
||||
runtimeActive={runtimeActive}
|
||||
runtimePulseNonce={runtimePulseNonce}
|
||||
className={`p-0 ${className}`}
|
||||
onDoubleClick={onDoubleClick}
|
||||
>
|
||||
|
|
|
|||
|
|
@ -17,6 +17,8 @@ export type FlowNodeData = {
|
|||
validationMessage?: string | null;
|
||||
selected_through_edge?: boolean;
|
||||
hovered_through_edge?: boolean;
|
||||
runtime_active?: boolean;
|
||||
runtime_pulse_nonce?: number;
|
||||
allow_interrupt?: boolean;
|
||||
extraction_enabled?: boolean;
|
||||
extraction_prompt?: string;
|
||||
|
|
|
|||
|
|
@ -62,7 +62,9 @@ function liveFeedbackItem(message: RealtimeFeedbackMessage, reasoningDurationMs?
|
|||
kind: "node-transition",
|
||||
id: message.id,
|
||||
timestamp: message.timestamp,
|
||||
nodeId: message.nodeId,
|
||||
nodeName: message.nodeName ?? message.text,
|
||||
previousNodeId: message.previousNodeId,
|
||||
previousNodeName: message.previousNode,
|
||||
allowInterrupt: message.allowInterrupt,
|
||||
};
|
||||
|
|
@ -241,7 +243,9 @@ export function conversationItemsFromRealtimeFeedbackEvents(events: RealtimeFeed
|
|||
kind: "node-transition",
|
||||
id: `node-${event.turn}-${index}`,
|
||||
timestamp: event.timestamp,
|
||||
nodeId: event.payload.node_id,
|
||||
nodeName: event.payload.node_name ?? feedbackEventText(event) ?? "Node",
|
||||
previousNodeId: event.payload.previous_node_id,
|
||||
previousNodeName: event.payload.previous_node_name ?? event.payload.previous_node,
|
||||
allowInterrupt: event.payload.allow_interrupt,
|
||||
});
|
||||
|
|
|
|||
|
|
@ -53,7 +53,9 @@ function conversationItemsFromTextChatEvents(
|
|||
id: `${turnId}-node-${index}`,
|
||||
turnId,
|
||||
timestamp,
|
||||
nodeId: asString(payload.node_id),
|
||||
nodeName,
|
||||
previousNodeId: asString(payload.previous_node_id),
|
||||
previousNodeName: asString(payload.previous_node_name),
|
||||
allowInterrupt: typeof payload.allow_interrupt === "boolean" ? payload.allow_interrupt : undefined,
|
||||
});
|
||||
|
|
|
|||
|
|
@ -20,7 +20,9 @@ export interface RealtimeFeedbackMessage {
|
|||
arguments?: unknown;
|
||||
result?: unknown;
|
||||
status?: "running" | "completed";
|
||||
nodeId?: string;
|
||||
nodeName?: string;
|
||||
previousNodeId?: string;
|
||||
previousNode?: string;
|
||||
allowInterrupt?: boolean;
|
||||
ttfbSeconds?: number;
|
||||
|
|
@ -40,7 +42,9 @@ export interface RealtimeFeedbackEvent {
|
|||
tool_call_id?: string;
|
||||
arguments?: unknown;
|
||||
result?: unknown;
|
||||
node_id?: string;
|
||||
node_name?: string;
|
||||
previous_node_id?: string;
|
||||
previous_node?: string;
|
||||
previous_node_name?: string;
|
||||
allow_interrupt?: boolean;
|
||||
|
|
@ -84,7 +88,9 @@ export interface ConversationToolCallItem extends ConversationItemBase {
|
|||
|
||||
export interface ConversationNodeTransitionItem extends ConversationItemBase {
|
||||
kind: "node-transition";
|
||||
nodeId?: string;
|
||||
nodeName: string;
|
||||
previousNodeId?: string;
|
||||
previousNodeName?: string;
|
||||
allowInterrupt?: boolean;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,15 +2,19 @@
|
|||
|
||||
import { createContext, useContext, useEffect, useState } from 'react';
|
||||
|
||||
export type TooltipKey = 'web_call' | 'customize_workflow'; // Add more tooltip keys as needed
|
||||
export type TooltipKey = 'web_call' | 'customize_workflow';
|
||||
export type OnboardingActionKey = 'web_call_started';
|
||||
|
||||
interface OnboardingState {
|
||||
seenTooltips: TooltipKey[];
|
||||
completedActions: OnboardingActionKey[];
|
||||
}
|
||||
|
||||
interface OnboardingContextType {
|
||||
hasSeenTooltip: (key: TooltipKey) => boolean;
|
||||
markTooltipSeen: (key: TooltipKey) => void;
|
||||
hasCompletedAction: (key: OnboardingActionKey) => boolean;
|
||||
markActionCompleted: (key: OnboardingActionKey) => void;
|
||||
resetOnboarding: () => void;
|
||||
}
|
||||
|
||||
|
|
@ -18,6 +22,7 @@ const ONBOARDING_STORAGE_KEY = 'dograh_onboarding_state';
|
|||
|
||||
const defaultState: OnboardingState = {
|
||||
seenTooltips: [],
|
||||
completedActions: [],
|
||||
};
|
||||
|
||||
const OnboardingContext = createContext<OnboardingContextType | undefined>(undefined);
|
||||
|
|
@ -59,6 +64,19 @@ export const OnboardingProvider = ({ children }: { children: React.ReactNode })
|
|||
}));
|
||||
};
|
||||
|
||||
const hasCompletedAction = (key: OnboardingActionKey): boolean => {
|
||||
return onboardingState.completedActions.includes(key);
|
||||
};
|
||||
|
||||
const markActionCompleted = (key: OnboardingActionKey) => {
|
||||
setOnboardingState(prev => ({
|
||||
...prev,
|
||||
completedActions: prev.completedActions.includes(key)
|
||||
? prev.completedActions
|
||||
: [...prev.completedActions, key]
|
||||
}));
|
||||
};
|
||||
|
||||
const resetOnboarding = () => {
|
||||
setOnboardingState(defaultState);
|
||||
localStorage.removeItem(ONBOARDING_STORAGE_KEY);
|
||||
|
|
@ -69,6 +87,8 @@ export const OnboardingProvider = ({ children }: { children: React.ReactNode })
|
|||
value={{
|
||||
hasSeenTooltip,
|
||||
markTooltipSeen,
|
||||
hasCompletedAction,
|
||||
markActionCompleted,
|
||||
resetOnboarding
|
||||
}}
|
||||
>
|
||||
|
|
|
|||
|
|
@ -131,9 +131,7 @@ export async function getServerAccessToken(): Promise<string | null> {
|
|||
}
|
||||
} else if (authProvider === 'local') {
|
||||
// Get token from cookies (created by middleware)
|
||||
const oss_token = await getOSSToken();
|
||||
logger.debug(`oss_token: ${oss_token}`);
|
||||
return oss_token;
|
||||
return await getOSSToken();
|
||||
}
|
||||
|
||||
return null;
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue