mirror of
https://github.com/dograh-hq/dograh.git
synced 2026-07-07 11:02:12 +02:00
feat: Update Dograh's UI Design (#67)
* feat: create app sidebar and update layout * fix: fix loading errors * fix: fix stack auth hydration issue * fix: fix design for create-workflow * fix: fix service configuration page design * Add header for workflow detail * feat: fix workflow editor design * Fix css classes * Fix callback status parsing for Vobiz * Fix filter and remove gender service
This commit is contained in:
parent
8342cd1dda
commit
a7f2238044
90 changed files with 4398 additions and 2312 deletions
|
|
@ -5,10 +5,11 @@ import { useForm } from "react-hook-form";
|
|||
|
||||
import { getDefaultConfigurationsApiV1UserConfigurationsDefaultsGet } from '@/client/sdk.gen';
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { useUserConfig } from "@/context/UserConfigContext";
|
||||
|
||||
type ServiceSegment = "llm" | "tts" | "stt";
|
||||
|
|
@ -33,6 +34,12 @@ interface FormValues {
|
|||
[key: string]: string | number | boolean;
|
||||
}
|
||||
|
||||
const TAB_CONFIG: { key: ServiceSegment; label: string }[] = [
|
||||
{ key: "llm", label: "LLM" },
|
||||
{ key: "tts", label: "Voice" },
|
||||
{ key: "stt", label: "Transcriber" },
|
||||
];
|
||||
|
||||
export default function ServiceConfiguration() {
|
||||
const [apiError, setApiError] = useState<string | null>(null);
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
|
|
@ -80,28 +87,6 @@ export default function ServiceConfiguration() {
|
|||
};
|
||||
|
||||
const setServicePropertyValues = (service: ServiceSegment) => {
|
||||
/*
|
||||
sets service properties like api_key, model etc. from default configurations
|
||||
if not present in user configurations
|
||||
|
||||
service - llm/ tts/ stt
|
||||
|
||||
|
||||
userConfig['llm'] = {
|
||||
provider: 'openai',
|
||||
api_key: 'sk-...'
|
||||
}
|
||||
|
||||
response.data.llm = {
|
||||
openai: {
|
||||
properties: {
|
||||
provider: 'openai'
|
||||
api_key: 'sk-...'
|
||||
}
|
||||
}
|
||||
}
|
||||
*/
|
||||
|
||||
if (userConfig?.[service]?.provider) {
|
||||
Object.entries(userConfig?.[service]).forEach(([field, value]) => {
|
||||
if (field !== "provider") {
|
||||
|
|
@ -110,8 +95,6 @@ export default function ServiceConfiguration() {
|
|||
});
|
||||
selectedProviders[service] = userConfig?.[service]?.provider as string;
|
||||
} else {
|
||||
// response.data['service'] will all providers for the given service
|
||||
// selectedProviders[service] will have the provider name
|
||||
const properties = response.data[service]?.[selectedProviders[service]]?.properties as Record<string, SchemaProperty>;
|
||||
if (properties) {
|
||||
Object.entries(properties).forEach(([field, schema]) => {
|
||||
|
|
@ -135,10 +118,6 @@ export default function ServiceConfiguration() {
|
|||
}, [reset, userConfig]);
|
||||
|
||||
const handleProviderChange = (service: ServiceSegment, providerName: string) => {
|
||||
/*
|
||||
service can be llm/ tts/ stt
|
||||
providerName is openAI/ Deepgram etc.
|
||||
*/
|
||||
if (!providerName) {
|
||||
return;
|
||||
}
|
||||
|
|
@ -170,10 +149,6 @@ export default function ServiceConfiguration() {
|
|||
|
||||
|
||||
const onSubmit = async (data: FormValues) => {
|
||||
/*
|
||||
data contains form values like llm_api_key: "sk...", llm_model: "gpt-4o" etc.
|
||||
extract the values in relevant form
|
||||
*/
|
||||
setApiError(null);
|
||||
setIsSaving(true);
|
||||
|
||||
|
|
@ -197,7 +172,7 @@ export default function ServiceConfiguration() {
|
|||
Object.entries(data).forEach(([property, value]) => {
|
||||
const parts = property.split('_');
|
||||
const service = parts[0] as ServiceSegment;
|
||||
const field = parts.slice(1).join('_'); // Join all parts after the service name
|
||||
const field = parts.slice(1).join('_');
|
||||
|
||||
if (userConfig[service] && !(field in userConfig[service])) {
|
||||
(userConfig[service] as Record<string, string>)[field] = value as string;
|
||||
|
|
@ -222,116 +197,166 @@ export default function ServiceConfiguration() {
|
|||
}
|
||||
};
|
||||
|
||||
const renderServiceSegmentFields = (service: ServiceSegment) => {
|
||||
// Segment is segments like llm, tts and stt
|
||||
const getConfigFields = (service: ServiceSegment): string[] => {
|
||||
const currentProvider = serviceProviders[service];
|
||||
const providerSchema = schemas?.[service]?.[currentProvider];
|
||||
const availableProviders = schemas?.[service] ? Object.keys(schemas[service]) : [];
|
||||
if (!providerSchema) return [];
|
||||
|
||||
return (
|
||||
<Card className="mb-6">
|
||||
<CardHeader>
|
||||
<CardTitle>{service.toUpperCase()} Configuration</CardTitle>
|
||||
<CardDescription>
|
||||
Configure your {service.toUpperCase()} service
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label>Provider</Label>
|
||||
<Select
|
||||
value={currentProvider}
|
||||
onValueChange={(providerName) => {
|
||||
handleProviderChange(service, providerName);
|
||||
}}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder={`Select ${service.toUpperCase()} provider`} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{availableProviders.map((provider) => (
|
||||
<SelectItem key={provider} value={provider}>
|
||||
{provider}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{currentProvider && providerSchema && (
|
||||
<div className="space-y-4">
|
||||
{Object.entries(providerSchema.properties).map(([field, schema]: [string, SchemaProperty]) => {
|
||||
// Handle $ref fields by getting the actual schema from $defs
|
||||
const actualSchema = schema.$ref && providerSchema.$defs
|
||||
? providerSchema.$defs[schema.$ref.split('/').pop() || '']
|
||||
: schema;
|
||||
|
||||
// Skip provider field as it's handled separately
|
||||
return field !== "provider" && (
|
||||
<div key={`${service}_${field}_${currentProvider}`} className="space-y-2">
|
||||
<Label>{field}</Label>
|
||||
{actualSchema?.enum ? (
|
||||
<Select
|
||||
value={watch(`${service}_${field}`) as string || ""}
|
||||
onValueChange={(value) => {
|
||||
setValue(`${service}_${field}`, value, { shouldDirty: true });
|
||||
}}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder={`Select ${field}`} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{actualSchema.enum.map((value: string) => (
|
||||
<SelectItem key={value} value={value}>
|
||||
{value}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
) : (
|
||||
<Input
|
||||
type={actualSchema?.type === "number" ? "number" : "text"}
|
||||
{...(actualSchema?.type === "number" && { step: "any" })}
|
||||
placeholder={`Enter ${field}`}
|
||||
{...register(`${service}_${field}`, {
|
||||
required: providerSchema.required?.includes(field),
|
||||
valueAsNumber: actualSchema?.type === "number"
|
||||
})}
|
||||
/>
|
||||
)}
|
||||
{errors[`${service}_${field}`] && (
|
||||
<p className="text-sm text-red-500">
|
||||
{typeof errors[`${service}_${field}`]?.message === 'string'
|
||||
? String(errors[`${service}_${field}`]?.message)
|
||||
: "This field is required"}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
// Find all config fields (not provider, not api_key)
|
||||
return Object.keys(providerSchema.properties).filter(
|
||||
field => field !== "provider" && field !== "api_key"
|
||||
);
|
||||
};
|
||||
|
||||
const renderServiceFields = (service: ServiceSegment) => {
|
||||
const currentProvider = serviceProviders[service];
|
||||
const providerSchema = schemas?.[service]?.[currentProvider];
|
||||
const availableProviders = schemas?.[service] ? Object.keys(schemas[service]) : [];
|
||||
const configFields = getConfigFields(service);
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Provider and first config field in one row */}
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label>Provider</Label>
|
||||
<Select
|
||||
value={currentProvider}
|
||||
onValueChange={(providerName) => {
|
||||
handleProviderChange(service, providerName);
|
||||
}}
|
||||
>
|
||||
<SelectTrigger className="w-full">
|
||||
<SelectValue placeholder="Select provider" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{availableProviders.map((provider) => (
|
||||
<SelectItem key={provider} value={provider}>
|
||||
{provider}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{currentProvider && providerSchema && configFields[0] && (
|
||||
<div className="space-y-2">
|
||||
<Label className="capitalize">{configFields[0].replace(/_/g, ' ')}</Label>
|
||||
{renderField(service, configFields[0], providerSchema)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Additional config fields (like voice for TTS) */}
|
||||
{currentProvider && providerSchema && configFields.length > 1 && (
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
{configFields.slice(1).map((field) => (
|
||||
<div key={field} className="space-y-2">
|
||||
<Label className="capitalize">{field.replace(/_/g, ' ')}</Label>
|
||||
{renderField(service, field, providerSchema)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* API Key in bottom row */}
|
||||
{currentProvider && providerSchema && providerSchema.properties.api_key && (
|
||||
<div className="space-y-2">
|
||||
<Label>API Key</Label>
|
||||
<Input
|
||||
type="password"
|
||||
placeholder="Enter API key"
|
||||
{...register(`${service}_api_key`, {
|
||||
required: providerSchema.required?.includes("api_key"),
|
||||
})}
|
||||
/>
|
||||
{errors[`${service}_api_key`] && (
|
||||
<p className="text-sm text-red-500">
|
||||
{typeof errors[`${service}_api_key`]?.message === 'string'
|
||||
? String(errors[`${service}_api_key`]?.message)
|
||||
: "This field is required"}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const renderField = (service: ServiceSegment, field: string, providerSchema: ProviderSchema) => {
|
||||
const schema = providerSchema.properties[field];
|
||||
const actualSchema = schema.$ref && providerSchema.$defs
|
||||
? providerSchema.$defs[schema.$ref.split('/').pop() || '']
|
||||
: schema;
|
||||
|
||||
if (actualSchema?.enum) {
|
||||
return (
|
||||
<Select
|
||||
value={watch(`${service}_${field}`) as string || ""}
|
||||
onValueChange={(value) => {
|
||||
setValue(`${service}_${field}`, value, { shouldDirty: true });
|
||||
}}
|
||||
>
|
||||
<SelectTrigger className="w-full">
|
||||
<SelectValue placeholder={`Select ${field}`} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{actualSchema.enum.map((value: string) => (
|
||||
<SelectItem key={value} value={value}>
|
||||
{value}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Input
|
||||
type={actualSchema?.type === "number" ? "number" : "text"}
|
||||
{...(actualSchema?.type === "number" && { step: "any" })}
|
||||
placeholder={`Enter ${field}`}
|
||||
{...register(`${service}_${field}`, {
|
||||
required: providerSchema.required?.includes(field),
|
||||
valueAsNumber: actualSchema?.type === "number"
|
||||
})}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="w-full max-w-4xl mx-auto py-8">
|
||||
<h1 className="text-2xl font-bold mb-6">Service Configuration</h1>
|
||||
<div className="w-full max-w-2xl mx-auto">
|
||||
<div className="mb-6">
|
||||
<h1 className="text-3xl font-bold mb-2">AI Models Configuration</h1>
|
||||
<p className="text-muted-foreground">
|
||||
Configure your AI model, voice, and transcription services.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSubmit(onSubmit)} className="space-y-6">
|
||||
{renderServiceSegmentFields("llm")}
|
||||
{renderServiceSegmentFields("tts")}
|
||||
{renderServiceSegmentFields("stt")}
|
||||
<form onSubmit={handleSubmit(onSubmit)}>
|
||||
<Card>
|
||||
<CardContent className="pt-6">
|
||||
<Tabs defaultValue="llm" className="w-full">
|
||||
<TabsList className="grid w-full grid-cols-3 mb-6">
|
||||
{TAB_CONFIG.map(({ key, label }) => (
|
||||
<TabsTrigger key={key} value={key}>
|
||||
{label}
|
||||
</TabsTrigger>
|
||||
))}
|
||||
</TabsList>
|
||||
|
||||
{apiError && <p className="text-red-500">{apiError}</p>}
|
||||
{TAB_CONFIG.map(({ key }) => (
|
||||
<TabsContent key={key} value={key} className="mt-0">
|
||||
{renderServiceFields(key)}
|
||||
</TabsContent>
|
||||
))}
|
||||
</Tabs>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Button type="submit" className="w-full" disabled={isSaving}>
|
||||
{apiError && <p className="text-red-500 mt-4">{apiError}</p>}
|
||||
|
||||
<Button type="submit" className="w-full mt-6" disabled={isSaving}>
|
||||
{isSaving ? "Saving..." : "Save Configuration"}
|
||||
</Button>
|
||||
</form>
|
||||
|
|
|
|||
|
|
@ -1,17 +1,31 @@
|
|||
"use client";
|
||||
|
||||
import { Moon,Sun } from "lucide-react";
|
||||
import { Moon, Sun } from "lucide-react";
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
export default function ThemeToggle() {
|
||||
const [theme, setTheme] = useState("light");
|
||||
interface ThemeToggleProps {
|
||||
className?: string;
|
||||
showLabel?: boolean;
|
||||
variant?: "ghost" | "outline" | "default";
|
||||
size?: "default" | "sm" | "lg" | "icon";
|
||||
}
|
||||
|
||||
export default function ThemeToggle({
|
||||
className,
|
||||
showLabel = false,
|
||||
variant = "ghost",
|
||||
size = "icon"
|
||||
}: ThemeToggleProps) {
|
||||
// Start with null to avoid hydration mismatch - theme is set by inline script in layout.tsx
|
||||
const [theme, setTheme] = useState<"light" | "dark" | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const storedTheme = localStorage.getItem("theme") || "light";
|
||||
setTheme(storedTheme);
|
||||
document.documentElement.classList.toggle("dark", storedTheme === "dark");
|
||||
// Read the current theme from the DOM (already set by inline script in layout.tsx)
|
||||
const isDark = document.documentElement.classList.contains("dark");
|
||||
setTheme(isDark ? "dark" : "light");
|
||||
}, []);
|
||||
|
||||
const toggleTheme = () => {
|
||||
|
|
@ -22,8 +36,27 @@ export default function ThemeToggle() {
|
|||
};
|
||||
|
||||
return (
|
||||
<Button variant="outline" className="absolute top-4 right-4" onClick={toggleTheme}>
|
||||
{theme === "light" ? <Moon className="h-5 w-5" /> : <Sun className="h-5 w-5" />}
|
||||
<Button
|
||||
variant={variant}
|
||||
size={size}
|
||||
className={cn(
|
||||
showLabel && "w-full justify-start",
|
||||
className
|
||||
)}
|
||||
onClick={toggleTheme}
|
||||
>
|
||||
<Sun className={cn(
|
||||
"h-4 w-4 rotate-0 scale-100 transition-all dark:-rotate-90 dark:scale-0",
|
||||
showLabel && "absolute"
|
||||
)} />
|
||||
<Moon className={cn(
|
||||
"h-4 w-4 rotate-90 scale-0 transition-all dark:rotate-0 dark:scale-100",
|
||||
!showLabel && "absolute"
|
||||
)} />
|
||||
{showLabel && theme && (
|
||||
<span className="ml-2">{theme === "light" ? "Light" : "Dark"} Mode</span>
|
||||
)}
|
||||
<span className="sr-only">Toggle theme</span>
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import { Globe, Headset, OctagonX, Play, X } from 'lucide-react';
|
||||
import { useEffect } from 'react';
|
||||
|
||||
import { Button } from '@/components/ui/button';
|
||||
|
||||
|
|
@ -41,9 +42,20 @@ const GLOBAL_NODE_TYPES = [
|
|||
]
|
||||
|
||||
export default function AddNodePanel({ isOpen, onNodeSelect, onClose }: AddNodePanelProps) {
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (event: KeyboardEvent) => {
|
||||
if (event.key === 'Escape' && isOpen) {
|
||||
onClose();
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener('keydown', handleKeyDown);
|
||||
return () => document.removeEventListener('keydown', handleKeyDown);
|
||||
}, [isOpen, onClose]);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`fixed z-51 right-0 top-0 h-full w-80 bg-white shadow-lg transform transition-transform duration-300 ease-in-out ${isOpen ? 'translate-x-0' : 'translate-x-full'
|
||||
className={`fixed z-51 right-0 top-0 h-full w-80 bg-background shadow-lg transform transition-transform duration-300 ease-in-out ${isOpen ? 'translate-x-0' : 'translate-x-full'
|
||||
}`}
|
||||
>
|
||||
<div className="p-4">
|
||||
|
|
@ -54,7 +66,7 @@ export default function AddNodePanel({ isOpen, onNodeSelect, onClose }: AddNodeP
|
|||
</Button>
|
||||
</div>
|
||||
|
||||
<h1 className="text-sm text-gray-500 mb-2">Agent Nodes</h1>
|
||||
<h1 className="text-sm text-muted-foreground mb-2">Agent Nodes</h1>
|
||||
|
||||
<div className="space-y-2">
|
||||
{NODE_TYPES.map((node) => (
|
||||
|
|
@ -65,19 +77,19 @@ export default function AddNodePanel({ isOpen, onNodeSelect, onClose }: AddNodeP
|
|||
onClick={() => onNodeSelect(node.type)}
|
||||
>
|
||||
<div className="flex items-center">
|
||||
<div className="bg-gray-100 p-2 rounded-lg mr-3 border border-gray-200">
|
||||
<div className="bg-muted p-2 rounded-lg mr-3 border border-border">
|
||||
<node.icon className="h-6 w-6" />
|
||||
</div>
|
||||
<div className="flex flex-col items-start">
|
||||
<span className="font-medium">{node.label}</span>
|
||||
<span className="text-sm text-gray-500">{node.description}</span>
|
||||
<span className="text-sm text-muted-foreground">{node.description}</span>
|
||||
</div>
|
||||
</div>
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<h1 className="text-sm text-gray-500 mb-2">Global Nodes</h1>
|
||||
<h1 className="text-sm text-muted-foreground mb-2">Global Nodes</h1>
|
||||
|
||||
<div className="space-y-2">
|
||||
{GLOBAL_NODE_TYPES.map((node) => (
|
||||
|
|
@ -88,12 +100,12 @@ export default function AddNodePanel({ isOpen, onNodeSelect, onClose }: AddNodeP
|
|||
onClick={() => onNodeSelect(node.type)}
|
||||
>
|
||||
<div className="flex items-center">
|
||||
<div className="bg-gray-100 p-2 rounded-lg mr-3 border border-gray-200">
|
||||
<div className="bg-muted p-2 rounded-lg mr-3 border border-border">
|
||||
<node.icon className="h-6 w-6" />
|
||||
</div>
|
||||
<div className="flex flex-col items-start">
|
||||
<span className="font-medium">{node.label}</span>
|
||||
<span className="text-sm text-gray-500">{node.description}</span>
|
||||
<span className="text-sm text-muted-foreground">{node.description}</span>
|
||||
</div>
|
||||
</div>
|
||||
</Button>
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { BaseEdge, type Edge, EdgeLabelRenderer, type EdgeProps, getBezierPath, useReactFlow } from '@xyflow/react';
|
||||
import { BaseEdge, type Edge, EdgeLabelRenderer, type EdgeProps, getSmoothStepPath, useReactFlow } from '@xyflow/react';
|
||||
import { AlertCircle, Pencil } from 'lucide-react';
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
|
||||
|
|
@ -54,7 +54,7 @@ const EdgeDetailsDialog = ({ open, onOpenChange, data, onSave }: EdgeDetailsDial
|
|||
<div className="grid gap-4 py-4">
|
||||
<div className="grid gap-2">
|
||||
<Label>Condition Label</Label>
|
||||
<Label className="text-xs text-gray-500">
|
||||
<Label className="text-xs text-muted-foreground">
|
||||
Enter a short label which helps identify this pathway in logs
|
||||
</Label>
|
||||
<Input
|
||||
|
|
@ -63,13 +63,13 @@ const EdgeDetailsDialog = ({ open, onOpenChange, data, onSave }: EdgeDetailsDial
|
|||
maxLength={64}
|
||||
onChange={(e) => setLabel(e.target.value)}
|
||||
/>
|
||||
<div className="text-xs text-gray-500">
|
||||
<div className="text-xs text-muted-foreground">
|
||||
{label.length}/64 characters
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<Label>Condition</Label>
|
||||
<Label className="text-xs text-gray-500">
|
||||
<Label className="text-xs text-muted-foreground">
|
||||
Describe a condition that will be evaluated to determine if this pathway should be taken
|
||||
</Label>
|
||||
<Textarea
|
||||
|
|
@ -126,15 +126,44 @@ export default function CustomEdge(props: CustomEdgeProps) {
|
|||
}
|
||||
}
|
||||
|
||||
// 3) draw the bezier path + get label coords
|
||||
const [edgePath, labelX, labelY] = getBezierPath({
|
||||
sourceX,
|
||||
sourceY,
|
||||
sourcePosition,
|
||||
targetX,
|
||||
targetY,
|
||||
targetPosition,
|
||||
});
|
||||
// Check if this is a self-loop (source and target are the same node)
|
||||
const isSelfLoop = source === target;
|
||||
|
||||
// 3) draw the edge path + get label coords
|
||||
// Use custom arc path for self-loops, smoothstep for regular edges
|
||||
let edgePath: string;
|
||||
let labelX: number;
|
||||
let labelY: number;
|
||||
|
||||
if (isSelfLoop) {
|
||||
// Create a loop arc that goes out and around the node
|
||||
const loopRadius = 50;
|
||||
const loopOffsetX = 80;
|
||||
// Arc path: start from source, curve out and back to target
|
||||
edgePath = `M ${sourceX} ${sourceY}
|
||||
C ${sourceX + loopOffsetX} ${sourceY - loopRadius},
|
||||
${targetX + loopOffsetX} ${targetY + loopRadius},
|
||||
${targetX} ${targetY}`;
|
||||
labelX = sourceX + loopOffsetX;
|
||||
labelY = sourceY;
|
||||
} else {
|
||||
// Use smoothstep path for orthogonal/elbow edges
|
||||
// borderRadius: 8 gives slightly rounded corners for a clean look
|
||||
// offset: 20 provides spacing before the first bend
|
||||
const [path, lx, ly] = getSmoothStepPath({
|
||||
sourceX,
|
||||
sourceY,
|
||||
sourcePosition,
|
||||
targetX,
|
||||
targetY,
|
||||
targetPosition,
|
||||
borderRadius: 8,
|
||||
offset: 20,
|
||||
});
|
||||
edgePath = path;
|
||||
labelX = lx;
|
||||
labelY = ly;
|
||||
}
|
||||
|
||||
// Update connected nodes when edge is selected or hovered
|
||||
useEffect(() => {
|
||||
|
|
@ -221,22 +250,22 @@ export default function CustomEdge(props: CustomEdgeProps) {
|
|||
{/* Show full EdgeLabel when selected or hovered, otherwise show simple label */}
|
||||
{(selected || isHovered) ? (
|
||||
<div className={cn(
|
||||
"flex flex-col gap-2 bg-white rounded-lg border-2 shadow-xl min-w-[200px]",
|
||||
"flex flex-col gap-2 bg-card rounded-lg border shadow-xl min-w-[220px]",
|
||||
"animate-in fade-in zoom-in duration-200",
|
||||
data?.invalid ? "border-red-500 shadow-[0_0_15px_rgba(239,68,68,0.5)]" : "border-gray-300"
|
||||
data?.invalid ? "border-destructive/50 shadow-[0_0_15px_rgba(239,68,68,0.3)]" : "border-border"
|
||||
)}>
|
||||
{/* Header with label */}
|
||||
<div className={cn(
|
||||
"flex items-center justify-between px-3 py-2 border-b",
|
||||
data?.invalid ? "bg-red-50 border-red-200" : "bg-gray-50 border-gray-200"
|
||||
data?.invalid ? "bg-destructive/10 border-destructive/30" : "bg-muted/50 border-border"
|
||||
)}>
|
||||
<span className="text-xs font-semibold text-gray-600 uppercase tracking-wide">
|
||||
Condition - EdgeID: {id}
|
||||
<span className="text-xs font-medium text-muted-foreground uppercase tracking-wide">
|
||||
Condition
|
||||
</span>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-6 w-6 p-0 hover:bg-gray-200"
|
||||
className="h-6 w-6 p-0 hover:bg-muted text-muted-foreground"
|
||||
onClick={() => setOpen(true)}
|
||||
>
|
||||
<Pencil className="h-3 w-3" />
|
||||
|
|
@ -244,20 +273,21 @@ export default function CustomEdge(props: CustomEdgeProps) {
|
|||
</div>
|
||||
{/* Content */}
|
||||
<div className="px-3 pb-3">
|
||||
<div className="text-sm font-medium text-gray-900 break-words">
|
||||
<div className="text-sm font-medium text-card-foreground break-words">
|
||||
{data?.label || data?.condition || 'Click to set condition'}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
/* Simple label shown by default */
|
||||
/* Simple label shown by default - amber/orange colored pill style */
|
||||
<div className={cn(
|
||||
"px-2 py-1 bg-white rounded border shadow-sm",
|
||||
data?.invalid ? "border-red-400 text-red-600" : "border-gray-300 text-gray-700"
|
||||
"px-3 py-1.5 rounded-full text-xs font-medium shadow-md",
|
||||
"transition-all duration-200",
|
||||
data?.invalid
|
||||
? "bg-destructive text-destructive-foreground"
|
||||
: "bg-amber-500 text-amber-950"
|
||||
)}>
|
||||
<div className="text-xs font-medium">
|
||||
{data?.label || data?.condition || 'No condition'}
|
||||
</div>
|
||||
{data?.label || data?.condition || 'No condition'}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -105,15 +105,15 @@ export const AgentNode = memo(({ data, selected, id }: AgentNodeProps) => {
|
|||
hovered_through_edge={data.hovered_through_edge}
|
||||
title={data.name || 'Agent'}
|
||||
icon={<Headset />}
|
||||
bgColor="bg-blue-300"
|
||||
nodeType="agent"
|
||||
hasSourceHandle={true}
|
||||
hasTargetHandle={true}
|
||||
onDoubleClick={() => setOpen(true)}
|
||||
nodeId={id}
|
||||
>
|
||||
<div className="text-sm text-muted-foreground">
|
||||
{data.prompt?.length > 30 ? `${data.prompt.substring(0, 30)}...` : data.prompt}
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground line-clamp-5 leading-relaxed">
|
||||
{data.prompt || 'No prompt configured'}
|
||||
</p>
|
||||
</NodeContent>
|
||||
|
||||
<NodeToolbar isVisible={selected} position={Position.Right}>
|
||||
|
|
@ -204,7 +204,7 @@ const AgentNodeEditForm = ({
|
|||
return (
|
||||
<div className="grid gap-2">
|
||||
<Label>Name</Label>
|
||||
<Label className="text-xs text-gray-500">
|
||||
<Label className="text-xs text-muted-foreground">
|
||||
The name of the agent that will be used to identify the agent in the call logs. It should be short and should identify the step in the call.
|
||||
</Label>
|
||||
<Input
|
||||
|
|
@ -215,7 +215,7 @@ const AgentNodeEditForm = ({
|
|||
<div className="flex items-center space-x-2 p-2 border rounded-md bg-muted/20">
|
||||
<Switch id="allow-interrupt" checked={allowInterrupt} onCheckedChange={setAllowInterrupt} />
|
||||
<Label htmlFor="allow-interrupt">Allow Interruption</Label>
|
||||
<Label className="text-xs text-gray-500 ml-2">
|
||||
<Label className="text-xs text-muted-foreground ml-2">
|
||||
Whether you would like user to be able to interrupt the bot.
|
||||
</Label>
|
||||
</div>
|
||||
|
|
@ -223,7 +223,7 @@ const AgentNodeEditForm = ({
|
|||
<div className="flex items-center space-x-2 p-2 border rounded-md bg-muted/20">
|
||||
<Switch id="add-global-prompt" checked={addGlobalPrompt} onCheckedChange={setAddGlobalPrompt} />
|
||||
<Label htmlFor="add-global-prompt">Add Global Prompt</Label>
|
||||
<Label className="text-xs text-gray-500 ml-2">
|
||||
<Label className="text-xs text-muted-foreground ml-2">
|
||||
Whether you want to add global prompt with this node's prompt.
|
||||
</Label>
|
||||
</div>
|
||||
|
|
@ -231,7 +231,7 @@ const AgentNodeEditForm = ({
|
|||
|
||||
<div className="pt-2 space-y-2">
|
||||
<Label>Prompt</Label>
|
||||
<Label className="text-xs text-gray-500">
|
||||
<Label className="text-xs text-muted-foreground">
|
||||
Enter the prompt for the agent. This will be used to generate the agent's response. Prompt engineering's best practices apply.
|
||||
</Label>
|
||||
<Textarea
|
||||
|
|
@ -249,7 +249,7 @@ const AgentNodeEditForm = ({
|
|||
<div className="flex items-center space-x-2 pt-2">
|
||||
<Switch id="enable-extraction" checked={extractionEnabled} onCheckedChange={setExtractionEnabled} />
|
||||
<Label htmlFor="enable-extraction">Enable Variable Extraction</Label>
|
||||
<Label className="text-xs text-gray-500 ml-2">
|
||||
<Label className="text-xs text-muted-foreground ml-2">
|
||||
Are there any variables you would like to extract from the conversation?
|
||||
</Label>
|
||||
</div>
|
||||
|
|
@ -257,7 +257,7 @@ const AgentNodeEditForm = ({
|
|||
{extractionEnabled && (
|
||||
<div className="border rounded-md p-3 mt-2 space-y-2 bg-muted/20">
|
||||
<Label>Extraction Prompt</Label>
|
||||
<Label className="text-xs text-gray-500">
|
||||
<Label className="text-xs text-muted-foreground">
|
||||
Provide an overall extraction prompt that guides how variables should be extracted from the conversation.
|
||||
</Label>
|
||||
<Textarea
|
||||
|
|
@ -268,7 +268,7 @@ const AgentNodeEditForm = ({
|
|||
/>
|
||||
|
||||
<Label>Variables</Label>
|
||||
<Label className="text-xs text-gray-500">
|
||||
<Label className="text-xs text-muted-foreground">
|
||||
Define each variable you want to extract along with its data type.
|
||||
</Label>
|
||||
|
||||
|
|
|
|||
|
|
@ -14,14 +14,19 @@ export const BaseNode = forwardRef<
|
|||
<div
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"relative rounded-md border bg-card p-5 text-card-foreground min-w-[300px] min-h-[100px]",
|
||||
// Base styling - larger with max width, uses semantic colors
|
||||
"relative rounded-lg border bg-card text-card-foreground min-w-[320px] max-w-[400px] min-h-[120px]",
|
||||
// Border styling
|
||||
"border-border",
|
||||
className,
|
||||
// Selected state
|
||||
selected ? "border-muted-foreground shadow-lg" : "",
|
||||
invalid ? "border-red-500 shadow-[0_0_10px_rgba(239,68,68,0.5)]" : "",
|
||||
// Invalid state
|
||||
invalid ? "border-destructive shadow-[0_0_10px_rgba(239,68,68,0.3)]" : "",
|
||||
// Hovered through edge takes precedence over selected through edge
|
||||
hovered_through_edge ? "ring-2 ring-blue-400 shadow-[0_0_12px_rgba(96,165,250,0.5)]" : "",
|
||||
!hovered_through_edge && selected_through_edge ? "ring-1 ring-blue-500 shadow-[0_0_8px_rgba(59,130,246,0.4)]" : "",
|
||||
!selected_through_edge && !hovered_through_edge && "hover:ring-1 hover:ring-gray-300",
|
||||
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)]" : "",
|
||||
!selected_through_edge && !hovered_through_edge && "hover:border-muted-foreground/50",
|
||||
)}
|
||||
tabIndex={0}
|
||||
{...props}
|
||||
|
|
|
|||
|
|
@ -103,14 +103,14 @@ export const EndCall = memo(({ data, selected, id }: EndCallNodeProps) => {
|
|||
hovered_through_edge={data.hovered_through_edge}
|
||||
title="End Call"
|
||||
icon={<OctagonX />}
|
||||
bgColor="bg-red-300"
|
||||
nodeType="end"
|
||||
hasTargetHandle={true}
|
||||
onDoubleClick={() => setOpen(true)}
|
||||
nodeId={id}
|
||||
>
|
||||
<div className="text-sm text-muted-foreground">
|
||||
{data.prompt?.length > 30 ? `${data.prompt.substring(0, 30)}...` : data.prompt}
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground line-clamp-5 leading-relaxed">
|
||||
{data.prompt || 'No prompt configured'}
|
||||
</p>
|
||||
</NodeContent>
|
||||
|
||||
<NodeToolbar isVisible={selected} position={Position.Right}>
|
||||
|
|
@ -191,13 +191,13 @@ const EndCallEditForm = ({
|
|||
return (
|
||||
<div className="grid gap-2">
|
||||
<Label>Name</Label>
|
||||
<Label className="text-xs text-gray-500">
|
||||
<Label className="text-xs text-muted-foreground">
|
||||
The name of the agent that will be used to identify the agent in the call logs. It should be short and should identify the step in the call.
|
||||
</Label>
|
||||
<Input value={name} onChange={(e) => setName(e.target.value)} />
|
||||
|
||||
<Label>Prompt</Label>
|
||||
<Label className="text-xs text-gray-500">
|
||||
<Label className="text-xs text-muted-foreground">
|
||||
Enter the prompt for the agent. This will be used to generate the agent's response. Prompt engineering's best practices apply.
|
||||
</Label>
|
||||
<Textarea
|
||||
|
|
@ -212,7 +212,7 @@ const EndCallEditForm = ({
|
|||
<div className="flex items-center space-x-2">
|
||||
<Switch id="add-global-prompt" checked={addGlobalPrompt} onCheckedChange={setAddGlobalPrompt} />
|
||||
<Label htmlFor="add-global-prompt">Add Global Prompt</Label>
|
||||
<Label className="text-xs text-gray-500">
|
||||
<Label className="text-xs text-muted-foreground">
|
||||
Whether you want to add global prompt with this node's prompt.
|
||||
</Label>
|
||||
</div>
|
||||
|
|
@ -221,7 +221,7 @@ const EndCallEditForm = ({
|
|||
<div className="flex items-center space-x-2 pt-2">
|
||||
<Switch id="enable-extraction" checked={extractionEnabled} onCheckedChange={setExtractionEnabled} />
|
||||
<Label htmlFor="enable-extraction">Enable Variable Extraction</Label>
|
||||
<Label className="text-xs text-gray-500 ml-2">
|
||||
<Label className="text-xs text-muted-foreground ml-2">
|
||||
Are there any variables you would like to extract from the conversation?
|
||||
</Label>
|
||||
</div>
|
||||
|
|
@ -229,7 +229,7 @@ const EndCallEditForm = ({
|
|||
{extractionEnabled && (
|
||||
<div className="border rounded-md p-3 mt-2 space-y-2 bg-muted/20">
|
||||
<Label>Extraction Prompt</Label>
|
||||
<Label className="text-xs text-gray-500">
|
||||
<Label className="text-xs text-muted-foreground">
|
||||
Provide an overall extraction prompt that guides how variables should be extracted from the conversation.
|
||||
</Label>
|
||||
<Textarea
|
||||
|
|
@ -240,7 +240,7 @@ const EndCallEditForm = ({
|
|||
/>
|
||||
|
||||
<Label>Variables</Label>
|
||||
<Label className="text-xs text-gray-500">
|
||||
<Label className="text-xs text-muted-foreground">
|
||||
Define each variable you want to extract along with its data type.
|
||||
</Label>
|
||||
|
||||
|
|
|
|||
|
|
@ -73,13 +73,13 @@ export const GlobalNode = memo(({ data, selected, id }: GlobalNodeProps) => {
|
|||
hovered_through_edge={data.hovered_through_edge}
|
||||
title={data.name || 'Global'}
|
||||
icon={<Headset />}
|
||||
bgColor="bg-orange-300"
|
||||
nodeType="global"
|
||||
onDoubleClick={() => setOpen(true)}
|
||||
nodeId={id}
|
||||
>
|
||||
<div className="text-sm text-muted-foreground">
|
||||
{data.prompt?.length > 30 ? `${data.prompt.substring(0, 30)}...` : data.prompt}
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground line-clamp-5 leading-relaxed">
|
||||
{data.prompt || 'No prompt configured'}
|
||||
</p>
|
||||
</NodeContent>
|
||||
|
||||
<NodeToolbar isVisible={selected} position={Position.Right}>
|
||||
|
|
@ -123,7 +123,7 @@ const GlobalNodeEditForm = ({
|
|||
return (
|
||||
<div className="grid gap-2">
|
||||
<Label>Name</Label>
|
||||
<Label className="text-xs text-gray-500">
|
||||
<Label className="text-xs text-muted-foreground">
|
||||
The name of the global node.
|
||||
</Label>
|
||||
<Input
|
||||
|
|
@ -132,7 +132,7 @@ const GlobalNodeEditForm = ({
|
|||
/>
|
||||
|
||||
<Label>Prompt</Label>
|
||||
<Label className="text-xs text-gray-500">
|
||||
<Label className="text-xs text-muted-foreground">
|
||||
This is the global prompt. This will be added to the system prompt of all the agents.
|
||||
</Label>
|
||||
<Textarea
|
||||
|
|
|
|||
|
|
@ -107,14 +107,14 @@ export const StartCall = memo(({ data, selected, id }: StartCallNodeProps) => {
|
|||
hovered_through_edge={data.hovered_through_edge}
|
||||
title="Start Call"
|
||||
icon={<Play />}
|
||||
bgColor="bg-green-300"
|
||||
nodeType="start"
|
||||
hasSourceHandle={true}
|
||||
onDoubleClick={() => setOpen(true)}
|
||||
nodeId={id}
|
||||
>
|
||||
<div className="text-sm text-muted-foreground">
|
||||
{data.prompt?.length > 30 ? `${data.prompt.substring(0, 30)}...` : data.prompt}
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground line-clamp-5 leading-relaxed">
|
||||
{data.prompt || 'No prompt configured'}
|
||||
</p>
|
||||
</NodeContent>
|
||||
|
||||
<NodeToolbar isVisible={selected} position={Position.Right}>
|
||||
|
|
@ -173,7 +173,7 @@ const StartCallEditForm = ({
|
|||
return (
|
||||
<div className="grid gap-2">
|
||||
<Label>Name</Label>
|
||||
<Label className="text-xs text-gray-500">
|
||||
<Label className="text-xs text-muted-foreground">
|
||||
The name of the agent that will be used to identify the agent in the call logs. It should be short and should identify the step in the call.
|
||||
</Label>
|
||||
<Input
|
||||
|
|
@ -182,7 +182,7 @@ const StartCallEditForm = ({
|
|||
/>
|
||||
|
||||
<Label>Prompt</Label>
|
||||
<Label className="text-xs text-gray-500">
|
||||
<Label className="text-xs text-muted-foreground">
|
||||
Enter the prompt for the agent. This will be used to generate the agent's response. Prompt engineering's best practices apply.
|
||||
</Label>
|
||||
<Textarea
|
||||
|
|
@ -197,7 +197,7 @@ const StartCallEditForm = ({
|
|||
<div className="flex items-center space-x-2">
|
||||
<Switch id="allow-interrupt" checked={allowInterrupt} onCheckedChange={setAllowInterrupt} />
|
||||
<Label htmlFor="allow-interrupt">Allow Interruption</Label>
|
||||
<Label className="text-xs text-gray-500">
|
||||
<Label className="text-xs text-muted-foreground">
|
||||
Whether you would like user to be able to interrupt the bot.
|
||||
</Label>
|
||||
</div>
|
||||
|
|
@ -221,7 +221,7 @@ const StartCallEditForm = ({
|
|||
<Label htmlFor="detect-voicemail">
|
||||
Detect Voicemail
|
||||
</Label>
|
||||
<Label className="text-xs text-gray-500">
|
||||
<Label className="text-xs text-muted-foreground">
|
||||
Automatically detect and end call if voicemail is reached.
|
||||
</Label>
|
||||
</div>
|
||||
|
|
@ -236,7 +236,7 @@ const StartCallEditForm = ({
|
|||
<Label htmlFor="delayed-start">
|
||||
Delayed Start
|
||||
</Label>
|
||||
<Label className="text-xs text-gray-500">
|
||||
<Label className="text-xs text-muted-foreground">
|
||||
Introduce a delay before the agent starts speaking.
|
||||
</Label>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@ import { ReactNode } from "react";
|
|||
|
||||
import { BaseHandle } from "@/components/flow/nodes/BaseHandle";
|
||||
import { BaseNode } from "@/components/flow/nodes/BaseNode";
|
||||
import { NodeHeader, NodeHeaderIcon, NodeHeaderTitle } from "@/components/flow/nodes/NodeHeader";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
interface NodeContentProps {
|
||||
selected: boolean;
|
||||
|
|
@ -12,7 +12,7 @@ interface NodeContentProps {
|
|||
hovered_through_edge?: boolean;
|
||||
title: string;
|
||||
icon: ReactNode;
|
||||
bgColor: string;
|
||||
nodeType?: 'start' | 'agent' | 'end' | 'global';
|
||||
hasSourceHandle?: boolean;
|
||||
hasTargetHandle?: boolean;
|
||||
children?: ReactNode;
|
||||
|
|
@ -21,6 +21,22 @@ interface NodeContentProps {
|
|||
nodeId?: string;
|
||||
}
|
||||
|
||||
// Get badge styling based on node type
|
||||
const getNodeTypeBadge = (nodeType?: string) => {
|
||||
switch (nodeType) {
|
||||
case 'start':
|
||||
return { label: 'Start Node', className: 'bg-emerald-500 text-white' };
|
||||
case 'agent':
|
||||
return { label: 'Agent Node', className: 'bg-blue-500 text-white' };
|
||||
case 'end':
|
||||
return { label: 'End Node', className: 'bg-rose-500 text-white' };
|
||||
case 'global':
|
||||
return { label: 'Global Node', className: 'bg-amber-500 text-white' };
|
||||
default:
|
||||
return { label: 'Node', className: 'bg-zinc-500 text-white' };
|
||||
}
|
||||
};
|
||||
|
||||
export const NodeContent = ({
|
||||
selected,
|
||||
invalid,
|
||||
|
|
@ -28,31 +44,52 @@ export const NodeContent = ({
|
|||
hovered_through_edge,
|
||||
title,
|
||||
icon,
|
||||
bgColor,
|
||||
nodeType,
|
||||
hasSourceHandle = false,
|
||||
hasTargetHandle = false,
|
||||
children,
|
||||
className = "",
|
||||
onDoubleClick,
|
||||
nodeId,
|
||||
}: NodeContentProps) => {
|
||||
const badge = getNodeTypeBadge(nodeType);
|
||||
|
||||
return (
|
||||
<BaseNode
|
||||
selected={selected}
|
||||
invalid={invalid}
|
||||
selected_through_edge={selected_through_edge}
|
||||
hovered_through_edge={hovered_through_edge}
|
||||
className={`p-0 overflow-hidden ${className}`}
|
||||
className={`p-0 ${className}`}
|
||||
onDoubleClick={onDoubleClick}
|
||||
>
|
||||
{hasTargetHandle && <BaseHandle type="target" position={Position.Top} />}
|
||||
<NodeHeader className={`px-3 py-2 border-b ${bgColor}`}>
|
||||
<NodeHeaderIcon>{icon}</NodeHeaderIcon>
|
||||
<NodeHeaderTitle>{title} - NodeID: {nodeId}</NodeHeaderTitle>
|
||||
</NodeHeader>
|
||||
<div className="p-3">
|
||||
|
||||
{/* Node type badge - positioned at top */}
|
||||
<div className="absolute -top-3 left-4">
|
||||
<span className={cn(
|
||||
"inline-flex items-center gap-1.5 px-2 py-0.5 rounded text-xs font-medium",
|
||||
badge.className
|
||||
)}>
|
||||
<span className="[&>*]:w-3 [&>*]:h-3">{icon}</span>
|
||||
{badge.label}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Header with title */}
|
||||
<div className="px-4 pt-5 pb-2 border-b border-border">
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className="text-sm font-semibold text-foreground truncate">
|
||||
{title}
|
||||
</h3>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Content area with prompt label */}
|
||||
<div className="p-4">
|
||||
<div className="text-xs text-muted-foreground mb-1.5 font-medium">Prompt:</div>
|
||||
{children}
|
||||
</div>
|
||||
|
||||
{hasSourceHandle && <BaseHandle type="source" position={Position.Bottom} />}
|
||||
</BaseNode>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -1,162 +0,0 @@
|
|||
"use client";
|
||||
|
||||
import { CircleDollarSign, HelpCircle,Star } from 'lucide-react';
|
||||
import Link from 'next/link';
|
||||
import { usePathname, useRouter } from 'next/navigation';
|
||||
import React from 'react';
|
||||
|
||||
import { useUserConfig } from '@/context/UserConfigContext';
|
||||
import { useAuth } from '@/lib/auth';
|
||||
|
||||
// Conditionally load Stack components only when using Stack auth
|
||||
const StackUserButton = React.lazy(() =>
|
||||
import('@stackframe/stack').then(mod => ({ default: mod.UserButton }))
|
||||
);
|
||||
const StackTeamSwitcher = React.lazy(() =>
|
||||
import('@stackframe/stack').then(mod => ({ default: mod.SelectedTeamSwitcher }))
|
||||
);
|
||||
|
||||
interface BaseHeaderProps {
|
||||
headerActions?: React.ReactNode,
|
||||
backButton?: React.ReactNode,
|
||||
showFeaturesNav?: boolean
|
||||
}
|
||||
|
||||
export default function BaseHeader({ headerActions, backButton, showFeaturesNav = true }: BaseHeaderProps) {
|
||||
const { permissions } = useUserConfig();
|
||||
const { provider, user } = useAuth();
|
||||
const pathname = usePathname();
|
||||
const router = useRouter();
|
||||
|
||||
const isActive = (path: string) => {
|
||||
return pathname.startsWith(path);
|
||||
};
|
||||
|
||||
const hasAdminPermission = Array.isArray(permissions) && permissions.some(p => p.id === 'admin');
|
||||
|
||||
|
||||
return (
|
||||
<header className="sticky top-0 z-50 w-full border-b border-gray-200 bg-white">
|
||||
<div className="container mx-auto px-4 py-4">
|
||||
<nav className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<Link href="/" className="text-xl font-semibold text-gray-800 hover:text-gray-600">
|
||||
Dograh
|
||||
</Link>
|
||||
{backButton}
|
||||
{showFeaturesNav && (
|
||||
<div className="flex items-center gap-4 ml-8">
|
||||
{hasAdminPermission && (
|
||||
<>
|
||||
<Link
|
||||
href="/workflow"
|
||||
className={`text-sm font-medium transition-colors hover:text-primary ${isActive('/workflow') ? 'text-primary' : 'text-gray-600'
|
||||
}`}
|
||||
>
|
||||
Workflows
|
||||
</Link>
|
||||
<Link
|
||||
href="/campaigns"
|
||||
className={`text-sm font-medium transition-colors hover:text-primary ${isActive('/campaigns') ? 'text-primary' : 'text-gray-600'
|
||||
}`}
|
||||
>
|
||||
Campaigns
|
||||
</Link>
|
||||
<Link
|
||||
href="/automation"
|
||||
className={`text-sm font-medium transition-colors hover:text-primary ${isActive('/automation') ? 'text-primary' : 'text-gray-600'
|
||||
}`}
|
||||
>
|
||||
Automation
|
||||
</Link>
|
||||
<Link
|
||||
href="/looptalk"
|
||||
className={`text-sm font-medium transition-colors hover:text-primary ${isActive('/looptalk') ? 'text-primary' : 'text-gray-600'
|
||||
}`}
|
||||
>
|
||||
LoopTalk
|
||||
</Link>
|
||||
</>
|
||||
)}
|
||||
<Link
|
||||
href="/usage"
|
||||
className={`text-sm font-medium transition-colors hover:text-primary ${isActive('/usage') ? 'text-primary' : 'text-gray-600'
|
||||
}`}
|
||||
>
|
||||
Usage
|
||||
</Link>
|
||||
<Link
|
||||
href="/reports"
|
||||
className={`text-sm font-medium transition-colors hover:text-primary ${isActive('/reports') ? 'text-primary' : 'text-gray-600'
|
||||
}`}
|
||||
>
|
||||
Reports
|
||||
</Link>
|
||||
<Link
|
||||
href="/api-keys"
|
||||
className={`text-sm font-medium transition-colors hover:text-primary ${isActive('/api-keys') ? 'text-primary' : 'text-gray-600'
|
||||
}`}
|
||||
>
|
||||
Developers
|
||||
</Link>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex-1 flex justify-center">
|
||||
{headerActions}
|
||||
</div>
|
||||
|
||||
{/* Use key to force remount when user changes to avoid hooks issues */}
|
||||
<div className="flex items-center gap-5" key={user ? 'logged-in' : 'logged-out'}>
|
||||
{provider === 'stack' ? (
|
||||
<React.Suspense fallback={
|
||||
<div className="flex items-center gap-5">
|
||||
{/* Match StackTeamSwitcher's internal skeleton */}
|
||||
<div className="h-9 w-40 animate-pulse bg-gray-100 rounded" />
|
||||
{/* Match StackUserButton dimensions: h-[34px] w-[34px] */}
|
||||
<div className="h-[34px] w-[34px] animate-pulse bg-gray-100 rounded-full" />
|
||||
</div>
|
||||
}>
|
||||
<div className="w-40 shrink-0">
|
||||
<StackTeamSwitcher
|
||||
onChange={() => {
|
||||
router.refresh();
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<StackUserButton
|
||||
extraItems={[{
|
||||
text: 'Usage',
|
||||
icon: <CircleDollarSign strokeWidth={2} size={16} />,
|
||||
onClick: () => router.push('/usage')
|
||||
}]}
|
||||
/>
|
||||
</React.Suspense>
|
||||
) : (
|
||||
<>
|
||||
<a
|
||||
href="https://github.com/dograh-hq/dograh/issues/new/choose"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex items-center gap-2 px-4 py-2 text-sm font-medium text-gray-700 bg-white border border-gray-300 rounded-md hover:bg-gray-50 transition-colors"
|
||||
>
|
||||
<HelpCircle className="w-4 h-4" />
|
||||
Get Help
|
||||
</a>
|
||||
<a
|
||||
href="https://github.com/dograh-hq/dograh"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex items-center gap-2 px-4 py-2 text-sm font-medium text-gray-700 bg-white border border-gray-300 rounded-md hover:bg-gray-50 transition-colors"
|
||||
>
|
||||
<Star className="w-4 h-4 fill-yellow-400 text-yellow-400" />
|
||||
Star us on GitHub
|
||||
</a>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</nav>
|
||||
</div>
|
||||
</header>
|
||||
);
|
||||
}
|
||||
72
ui/src/components/layout/AppLayout.tsx
Normal file
72
ui/src/components/layout/AppLayout.tsx
Normal file
|
|
@ -0,0 +1,72 @@
|
|||
"use client";
|
||||
|
||||
import { usePathname } from "next/navigation";
|
||||
import React, { ReactNode } from "react";
|
||||
|
||||
import { SidebarInset, SidebarProvider } from "@/components/ui/sidebar";
|
||||
|
||||
import { AppSidebar } from "./AppSidebar";
|
||||
|
||||
interface AppLayoutProps {
|
||||
children: ReactNode;
|
||||
headerActions?: ReactNode;
|
||||
stickyTabs?: ReactNode;
|
||||
}
|
||||
|
||||
const AppLayout: React.FC<AppLayoutProps> = ({
|
||||
children,
|
||||
headerActions,
|
||||
stickyTabs,
|
||||
}) => {
|
||||
const pathname = usePathname();
|
||||
|
||||
// Check if current route should have sidebar
|
||||
// Hide sidebar for root (/) and /handler routes (Stack Auth routes)
|
||||
const shouldShowSidebar = pathname !== "/" && !pathname.startsWith("/handler");
|
||||
|
||||
// Check if we're in workflow editor mode - collapse sidebar by default
|
||||
const isWorkflowEditor = /^\/workflow\/\d+/.test(pathname);
|
||||
|
||||
// If no sidebar needed, just return children
|
||||
if (!shouldShowSidebar) {
|
||||
return <>{children}</>;
|
||||
}
|
||||
|
||||
return (
|
||||
<SidebarProvider defaultOpen={!isWorkflowEditor}>
|
||||
<div className="flex h-screen w-full">
|
||||
<AppSidebar />
|
||||
<SidebarInset className="flex-1">
|
||||
{/* Optional header area for specific pages */}
|
||||
{headerActions && (
|
||||
<header className="sticky top-0 z-50 w-full border-b bg-background">
|
||||
<div className="container mx-auto px-4 py-4">
|
||||
<div className="flex items-center justify-center">
|
||||
{headerActions}
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
)}
|
||||
|
||||
{/* Optional sticky tabs */}
|
||||
{stickyTabs && (
|
||||
<div className="sticky top-0 z-40 bg-[#2a2e39] border-b border-gray-700">
|
||||
<div className="container mx-auto px-4">
|
||||
<div className="flex items-center justify-center py-2">
|
||||
{stickyTabs}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Main content area */}
|
||||
<main className="flex-1 overflow-auto">
|
||||
{children}
|
||||
</main>
|
||||
</SidebarInset>
|
||||
</div>
|
||||
</SidebarProvider>
|
||||
);
|
||||
};
|
||||
|
||||
export default AppLayout;
|
||||
440
ui/src/components/layout/AppSidebar.tsx
Normal file
440
ui/src/components/layout/AppSidebar.tsx
Normal file
|
|
@ -0,0 +1,440 @@
|
|||
"use client";
|
||||
|
||||
import type { Team } from "@stackframe/stack";
|
||||
import {
|
||||
Brain,
|
||||
ChevronLeft,
|
||||
ChevronRight,
|
||||
CircleDollarSign,
|
||||
FileText,
|
||||
HelpCircle,
|
||||
Home,
|
||||
Key,
|
||||
Megaphone,
|
||||
MessageSquare,
|
||||
Phone,
|
||||
Star,
|
||||
TrendingUp,
|
||||
Workflow,
|
||||
Zap,
|
||||
} from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { usePathname, useRouter } from "next/navigation";
|
||||
import React from "react";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Sidebar,
|
||||
SidebarContent,
|
||||
SidebarFooter,
|
||||
SidebarGroup,
|
||||
SidebarGroupLabel,
|
||||
SidebarHeader,
|
||||
SidebarMenu,
|
||||
SidebarMenuButton,
|
||||
SidebarMenuItem,
|
||||
SidebarRail,
|
||||
SidebarTrigger,
|
||||
useSidebar,
|
||||
} from "@/components/ui/sidebar";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
} from "@/components/ui/tooltip";
|
||||
import { useAuth } from "@/lib/auth";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
// Conditionally load Stack components only when using Stack auth
|
||||
const StackUserButton = React.lazy(() =>
|
||||
import("@stackframe/stack").then((mod) => ({ default: mod.UserButton }))
|
||||
);
|
||||
|
||||
// Lazy load SelectedTeamSwitcher - we'll pass selectedTeam from our context
|
||||
const StackTeamSwitcher = React.lazy(() =>
|
||||
import("@stackframe/stack").then((mod) => ({
|
||||
default: mod.SelectedTeamSwitcher,
|
||||
}))
|
||||
);
|
||||
|
||||
export function AppSidebar() {
|
||||
const pathname = usePathname();
|
||||
const router = useRouter();
|
||||
const { state } = useSidebar();
|
||||
const { provider, getSelectedTeam } = useAuth();
|
||||
|
||||
// Get selected team for Stack auth (cast to Team type from Stack)
|
||||
const selectedTeam = provider === "stack" && getSelectedTeam ? getSelectedTeam() as Team | null : null;
|
||||
|
||||
const isActive = (path: string) => {
|
||||
return pathname.startsWith(path);
|
||||
};
|
||||
|
||||
|
||||
// Organize navigation into sections
|
||||
const overviewSection = [
|
||||
{
|
||||
title: "Overview",
|
||||
url: "/overview",
|
||||
icon: Home,
|
||||
},
|
||||
];
|
||||
|
||||
const buildSection = [
|
||||
{
|
||||
title: "Voice Agents",
|
||||
url: "/workflow",
|
||||
icon: Workflow,
|
||||
},
|
||||
{
|
||||
title: "Campaigns",
|
||||
url: "/campaigns",
|
||||
icon: Megaphone,
|
||||
},
|
||||
{
|
||||
title: "Automation",
|
||||
url: "/automation",
|
||||
icon: Zap,
|
||||
},
|
||||
{
|
||||
title: "Models",
|
||||
url: "/model-configurations",
|
||||
icon: Brain,
|
||||
},
|
||||
{
|
||||
title: "Telephony",
|
||||
url: "/telephony-configurations",
|
||||
icon: Phone,
|
||||
},
|
||||
// {
|
||||
// title: "Integrations",
|
||||
// url: "/integrations",
|
||||
// icon: Plug,
|
||||
// },
|
||||
{
|
||||
title: "Developers",
|
||||
url: "/api-keys",
|
||||
icon: Key,
|
||||
},
|
||||
];
|
||||
|
||||
const observeSection = [
|
||||
{
|
||||
title: "Usage",
|
||||
url: "/usage",
|
||||
icon: TrendingUp,
|
||||
},
|
||||
{
|
||||
title: "Reports",
|
||||
url: "/reports",
|
||||
icon: FileText,
|
||||
},
|
||||
{
|
||||
title: "LoopTalk",
|
||||
url: "/looptalk",
|
||||
icon: MessageSquare,
|
||||
},
|
||||
];
|
||||
|
||||
const SidebarLink = ({ item }: { item: typeof overviewSection[0] }) => {
|
||||
const isItemActive = isActive(item.url);
|
||||
const Icon = item.icon;
|
||||
|
||||
if (state === "collapsed") {
|
||||
return (
|
||||
<TooltipProvider delayDuration={0}>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<SidebarMenuButton
|
||||
asChild
|
||||
className={cn(
|
||||
"hover:bg-accent hover:text-accent-foreground",
|
||||
isItemActive && "bg-accent text-accent-foreground"
|
||||
)}
|
||||
>
|
||||
<Link href={item.url}>
|
||||
<Icon className="h-4 w-4" />
|
||||
<span className="sr-only">{item.title}</span>
|
||||
</Link>
|
||||
</SidebarMenuButton>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="right">
|
||||
<p>{item.title}</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<SidebarMenuButton
|
||||
asChild
|
||||
className={cn(
|
||||
"hover:bg-accent hover:text-accent-foreground",
|
||||
isItemActive && "bg-accent text-accent-foreground"
|
||||
)}
|
||||
>
|
||||
<Link href={item.url}>
|
||||
<Icon className="h-4 w-4" />
|
||||
<span>{item.title}</span>
|
||||
</Link>
|
||||
</SidebarMenuButton>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<Sidebar collapsible="icon" className="border-r">
|
||||
<SidebarHeader className="border-b px-2 py-3">
|
||||
<div className="flex items-center justify-between">
|
||||
{/* Logo - only show when expanded */}
|
||||
{state === "expanded" && (
|
||||
<Link
|
||||
href="/"
|
||||
className="flex items-center gap-2 px-2 text-xl font-bold"
|
||||
>
|
||||
Dograh
|
||||
</Link>
|
||||
)}
|
||||
{/* Toggle button - center it when collapsed */}
|
||||
<SidebarTrigger className={cn(
|
||||
"hover:bg-accent",
|
||||
state === "collapsed" && "mx-auto"
|
||||
)}>
|
||||
{state === "expanded" ? (
|
||||
<ChevronLeft className="h-4 w-4" />
|
||||
) : (
|
||||
<ChevronRight className="h-4 w-4" />
|
||||
)}
|
||||
</SidebarTrigger>
|
||||
</div>
|
||||
|
||||
{/* Team Switcher for Stack Auth - at the top */}
|
||||
{provider === "stack" && state === "expanded" && (
|
||||
<div className="mt-3">
|
||||
<React.Suspense
|
||||
fallback={
|
||||
<div className="h-9 w-full animate-pulse bg-muted rounded" />
|
||||
}
|
||||
>
|
||||
<StackTeamSwitcher
|
||||
selectedTeam={selectedTeam || undefined}
|
||||
onChange={() => {
|
||||
router.refresh();
|
||||
}}
|
||||
/>
|
||||
</React.Suspense>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Star us on GitHub for OSS mode - at the top */}
|
||||
{provider !== "stack" && (
|
||||
<div className="mt-3 px-2">
|
||||
{state === "collapsed" ? (
|
||||
<TooltipProvider delayDuration={0}>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="w-full hover:bg-accent hover:text-accent-foreground"
|
||||
asChild
|
||||
>
|
||||
<a
|
||||
href="https://github.com/dograh-hq/dograh"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<Star className="h-4 w-4 fill-yellow-400 text-yellow-400" />
|
||||
<span className="sr-only">Star us on GitHub</span>
|
||||
</a>
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="right">
|
||||
<p>Star us on GitHub</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
) : (
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="w-full justify-start hover:bg-accent hover:text-accent-foreground"
|
||||
asChild
|
||||
>
|
||||
<a
|
||||
href="https://github.com/dograh-hq/dograh"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<Star className="h-4 w-4 fill-yellow-400 text-yellow-400" />
|
||||
<span className="ml-2">Star us on GitHub</span>
|
||||
</a>
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</SidebarHeader>
|
||||
|
||||
<SidebarContent className={cn(
|
||||
state === "collapsed" && "px-0"
|
||||
)}>
|
||||
{/* Overview Section */}
|
||||
<SidebarGroup className="mt-2">
|
||||
<SidebarMenu>
|
||||
{overviewSection.map((item) => (
|
||||
<SidebarMenuItem key={item.title}>
|
||||
<SidebarLink item={item} />
|
||||
</SidebarMenuItem>
|
||||
))}
|
||||
</SidebarMenu>
|
||||
</SidebarGroup>
|
||||
|
||||
{/* BUILD Section */}
|
||||
{buildSection.length > 0 && (
|
||||
<SidebarGroup className="mt-6">
|
||||
{state === "expanded" && (
|
||||
<SidebarGroupLabel className="text-xs font-semibold text-muted-foreground uppercase tracking-wider">
|
||||
BUILD
|
||||
</SidebarGroupLabel>
|
||||
)}
|
||||
<SidebarMenu>
|
||||
{buildSection.map((item) => (
|
||||
<SidebarMenuItem key={item.title}>
|
||||
<SidebarLink item={item} />
|
||||
</SidebarMenuItem>
|
||||
))}
|
||||
</SidebarMenu>
|
||||
</SidebarGroup>
|
||||
)}
|
||||
|
||||
{/* OBSERVE Section */}
|
||||
<SidebarGroup className="mt-6">
|
||||
{state === "expanded" && (
|
||||
<SidebarGroupLabel className="text-xs font-semibold text-muted-foreground uppercase tracking-wider">
|
||||
OBSERVE
|
||||
</SidebarGroupLabel>
|
||||
)}
|
||||
<SidebarMenu>
|
||||
{observeSection.map((item) => (
|
||||
<SidebarMenuItem key={item.title}>
|
||||
<SidebarLink item={item} />
|
||||
</SidebarMenuItem>
|
||||
))}
|
||||
</SidebarMenu>
|
||||
</SidebarGroup>
|
||||
</SidebarContent>
|
||||
|
||||
<SidebarFooter className={cn(
|
||||
"border-t p-4",
|
||||
state === "collapsed" && "p-2"
|
||||
)}>
|
||||
{/* Bottom Actions */}
|
||||
<div className="space-y-2">
|
||||
{/* Get Help - for OSS mode */}
|
||||
{provider !== "stack" && (
|
||||
<>
|
||||
{state === "collapsed" ? (
|
||||
<TooltipProvider delayDuration={0}>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="w-full hover:bg-accent hover:text-accent-foreground"
|
||||
asChild
|
||||
>
|
||||
<a
|
||||
href="https://github.com/dograh-hq/dograh/issues/new/choose"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<HelpCircle className="h-4 w-4" />
|
||||
<span className="sr-only">Get Help</span>
|
||||
</a>
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="right">
|
||||
<p>Get Help</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
) : (
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="w-full justify-start hover:bg-accent hover:text-accent-foreground"
|
||||
asChild
|
||||
>
|
||||
<a
|
||||
href="https://github.com/dograh-hq/dograh/issues/new/choose"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<HelpCircle className="h-4 w-4" />
|
||||
<span className="ml-2">Get Help</span>
|
||||
</a>
|
||||
</Button>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* User Button for Stack Auth - at the bottom */}
|
||||
{provider === "stack" && (
|
||||
<React.Suspense
|
||||
fallback={
|
||||
<div className={cn(
|
||||
"animate-pulse bg-muted rounded",
|
||||
state === "collapsed" ? "h-8 w-8" : "h-[34px] w-[34px]"
|
||||
)} />
|
||||
}
|
||||
>
|
||||
<div className={cn(
|
||||
"flex",
|
||||
state === "collapsed" ? "justify-center" : "justify-start"
|
||||
)}>
|
||||
<StackUserButton
|
||||
extraItems={[
|
||||
{
|
||||
text: "Usage",
|
||||
icon: <CircleDollarSign strokeWidth={2} size={16} />,
|
||||
onClick: () => router.push("/usage"),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
</React.Suspense>
|
||||
)}
|
||||
|
||||
{/* Theme Toggle - at the very bottom */}
|
||||
{/* <div className={cn(
|
||||
"mt-2 pt-2 border-t",
|
||||
state === "collapsed" ? "flex justify-center" : ""
|
||||
)}>
|
||||
{state === "collapsed" ? (
|
||||
<TooltipProvider delayDuration={0}>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<div>
|
||||
<ThemeToggle
|
||||
showLabel={false}
|
||||
className="hover:bg-accent hover:text-accent-foreground"
|
||||
/>
|
||||
</div>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="right">
|
||||
<p>Toggle theme</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
) : (
|
||||
<ThemeToggle
|
||||
showLabel={true}
|
||||
className="hover:bg-accent hover:text-accent-foreground"
|
||||
/>
|
||||
)}
|
||||
</div> */}
|
||||
</div>
|
||||
</SidebarFooter>
|
||||
<SidebarRail />
|
||||
</Sidebar>
|
||||
);
|
||||
}
|
||||
33
ui/src/components/ui/collapsible.tsx
Normal file
33
ui/src/components/ui/collapsible.tsx
Normal file
|
|
@ -0,0 +1,33 @@
|
|||
"use client"
|
||||
|
||||
import * as CollapsiblePrimitive from "@radix-ui/react-collapsible"
|
||||
|
||||
function Collapsible({
|
||||
...props
|
||||
}: React.ComponentProps<typeof CollapsiblePrimitive.Root>) {
|
||||
return <CollapsiblePrimitive.Root data-slot="collapsible" {...props} />
|
||||
}
|
||||
|
||||
function CollapsibleTrigger({
|
||||
...props
|
||||
}: React.ComponentProps<typeof CollapsiblePrimitive.CollapsibleTrigger>) {
|
||||
return (
|
||||
<CollapsiblePrimitive.CollapsibleTrigger
|
||||
data-slot="collapsible-trigger"
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CollapsibleContent({
|
||||
...props
|
||||
}: React.ComponentProps<typeof CollapsiblePrimitive.CollapsibleContent>) {
|
||||
return (
|
||||
<CollapsiblePrimitive.CollapsibleContent
|
||||
data-slot="collapsible-content"
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Collapsible, CollapsibleContent,CollapsibleTrigger }
|
||||
|
|
@ -8,7 +8,7 @@ import { Button } from "@/components/ui/button";
|
|||
export function CreateWorkflowButton() {
|
||||
const router = useRouter();
|
||||
const handleClick = () => {
|
||||
router.push('/create-workflow');
|
||||
router.push('/workflow/create');
|
||||
};
|
||||
|
||||
return (
|
||||
|
|
@ -16,7 +16,7 @@ export function CreateWorkflowButton() {
|
|||
onClick={handleClick}
|
||||
>
|
||||
<PlusIcon className="w-4 h-4" />
|
||||
Create Workflow
|
||||
Create Agent
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -90,13 +90,13 @@ export function UploadWorkflowButton() {
|
|||
variant="outline"
|
||||
>
|
||||
<Upload className="w-4 h-4 mr-2" />
|
||||
Upload Workflow
|
||||
Upload Agent Definition
|
||||
</Button>
|
||||
|
||||
<Dialog open={isOpen} onOpenChange={setIsOpen}>
|
||||
<DialogContent className="sm:max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Upload Workflow</DialogTitle>
|
||||
<DialogTitle>Upload Agent Definition</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div
|
||||
className={`mt-4 border-2 border-dashed rounded-lg p-8 text-center ${isDragging ? 'border-primary bg-primary/5' : 'border-gray-300'
|
||||
|
|
|
|||
|
|
@ -79,11 +79,11 @@ export function WorkflowTable({ workflows, showArchived }: WorkflowTableProps) {
|
|||
};
|
||||
|
||||
return (
|
||||
<div className="bg-white border rounded-lg overflow-hidden shadow-sm">
|
||||
<div className="bg-card border rounded-lg overflow-hidden shadow-sm">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow className="bg-gray-50">
|
||||
<TableHead className="font-semibold">Workflow Name</TableHead>
|
||||
<TableRow>
|
||||
<TableHead className="font-semibold">Agent Name</TableHead>
|
||||
<TableHead className="font-semibold">Created At</TableHead>
|
||||
<TableHead className="font-semibold text-center">Total Runs</TableHead>
|
||||
<TableHead className="font-semibold text-right">Actions</TableHead>
|
||||
|
|
@ -93,7 +93,7 @@ export function WorkflowTable({ workflows, showArchived }: WorkflowTableProps) {
|
|||
{workflows.map((workflow) => (
|
||||
<TableRow
|
||||
key={workflow.id}
|
||||
className={`hover:bg-gray-50 transition-colors ${showArchived ? 'opacity-60' : ''}`}
|
||||
className={`hover:bg-accent transition-colors ${showArchived ? 'opacity-60' : ''}`}
|
||||
>
|
||||
<TableCell className="font-medium">
|
||||
{workflow.name}
|
||||
|
|
@ -106,7 +106,7 @@ export function WorkflowTable({ workflows, showArchived }: WorkflowTableProps) {
|
|||
})}
|
||||
</TableCell>
|
||||
<TableCell className="text-center">
|
||||
<span className="inline-flex items-center justify-center min-w-[2rem] px-2 py-1 text-sm font-semibold bg-gray-100 rounded-full">
|
||||
<span className="inline-flex items-center justify-center min-w-[2rem] px-2 py-1 text-sm font-semibold bg-muted rounded-full">
|
||||
{workflow.total_runs || 0}
|
||||
</span>
|
||||
</TableCell>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue