mirror of
https://github.com/dograh-hq/dograh.git
synced 2026-06-07 07:55:16 +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,15 +5,15 @@ Revises: e02f387b7538
|
|||
Create Date: 2025-11-27 21:24:34.072030
|
||||
|
||||
"""
|
||||
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
from alembic_postgresql_enum import TableReference
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = 'a188ff90e76f'
|
||||
down_revision: Union[str, None] = 'e02f387b7538'
|
||||
revision: str = "a188ff90e76f"
|
||||
down_revision: Union[str, None] = "e02f387b7538"
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
|
@ -21,10 +21,23 @@ depends_on: Union[str, Sequence[str], None] = None
|
|||
def upgrade() -> None:
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
op.sync_enum_values(
|
||||
enum_schema='public',
|
||||
enum_name='workflow_run_mode',
|
||||
new_values=['twilio', 'vonage', 'vobiz', 'stasis', 'webrtc', 'smallwebrtc', 'VOICE', 'CHAT'],
|
||||
affected_columns=[TableReference(table_schema='public', table_name='workflow_runs', column_name='mode')],
|
||||
enum_schema="public",
|
||||
enum_name="workflow_run_mode",
|
||||
new_values=[
|
||||
"twilio",
|
||||
"vonage",
|
||||
"vobiz",
|
||||
"stasis",
|
||||
"webrtc",
|
||||
"smallwebrtc",
|
||||
"VOICE",
|
||||
"CHAT",
|
||||
],
|
||||
affected_columns=[
|
||||
TableReference(
|
||||
table_schema="public", table_name="workflow_runs", column_name="mode"
|
||||
)
|
||||
],
|
||||
enum_values_to_rename=[],
|
||||
)
|
||||
# ### end Alembic commands ###
|
||||
|
|
@ -33,10 +46,22 @@ def upgrade() -> None:
|
|||
def downgrade() -> None:
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
op.sync_enum_values(
|
||||
enum_schema='public',
|
||||
enum_name='workflow_run_mode',
|
||||
new_values=['twilio', 'vonage', 'stasis', 'webrtc', 'smallwebrtc', 'VOICE', 'CHAT'],
|
||||
affected_columns=[TableReference(table_schema='public', table_name='workflow_runs', column_name='mode')],
|
||||
enum_schema="public",
|
||||
enum_name="workflow_run_mode",
|
||||
new_values=[
|
||||
"twilio",
|
||||
"vonage",
|
||||
"stasis",
|
||||
"webrtc",
|
||||
"smallwebrtc",
|
||||
"VOICE",
|
||||
"CHAT",
|
||||
],
|
||||
affected_columns=[
|
||||
TableReference(
|
||||
table_schema="public", table_name="workflow_runs", column_name="mode"
|
||||
)
|
||||
],
|
||||
enum_values_to_rename=[],
|
||||
)
|
||||
# ### end Alembic commands ###
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@
|
|||
from datetime import datetime
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
from sqlalchemy import Integer, and_, cast
|
||||
from sqlalchemy import Integer, and_, cast, func
|
||||
from sqlalchemy.dialects.postgresql import JSONB
|
||||
|
||||
from api.db.models import WorkflowRunModel
|
||||
|
|
@ -128,10 +128,16 @@ def apply_workflow_run_filters(
|
|||
):
|
||||
tags = value.get("codes", [])
|
||||
if tags:
|
||||
# The gathered_context column is JSON type (not JSONB)
|
||||
# JSON type doesn't support subscripting, so we must cast to JSONB first
|
||||
# Then extract call_tags and check containment with @>
|
||||
gathered_context_jsonb = cast(
|
||||
WorkflowRunModel.gathered_context, JSONB
|
||||
)
|
||||
# Use -> operator with literal text key to get call_tags as JSONB
|
||||
call_tags = gathered_context_jsonb.op("->")("call_tags")
|
||||
filter_conditions.append(
|
||||
cast(WorkflowRunModel.gathered_context, JSONB)[
|
||||
"call_tags"
|
||||
].contains(tags)
|
||||
call_tags.op("@>")(func.cast(tags, JSONB))
|
||||
)
|
||||
|
||||
elif filter_type == "text" and field == "initial_context.phone":
|
||||
|
|
|
|||
|
|
@ -277,7 +277,6 @@ async def handle_twilio_status_callback(
|
|||
# Parse form data
|
||||
form_data = await request.form()
|
||||
callback_data = dict(form_data)
|
||||
|
||||
logger.info(
|
||||
f"[run {workflow_run_id}] Received status callback: {json.dumps(callback_data)}"
|
||||
)
|
||||
|
|
@ -519,20 +518,11 @@ async def handle_vobiz_hangup_callback(
|
|||
This includes call duration, status, and billing information.
|
||||
"""
|
||||
# Parse the callback data (Vobiz sends form data or JSON)
|
||||
try:
|
||||
callback_data = await request.json()
|
||||
logger.info(
|
||||
f"[run {workflow_run_id}] Received Vobiz hangup callback (JSON): "
|
||||
f"{json.dumps(callback_data)}"
|
||||
)
|
||||
except Exception:
|
||||
# Fallback to form data if JSON fails
|
||||
form_data = await request.form()
|
||||
callback_data = dict(form_data)
|
||||
logger.info(
|
||||
f"[run {workflow_run_id}] Received Vobiz hangup callback (form): "
|
||||
f"{json.dumps(callback_data)}"
|
||||
)
|
||||
form_data = await request.form()
|
||||
callback_data = dict(form_data)
|
||||
logger.info(
|
||||
f"[run {workflow_run_id}] Received Vobiz hangup callback {json.dumps(callback_data)}"
|
||||
)
|
||||
|
||||
# Get workflow run for processing
|
||||
workflow_run = await db_client.get_workflow_run_by_id(workflow_run_id)
|
||||
|
|
@ -591,19 +581,11 @@ async def handle_vobiz_ring_callback(
|
|||
This is optional and used for tracking ringing status.
|
||||
"""
|
||||
# Parse the callback data
|
||||
try:
|
||||
callback_data = await request.json()
|
||||
logger.info(
|
||||
f"[run {workflow_run_id}] Received Vobiz ring callback (JSON): "
|
||||
f"{json.dumps(callback_data)}"
|
||||
)
|
||||
except Exception:
|
||||
form_data = await request.form()
|
||||
callback_data = dict(form_data)
|
||||
logger.info(
|
||||
f"[run {workflow_run_id}] Received Vobiz ring callback (form): "
|
||||
f"{json.dumps(callback_data)}"
|
||||
)
|
||||
form_data = await request.form()
|
||||
callback_data = dict(form_data)
|
||||
logger.info(
|
||||
f"[run {workflow_run_id}] Received Vobiz ring callback {json.dumps(callback_data)}"
|
||||
)
|
||||
|
||||
# Get workflow run for processing
|
||||
workflow_run = await db_client.get_workflow_run_by_id(workflow_run_id)
|
||||
|
|
|
|||
|
|
@ -275,13 +275,13 @@ class VobizProvider(TelephonyProvider):
|
|||
- status, from, to, duration, etc.
|
||||
"""
|
||||
return {
|
||||
"call_id": data.get("call_uuid", data.get("CallUUID", "")),
|
||||
"status": data.get("status", data.get("Status", "")),
|
||||
"from_number": data.get("from", data.get("From")),
|
||||
"to_number": data.get("to", data.get("To")),
|
||||
"direction": data.get("direction", data.get("Direction")),
|
||||
"duration": data.get("duration", data.get("Duration")),
|
||||
"extra": data, # Include all original data
|
||||
"call_id": data.get("CallUUID", ""),
|
||||
"status": data.get("CallStatus", ""),
|
||||
"from_number": data.get("From"),
|
||||
"to_number": data.get("To"),
|
||||
"direction": data.get("Direction"),
|
||||
"duration": data.get("Duration"),
|
||||
"extra": data,
|
||||
}
|
||||
|
||||
async def handle_websocket(
|
||||
|
|
|
|||
|
|
@ -1,7 +1,6 @@
|
|||
from typing import TYPE_CHECKING, Any, Awaitable, Callable, Optional, Union
|
||||
|
||||
from api.constants import DEPLOYMENT_MODE, ENABLE_TRACING, VOICEMAIL_RECORDING_DURATION
|
||||
from api.services.gender.gender_service import GenderService
|
||||
from api.services.workflow.disposition_mapper import (
|
||||
apply_disposition_mapping,
|
||||
get_organization_id_from_workflow_run,
|
||||
|
|
@ -94,8 +93,6 @@ class PipecatEngine:
|
|||
# access to _context
|
||||
self._variable_extraction_manager = None
|
||||
|
||||
self._gender_service = GenderService(confidence_threshold=0.5)
|
||||
|
||||
# Voicemail detection state
|
||||
self._detect_voicemail = False
|
||||
self._voicemail_detector = None
|
||||
|
|
@ -160,13 +157,6 @@ class PipecatEngine:
|
|||
# Register built-in functions with the LLM
|
||||
await self._register_builtin_functions()
|
||||
|
||||
# Set gender in initial context predicted from first name
|
||||
if "first_name" in self._call_context_vars:
|
||||
salutation = await self._gender_service.get_salutation(
|
||||
self._call_context_vars["first_name"]
|
||||
)
|
||||
self._call_context_vars["salutation"] = salutation
|
||||
|
||||
await self.set_node(self.workflow.start_node_id)
|
||||
logger.debug(f"{self.__class__.__name__} initialized")
|
||||
except Exception as e:
|
||||
|
|
|
|||
2114
ui/package-lock.json
generated
2114
ui/package-lock.json
generated
File diff suppressed because it is too large
Load diff
|
|
@ -16,20 +16,21 @@
|
|||
"@livekit/components-react": "^2.9.0",
|
||||
"@nangohq/frontend": "^0.60.3",
|
||||
"@radix-ui/react-checkbox": "^1.3.2",
|
||||
"@radix-ui/react-dialog": "^1.1.13",
|
||||
"@radix-ui/react-collapsible": "^1.1.12",
|
||||
"@radix-ui/react-dialog": "^1.1.15",
|
||||
"@radix-ui/react-dropdown-menu": "^2.1.7",
|
||||
"@radix-ui/react-label": "^2.1.3",
|
||||
"@radix-ui/react-popover": "^1.1.14",
|
||||
"@radix-ui/react-progress": "^1.1.7",
|
||||
"@radix-ui/react-radio-group": "^1.3.7",
|
||||
"@radix-ui/react-select": "^2.2.2",
|
||||
"@radix-ui/react-separator": "^1.1.3",
|
||||
"@radix-ui/react-slot": "^1.2.3",
|
||||
"@radix-ui/react-separator": "^1.1.8",
|
||||
"@radix-ui/react-slot": "^1.2.4",
|
||||
"@radix-ui/react-switch": "^1.1.4",
|
||||
"@radix-ui/react-tabs": "^1.1.13",
|
||||
"@radix-ui/react-tooltip": "^1.2.0",
|
||||
"@radix-ui/react-tooltip": "^1.2.8",
|
||||
"@sentry/nextjs": "^9.28.1",
|
||||
"@stackframe/stack": "^2.8.28",
|
||||
"@stackframe/stack": "^2.8.52",
|
||||
"@xyflow/react": "^12.9.2",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
|
|
|
|||
|
|
@ -52,8 +52,8 @@ export default async function AfterSignInPage() {
|
|||
logger.debug('[AfterSignInPage] Redirecting to /workflow - user has workflows');
|
||||
redirect('/workflow');
|
||||
} else {
|
||||
logger.debug('[AfterSignInPage] Redirecting to /create-workflow - no workflows found');
|
||||
redirect('/create-workflow');
|
||||
logger.debug('[AfterSignInPage] Redirecting to /workflow/create - no workflows found');
|
||||
redirect('/workflow/create');
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
|
|
@ -61,6 +61,6 @@ export default async function AfterSignInPage() {
|
|||
}
|
||||
|
||||
// Default fallback
|
||||
logger.debug('[AfterSignInPage] Final fallback redirect to /create-workflow');
|
||||
redirect('/create-workflow');
|
||||
logger.debug('[AfterSignInPage] Final fallback redirect to /workflow/create');
|
||||
redirect('/workflow/create');
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,14 +0,0 @@
|
|||
import BaseHeader from "@/components/header/BaseHeader";
|
||||
|
||||
export default function APIKeysLayout({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<>
|
||||
<BaseHeader/>
|
||||
{children}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
@ -302,7 +302,7 @@ export default function APIKeysPage() {
|
|||
// Don't render content until auth is loaded
|
||||
if (loading || !user) {
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50 flex items-center justify-center">
|
||||
<div className="min-h-screen bg-background flex items-center justify-center">
|
||||
<div className="space-y-4">
|
||||
<Skeleton className="h-12 w-64" />
|
||||
<Skeleton className="h-64 w-96" />
|
||||
|
|
@ -317,16 +317,16 @@ export default function APIKeysPage() {
|
|||
const showServiceKeyArchiveControls = !isOSSMode();
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50">
|
||||
<div className="min-h-screen bg-background">
|
||||
<div className="container mx-auto px-4 py-8">
|
||||
<div className="max-w-6xl mx-auto">
|
||||
<div className="mb-8">
|
||||
<h1 className="text-3xl font-bold text-gray-900 mb-2">Developer Portal</h1>
|
||||
<p className="text-gray-600">Manage your API keys to access Dograh services programmatically</p>
|
||||
<h1 className="text-3xl font-bold mb-2">Developer Portal</h1>
|
||||
<p className="text-muted-foreground">Manage your API keys to access Dograh services programmatically</p>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="mb-4 p-4 bg-red-50 border border-red-200 rounded-lg text-red-700">
|
||||
<div className="mb-4 p-4 bg-destructive/10 border border-destructive/20 rounded-lg text-destructive">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
|
@ -374,8 +374,8 @@ export default function APIKeysPage() {
|
|||
</div>
|
||||
) : apiKeys.length === 0 ? (
|
||||
<div className="text-center py-12">
|
||||
<Key className="w-12 h-12 text-gray-400 mx-auto mb-4" />
|
||||
<p className="text-gray-600 mb-4">No API keys found</p>
|
||||
<Key className="w-12 h-12 text-muted-foreground mx-auto mb-4" />
|
||||
<p className="text-muted-foreground mb-4">No API keys found</p>
|
||||
<Button onClick={() => setIsCreateDialogOpen(true)}>
|
||||
Create Your First API Key
|
||||
</Button>
|
||||
|
|
@ -386,12 +386,12 @@ export default function APIKeysPage() {
|
|||
<div
|
||||
key={key.id}
|
||||
className={`flex items-center justify-between p-4 border rounded-lg ${
|
||||
key.archived_at ? 'bg-gray-50 opacity-60' : 'bg-white'
|
||||
key.archived_at ? 'bg-muted opacity-60' : 'bg-card'
|
||||
}`}
|
||||
>
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<span className="font-medium text-gray-900">{key.name}</span>
|
||||
<span className="font-medium">{key.name}</span>
|
||||
{key.archived_at ? (
|
||||
<Badge variant="secondary">Archived</Badge>
|
||||
) : key.is_active ? (
|
||||
|
|
@ -400,13 +400,13 @@ export default function APIKeysPage() {
|
|||
<Badge variant="destructive">Inactive</Badge>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-2 text-sm text-gray-600">
|
||||
<span className="font-mono bg-gray-100 px-2 py-1 rounded">{key.key_prefix}...</span>
|
||||
<span className="text-xs text-gray-400">
|
||||
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||
<span className="font-mono bg-muted px-2 py-1 rounded">{key.key_prefix}...</span>
|
||||
<span className="text-xs text-muted-foreground/70">
|
||||
(Full key hidden for security)
|
||||
</span>
|
||||
</div>
|
||||
<div className="mt-2 text-xs text-gray-500">
|
||||
<div className="mt-2 text-xs text-muted-foreground">
|
||||
Created: {formatDate(key.created_at)} •
|
||||
Last used: {formatDate(key.last_used_at ?? null)}
|
||||
</div>
|
||||
|
|
@ -426,7 +426,7 @@ export default function APIKeysPage() {
|
|||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => handleArchiveKey(key.id)}
|
||||
className="text-red-600 hover:text-red-700"
|
||||
className="text-destructive hover:text-destructive/90"
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</Button>
|
||||
|
|
@ -487,8 +487,8 @@ export default function APIKeysPage() {
|
|||
</div>
|
||||
) : serviceKeys.length === 0 ? (
|
||||
<div className="text-center py-12">
|
||||
<Key className="w-12 h-12 text-gray-400 mx-auto mb-4" />
|
||||
<p className="text-gray-600 mb-4">No service keys found</p>
|
||||
<Key className="w-12 h-12 text-muted-foreground mx-auto mb-4" />
|
||||
<p className="text-muted-foreground mb-4">No service keys found</p>
|
||||
{canCreateServiceKey && (
|
||||
<Button onClick={() => setIsCreateServiceDialogOpen(true)}>
|
||||
Create Your First Service Key
|
||||
|
|
@ -501,12 +501,12 @@ export default function APIKeysPage() {
|
|||
<div
|
||||
key={key.id}
|
||||
className={`flex items-center justify-between p-4 border rounded-lg ${
|
||||
key.archived_at ? 'bg-gray-50 opacity-60' : 'bg-white'
|
||||
key.archived_at ? 'bg-muted opacity-60' : 'bg-card'
|
||||
}`}
|
||||
>
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<span className="font-medium text-gray-900">{key.name}</span>
|
||||
<span className="font-medium">{key.name}</span>
|
||||
{key.archived_at ? (
|
||||
<Badge variant="secondary">Archived</Badge>
|
||||
) : key.is_active ? (
|
||||
|
|
@ -520,13 +520,13 @@ export default function APIKeysPage() {
|
|||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-2 text-sm text-gray-600">
|
||||
<span className="font-mono bg-gray-100 px-2 py-1 rounded">{key.key_prefix}...</span>
|
||||
<span className="text-xs text-gray-400">
|
||||
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||
<span className="font-mono bg-muted px-2 py-1 rounded">{key.key_prefix}...</span>
|
||||
<span className="text-xs text-muted-foreground/70">
|
||||
(Full key hidden for security)
|
||||
</span>
|
||||
</div>
|
||||
<div className="mt-2 text-xs text-gray-500">
|
||||
<div className="mt-2 text-xs text-muted-foreground">
|
||||
Created: {formatDate(key.created_at)} •
|
||||
Last used: {formatDate(key.last_used_at ?? null)}
|
||||
</div>
|
||||
|
|
@ -537,7 +537,7 @@ export default function APIKeysPage() {
|
|||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => handleArchiveServiceKey(String(key.id))}
|
||||
className="text-red-600 hover:text-red-700"
|
||||
className="text-destructive hover:text-destructive/90"
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</Button>
|
||||
|
|
@ -550,8 +550,8 @@ export default function APIKeysPage() {
|
|||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<div className="p-4 bg-yellow-50 border border-yellow-200 rounded-lg">
|
||||
<p className="text-sm text-yellow-800">
|
||||
<div className="p-4 bg-yellow-500/10 border border-yellow-500/20 rounded-lg">
|
||||
<p className="text-sm text-yellow-600 dark:text-yellow-500">
|
||||
<strong>Important:</strong> Keep your API keys secure. Never share them publicly or commit them to version control.
|
||||
API keys provide full access to your organization's resources.
|
||||
</p>
|
||||
|
|
@ -601,10 +601,10 @@ export default function APIKeysPage() {
|
|||
</DialogHeader>
|
||||
{createdKey && (
|
||||
<div className="space-y-4">
|
||||
<div className="p-4 bg-gray-100 rounded-lg">
|
||||
<p className="text-sm text-gray-600 mb-2">Your API Key:</p>
|
||||
<div className="p-4 bg-muted rounded-lg">
|
||||
<p className="text-sm text-muted-foreground mb-2">Your API Key:</p>
|
||||
<div className="flex items-center gap-2">
|
||||
<code className="flex-1 p-2 bg-white rounded text-sm font-mono break-all">
|
||||
<code className="flex-1 p-2 bg-background rounded text-sm font-mono break-all">
|
||||
{createdKey.api_key}
|
||||
</code>
|
||||
<Button
|
||||
|
|
@ -616,8 +616,8 @@ export default function APIKeysPage() {
|
|||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="p-4 bg-yellow-50 border border-yellow-200 rounded-lg">
|
||||
<p className="text-sm text-yellow-800">
|
||||
<div className="p-4 bg-yellow-500/10 border border-yellow-500/20 rounded-lg">
|
||||
<p className="text-sm text-yellow-600 dark:text-yellow-500">
|
||||
Store this key securely. It will only be shown once and cannot be retrieved later.
|
||||
</p>
|
||||
</div>
|
||||
|
|
@ -676,10 +676,10 @@ export default function APIKeysPage() {
|
|||
</DialogHeader>
|
||||
{createdServiceKey && (
|
||||
<div className="space-y-4">
|
||||
<div className="p-4 bg-gray-100 rounded-lg">
|
||||
<p className="text-sm text-gray-600 mb-2">Your Service Key:</p>
|
||||
<div className="p-4 bg-muted rounded-lg">
|
||||
<p className="text-sm text-muted-foreground mb-2">Your Service Key:</p>
|
||||
<div className="flex items-center gap-2">
|
||||
<code className="flex-1 p-2 bg-white rounded text-sm font-mono break-all">
|
||||
<code className="flex-1 p-2 bg-background rounded text-sm font-mono break-all">
|
||||
{createdServiceKey.service_key}
|
||||
</code>
|
||||
<Button
|
||||
|
|
@ -691,8 +691,8 @@ export default function APIKeysPage() {
|
|||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="p-4 bg-blue-50 border border-blue-200 rounded-lg">
|
||||
<p className="text-sm text-blue-800">
|
||||
<div className="p-4 bg-blue-500/10 border border-blue-500/20 rounded-lg">
|
||||
<p className="text-sm text-blue-600 dark:text-blue-500">
|
||||
This key provides access to Dograh AI services including LLM, Text-to-Speech, and Speech-to-Text.
|
||||
{createdServiceKey.expires_at && (
|
||||
<span className="block mt-1">
|
||||
|
|
@ -701,8 +701,8 @@ export default function APIKeysPage() {
|
|||
)}
|
||||
</p>
|
||||
</div>
|
||||
<div className="p-4 bg-yellow-50 border border-yellow-200 rounded-lg">
|
||||
<p className="text-sm text-yellow-800">
|
||||
<div className="p-4 bg-yellow-500/10 border border-yellow-500/20 rounded-lg">
|
||||
<p className="text-sm text-yellow-600 dark:text-yellow-500">
|
||||
Store this key securely. It will only be shown once and cannot be retrieved later.
|
||||
</p>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,14 +0,0 @@
|
|||
import BaseHeader from "@/components/header/BaseHeader"
|
||||
|
||||
export default function CampaignsLayout({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode
|
||||
}) {
|
||||
return (
|
||||
<>
|
||||
<BaseHeader />
|
||||
{children}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
|
@ -1,33 +1,39 @@
|
|||
"use client";
|
||||
|
||||
import { Zap } from 'lucide-react';
|
||||
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
|
||||
export default function AutomationPage() {
|
||||
return (
|
||||
<div className="container mx-auto p-6 space-y-6">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold text-gray-900 mb-2">Automation</h1>
|
||||
<p className="text-gray-600">Automate your workflows and processes</p>
|
||||
<h1 className="text-3xl font-bold mb-2">Automation</h1>
|
||||
<p>Automate your workflows and processes</p>
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Coming Soon</CardTitle>
|
||||
<CardDescription>
|
||||
Automation features are currently under development
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-center py-12">
|
||||
<p className="text-gray-500 text-lg mb-4">
|
||||
We're working on powerful automation features to help you streamline your workflows.
|
||||
</p>
|
||||
<p className="text-gray-500">
|
||||
Check back soon for updates!
|
||||
</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Coming Soon</CardTitle>
|
||||
<CardDescription>
|
||||
Automation features are currently under development
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-center py-12">
|
||||
<Zap className="w-16 h-16 mx-auto mb-6" />
|
||||
<p className="text-lg mb-4">
|
||||
We're working on powerful automation features to help you streamline your workflows.
|
||||
</p>
|
||||
<p>
|
||||
Automate repetitive tasks, trigger actions based on events, and create intelligent workflow pipelines.
|
||||
</p>
|
||||
<p className="mt-4">
|
||||
Check back soon for updates!
|
||||
</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -126,12 +126,12 @@ export default function CsvUploadSelector({ accessToken, onFileUploaded, selecte
|
|||
</Button>
|
||||
{selectedFileName && !uploading && (
|
||||
<div className="flex-1 text-sm">
|
||||
<span className="text-gray-600">Selected: </span>
|
||||
<span className="text-blue-600">{selectedFileName}</span>
|
||||
<span className="text-muted-foreground">Selected: </span>
|
||||
<span className="text-primary">{selectedFileName}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-sm text-gray-500">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Upload a CSV file with contact data. Must include phone_number, first_name, and last_name columns. Max 10MB.
|
||||
</p>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -178,7 +178,7 @@ export default function GoogleSheetSelector({ accessToken, onSheetSelected, sele
|
|||
return (
|
||||
<div className="space-y-2">
|
||||
<Label>Google Sheet</Label>
|
||||
<div className="text-sm text-gray-500">Checking Google integration...</div>
|
||||
<div className="text-sm text-muted-foreground">Checking Google integration...</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -217,7 +217,7 @@ export default function GoogleSheetSelector({ accessToken, onSheetSelected, sele
|
|||
</Button>
|
||||
{selectedSheetUrl && (
|
||||
<div className="flex-1 text-sm">
|
||||
<span className="text-gray-600">Selected: </span>
|
||||
<span className="text-muted-foreground">Selected: </span>
|
||||
<a
|
||||
href={selectedSheetUrl}
|
||||
target="_blank"
|
||||
|
|
@ -229,7 +229,7 @@ export default function GoogleSheetSelector({ accessToken, onSheetSelected, sele
|
|||
</div>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-sm text-gray-500">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Select a Google Sheet from your connected Google account
|
||||
</p>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -314,8 +314,8 @@ export default function CampaignDetailPage() {
|
|||
return (
|
||||
<div className="container mx-auto p-6 space-y-6">
|
||||
<div className="animate-pulse">
|
||||
<div className="h-8 bg-gray-200 rounded w-1/4 mb-4"></div>
|
||||
<div className="h-64 bg-gray-200 rounded"></div>
|
||||
<div className="h-8 bg-muted rounded w-1/4 mb-4"></div>
|
||||
<div className="h-64 bg-muted rounded"></div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
|
@ -324,7 +324,7 @@ export default function CampaignDetailPage() {
|
|||
if (!campaign) {
|
||||
return (
|
||||
<div className="container mx-auto p-6 space-y-6">
|
||||
<p className="text-center text-gray-500">Campaign not found</p>
|
||||
<p className="text-center text-muted-foreground">Campaign not found</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -342,12 +342,12 @@ export default function CampaignDetailPage() {
|
|||
</Button>
|
||||
<div className="flex justify-between items-start">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold text-gray-900 mb-2">{campaign.name}</h1>
|
||||
<h1 className="text-3xl font-bold mb-2">{campaign.name}</h1>
|
||||
<div className="flex items-center gap-4">
|
||||
<Badge variant={getStateBadgeVariant(campaign.state)}>
|
||||
{campaign.state}
|
||||
</Badge>
|
||||
<span className="text-gray-600">
|
||||
<span className="text-muted-foreground">
|
||||
Created {formatDate(campaign.created_at)}
|
||||
</span>
|
||||
</div>
|
||||
|
|
@ -367,7 +367,7 @@ export default function CampaignDetailPage() {
|
|||
<CardContent>
|
||||
<dl className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<dt className="text-sm font-medium text-gray-500">Workflow</dt>
|
||||
<dt className="text-sm font-medium">Workflow</dt>
|
||||
<dd className="mt-1">
|
||||
<button
|
||||
onClick={handleWorkflowClick}
|
||||
|
|
@ -378,11 +378,11 @@ export default function CampaignDetailPage() {
|
|||
</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt className="text-sm font-medium text-gray-500">Source Type</dt>
|
||||
<dt className="text-sm font-medium">Source Type</dt>
|
||||
<dd className="mt-1 capitalize">{campaign.source_type.replace('-', ' ')}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt className="text-sm font-medium text-gray-500">
|
||||
<dt className="text-sm font-medium">
|
||||
{campaign.source_type === 'csv' ? 'Source File' : 'Source Sheet'}
|
||||
</dt>
|
||||
<dd className="mt-1">
|
||||
|
|
@ -406,18 +406,18 @@ export default function CampaignDetailPage() {
|
|||
</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt className="text-sm font-medium text-gray-500">State</dt>
|
||||
<dt className="text-sm font-medium">State</dt>
|
||||
<dd className="mt-1 capitalize">{campaign.state}</dd>
|
||||
</div>
|
||||
{campaign.started_at && (
|
||||
<div>
|
||||
<dt className="text-sm font-medium text-gray-500">Started At</dt>
|
||||
<dt className="text-sm font-medium">Started At</dt>
|
||||
<dd className="mt-1">{formatDateTime(campaign.started_at)}</dd>
|
||||
</div>
|
||||
)}
|
||||
{campaign.completed_at && (
|
||||
<div>
|
||||
<dt className="text-sm font-medium text-gray-500">Completed At</dt>
|
||||
<dt className="text-sm font-medium">Completed At</dt>
|
||||
<dd className="mt-1">{formatDateTime(campaign.completed_at)}</dd>
|
||||
</div>
|
||||
)}
|
||||
|
|
@ -437,7 +437,7 @@ export default function CampaignDetailPage() {
|
|||
{isLoadingRuns ? (
|
||||
<div className="animate-pulse space-y-3">
|
||||
{[...Array(3)].map((_, i) => (
|
||||
<div key={i} className="h-12 bg-gray-200 rounded"></div>
|
||||
<div key={i} className="h-12 bg-muted rounded"></div>
|
||||
))}
|
||||
</div>
|
||||
) : runs.length > 0 ? (
|
||||
|
|
@ -455,7 +455,7 @@ export default function CampaignDetailPage() {
|
|||
{runs.map((run) => (
|
||||
<TableRow
|
||||
key={run.id}
|
||||
className="cursor-pointer hover:bg-gray-50"
|
||||
className="cursor-pointer hover:bg-muted/50"
|
||||
onClick={() => handleRunClick(run.id)}
|
||||
>
|
||||
<TableCell className="font-mono text-sm">#{run.id}</TableCell>
|
||||
|
|
@ -483,7 +483,7 @@ export default function CampaignDetailPage() {
|
|||
</Table>
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-center py-8 text-gray-500">
|
||||
<p className="text-center py-8 text-muted-foreground">
|
||||
{campaign.state === 'created'
|
||||
? 'No runs yet. Start the campaign to begin execution.'
|
||||
: 'No workflow runs found for this campaign.'}
|
||||
|
|
|
|||
|
|
@ -1,14 +0,0 @@
|
|||
import BaseHeader from "@/components/header/BaseHeader"
|
||||
|
||||
export default function CampaignsLayout({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode
|
||||
}) {
|
||||
return (
|
||||
<>
|
||||
<BaseHeader />
|
||||
{children}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
|
@ -141,8 +141,8 @@ export default function NewCampaignPage() {
|
|||
<ArrowLeft className="h-4 w-4 mr-2" />
|
||||
Back to Campaigns
|
||||
</Button>
|
||||
<h1 className="text-3xl font-bold text-gray-900 mb-2">Create New Campaign</h1>
|
||||
<p className="text-gray-600">Set up a new campaign to execute workflows at scale</p>
|
||||
<h1 className="text-3xl font-bold mb-2">Create New Campaign</h1>
|
||||
<p className="text-muted-foreground">Set up a new campaign to execute workflows at scale</p>
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
|
|
@ -164,7 +164,7 @@ export default function NewCampaignPage() {
|
|||
maxLength={255}
|
||||
required
|
||||
/>
|
||||
<p className="text-sm text-gray-500">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Choose a descriptive name for your campaign
|
||||
</p>
|
||||
</div>
|
||||
|
|
@ -200,7 +200,7 @@ export default function NewCampaignPage() {
|
|||
)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<p className="text-sm text-gray-500">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Select the workflow to execute for each row in the data source
|
||||
</p>
|
||||
</div>
|
||||
|
|
@ -220,11 +220,11 @@ export default function NewCampaignPage() {
|
|||
<SelectValue placeholder="Select source type" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="google-sheet">Google Sheet</SelectItem>
|
||||
{/* <SelectItem value="google-sheet">Google Sheet</SelectItem> */}
|
||||
<SelectItem value="csv">CSV File</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<p className="text-sm text-gray-500">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Choose where your contact data is stored
|
||||
</p>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@
|
|||
|
||||
import { Plus } from 'lucide-react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
|
||||
import { getCampaignsApiV1CampaignGet } from '@/client/sdk.gen';
|
||||
import type { CampaignsResponse } from '@/client/types.gen';
|
||||
|
|
@ -23,9 +23,9 @@ export default function CampaignsPage() {
|
|||
const { user, getAccessToken, redirectToLogin, loading } = useAuth();
|
||||
const router = useRouter();
|
||||
|
||||
// Campaigns state
|
||||
const [campaignsData, setCampaignsData] = useState<CampaignsResponse | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const hasFetched = useRef(false);
|
||||
|
||||
// Redirect if not authenticated
|
||||
useEffect(() => {
|
||||
|
|
@ -34,51 +34,48 @@ export default function CampaignsPage() {
|
|||
}
|
||||
}, [loading, user, redirectToLogin]);
|
||||
|
||||
// Fetch campaigns
|
||||
const fetchCampaigns = useCallback(async () => {
|
||||
if (!user) return;
|
||||
setIsLoading(true);
|
||||
try {
|
||||
const accessToken = await getAccessToken();
|
||||
const response = await getCampaignsApiV1CampaignGet({
|
||||
headers: {
|
||||
'Authorization': `Bearer ${accessToken}`,
|
||||
}
|
||||
});
|
||||
|
||||
if (response.data) {
|
||||
setCampaignsData(response.data);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch campaigns:', error);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, [user, getAccessToken]);
|
||||
|
||||
// Initial load
|
||||
// Fetch campaigns once when user is ready
|
||||
useEffect(() => {
|
||||
if (user) {
|
||||
fetchCampaigns();
|
||||
if (loading || !user || hasFetched.current) {
|
||||
return;
|
||||
}
|
||||
}, [fetchCampaigns, user]);
|
||||
hasFetched.current = true;
|
||||
|
||||
const fetchCampaigns = async () => {
|
||||
setIsLoading(true);
|
||||
try {
|
||||
const accessToken = await getAccessToken();
|
||||
const response = await getCampaignsApiV1CampaignGet({
|
||||
headers: {
|
||||
'Authorization': `Bearer ${accessToken}`,
|
||||
}
|
||||
});
|
||||
|
||||
if (response.data) {
|
||||
setCampaignsData(response.data);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch campaigns:', error);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
fetchCampaigns();
|
||||
}, [loading, user, getAccessToken]);
|
||||
|
||||
// Handle row click to navigate to campaign detail
|
||||
const handleRowClick = (campaignId: number) => {
|
||||
router.push(`/campaigns/${campaignId}`);
|
||||
};
|
||||
|
||||
// Handle create campaign button
|
||||
const handleCreateCampaign = () => {
|
||||
router.push('/campaigns/new');
|
||||
};
|
||||
|
||||
// Format date for display
|
||||
const formatDate = (dateString: string) => {
|
||||
return new Date(dateString).toLocaleDateString();
|
||||
};
|
||||
|
||||
// Get badge variant for state
|
||||
const getStateBadgeVariant = (state: string) => {
|
||||
switch (state) {
|
||||
case 'created':
|
||||
|
|
@ -100,8 +97,8 @@ export default function CampaignsPage() {
|
|||
<div className="container mx-auto p-6 space-y-6">
|
||||
<div className="flex justify-between items-center">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold text-gray-900 mb-2">Campaigns</h1>
|
||||
<p className="text-gray-600">Manage your bulk workflow execution campaigns</p>
|
||||
<h1 className="text-3xl font-bold mb-2">Campaigns</h1>
|
||||
<p>Manage your bulk workflow execution campaigns</p>
|
||||
</div>
|
||||
<Button onClick={handleCreateCampaign}>
|
||||
<Plus className="h-4 w-4 mr-2" />
|
||||
|
|
@ -109,7 +106,6 @@ export default function CampaignsPage() {
|
|||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Campaigns Table */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>All Campaigns</CardTitle>
|
||||
|
|
@ -121,7 +117,7 @@ export default function CampaignsPage() {
|
|||
{isLoading ? (
|
||||
<div className="animate-pulse space-y-3">
|
||||
{[...Array(5)].map((_, i) => (
|
||||
<div key={i} className="h-12 bg-gray-200 rounded"></div>
|
||||
<div key={i} className="h-12 bg-muted rounded"></div>
|
||||
))}
|
||||
</div>
|
||||
) : campaignsData && campaignsData.campaigns.length > 0 ? (
|
||||
|
|
@ -140,7 +136,7 @@ export default function CampaignsPage() {
|
|||
{campaignsData.campaigns.map((campaign) => (
|
||||
<TableRow
|
||||
key={campaign.id}
|
||||
className="cursor-pointer hover:bg-gray-50"
|
||||
className="cursor-pointer hover:bg-muted/50"
|
||||
onClick={() => handleRowClick(campaign.id)}
|
||||
>
|
||||
<TableCell className="font-medium">{campaign.name}</TableCell>
|
||||
|
|
@ -170,7 +166,7 @@ export default function CampaignsPage() {
|
|||
</div>
|
||||
) : (
|
||||
<div className="text-center py-8">
|
||||
<p className="text-gray-500 mb-4">No campaigns found</p>
|
||||
<p className="mb-4">No campaigns found</p>
|
||||
<Button onClick={handleCreateCampaign} variant="outline">
|
||||
<Plus className="h-4 w-4 mr-2" />
|
||||
Create your first campaign
|
||||
|
|
|
|||
|
|
@ -1,14 +0,0 @@
|
|||
import BaseHeader from "@/components/header/BaseHeader"
|
||||
|
||||
export default function ConfigureTelephonyLayout({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode
|
||||
}) {
|
||||
return (
|
||||
<>
|
||||
<BaseHeader />
|
||||
{children}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
|
@ -1,279 +0,0 @@
|
|||
'use client';
|
||||
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { useState } from 'react';
|
||||
|
||||
import { createWorkflowFromTemplateApiV1WorkflowCreateTemplatePost, createWorkflowRunApiV1WorkflowWorkflowIdRunsPost } from '@/client/sdk.gen';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Card, CardContent, CardDescription, CardHeader } from '@/components/ui/card';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
||||
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();
|
||||
const { user, getAccessToken } = useAuth();
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [showSuccessModal, setShowSuccessModal] = useState(false);
|
||||
const [workflowId, setWorkflowId] = useState<string | null>(null);
|
||||
|
||||
const [callType, setCallType] = useState<'INBOUND' | 'OUTBOUND'>('INBOUND');
|
||||
const [useCase, setUseCase] = useState('');
|
||||
const [activityDescription, setActivityDescription] = useState('');
|
||||
|
||||
const handleCreateWorkflow = async () => {
|
||||
if (!useCase || !activityDescription) {
|
||||
setError('Please fill in all fields');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!user) {
|
||||
setError('You must be logged in to create a workflow');
|
||||
return;
|
||||
}
|
||||
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const accessToken = await getAccessToken();
|
||||
|
||||
// Call the API to create workflow from template
|
||||
const response = await createWorkflowFromTemplateApiV1WorkflowCreateTemplatePost({
|
||||
body: {
|
||||
call_type: callType,
|
||||
use_case: useCase,
|
||||
activity_description: activityDescription,
|
||||
},
|
||||
headers: {
|
||||
'Authorization': `Bearer ${accessToken}`,
|
||||
},
|
||||
});
|
||||
|
||||
if (response.data?.id) {
|
||||
setWorkflowId(String(response.data.id));
|
||||
setShowSuccessModal(true);
|
||||
}
|
||||
} catch (err) {
|
||||
setError('Failed to create workflow. Please try again.');
|
||||
logger.error(`Error creating workflow: ${err}`);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
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}`);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-[100vh] flex items-center justify-center p-4 bg-gradient-to-br from-gray-50 to-gray-100 dark:from-gray-900 dark:to-gray-800">
|
||||
<Card className="w-full max-w-4xl shadow-xl border-0 bg-white/95 dark:bg-gray-900/95 backdrop-blur">
|
||||
<CardHeader className="text-center pb-4 pt-6">
|
||||
<h1 className="text-2xl font-bold bg-gradient-to-r from-blue-600 to-purple-600 bg-clip-text text-transparent">
|
||||
Create Your Voice Agent Workflow
|
||||
</h1>
|
||||
<CardDescription className="text-base mt-2 text-gray-600 dark:text-gray-400">
|
||||
Tell us about your use case and we'll create a customized workflow for you
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4 px-6 pb-6">
|
||||
<div className="space-y-4">
|
||||
<div className="flex flex-col space-y-4">
|
||||
<div className="bg-gray-50 dark:bg-gray-800 rounded-lg p-4">
|
||||
<div className="flex items-center flex-wrap gap-2">
|
||||
<span className="text-base font-medium text-gray-700 dark:text-gray-300">I want to create an</span>
|
||||
<Select value={callType} onValueChange={(value) => setCallType(value as 'INBOUND' | 'OUTBOUND')}>
|
||||
<SelectTrigger className="w-[180px] h-10 text-sm font-semibold border-2 focus:ring-2 focus:ring-blue-500">
|
||||
<SelectValue placeholder="Select type" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="INBOUND" className="text-sm">
|
||||
<span className="font-medium">📞 INBOUND</span>
|
||||
<span className="text-xs text-gray-500 ml-1">(Users call AI)</span>
|
||||
</SelectItem>
|
||||
<SelectItem value="OUTBOUND" className="text-sm">
|
||||
<span className="font-medium">☎️ OUTBOUND</span>
|
||||
<span className="text-xs text-gray-500 ml-1">(AI calls users)</span>
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<span className="text-base font-medium text-gray-700 dark:text-gray-300">voice agent</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-gray-50 dark:bg-gray-800 rounded-lg p-4">
|
||||
<div className="flex items-start flex-col gap-2">
|
||||
<span className="text-base font-medium text-gray-700 dark:text-gray-300">For the use case of</span>
|
||||
<Input
|
||||
className="w-full h-10 text-sm px-3 border-2 focus:ring-2 focus:ring-blue-500 transition-all"
|
||||
placeholder="e.g., Lead Qualification, HR Screening, Customer Support"
|
||||
value={useCase}
|
||||
onChange={(e) => setUseCase(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-gray-50 dark:bg-gray-800 rounded-lg p-4">
|
||||
<div className="flex items-start flex-col gap-2">
|
||||
<span className="text-base font-medium text-gray-700 dark:text-gray-300">Which can</span>
|
||||
<textarea
|
||||
className="w-full min-h-[80px] text-sm px-3 py-2 border-2 rounded-md focus:ring-2 focus:ring-blue-500 transition-all resize-none"
|
||||
placeholder="Describe briefly what your voice agent will do (e.g., Qualify leads for real estate, Screen candidates for roles, Handle customer support). This will be a prompt to an LLM."
|
||||
value={activityDescription}
|
||||
onChange={(e) => setActivityDescription(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="text-sm text-red-600 bg-red-50 dark:bg-red-900/20 p-3 rounded-lg border border-red-200 dark:border-red-800 flex items-center gap-2">
|
||||
<svg className="w-5 h-5 flex-shrink-0" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path fillRule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z" clipRule="evenodd" />
|
||||
</svg>
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="pt-2">
|
||||
<Button
|
||||
onClick={handleCreateWorkflow}
|
||||
disabled={isLoading || !useCase || !activityDescription}
|
||||
className="w-full h-12 text-base font-semibold bg-gradient-to-r from-blue-600 to-purple-600 hover:from-blue-700 hover:to-purple-700 transition-all transform hover:scale-[1.02] disabled:opacity-50 disabled:cursor-not-allowed disabled:transform-none"
|
||||
size="lg"
|
||||
>
|
||||
{isLoading ? (
|
||||
<span className="flex items-center gap-3">
|
||||
<svg className="animate-spin h-5 w-5" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
|
||||
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4"></circle>
|
||||
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
||||
</svg>
|
||||
Creating Your Workflow...
|
||||
</span>
|
||||
) : (
|
||||
<span className="flex items-center gap-2">
|
||||
Create Workflow
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 7l5 5m0 0l-5 5m5-5H6" />
|
||||
</svg>
|
||||
</span>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Loading Overlay */}
|
||||
{isLoading && (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm">
|
||||
<Card className="w-full max-w-md p-8 bg-white dark:bg-gray-900 border-0 shadow-2xl">
|
||||
<div className="flex flex-col items-center space-y-6">
|
||||
{/* Animated spinner */}
|
||||
<div className="relative">
|
||||
<div className="w-20 h-20 border-4 border-gray-200 dark:border-gray-700 rounded-full"></div>
|
||||
<div className="absolute top-0 left-0 w-20 h-20 border-4 border-transparent border-t-blue-600 rounded-full animate-spin"></div>
|
||||
<div className="absolute top-2 left-2 w-16 h-16 border-4 border-transparent border-t-purple-600 rounded-full animate-spin-slow"></div>
|
||||
</div>
|
||||
|
||||
<div className="text-center space-y-2">
|
||||
<h3 className="text-xl font-bold bg-gradient-to-r from-blue-600 to-purple-600 bg-clip-text text-transparent">
|
||||
Creating Your Workflow
|
||||
</h3>
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400 max-w-xs">
|
||||
We're setting up your voice agent with your specifications. This will just take a moment...
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Animated dots */}
|
||||
<div className="flex space-x-2">
|
||||
<div className="w-2 h-2 bg-blue-600 rounded-full animate-bounce" style={{ animationDelay: '0ms' }}></div>
|
||||
<div className="w-2 h-2 bg-purple-600 rounded-full animate-bounce" style={{ animationDelay: '150ms' }}></div>
|
||||
<div className="w-2 h-2 bg-blue-600 rounded-full animate-bounce" style={{ animationDelay: '300ms' }}></div>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Success Modal */}
|
||||
<Dialog open={showSuccessModal} onOpenChange={setShowSuccessModal}>
|
||||
<DialogContent className="sm:max-w-lg">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="text-xl font-bold flex items-center gap-2">
|
||||
<svg className="w-6 h-6 text-green-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
Workflow Created Successfully!
|
||||
</DialogTitle>
|
||||
<DialogDescription asChild>
|
||||
<div className="text-base mt-4 space-y-3 text-gray-700 dark:text-gray-300">
|
||||
<p>
|
||||
A voice agent workflow has been generated for your use case, with some artificial data and sample actions.
|
||||
</p>
|
||||
<p>
|
||||
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.
|
||||
</p>
|
||||
</div>
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogFooter className="mt-6">
|
||||
<Button
|
||||
onClick={handleModalContinue}
|
||||
className="w-full bg-gradient-to-r from-blue-600 to-purple-600 hover:from-blue-700 hover:to-purple-700 font-semibold"
|
||||
>
|
||||
Start Web Call
|
||||
<svg className="w-4 h-4 ml-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 7l5 5m0 0l-5 5m5-5H6" />
|
||||
</svg>
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,14 +0,0 @@
|
|||
import BaseHeader from "@/components/header/BaseHeader"
|
||||
|
||||
export default function StackLayout({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode
|
||||
}) {
|
||||
return (
|
||||
<>
|
||||
<BaseHeader />
|
||||
{children}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
|
@ -12,7 +12,7 @@ export async function GET(request: NextRequest) {
|
|||
const { searchParams } = new URL(request.url);
|
||||
|
||||
const refreshToken = searchParams.get("refresh_token");
|
||||
const redirectPath = searchParams.get("redirect_path") ?? "/create-workflow";
|
||||
const redirectPath = searchParams.get("redirect_path") ?? "/workflow/create";
|
||||
|
||||
if (!refreshToken) {
|
||||
return new Response("Missing refresh_token", { status: 400 });
|
||||
|
|
|
|||
|
|
@ -291,7 +291,7 @@ export default function GmailSearchPage() {
|
|||
<button
|
||||
onClick={searchEmails}
|
||||
disabled={!accessToken || loading || !searchQuery.trim()}
|
||||
className="px-6 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700 disabled:bg-gray-400 disabled:cursor-not-allowed"
|
||||
className="px-6 py-2 bg-primary text-primary-foreground rounded-md hover:bg-primary/90 disabled:bg-muted disabled:text-muted-foreground disabled:cursor-not-allowed"
|
||||
>
|
||||
{loading ? 'Searching...' : 'Search'}
|
||||
</button>
|
||||
|
|
@ -306,13 +306,13 @@ export default function GmailSearchPage() {
|
|||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
{/* Email List */}
|
||||
<div className="bg-white border border-gray-200 rounded-lg">
|
||||
<div className="p-4 border-b border-gray-200">
|
||||
<div className="bg-card border border-border rounded-lg">
|
||||
<div className="p-4 border-b border-border">
|
||||
<h2 className="text-lg font-semibold">Search Results</h2>
|
||||
</div>
|
||||
<div className="divide-y divide-gray-200 max-h-[600px] overflow-y-auto">
|
||||
<div className="divide-y divide-border max-h-[600px] overflow-y-auto">
|
||||
{emails.length === 0 && !loading && (
|
||||
<div className="p-8 text-center text-gray-500">
|
||||
<div className="p-8 text-center text-muted-foreground">
|
||||
{searchQuery ? 'No emails found' : 'Enter a search query to find emails'}
|
||||
</div>
|
||||
)}
|
||||
|
|
@ -320,27 +320,27 @@ export default function GmailSearchPage() {
|
|||
<div
|
||||
key={email.id}
|
||||
onClick={() => loadEmailDetail(email.id)}
|
||||
className={`p-4 cursor-pointer hover:bg-gray-50 ${
|
||||
selectedEmail?.id === email.id ? 'bg-blue-50' : ''
|
||||
className={`p-4 cursor-pointer hover:bg-muted/50 ${
|
||||
selectedEmail?.id === email.id ? 'bg-accent' : ''
|
||||
}`}
|
||||
>
|
||||
<div className="font-medium text-sm mb-1">{email.subject}</div>
|
||||
<div className="text-xs text-gray-600 mb-1">{email.from}</div>
|
||||
<div className="text-xs text-gray-500">{email.snippet}</div>
|
||||
<div className="text-xs text-gray-400 mt-1">{email.date}</div>
|
||||
<div className="text-xs text-muted-foreground mb-1">{email.from}</div>
|
||||
<div className="text-xs text-muted-foreground">{email.snippet}</div>
|
||||
<div className="text-xs text-muted-foreground/70 mt-1">{email.date}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Email Detail and Reply */}
|
||||
<div className="bg-white border border-gray-200 rounded-lg">
|
||||
<div className="p-4 border-b border-gray-200">
|
||||
<div className="bg-card border border-border rounded-lg">
|
||||
<div className="p-4 border-b border-border">
|
||||
<h2 className="text-lg font-semibold">Email Details</h2>
|
||||
</div>
|
||||
{selectedEmail ? (
|
||||
<div className="p-4">
|
||||
<div className="mb-4 pb-4 border-b border-gray-200">
|
||||
<div className="mb-4 pb-4 border-b border-border">
|
||||
<div className="mb-2">
|
||||
<span className="font-medium">Subject:</span> {selectedEmail.subject}
|
||||
</div>
|
||||
|
|
@ -350,36 +350,36 @@ export default function GmailSearchPage() {
|
|||
<div className="mb-2 text-sm">
|
||||
<span className="font-medium">To:</span> {selectedEmail.to}
|
||||
</div>
|
||||
<div className="text-sm text-gray-500">
|
||||
<div className="text-sm text-muted-foreground">
|
||||
<span className="font-medium">Date:</span> {selectedEmail.date}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mb-4 p-4 bg-gray-50 rounded max-h-[200px] overflow-y-auto">
|
||||
<div className="mb-4 p-4 bg-muted rounded max-h-[200px] overflow-y-auto">
|
||||
<pre className="whitespace-pre-wrap text-sm">{selectedEmail.body}</pre>
|
||||
</div>
|
||||
|
||||
<div className="border-t border-gray-200 pt-4">
|
||||
<div className="border-t border-border pt-4">
|
||||
<h3 className="font-medium mb-2">Reply</h3>
|
||||
<textarea
|
||||
value={replyText}
|
||||
onChange={(e) => setReplyText(e.target.value)}
|
||||
placeholder="Type your reply here..."
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 mb-2"
|
||||
className="w-full px-3 py-2 border border-border rounded-md focus:outline-none focus:ring-2 focus:ring-ring mb-2 bg-background"
|
||||
rows={6}
|
||||
disabled={sendingReply}
|
||||
/>
|
||||
<button
|
||||
onClick={sendReply}
|
||||
disabled={sendingReply || !replyText.trim()}
|
||||
className="px-4 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700 disabled:bg-gray-400 disabled:cursor-not-allowed"
|
||||
className="px-4 py-2 bg-primary text-primary-foreground rounded-md hover:bg-primary/90 disabled:bg-muted disabled:text-muted-foreground disabled:cursor-not-allowed"
|
||||
>
|
||||
{sendingReply ? 'Sending...' : 'Send Reply'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="p-8 text-center text-gray-500">
|
||||
<div className="p-8 text-center text-muted-foreground">
|
||||
Select an email to view details and reply
|
||||
</div>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -1,14 +0,0 @@
|
|||
import BaseHeader from "@/components/header/BaseHeader"
|
||||
|
||||
export default function IntegrationsLayout({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode
|
||||
}) {
|
||||
return (
|
||||
<>
|
||||
<BaseHeader />
|
||||
{children}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
|
@ -42,7 +42,7 @@ async function IntegrationList() {
|
|||
|
||||
if (integrations.length === 0) {
|
||||
return (
|
||||
<div className="text-center py-8 text-gray-500">
|
||||
<div className="text-center py-8 text-muted-foreground">
|
||||
No integrations found. Create your first integration to get started.
|
||||
</div>
|
||||
);
|
||||
|
|
@ -51,39 +51,39 @@ async function IntegrationList() {
|
|||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="overflow-x-auto">
|
||||
<table className="min-w-full bg-white border border-gray-200">
|
||||
<thead className="bg-gray-50">
|
||||
<table className="min-w-full bg-card border border-border">
|
||||
<thead className="bg-muted">
|
||||
<tr>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-muted-foreground uppercase tracking-wider">
|
||||
Provider
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-muted-foreground uppercase tracking-wider">
|
||||
Channel
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-muted-foreground uppercase tracking-wider">
|
||||
Action
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-muted-foreground uppercase tracking-wider">
|
||||
Created At
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-muted-foreground uppercase tracking-wider">
|
||||
Actions
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="bg-white divide-y divide-gray-200">
|
||||
<tbody className="bg-card divide-y divide-border">
|
||||
{integrations.map((integration) => (
|
||||
<tr key={integration.id} className="hover:bg-gray-50">
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm font-medium text-gray-900">
|
||||
<tr key={integration.id} className="hover:bg-muted/50">
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm font-medium">
|
||||
{integration.provider}
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-muted-foreground">
|
||||
{integration.provider === 'slack' && integration.provider_data ? (integration.provider_data.channel as string) || '-' : '-'}
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-muted-foreground">
|
||||
{integration.action}
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-muted-foreground">
|
||||
{new Date(integration.created_at).toLocaleDateString('en-US', {
|
||||
year: 'numeric',
|
||||
month: 'short',
|
||||
|
|
@ -92,7 +92,7 @@ async function IntegrationList() {
|
|||
minute: '2-digit'
|
||||
})}
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-muted-foreground">
|
||||
{integration.provider === 'google-mail' && (
|
||||
<Link
|
||||
href={`/integrations/${integration.id}/gmail`}
|
||||
|
|
@ -140,41 +140,41 @@ function IntegrationsLoading() {
|
|||
<div className="container mx-auto px-4 py-8">
|
||||
<div className="mb-6">
|
||||
<div className="flex justify-between items-center mb-6">
|
||||
<div className="h-8 w-48 bg-gray-200 rounded"></div>
|
||||
<div className="h-10 w-32 bg-gray-200 rounded"></div>
|
||||
<div className="h-8 w-48 bg-muted rounded"></div>
|
||||
<div className="h-10 w-32 bg-muted rounded"></div>
|
||||
</div>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="min-w-full bg-white border border-gray-200">
|
||||
<thead className="bg-gray-50">
|
||||
<table className="min-w-full bg-card border border-border">
|
||||
<thead className="bg-muted">
|
||||
<tr>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-muted-foreground uppercase tracking-wider">
|
||||
Integration ID
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-muted-foreground uppercase tracking-wider">
|
||||
Channel
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-muted-foreground uppercase tracking-wider">
|
||||
Action
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-muted-foreground uppercase tracking-wider">
|
||||
Created At
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="bg-white divide-y divide-gray-200">
|
||||
<tbody className="bg-card divide-y divide-border">
|
||||
{Array.from({ length: 5 }, (_, i) => (
|
||||
<tr key={i}>
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
<div className="h-4 w-32 bg-gray-200 rounded"></div>
|
||||
<div className="h-4 w-32 bg-muted rounded"></div>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
<div className="h-4 w-24 bg-gray-200 rounded"></div>
|
||||
<div className="h-4 w-24 bg-muted rounded"></div>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
<div className="h-4 w-24 bg-gray-200 rounded"></div>
|
||||
<div className="h-4 w-24 bg-muted rounded"></div>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
<div className="h-4 w-24 bg-gray-200 rounded"></div>
|
||||
<div className="h-4 w-24 bg-muted rounded"></div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ import { Geist, Geist_Mono } from "next/font/google";
|
|||
import { Suspense } from "react";
|
||||
|
||||
import ChatwootWidget from "@/components/ChatwootWidget";
|
||||
import AppLayout from "@/components/layout/AppLayout";
|
||||
import PostHogIdentify from "@/components/PostHogIdentify";
|
||||
import SpinLoader from "@/components/SpinLoader";
|
||||
import { Toaster } from "@/components/ui/sonner";
|
||||
|
|
@ -35,7 +36,26 @@ export default function RootLayout({
|
|||
}) {
|
||||
|
||||
return (
|
||||
<html lang="en">
|
||||
<html lang="en" suppressHydrationWarning>
|
||||
<head>
|
||||
{/* Inline script to prevent flash of light theme - runs before React hydrates */}
|
||||
<script
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: `
|
||||
(function() {
|
||||
try {
|
||||
var theme = localStorage.getItem('theme');
|
||||
if (theme === 'dark' || (!theme && window.matchMedia('(prefers-color-scheme: dark)').matches)) {
|
||||
document.documentElement.classList.add('dark');
|
||||
} else {
|
||||
document.documentElement.classList.remove('dark');
|
||||
}
|
||||
} catch (e) {}
|
||||
})();
|
||||
`,
|
||||
}}
|
||||
/>
|
||||
</head>
|
||||
<body
|
||||
className={`${geistSans.variable} ${geistMono.variable} antialiased`}>
|
||||
<AuthProvider>
|
||||
|
|
@ -43,7 +63,9 @@ export default function RootLayout({
|
|||
<UserConfigProvider>
|
||||
<OnboardingProvider>
|
||||
<PostHogIdentify />
|
||||
{children}
|
||||
<AppLayout>
|
||||
{children}
|
||||
</AppLayout>
|
||||
<Toaster />
|
||||
<ChatwootWidget />
|
||||
</OnboardingProvider>
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import React, { ReactNode } from 'react'
|
||||
|
||||
import BaseHeader from '@/components/header/BaseHeader'
|
||||
import AppLayout from '@/components/layout/AppLayout'
|
||||
|
||||
interface LoopTalkLayoutProps {
|
||||
children: ReactNode,
|
||||
|
|
@ -8,12 +8,13 @@ interface LoopTalkLayoutProps {
|
|||
backButton?: ReactNode,
|
||||
}
|
||||
|
||||
const LoopTalkLayout: React.FC<LoopTalkLayoutProps> = ({ children, headerActions, backButton }) => {
|
||||
const LoopTalkLayout: React.FC<LoopTalkLayoutProps> = ({ children, headerActions }) => {
|
||||
// backButton is kept in interface for backward compatibility
|
||||
// but not used with the new sidebar layout
|
||||
return (
|
||||
<>
|
||||
<BaseHeader headerActions={headerActions} backButton={backButton} />
|
||||
<AppLayout headerActions={headerActions}>
|
||||
{children}
|
||||
</>
|
||||
</AppLayout>
|
||||
)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -98,9 +98,9 @@ function TestSessionLoading() {
|
|||
return (
|
||||
<div className="container mx-auto px-4 py-8">
|
||||
<div className="space-y-4">
|
||||
<div className="h-32 bg-gray-200 rounded-lg animate-pulse"></div>
|
||||
<div className="h-20 bg-gray-200 rounded-lg animate-pulse"></div>
|
||||
<div className="h-64 bg-gray-200 rounded-lg animate-pulse"></div>
|
||||
<div className="h-32 bg-muted rounded-lg animate-pulse"></div>
|
||||
<div className="h-20 bg-muted rounded-lg animate-pulse"></div>
|
||||
<div className="h-64 bg-muted rounded-lg animate-pulse"></div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -1,79 +1,40 @@
|
|||
import { Suspense } from 'react';
|
||||
"use client";
|
||||
|
||||
import { CreateTestSessionButton } from '@/components/looptalk/CreateTestSessionButton';
|
||||
import { LoopTalkTestSessionsList } from '@/components/looptalk/LoopTalkTestSessionsList';
|
||||
import { getServerAuthProvider, isServerAuthenticated } from '@/lib/auth/server';
|
||||
import { MessageSquare } from 'lucide-react';
|
||||
|
||||
import LoopTalkLayout from "./LoopTalkLayout";
|
||||
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
async function PageContent() {
|
||||
const authProvider = getServerAuthProvider();
|
||||
const isAuthenticated = await isServerAuthenticated();
|
||||
|
||||
if (authProvider === 'stack' && !isAuthenticated) {
|
||||
const { redirect } = await import('next/navigation');
|
||||
redirect('/');
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="container mx-auto px-4 py-8">
|
||||
{/* Active Tests Section */}
|
||||
<div className="mb-12">
|
||||
<div className="flex justify-between items-center mb-6">
|
||||
<h2 className="text-2xl font-bold">Active Tests</h2>
|
||||
</div>
|
||||
<LoopTalkTestSessionsList status="active" />
|
||||
</div>
|
||||
|
||||
{/* Test Sessions Section */}
|
||||
<div className="mb-6">
|
||||
<div className="flex justify-between items-center mb-6">
|
||||
<h1 className="text-2xl font-bold">Test Sessions</h1>
|
||||
<CreateTestSessionButton />
|
||||
</div>
|
||||
<LoopTalkTestSessionsList />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function LoopTalkLoading() {
|
||||
return (
|
||||
<div className="container mx-auto px-4 py-8">
|
||||
{/* Active Tests Section Loading */}
|
||||
<div className="mb-12">
|
||||
<div className="h-8 w-48 bg-gray-200 rounded mb-6"></div>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||
{Array.from({ length: 3 }, (_, i) => (
|
||||
<div key={i} className="bg-gray-200 rounded-lg h-40"></div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Test Sessions Section Loading */}
|
||||
<div className="mb-6">
|
||||
<div className="flex justify-between items-center mb-6">
|
||||
<div className="h-8 w-48 bg-gray-200 rounded"></div>
|
||||
<div className="h-10 w-32 bg-gray-200 rounded"></div>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||
{Array.from({ length: 6 }, (_, i) => (
|
||||
<div key={i} className="bg-gray-200 rounded-lg h-32"></div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
|
||||
export default function LoopTalkPage() {
|
||||
return (
|
||||
<LoopTalkLayout>
|
||||
<Suspense fallback={<LoopTalkLoading />}>
|
||||
<PageContent />
|
||||
</Suspense>
|
||||
</LoopTalkLayout>
|
||||
<div className="container mx-auto p-6 space-y-6">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold mb-2">LoopTalk</h1>
|
||||
<p>Enable voice agents to talk to each other and create artificial datasets</p>
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Coming Soon</CardTitle>
|
||||
<CardDescription>
|
||||
LoopTalk features are currently under development
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-center py-12">
|
||||
<MessageSquare className="w-16 h-16 mx-auto mb-6" />
|
||||
<p className="text-lg mb-4">
|
||||
We're building LoopTalk to enable voice agents to communicate with each other,
|
||||
allowing you to generate artificial datasets for training and testing.
|
||||
</p>
|
||||
<p>
|
||||
This powerful feature will help you create comprehensive test scenarios and improve your voice AI workflows.
|
||||
</p>
|
||||
<p className="mt-4">
|
||||
Check back soon for updates!
|
||||
</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,9 +2,9 @@ import ServiceConfiguration from "@/components/ServiceConfiguration";
|
|||
|
||||
export default function ServiceConfigurationPage() {
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50">
|
||||
<div className="container mx-auto px-4 py-8">
|
||||
<div className="max-w-4xl mx-auto">
|
||||
<div className="min-h-screen bg-background">
|
||||
<div className="container mx-auto px-4 py-8">
|
||||
<div className="max-w-4xl mx-auto">
|
||||
<ServiceConfiguration />
|
||||
</div>
|
||||
</div>
|
||||
123
ui/src/app/overview/page.tsx
Normal file
123
ui/src/app/overview/page.tsx
Normal file
|
|
@ -0,0 +1,123 @@
|
|||
"use client";
|
||||
|
||||
import { Star } from 'lucide-react';
|
||||
import Link from 'next/link';
|
||||
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { useAuth } from '@/lib/auth';
|
||||
|
||||
export default function OverviewPage() {
|
||||
const { user, provider } = useAuth();
|
||||
const isOSSMode = provider !== 'stack';
|
||||
|
||||
return (
|
||||
<div className="container mx-auto px-4 py-8">
|
||||
<div className="max-w-4xl mx-auto">
|
||||
{/* Welcome Card */}
|
||||
<Card className="mb-8">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-3xl">
|
||||
{isOSSMode ? (
|
||||
"Welcome to Dograh"
|
||||
) : (
|
||||
`Welcome${user?.displayName ? `, ${user.displayName.split(' ')[0]}` : ''}!`
|
||||
)}
|
||||
</CardTitle>
|
||||
<CardDescription className="text-lg mt-2">
|
||||
{isOSSMode ? (
|
||||
<>
|
||||
Open source alternative to Vapi. Help us support the project by giving us a star on GitHub.
|
||||
</>
|
||||
) : (
|
||||
"Get started with building voice AI workflows"
|
||||
)}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{isOSSMode && (
|
||||
<Button asChild className="mb-6">
|
||||
<a
|
||||
href="https://github.com/dograh-hq/dograh"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="inline-flex items-center"
|
||||
>
|
||||
<Star className="h-4 w-4 fill-yellow-400 text-yellow-400" />
|
||||
Star us on GitHub
|
||||
</a>
|
||||
</Button>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Quick Actions */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Create and Manage your Voice Agents</CardTitle>
|
||||
<CardDescription>
|
||||
Build powerful AI Voice Agents with our visual editor
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Button asChild>
|
||||
<Link href="/workflow">
|
||||
Go to Agents
|
||||
</Link>
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Configure Services</CardTitle>
|
||||
<CardDescription>
|
||||
Set up your AI services like LLM, TTS, and STT providers
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Button asChild variant="outline">
|
||||
<Link href="/model-configurations">
|
||||
Configure Models
|
||||
</Link>
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Resources Section */}
|
||||
<Card className="mt-8">
|
||||
<CardHeader>
|
||||
<CardTitle>Resources</CardTitle>
|
||||
<CardDescription>
|
||||
Get help and learn more about Dograh
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="flex flex-wrap gap-4">
|
||||
<Button asChild variant="outline">
|
||||
<a
|
||||
href="https://docs.dograh.com"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
Documentation
|
||||
</a>
|
||||
</Button>
|
||||
<Button asChild variant="outline">
|
||||
<a
|
||||
href="https://github.com/dograh-hq/dograh/issues"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
Report an Issue
|
||||
</a>
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -39,8 +39,8 @@ export default async function Home() {
|
|||
logger.debug('[HomePage] Redirecting to /workflow - user has workflows');
|
||||
redirect('/workflow');
|
||||
} else {
|
||||
logger.debug('[HomePage] Redirecting to /create-workflow - no workflows found');
|
||||
redirect('/create-workflow');
|
||||
logger.debug('[HomePage] Redirecting to /workflow/create - no workflows found');
|
||||
redirect('/workflow/create');
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
|
|
@ -50,9 +50,9 @@ export default async function Home() {
|
|||
}
|
||||
|
||||
logger.error('[HomePage] Error checking workflows for local provider:', error);
|
||||
// Default to create-workflow on actual errors
|
||||
logger.debug('[HomePage] Defaulting to /create-workflow due to error');
|
||||
redirect('/create-workflow');
|
||||
// Default to /workflow/create on actual errors
|
||||
logger.debug('[HomePage] Defaulting to /workflow/create due to error');
|
||||
redirect('/workflow/create');
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,14 +0,0 @@
|
|||
import BaseHeader from "@/components/header/BaseHeader"
|
||||
|
||||
export default function ReportsLayout({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode
|
||||
}) {
|
||||
return (
|
||||
<>
|
||||
<BaseHeader />
|
||||
{children}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
|
@ -1,14 +0,0 @@
|
|||
import BaseHeader from "@/components/header/BaseHeader"
|
||||
|
||||
export default function ServiceConfigurationLayout({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode
|
||||
}) {
|
||||
return (
|
||||
<>
|
||||
<BaseHeader />
|
||||
{children}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
|
@ -1,14 +0,0 @@
|
|||
import BaseHeader from "@/components/header/BaseHeader"
|
||||
|
||||
export default function SuperAdminLayout({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode
|
||||
}) {
|
||||
return (
|
||||
<>
|
||||
<BaseHeader />
|
||||
{children}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
|
@ -51,8 +51,8 @@ export default function SuperadminPage() {
|
|||
<>
|
||||
<main className="container mx-auto p-6 space-y-6 max-w-4xl">
|
||||
<div className="text-center">
|
||||
<h1 className="text-3xl font-bold text-gray-900 mb-2">Superadmin Dashboard</h1>
|
||||
<p className="text-sm text-gray-600">Manage users and view system-wide data</p>
|
||||
<h1 className="text-3xl font-bold mb-2">Superadmin Dashboard</h1>
|
||||
<p className="text-sm text-muted-foreground">Manage users and view system-wide data</p>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-6 md:grid-cols-2">
|
||||
|
|
@ -111,7 +111,7 @@ export default function SuperadminPage() {
|
|||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-4">
|
||||
<p className="text-sm text-gray-600">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Access detailed information about all workflow runs, including status,
|
||||
recordings, transcripts, and usage data.
|
||||
</p>
|
||||
|
|
|
|||
|
|
@ -328,8 +328,8 @@ export default function RunsPage() {
|
|||
return (
|
||||
<div className="container mx-auto p-6 space-y-6 max-w-full">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold text-gray-900 mb-2">Workflow Runs</h1>
|
||||
<p className="text-gray-600">View and manage all workflow runs across organizations</p>
|
||||
<h1 className="text-3xl font-bold mb-2">Workflow Runs</h1>
|
||||
<p className="text-muted-foreground">View and manage all workflow runs across organizations</p>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
|
|
@ -368,15 +368,15 @@ export default function RunsPage() {
|
|||
</CardHeader>
|
||||
<CardContent>
|
||||
{runs.length === 0 ? (
|
||||
<div className="text-center py-8 text-gray-500">
|
||||
<div className="text-center py-8 text-muted-foreground">
|
||||
No workflow runs found.
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<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">
|
||||
<TableRow className="bg-muted">
|
||||
<TableHead className="font-semibold">ID</TableHead>
|
||||
<TableHead className="font-semibold">Workflow</TableHead>
|
||||
<TableHead className="font-semibold">Status</TableHead>
|
||||
|
|
@ -406,7 +406,7 @@ export default function RunsPage() {
|
|||
: run.workflow_name
|
||||
) : 'Unknown Workflow'}
|
||||
</span>
|
||||
<span className="text-xs text-gray-500 font-mono">
|
||||
<span className="text-xs text-muted-foreground font-mono">
|
||||
ID: {String(run.workflow_id).length > 12
|
||||
? `${String(run.workflow_id).substring(0, 12)}...`
|
||||
: run.workflow_id}
|
||||
|
|
@ -446,7 +446,7 @@ export default function RunsPage() {
|
|||
{run.admin_comment ? (
|
||||
<span>{run.admin_comment}</span>
|
||||
) : (
|
||||
<span className="text-gray-400 italic">No comment</span>
|
||||
<span className="text-muted-foreground/70 italic">No comment</span>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell className="text-sm whitespace-pre-wrap break-words">
|
||||
|
|
@ -464,7 +464,7 @@ export default function RunsPage() {
|
|||
{(run.usage_info || run.cost_info) && (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Info className="h-4 w-4 text-gray-500 cursor-pointer" />
|
||||
<Info className="h-4 w-4 text-muted-foreground cursor-pointer" />
|
||||
</TooltipTrigger>
|
||||
<TooltipContent sideOffset={4} className="max-w-xs whitespace-pre-wrap break-words">
|
||||
<pre className="max-w-xs whitespace-pre-wrap break-words">
|
||||
|
|
@ -584,7 +584,7 @@ export default function RunsPage() {
|
|||
{/* Pagination */}
|
||||
{totalPages > 1 && (
|
||||
<div className="flex items-center justify-between mt-6">
|
||||
<div className="text-sm text-gray-500">
|
||||
<div className="text-sm text-muted-foreground">
|
||||
Page {currentPage} of {totalPages} ({totalCount} total runs)
|
||||
</div>
|
||||
<div className="flex space-x-2">
|
||||
|
|
|
|||
|
|
@ -1,6 +1,5 @@
|
|||
"use client";
|
||||
|
||||
import { useRouter, useSearchParams } from "next/navigation";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { toast } from "sonner";
|
||||
|
|
@ -45,14 +44,14 @@ interface TelephonyConfigForm {
|
|||
}
|
||||
|
||||
export default function ConfigureTelephonyPage() {
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
const { user, getAccessToken, loading: authLoading } = useAuth();
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [hasExistingConfig, setHasExistingConfig] = useState(false);
|
||||
|
||||
// Get returnTo parameter from URL
|
||||
const returnTo = searchParams.get("returnTo") || "/workflow";
|
||||
// Clean up any stale pointer-events from dialogs that weren't properly closed before navigation
|
||||
useEffect(() => {
|
||||
document.body.style.pointerEvents = '';
|
||||
}, []);
|
||||
|
||||
const {
|
||||
register,
|
||||
|
|
@ -167,9 +166,6 @@ export default function ConfigureTelephonyPage() {
|
|||
}
|
||||
|
||||
toast.success("Telephony configuration saved successfully");
|
||||
|
||||
// Redirect back to the page that sent us here
|
||||
router.push(returnTo);
|
||||
} catch (error) {
|
||||
toast.error(
|
||||
error instanceof Error
|
||||
|
|
@ -182,14 +178,15 @@ export default function ConfigureTelephonyPage() {
|
|||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50">
|
||||
<div className="container mx-auto px-4 py-8">
|
||||
<div className="container mx-auto p-6 space-y-6">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold mb-2">Configure Telephony</h1>
|
||||
<p className="text-gray-600 mb-6">
|
||||
<p className="text-muted-foreground">
|
||||
Set up your telephony provider to make phone calls
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
<div>
|
||||
<Card className="h-full">
|
||||
<CardHeader>
|
||||
|
|
@ -245,7 +242,7 @@ export default function ConfigureTelephonyPage() {
|
|||
</SelectContent>
|
||||
</Select>
|
||||
{hasExistingConfig && (
|
||||
<p className="text-sm text-amber-600">
|
||||
<p className="text-sm text-yellow-600 dark:text-yellow-500">
|
||||
⚠️ Switching providers will require entering new credentials
|
||||
</p>
|
||||
)}
|
||||
|
|
@ -481,7 +478,6 @@ export default function ConfigureTelephonyPage() {
|
|||
</form>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
|
@ -1,14 +0,0 @@
|
|||
import BaseHeader from "@/components/header/BaseHeader"
|
||||
|
||||
export default function UsageLayout({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode
|
||||
}) {
|
||||
return (
|
||||
<>
|
||||
<BaseHeader />
|
||||
{children}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
|
@ -313,11 +313,11 @@ export default function UsagePage() {
|
|||
<div>
|
||||
<div className="flex justify-between items-start">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold text-gray-900 mb-2">Usage Dashboard</h1>
|
||||
<p className="text-gray-600">Monitor your Dograh Token usage and quota</p>
|
||||
<h1 className="text-3xl font-bold mb-2">Usage Dashboard</h1>
|
||||
<p className="text-muted-foreground">Monitor your Dograh Token usage and quota</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Globe className="h-4 w-4 text-gray-500" />
|
||||
<Globe className="h-4 w-4 text-muted-foreground" />
|
||||
<div className="w-[300px]">
|
||||
<TimezoneSelect
|
||||
instanceId={timezoneSelectId}
|
||||
|
|
@ -353,9 +353,9 @@ export default function UsagePage() {
|
|||
<CardContent>
|
||||
{isLoadingCurrent ? (
|
||||
<div className="animate-pulse space-y-4">
|
||||
<div className="h-4 bg-gray-200 rounded w-1/4"></div>
|
||||
<div className="h-8 bg-gray-200 rounded"></div>
|
||||
<div className="h-4 bg-gray-200 rounded w-1/3"></div>
|
||||
<div className="h-4 bg-muted rounded w-1/4"></div>
|
||||
<div className="h-8 bg-muted rounded"></div>
|
||||
<div className="h-4 bg-muted rounded w-1/3"></div>
|
||||
</div>
|
||||
) : currentUsage ? (
|
||||
<div className="space-y-4">
|
||||
|
|
@ -366,8 +366,8 @@ export default function UsagePage() {
|
|||
<p className="text-2xl font-bold">
|
||||
${(currentUsage.used_amount_usd || 0).toFixed(2)}
|
||||
</p>
|
||||
<p className="text-sm text-gray-600">Total Cost (USD)</p>
|
||||
<p className="text-xs text-gray-500 mt-1">
|
||||
<p className="text-sm text-muted-foreground">Total Cost (USD)</p>
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
Rate: ${(organizationPricing.price_per_second_usd * 60).toFixed(4)}/minute
|
||||
</p>
|
||||
</>
|
||||
|
|
@ -376,14 +376,14 @@ export default function UsagePage() {
|
|||
<p className="text-2xl font-bold">
|
||||
{currentUsage.used_dograh_tokens.toLocaleString()} / {currentUsage.quota_dograh_tokens.toLocaleString()}
|
||||
</p>
|
||||
<p className="text-sm text-gray-600">Dograh Tokens</p>
|
||||
<p className="text-sm text-muted-foreground">Dograh Tokens</p>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
{!organizationPricing?.price_per_second_usd && (
|
||||
<div className="text-right">
|
||||
<p className="text-lg font-semibold">{currentUsage.percentage_used}%</p>
|
||||
<p className="text-sm text-gray-600">Used</p>
|
||||
<p className="text-sm text-muted-foreground">Used</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
|
@ -392,18 +392,18 @@ export default function UsagePage() {
|
|||
<Progress value={currentUsage.percentage_used} className="h-3" />
|
||||
)}
|
||||
|
||||
<div className="flex justify-between items-center text-sm text-gray-600">
|
||||
<div className="flex justify-between items-center text-sm text-muted-foreground">
|
||||
<div className="flex items-center">
|
||||
<Calendar className="h-4 w-4 mr-1" />
|
||||
Next refresh: {formatDate(currentUsage.next_refresh_date)}
|
||||
</div>
|
||||
<div>
|
||||
Total Duration: <span className="font-medium text-gray-900">{formatDuration(currentUsage.total_duration_seconds)}</span>
|
||||
Total Duration: <span className="font-medium text-foreground">{formatDuration(currentUsage.total_duration_seconds)}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-gray-500">Unable to load usage data</p>
|
||||
<p className="text-muted-foreground">Unable to load usage data</p>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
|
@ -446,17 +446,17 @@ export default function UsagePage() {
|
|||
{isLoadingHistory ? (
|
||||
<div className="animate-pulse space-y-3">
|
||||
{[...Array(5)].map((_, i) => (
|
||||
<div key={i} className="h-12 bg-gray-200 rounded"></div>
|
||||
<div key={i} className="h-12 bg-muted rounded"></div>
|
||||
))}
|
||||
</div>
|
||||
) : usageHistory && usageHistory.runs.length > 0 ? (
|
||||
<>
|
||||
<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">
|
||||
<TableRow className="bg-muted/50">
|
||||
<TableHead className="font-semibold">Run ID</TableHead>
|
||||
<TableHead className="font-semibold">Workflow Name</TableHead>
|
||||
<TableHead className="font-semibold">Agent Name</TableHead>
|
||||
<TableHead className="font-semibold">Phone Number</TableHead>
|
||||
<TableHead className="font-semibold">Disposition</TableHead>
|
||||
<TableHead className="font-semibold">Date</TableHead>
|
||||
|
|
@ -518,13 +518,13 @@ export default function UsagePage() {
|
|||
|
||||
{/* Summary */}
|
||||
{activeFilters.length > 0 && (
|
||||
<div className="mt-4 p-3 bg-gray-50 rounded-md">
|
||||
<p className="text-sm text-gray-600">
|
||||
Total for filtered period: <span className="font-semibold text-gray-900">
|
||||
<div className="mt-4 p-3 bg-muted rounded-md">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Total for filtered period: <span className="font-semibold text-foreground">
|
||||
{usageHistory.total_dograh_tokens.toLocaleString()} Dograh Tokens
|
||||
</span>
|
||||
{' • '}
|
||||
<span className="font-semibold text-gray-900">
|
||||
<span className="font-semibold text-foreground">
|
||||
{formatDuration(usageHistory.total_duration_seconds)}
|
||||
</span>
|
||||
</p>
|
||||
|
|
@ -534,7 +534,7 @@ export default function UsagePage() {
|
|||
{/* Pagination */}
|
||||
{usageHistory.total_pages > 1 && (
|
||||
<div className="flex items-center justify-between mt-6">
|
||||
<p className="text-sm text-gray-600">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Page {usageHistory.page} of {usageHistory.total_pages} ({usageHistory.total_count} total runs)
|
||||
</p>
|
||||
<div className="flex gap-2">
|
||||
|
|
@ -561,7 +561,7 @@ export default function UsagePage() {
|
|||
)}
|
||||
</>
|
||||
) : (
|
||||
<p className="text-center py-8 text-gray-500">No usage history found</p>
|
||||
<p className="text-center py-8 text-muted-foreground">No usage history found</p>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
|
|
|||
|
|
@ -1,28 +1,17 @@
|
|||
import React, { ReactNode } from 'react'
|
||||
|
||||
import BaseHeader from '@/components/header/BaseHeader'
|
||||
|
||||
interface WorkflowLayoutProps {
|
||||
children: ReactNode,
|
||||
headerActions?: ReactNode,
|
||||
backButton?: ReactNode,
|
||||
showFeaturesNav?: boolean,
|
||||
stickyTabs?: ReactNode
|
||||
}
|
||||
|
||||
const WorkflowLayout: React.FC<WorkflowLayoutProps> = ({ children, headerActions, backButton, showFeaturesNav = true, stickyTabs }) => {
|
||||
const WorkflowLayout: React.FC<WorkflowLayoutProps> = ({ children }) => {
|
||||
// This component is kept for backward compatibility
|
||||
// AppLayout is now applied globally in the root layout
|
||||
return (
|
||||
<>
|
||||
<BaseHeader headerActions={headerActions} backButton={backButton} showFeaturesNav={showFeaturesNav} />
|
||||
{stickyTabs && (
|
||||
<div className="sticky top-[73px] 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>
|
||||
)}
|
||||
{children}
|
||||
</>
|
||||
)
|
||||
|
|
|
|||
|
|
@ -3,14 +3,12 @@ import '@xyflow/react/dist/style.css';
|
|||
import {
|
||||
Background,
|
||||
BackgroundVariant,
|
||||
MiniMap,
|
||||
Panel,
|
||||
ReactFlow,
|
||||
} from "@xyflow/react";
|
||||
import { BrushCleaning, Maximize2, Minus, Plus, Rocket, Settings, Variable } from 'lucide-react';
|
||||
import React, { useMemo, useState } from 'react';
|
||||
|
||||
import WorkflowLayout from '@/app/workflow/WorkflowLayout';
|
||||
import { FlowEdge, FlowNode, NodeType } from "@/components/flow/types";
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
|
||||
|
|
@ -21,9 +19,9 @@ import CustomEdge from "../../../components/flow/edges/CustomEdge";
|
|||
import { AgentNode, EndCall, GlobalNode, StartCall } from "../../../components/flow/nodes";
|
||||
import { ConfigurationsDialog } from './components/ConfigurationsDialog';
|
||||
import { EmbedDialog } from './components/EmbedDialog';
|
||||
import { PhoneCallDialog } from './components/PhoneCallDialog';
|
||||
import { TemplateContextVariablesDialog } from './components/TemplateContextVariablesDialog';
|
||||
import WorkflowHeader from "./components/WorkflowHeader";
|
||||
import { WorkflowTabs } from './components/WorkflowTabs';
|
||||
import { WorkflowEditorHeader } from "./components/WorkflowEditorHeader";
|
||||
import { WorkflowProvider } from "./contexts/WorkflowContext";
|
||||
import { useWorkflowState } from "./hooks/useWorkflowState";
|
||||
import { layoutNodes } from './utils/layoutNodes';
|
||||
|
|
@ -40,22 +38,6 @@ const edgeTypes = {
|
|||
custom: CustomEdge,
|
||||
};
|
||||
|
||||
// Helper function for MiniMap node colors
|
||||
const getNodeColor = (node: FlowNode) => {
|
||||
switch (node.type) {
|
||||
case NodeType.START_CALL:
|
||||
return '#10B981'; // green-500
|
||||
case NodeType.AGENT_NODE:
|
||||
return '#3B82F6'; // blue-500
|
||||
case NodeType.END_CALL:
|
||||
return '#EF4444'; // red-500
|
||||
case NodeType.GLOBAL_NODE:
|
||||
return '#F59E0B'; // orange-500
|
||||
default:
|
||||
return '#6B7280'; // gray-500
|
||||
}
|
||||
};
|
||||
|
||||
interface RenderWorkflowProps {
|
||||
initialWorkflowName: string;
|
||||
workflowId: number;
|
||||
|
|
@ -78,6 +60,7 @@ function RenderWorkflow({ initialWorkflowName, workflowId, initialFlow, initialT
|
|||
const [isContextVarsDialogOpen, setIsContextVarsDialogOpen] = useState(false);
|
||||
const [isConfigurationsDialogOpen, setIsConfigurationsDialogOpen] = useState(false);
|
||||
const [isEmbedDialogOpen, setIsEmbedDialogOpen] = useState(false);
|
||||
const [isPhoneCallDialogOpen, setIsPhoneCallDialogOpen] = useState(false);
|
||||
|
||||
const {
|
||||
rfInstance,
|
||||
|
|
@ -90,6 +73,7 @@ function RenderWorkflow({ initialWorkflowName, workflowId, initialFlow, initialT
|
|||
templateContextVariables,
|
||||
workflowConfigurations,
|
||||
setNodes,
|
||||
setIsDirty,
|
||||
setIsAddNodePanelOpen,
|
||||
handleNodeSelect,
|
||||
saveWorkflow,
|
||||
|
|
@ -115,29 +99,28 @@ function RenderWorkflow({ initialWorkflowName, workflowId, initialFlow, initialT
|
|||
type: "custom"
|
||||
}), []);
|
||||
|
||||
const headerActions = (
|
||||
<WorkflowHeader
|
||||
workflowValidationErrors={workflowValidationErrors}
|
||||
isDirty={isDirty}
|
||||
workflowName={workflowName}
|
||||
rfInstance={rfInstance}
|
||||
onRun={onRun}
|
||||
workflowId={workflowId}
|
||||
saveWorkflow={saveWorkflow}
|
||||
user={user}
|
||||
getAccessToken={getAccessToken}
|
||||
/>
|
||||
);
|
||||
|
||||
const stickyTabs = <WorkflowTabs workflowId={workflowId} currentTab="editor" />;
|
||||
|
||||
// Memoize the context value to prevent unnecessary re-renders
|
||||
const workflowContextValue = useMemo(() => ({ saveWorkflow }), [saveWorkflow]);
|
||||
|
||||
return (
|
||||
<WorkflowProvider value={workflowContextValue}>
|
||||
<WorkflowLayout headerActions={headerActions} showFeaturesNav={false} stickyTabs={stickyTabs}>
|
||||
<div className="h-[calc(100vh-128px)] relative">
|
||||
<div className="flex flex-col h-screen">
|
||||
{/* New Workflow Editor Header */}
|
||||
<WorkflowEditorHeader
|
||||
workflowName={workflowName}
|
||||
isDirty={isDirty}
|
||||
workflowValidationErrors={workflowValidationErrors}
|
||||
rfInstance={rfInstance}
|
||||
onRun={onRun}
|
||||
workflowId={workflowId}
|
||||
saveWorkflow={saveWorkflow}
|
||||
user={user}
|
||||
getAccessToken={getAccessToken}
|
||||
onPhoneCallClick={() => setIsPhoneCallDialogOpen(true)}
|
||||
/>
|
||||
|
||||
{/* Workflow Canvas */}
|
||||
<div className="flex-1 relative">
|
||||
<ReactFlow
|
||||
nodes={nodes}
|
||||
edges={edges}
|
||||
|
|
@ -162,12 +145,6 @@ function RenderWorkflow({ initialWorkflowName, workflowId, initialFlow, initialT
|
|||
size={1}
|
||||
color="#94a3b8"
|
||||
/>
|
||||
<MiniMap
|
||||
nodeColor={getNodeColor}
|
||||
position="bottom-right"
|
||||
className="bg-white/90 border rounded shadow-lg"
|
||||
maskColor="rgb(0, 0, 0, 0.1)"
|
||||
/>
|
||||
|
||||
{/* Top-right controls - vertical layout */}
|
||||
<Panel position="top-right">
|
||||
|
|
@ -301,7 +278,10 @@ function RenderWorkflow({ initialWorkflowName, workflowId, initialFlow, initialT
|
|||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
onClick={() => setNodes(layoutNodes(nodes, edges, 'LR', rfInstance))}
|
||||
onClick={() => {
|
||||
setNodes(layoutNodes(nodes, edges, 'TB', rfInstance));
|
||||
setIsDirty(true);
|
||||
}}
|
||||
className="bg-white shadow-sm hover:shadow-md h-8 w-8"
|
||||
>
|
||||
<BrushCleaning className="h-4 w-4" />
|
||||
|
|
@ -343,7 +323,15 @@ function RenderWorkflow({ initialWorkflowName, workflowId, initialFlow, initialT
|
|||
workflowName={workflowName}
|
||||
getAccessToken={getAccessToken}
|
||||
/>
|
||||
</WorkflowLayout>
|
||||
|
||||
<PhoneCallDialog
|
||||
open={isPhoneCallDialogOpen}
|
||||
onOpenChange={setIsPhoneCallDialogOpen}
|
||||
workflowId={workflowId}
|
||||
getAccessToken={getAccessToken}
|
||||
user={user}
|
||||
/>
|
||||
</div>
|
||||
</WorkflowProvider>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -98,9 +98,9 @@ export const ConfigurationsDialog = ({
|
|||
{/* Workflow Name Section */}
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold mb-1">Workflow Name</h3>
|
||||
<p className="text-xs text-gray-500">
|
||||
The name of your workflow
|
||||
<h3 className="text-sm font-semibold mb-1">Agent Name</h3>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
The name of your agent
|
||||
</p>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
|
|
@ -112,7 +112,7 @@ export const ConfigurationsDialog = ({
|
|||
type="text"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
placeholder="Enter workflow name"
|
||||
placeholder="Enter Agent name"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -121,7 +121,7 @@ export const ConfigurationsDialog = ({
|
|||
<div className="space-y-4">
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold mb-1">Voice Activity Detection</h3>
|
||||
<p className="text-xs text-gray-500">
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Hyperparameters to set for voice activity detection. Already configured with defaults.
|
||||
</p>
|
||||
</div>
|
||||
|
|
@ -191,7 +191,7 @@ export const ConfigurationsDialog = ({
|
|||
<div className="space-y-4">
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold mb-1">Ambient Noise</h3>
|
||||
<p className="text-xs text-gray-500">
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Add background office ambient noise to make the conversation sound more natural.
|
||||
</p>
|
||||
</div>
|
||||
|
|
@ -238,7 +238,7 @@ export const ConfigurationsDialog = ({
|
|||
<div className="space-y-4">
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold mb-1">Call Management</h3>
|
||||
<p className="text-xs text-gray-500">
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Configure call duration limits and idle timeout settings.
|
||||
</p>
|
||||
</div>
|
||||
|
|
@ -261,7 +261,7 @@ export const ConfigurationsDialog = ({
|
|||
}
|
||||
}}
|
||||
/>
|
||||
<p className="text-xs text-gray-500">Default: 600 (10 minutes)</p>
|
||||
<p className="text-xs text-muted-foreground">Default: 600 (10 minutes)</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
|
|
@ -281,7 +281,7 @@ export const ConfigurationsDialog = ({
|
|||
}
|
||||
}}
|
||||
/>
|
||||
<p className="text-xs text-gray-500">Default: 10 seconds</p>
|
||||
<p className="text-xs text-muted-foreground">Default: 10 seconds</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -200,7 +200,7 @@ export function EmbedDialog({
|
|||
|
||||
{loading ? (
|
||||
<div className="flex items-center justify-center py-8">
|
||||
<Loader2 className="h-8 w-8 animate-spin text-gray-500" />
|
||||
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-6">
|
||||
|
|
|
|||
220
ui/src/app/workflow/[workflowId]/components/PhoneCallDialog.tsx
Normal file
220
ui/src/app/workflow/[workflowId]/components/PhoneCallDialog.tsx
Normal file
|
|
@ -0,0 +1,220 @@
|
|||
"use client";
|
||||
|
||||
import 'react-international-phone/style.css';
|
||||
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useEffect, useState } from "react";
|
||||
import { PhoneInput } from 'react-international-phone';
|
||||
|
||||
import {
|
||||
getTelephonyConfigurationApiV1OrganizationsTelephonyConfigGet,
|
||||
initiateCallApiV1TelephonyInitiateCallPost
|
||||
} from '@/client/sdk.gen';
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogClose,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { useUserConfig } from "@/context/UserConfigContext";
|
||||
|
||||
interface PhoneCallDialogProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
workflowId: number;
|
||||
getAccessToken: () => Promise<string>;
|
||||
user: { id: string; email?: string };
|
||||
}
|
||||
|
||||
export const PhoneCallDialog = ({
|
||||
open,
|
||||
onOpenChange,
|
||||
workflowId,
|
||||
getAccessToken,
|
||||
user,
|
||||
}: PhoneCallDialogProps) => {
|
||||
const router = useRouter();
|
||||
const { userConfig, saveUserConfig } = useUserConfig();
|
||||
const [phoneNumber, setPhoneNumber] = useState(userConfig?.test_phone_number || "");
|
||||
const [callLoading, setCallLoading] = useState(false);
|
||||
const [callError, setCallError] = useState<string | null>(null);
|
||||
const [callSuccessMsg, setCallSuccessMsg] = useState<string | null>(null);
|
||||
const [phoneChanged, setPhoneChanged] = useState(false);
|
||||
const [configureDialogOpen, setConfigureDialogOpen] = useState(false);
|
||||
const [needsConfiguration, setNeedsConfiguration] = useState(false);
|
||||
|
||||
// Check telephony configuration when dialog opens
|
||||
useEffect(() => {
|
||||
const checkConfig = async () => {
|
||||
if (!open) return;
|
||||
|
||||
try {
|
||||
const accessToken = await getAccessToken();
|
||||
const configResponse = await getTelephonyConfigurationApiV1OrganizationsTelephonyConfigGet({
|
||||
headers: { 'Authorization': `Bearer ${accessToken}` },
|
||||
});
|
||||
|
||||
if (configResponse.error || (!configResponse.data?.twilio && !configResponse.data?.vonage && !configResponse.data?.vobiz)) {
|
||||
setNeedsConfiguration(true);
|
||||
setConfigureDialogOpen(true);
|
||||
onOpenChange(false);
|
||||
} else {
|
||||
setNeedsConfiguration(false);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("Failed to check telephony config:", err);
|
||||
}
|
||||
};
|
||||
|
||||
checkConfig();
|
||||
}, [open, getAccessToken, onOpenChange]);
|
||||
|
||||
// Reset state when dialog closes
|
||||
useEffect(() => {
|
||||
if (!open) {
|
||||
setCallError(null);
|
||||
setCallSuccessMsg(null);
|
||||
setCallLoading(false);
|
||||
}
|
||||
}, [open]);
|
||||
|
||||
// Keep phoneNumber in sync with userConfig when dialog opens
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
setPhoneNumber(userConfig?.test_phone_number || "");
|
||||
setPhoneChanged(false);
|
||||
setCallError(null);
|
||||
setCallSuccessMsg(null);
|
||||
setCallLoading(false);
|
||||
}
|
||||
}, [open, userConfig?.test_phone_number]);
|
||||
|
||||
const handlePhoneInputChange = (formattedValue: string) => {
|
||||
setPhoneNumber(formattedValue);
|
||||
setPhoneChanged(formattedValue !== userConfig?.test_phone_number);
|
||||
setCallError(null);
|
||||
setCallSuccessMsg(null);
|
||||
};
|
||||
|
||||
const handleConfigureContinue = () => {
|
||||
setConfigureDialogOpen(false);
|
||||
router.push('/telephony-configurations');
|
||||
};
|
||||
|
||||
const handleStartCall = async () => {
|
||||
setCallLoading(true);
|
||||
setCallError(null);
|
||||
setCallSuccessMsg(null);
|
||||
try {
|
||||
if (!user || !userConfig) return;
|
||||
const accessToken = await getAccessToken();
|
||||
|
||||
// Save phone number if it has changed
|
||||
if (phoneChanged) {
|
||||
await saveUserConfig({ ...userConfig, test_phone_number: phoneNumber });
|
||||
setPhoneChanged(false);
|
||||
}
|
||||
|
||||
const response = await initiateCallApiV1TelephonyInitiateCallPost({
|
||||
body: {
|
||||
workflow_id: workflowId,
|
||||
phone_number: phoneNumber
|
||||
},
|
||||
headers: { 'Authorization': `Bearer ${accessToken}` },
|
||||
});
|
||||
|
||||
if (response.error) {
|
||||
let errMsg = "Failed to initiate call";
|
||||
if (typeof response.error === "string") {
|
||||
errMsg = response.error;
|
||||
} else if (response.error && typeof response.error === "object") {
|
||||
errMsg = (response.error as unknown as { detail: string }).detail || JSON.stringify(response.error);
|
||||
}
|
||||
setCallError(errMsg);
|
||||
} else {
|
||||
const msg = response.data && (response.data as unknown as { message: string }).message || "Call initiated successfully!";
|
||||
setCallSuccessMsg(typeof msg === "string" ? msg : JSON.stringify(msg));
|
||||
}
|
||||
} catch (err: unknown) {
|
||||
setCallError(err instanceof Error ? err.message : "Failed to initiate call");
|
||||
} finally {
|
||||
setCallLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Phone Call Dialog */}
|
||||
<Dialog open={open && !needsConfiguration} onOpenChange={onOpenChange}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Phone Call</DialogTitle>
|
||||
<DialogDescription>
|
||||
Enter the phone number to call. The number will be saved automatically.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<PhoneInput
|
||||
defaultCountry="in"
|
||||
value={phoneNumber}
|
||||
onChange={handlePhoneInputChange}
|
||||
/>
|
||||
<DialogFooter className="flex-col sm:flex-row gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
onOpenChange(false);
|
||||
router.push('/telephony-configurations');
|
||||
}}
|
||||
>
|
||||
Configure Telephony
|
||||
</Button>
|
||||
<div className="flex gap-2 flex-1 justify-end">
|
||||
<DialogClose asChild>
|
||||
<Button variant="outline">Cancel</Button>
|
||||
</DialogClose>
|
||||
{!callSuccessMsg ? (
|
||||
<Button
|
||||
onClick={handleStartCall}
|
||||
disabled={callLoading || !phoneNumber}
|
||||
>
|
||||
{callLoading ? "Calling..." : "Start Call"}
|
||||
</Button>
|
||||
) : (
|
||||
<Button onClick={() => onOpenChange(false)}>
|
||||
Close
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</DialogFooter>
|
||||
{callError && <div className="text-red-500 text-sm mt-2">{callError}</div>}
|
||||
{callSuccessMsg && <div className="text-green-600 text-sm mt-2">{callSuccessMsg}</div>}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* Configure Telephony Dialog */}
|
||||
<Dialog open={configureDialogOpen} onOpenChange={setConfigureDialogOpen}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Configure Telephony</DialogTitle>
|
||||
<DialogDescription>
|
||||
You need to configure your telephony settings before making phone calls.
|
||||
You will be redirected to the telephony configuration page.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogFooter>
|
||||
<Button variant="ghost" onClick={() => setConfigureDialogOpen(false)}>
|
||||
Do it Later
|
||||
</Button>
|
||||
<Button onClick={handleConfigureContinue}>
|
||||
Continue
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
|
@ -77,7 +77,7 @@ export const TemplateContextVariablesDialog = ({
|
|||
<div key={key} className="flex items-center gap-2 p-2 border rounded-md">
|
||||
<div className="flex-1">
|
||||
<div className="text-sm font-medium">{key}</div>
|
||||
<div className="text-xs text-gray-500 truncate">{value}</div>
|
||||
<div className="text-xs text-muted-foreground truncate">{value}</div>
|
||||
</div>
|
||||
<Button
|
||||
size="sm"
|
||||
|
|
|
|||
|
|
@ -0,0 +1,184 @@
|
|||
"use client";
|
||||
|
||||
import { ReactFlowInstance } from "@xyflow/react";
|
||||
import { ArrowLeft, ChevronDown, Download, History, LoaderCircle, MoreVertical, Phone } from "lucide-react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useState } from "react";
|
||||
|
||||
import { WorkflowError } from "@/client/types.gen";
|
||||
import { FlowEdge, FlowNode } from "@/components/flow/types";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { WORKFLOW_RUN_MODES } from "@/constants/workflowRunModes";
|
||||
|
||||
interface WorkflowEditorHeaderProps {
|
||||
workflowName: string;
|
||||
isDirty: boolean;
|
||||
workflowValidationErrors: WorkflowError[];
|
||||
rfInstance: React.RefObject<ReactFlowInstance<FlowNode, FlowEdge> | null>;
|
||||
onRun: (mode: string) => Promise<void>;
|
||||
workflowId: number;
|
||||
saveWorkflow: (updateWorkflowDefinition?: boolean) => Promise<void>;
|
||||
user: { id: string; email?: string };
|
||||
getAccessToken: () => Promise<string>;
|
||||
onPhoneCallClick: () => void;
|
||||
}
|
||||
|
||||
export const WorkflowEditorHeader = ({
|
||||
workflowName,
|
||||
isDirty,
|
||||
workflowValidationErrors,
|
||||
rfInstance,
|
||||
saveWorkflow,
|
||||
onRun,
|
||||
onPhoneCallClick,
|
||||
workflowId,
|
||||
}: WorkflowEditorHeaderProps) => {
|
||||
const router = useRouter();
|
||||
const [savingWorkflow, setSavingWorkflow] = useState(false);
|
||||
|
||||
const hasValidationErrors = workflowValidationErrors.length > 0;
|
||||
const isCallDisabled = isDirty || hasValidationErrors;
|
||||
|
||||
const handleSave = async () => {
|
||||
setSavingWorkflow(true);
|
||||
await saveWorkflow();
|
||||
setSavingWorkflow(false);
|
||||
};
|
||||
|
||||
const handleBack = () => {
|
||||
router.push("/workflow");
|
||||
};
|
||||
|
||||
const handleDownloadWorkflow = () => {
|
||||
if (!rfInstance.current) return;
|
||||
|
||||
const workflowDefinition = rfInstance.current.toObject();
|
||||
const exportData = {
|
||||
name: workflowName,
|
||||
workflow_definition: workflowDefinition,
|
||||
};
|
||||
|
||||
const blob = new Blob([JSON.stringify(exportData, null, 2)], { type: "application/json" });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const link = document.createElement("a");
|
||||
link.href = url;
|
||||
link.download = `${workflowName}.json`;
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
document.body.removeChild(link);
|
||||
URL.revokeObjectURL(url);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex items-center justify-between w-full h-14 px-4 bg-[#1a1a1a] border-b border-[#2a2a2a]">
|
||||
{/* Left section: Back button + Workflow name */}
|
||||
<div className="flex items-center gap-3">
|
||||
<button
|
||||
onClick={handleBack}
|
||||
className="flex items-center justify-center w-8 h-8 rounded-lg hover:bg-[#2a2a2a] transition-colors"
|
||||
>
|
||||
<ArrowLeft className="w-5 h-5 text-gray-400" />
|
||||
</button>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<h1 className="text-base font-medium text-white">
|
||||
{workflowName}
|
||||
</h1>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Right section: Unsaved indicator + Call button + Save button */}
|
||||
<div className="flex items-center gap-3">
|
||||
{/* Unsaved changes indicator */}
|
||||
{isDirty && (
|
||||
<div className="flex items-center gap-2 px-3 py-1.5 rounded-md border border-yellow-500/30 bg-yellow-500/10">
|
||||
<div className="w-2 h-2 rounded-full bg-yellow-500" />
|
||||
<span className="text-sm text-yellow-500">Unsaved changes</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Call button with dropdown */}
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
className="flex items-center gap-2 bg-transparent border-[#3a3a3a] hover:bg-[#2a2a2a] text-white"
|
||||
disabled={isCallDisabled}
|
||||
>
|
||||
<Phone className="w-4 h-4" />
|
||||
Call
|
||||
<ChevronDown className="w-4 h-4" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="bg-[#1a1a1a] border-[#3a3a3a]">
|
||||
<DropdownMenuItem
|
||||
onClick={() => onRun(WORKFLOW_RUN_MODES.SMALL_WEBRTC)}
|
||||
className="text-white hover:bg-[#2a2a2a] cursor-pointer"
|
||||
>
|
||||
<Phone className="w-4 h-4 mr-2" />
|
||||
Web Call
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={onPhoneCallClick}
|
||||
className="text-white hover:bg-[#2a2a2a] cursor-pointer"
|
||||
>
|
||||
<Phone className="w-4 h-4 mr-2" />
|
||||
Phone Call
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
|
||||
{/* Save button */}
|
||||
<Button
|
||||
onClick={handleSave}
|
||||
disabled={!isDirty || savingWorkflow}
|
||||
className="bg-teal-600 hover:bg-teal-700 text-white px-4"
|
||||
>
|
||||
{savingWorkflow ? (
|
||||
<>
|
||||
<LoaderCircle className="w-4 h-4 mr-2 animate-spin" />
|
||||
Saving...
|
||||
</>
|
||||
) : (
|
||||
"Save"
|
||||
)}
|
||||
</Button>
|
||||
|
||||
{/* More options dropdown */}
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="text-gray-400 hover:text-white hover:bg-[#2a2a2a]"
|
||||
>
|
||||
<MoreVertical className="w-5 h-5" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="bg-[#1a1a1a] border-[#3a3a3a]">
|
||||
<DropdownMenuItem
|
||||
onClick={() => router.push(`/workflow/${workflowId}/runs`)}
|
||||
className="text-white hover:bg-[#2a2a2a] cursor-pointer"
|
||||
>
|
||||
<History className="w-4 h-4 mr-2" />
|
||||
View Runs
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={handleDownloadWorkflow}
|
||||
className="text-white hover:bg-[#2a2a2a] cursor-pointer"
|
||||
>
|
||||
<Download className="w-4 h-4 mr-2" />
|
||||
Download Workflow
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
@ -137,7 +137,6 @@ export function WorkflowExecutions({ workflowId, searchParams }: WorkflowExecuti
|
|||
|
||||
const updatePageInUrl = useCallback((page: number, filters?: ActiveFilter[]) => {
|
||||
const params = new URLSearchParams();
|
||||
params.set('tab', 'executions');
|
||||
params.set('page', page.toString());
|
||||
|
||||
// Add filters to URL if present
|
||||
|
|
@ -149,7 +148,7 @@ export function WorkflowExecutions({ workflowId, searchParams }: WorkflowExecuti
|
|||
}
|
||||
}
|
||||
|
||||
router.push(`/workflow/${workflowId}?${params.toString()}`, { scroll: false });
|
||||
router.push(`/workflow/${workflowId}/runs?${params.toString()}`, { scroll: false });
|
||||
}, [router, workflowId]);
|
||||
|
||||
useEffect(() => {
|
||||
|
|
@ -194,12 +193,12 @@ export function WorkflowExecutions({ workflowId, searchParams }: WorkflowExecuti
|
|||
<div className="animate-pulse">Loading workflow runs...</div>
|
||||
</div>
|
||||
) : error ? (
|
||||
<div className="bg-red-50 border border-red-200 text-red-700 px-4 py-3 rounded">
|
||||
<div className="bg-destructive/10 border border-destructive/30 text-destructive px-4 py-3 rounded">
|
||||
{error}
|
||||
</div>
|
||||
) : workflowRuns.length === 0 ? (
|
||||
<div className="text-center py-8">
|
||||
<p className="text-gray-500">No workflow runs found</p>
|
||||
<p className="text-muted-foreground">No workflow runs found</p>
|
||||
</div>
|
||||
) : (
|
||||
<Card>
|
||||
|
|
@ -210,10 +209,10 @@ export function WorkflowExecutions({ workflowId, searchParams }: WorkflowExecuti
|
|||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="bg-white border rounded-lg overflow-hidden shadow-sm">
|
||||
<div className="bg-card border border-border rounded-lg overflow-hidden shadow-sm">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow className="bg-gray-50">
|
||||
<TableRow className="bg-muted/50">
|
||||
<TableHead className="font-semibold">ID</TableHead>
|
||||
<TableHead className="font-semibold">Status</TableHead>
|
||||
<TableHead className="font-semibold">Created At</TableHead>
|
||||
|
|
@ -306,7 +305,7 @@ export function WorkflowExecutions({ workflowId, searchParams }: WorkflowExecuti
|
|||
{/* Pagination */}
|
||||
{totalPages > 1 && (
|
||||
<div className="flex items-center justify-between mt-6">
|
||||
<p className="text-sm text-gray-600">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Page {currentPage} of {totalPages}
|
||||
</p>
|
||||
<div className="flex gap-2">
|
||||
|
|
|
|||
|
|
@ -1,427 +0,0 @@
|
|||
import 'react-international-phone/style.css';
|
||||
|
||||
import { ReactFlowInstance, ReactFlowJsonObject } from "@xyflow/react";
|
||||
import { AlertTriangle, CheckCheck, Download, LoaderCircle, Phone, ShieldCheck } from "lucide-react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { PhoneInput } from 'react-international-phone';
|
||||
|
||||
import { getTelephonyConfigurationApiV1OrganizationsTelephonyConfigGet, initiateCallApiV1TelephonyInitiateCallPost } from '@/client/sdk.gen';
|
||||
import { WorkflowError } from '@/client/types.gen';
|
||||
import { FlowEdge, FlowNode } from "@/components/flow/types";
|
||||
import { OnboardingTooltip } from '@/components/onboarding/OnboardingTooltip';
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Dialog, DialogClose, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog";
|
||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
|
||||
import { WORKFLOW_RUN_MODES } from '@/constants/workflowRunModes';
|
||||
import { useOnboarding } from '@/context/OnboardingContext';
|
||||
import { useUserConfig } from "@/context/UserConfigContext";
|
||||
|
||||
interface WorkflowHeaderProps {
|
||||
isDirty: boolean;
|
||||
workflowName: string;
|
||||
rfInstance: React.RefObject<ReactFlowInstance<FlowNode, FlowEdge> | null>;
|
||||
onRun: (mode: string) => Promise<void>;
|
||||
workflowId: number;
|
||||
workflowValidationErrors: WorkflowError[];
|
||||
saveWorkflow: (updateWorkflowDefinition?: boolean) => Promise<void>;
|
||||
user: { id: string; email?: string };
|
||||
getAccessToken: () => Promise<string>;
|
||||
}
|
||||
|
||||
const handleExport = (workflow_name: string, workflow_definition: ReactFlowJsonObject<FlowNode, FlowEdge> | undefined) => {
|
||||
if (!workflow_definition) return { nodes: [], edges: [], viewport: { x: 0, y: 0, zoom: 1 } };
|
||||
|
||||
const exportData = {
|
||||
name: workflow_name,
|
||||
workflow_definition: workflow_definition
|
||||
};
|
||||
|
||||
// Convert to JSON string with proper formatting
|
||||
const jsonString = JSON.stringify(exportData, null, 2);
|
||||
|
||||
// Create a blob with the JSON data
|
||||
const blob = new Blob([jsonString], { type: 'application/json' });
|
||||
|
||||
// Create a download link
|
||||
const url = URL.createObjectURL(blob);
|
||||
const link = document.createElement('a');
|
||||
link.href = url;
|
||||
link.download = `${workflow_name.replace(/\s+/g, '_')}.json`;
|
||||
|
||||
// Trigger download
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
|
||||
// Cleanup
|
||||
document.body.removeChild(link);
|
||||
URL.revokeObjectURL(url);
|
||||
};
|
||||
|
||||
const WorkflowHeader = ({ isDirty, workflowName, rfInstance, onRun, workflowId, workflowValidationErrors, saveWorkflow, user, getAccessToken }: WorkflowHeaderProps) => {
|
||||
const router = useRouter();
|
||||
const { userConfig, saveUserConfig } = useUserConfig();
|
||||
const { hasSeenTooltip, markTooltipSeen } = useOnboarding();
|
||||
const [dialogOpen, setDialogOpen] = useState(false);
|
||||
const [phoneNumber, setPhoneNumber] = useState(userConfig?.test_phone_number || "");
|
||||
const [savingWorkflow, setSavingWorkflow] = useState(false);
|
||||
const [callLoading, setCallLoading] = useState(false);
|
||||
const [callError, setCallError] = useState<string | null>(null);
|
||||
const [callSuccessMsg, setCallSuccessMsg] = useState<string | null>(null);
|
||||
const [phoneChanged, setPhoneChanged] = useState(false);
|
||||
const [validationDialogOpen, setValidationDialogOpen] = useState(false);
|
||||
const [configureDialogOpen, setConfigureDialogOpen] = useState(false);
|
||||
const webCallButtonRef = useRef<HTMLButtonElement>(null);
|
||||
|
||||
const hasValidationErrors = workflowValidationErrors.length > 0;
|
||||
|
||||
// Reset call-related state whenever the dialog is closed so that a new call can be placed
|
||||
useEffect(() => {
|
||||
if (!dialogOpen) {
|
||||
setCallError(null);
|
||||
setCallSuccessMsg(null);
|
||||
setCallLoading(false);
|
||||
}
|
||||
}, [dialogOpen]);
|
||||
|
||||
// Keep phoneNumber in sync with userConfig when dialog opens
|
||||
const handleDialogOpenChange = (open: boolean) => {
|
||||
setDialogOpen(open);
|
||||
if (open) {
|
||||
setPhoneNumber(userConfig?.test_phone_number || "");
|
||||
setPhoneChanged(false);
|
||||
setCallError(null);
|
||||
setCallSuccessMsg(null);
|
||||
setCallLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handlePhoneInputChange = (
|
||||
formattedValue: string
|
||||
) => {
|
||||
// `value` is the raw E.164 value, e.g. "+14155552671"
|
||||
setPhoneNumber(formattedValue);
|
||||
setPhoneChanged(formattedValue !== userConfig?.test_phone_number);
|
||||
|
||||
// clear any prior errors, etc.
|
||||
setCallError(null);
|
||||
setCallSuccessMsg(null);
|
||||
};
|
||||
|
||||
const handlePhoneCallClick = async () => {
|
||||
// Check telephony configuration before opening dialog
|
||||
try {
|
||||
const accessToken = await getAccessToken();
|
||||
const configResponse = await getTelephonyConfigurationApiV1OrganizationsTelephonyConfigGet({
|
||||
headers: { 'Authorization': `Bearer ${accessToken}` },
|
||||
});
|
||||
|
||||
// If no configuration exists, show configure dialog
|
||||
// Check if any telephony provider is configured (Twilio, Vonage, or Vobiz)
|
||||
if (configResponse.error || (!configResponse.data?.twilio && !configResponse.data?.vonage && !configResponse.data?.vobiz)) {
|
||||
setConfigureDialogOpen(true);
|
||||
return;
|
||||
}
|
||||
|
||||
// Configuration exists, open the phone call dialog
|
||||
setDialogOpen(true);
|
||||
} catch (err: unknown) {
|
||||
console.error("Failed to check telephony config:", err);
|
||||
// Still open dialog to show the error
|
||||
setDialogOpen(true);
|
||||
}
|
||||
};
|
||||
|
||||
const handleConfigureContinue = () => {
|
||||
setConfigureDialogOpen(false);
|
||||
router.push(`/configure-telephony?returnTo=/workflow/${workflowId}`);
|
||||
};
|
||||
|
||||
const handleStartCall = async () => {
|
||||
setCallLoading(true);
|
||||
setCallError(null);
|
||||
setCallSuccessMsg(null);
|
||||
try {
|
||||
if (!user || !userConfig) return;
|
||||
const accessToken = await getAccessToken();
|
||||
|
||||
// Save phone number if it has changed
|
||||
if (phoneChanged) {
|
||||
await saveUserConfig({ ...userConfig, test_phone_number: phoneNumber });
|
||||
setPhoneChanged(false);
|
||||
}
|
||||
|
||||
// Configuration exists, proceed with call initiation
|
||||
const response = await initiateCallApiV1TelephonyInitiateCallPost({
|
||||
body: {
|
||||
workflow_id: workflowId,
|
||||
phone_number: phoneNumber
|
||||
},
|
||||
headers: { 'Authorization': `Bearer ${accessToken}` },
|
||||
});
|
||||
|
||||
if (response.error) {
|
||||
let errMsg = "Failed to initiate call";
|
||||
if (typeof response.error === "string") {
|
||||
errMsg = response.error;
|
||||
} else if (response.error && typeof response.error === "object") {
|
||||
errMsg = (response.error as unknown as { detail: string }).detail || JSON.stringify(response.error);
|
||||
}
|
||||
setCallError(errMsg);
|
||||
} else {
|
||||
// Try to show a message from the response, fallback to generic
|
||||
const msg = response.data && (response.data as unknown as { message: string }).message || "Call initiated successfully!";
|
||||
setCallSuccessMsg(typeof msg === "string" ? msg : JSON.stringify(msg));
|
||||
}
|
||||
} catch (err: unknown) {
|
||||
setCallError(err instanceof Error ? err.message : "Failed to initiate call");
|
||||
} finally {
|
||||
setCallLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<div className="flex items-center gap-1 text-sm text-gray-500 mr-2">
|
||||
{hasValidationErrors ? (
|
||||
<AlertTriangle className="h-4 w-4 text-red-500" />
|
||||
) : (
|
||||
<ShieldCheck className="h-4 w-4 text-green-500" />
|
||||
)}
|
||||
<span>{hasValidationErrors ? 'Invalid' : 'Valid'}</span>
|
||||
{hasValidationErrors && (
|
||||
<Button
|
||||
size="sm"
|
||||
className="ml-1 h-6 px-2 text-xs"
|
||||
onClick={() => setValidationDialogOpen(true)}
|
||||
>
|
||||
View Issues
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
{hasValidationErrors
|
||||
? `Workflow has ${workflowValidationErrors.length} validation ${workflowValidationErrors.length === 1 ? 'issue' : 'issues'}`
|
||||
: 'Workflow is valid'}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<span>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => handleExport(workflowName, rfInstance.current?.toObject())}
|
||||
disabled={isDirty || hasValidationErrors}
|
||||
>
|
||||
<Download className="mr-2 h-4 w-4" />
|
||||
Export Pathway
|
||||
</Button>
|
||||
</span>
|
||||
</TooltipTrigger>
|
||||
{(isDirty || hasValidationErrors) && (
|
||||
<TooltipContent>
|
||||
{isDirty ? 'Save the workflow before exporting' : 'Fix validation errors before exporting'}
|
||||
</TooltipContent>
|
||||
)}
|
||||
</Tooltip>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<span>
|
||||
<Button
|
||||
ref={webCallButtonRef}
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
// Mark the tooltip as seen when the button is clicked
|
||||
if (!hasSeenTooltip('web_call')) {
|
||||
markTooltipSeen('web_call');
|
||||
}
|
||||
onRun(WORKFLOW_RUN_MODES.SMALL_WEBRTC);
|
||||
}}
|
||||
disabled={isDirty || hasValidationErrors}
|
||||
>
|
||||
<Phone className="mr-2 h-4 w-4" />
|
||||
Web Call
|
||||
</Button>
|
||||
</span>
|
||||
</TooltipTrigger>
|
||||
{(isDirty || hasValidationErrors) && (
|
||||
<TooltipContent>
|
||||
{isDirty ? 'Save the workflow before testing' : 'Fix validation errors before testing'}
|
||||
</TooltipContent>
|
||||
)}
|
||||
</Tooltip>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<span>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handlePhoneCallClick}
|
||||
disabled={isDirty || hasValidationErrors}
|
||||
>
|
||||
<Phone className="mr-2 h-4 w-4" />
|
||||
Phone Call
|
||||
</Button>
|
||||
</span>
|
||||
</TooltipTrigger>
|
||||
{(isDirty || hasValidationErrors) && (
|
||||
<TooltipContent>
|
||||
{isDirty ? 'Save the workflow before making a call' : 'Fix validation errors before making a call'}
|
||||
</TooltipContent>
|
||||
)}
|
||||
</Tooltip>
|
||||
|
||||
{isDirty ? (
|
||||
<Button
|
||||
variant="default"
|
||||
size="sm"
|
||||
onClick={async () => {
|
||||
setSavingWorkflow(true);
|
||||
await saveWorkflow();
|
||||
setSavingWorkflow(false);
|
||||
}}
|
||||
disabled={savingWorkflow}
|
||||
className="animate-pulse"
|
||||
>
|
||||
{savingWorkflow ? (
|
||||
<>
|
||||
<LoaderCircle className="mr-2 h-4 w-4 animate-spin" />
|
||||
Saving...
|
||||
</>
|
||||
) : (
|
||||
'Save Changes'
|
||||
)}
|
||||
</Button>
|
||||
) : (
|
||||
<div className="flex items-center gap-1 text-sm text-gray-500">
|
||||
<CheckCheck className="h-4 w-4 text-green-500" />
|
||||
<span className='mr-2'>Saved</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Validation Errors Dialog */}
|
||||
<Dialog open={validationDialogOpen} onOpenChange={setValidationDialogOpen}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Workflow Validation Issues</DialogTitle>
|
||||
<DialogDescription>
|
||||
Please fix the following issues before running the workflow.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="max-h-[60vh] overflow-y-auto">
|
||||
<ul className="space-y-2">
|
||||
{workflowValidationErrors.map((error, index) => (
|
||||
<li key={index} className="border-l-2 border-red-500 pl-3 py-2">
|
||||
<div className="font-medium">{error.message}</div>
|
||||
{error.id && (
|
||||
<div className="text-sm text-gray-500">
|
||||
{error.kind === 'node' ? 'Node' : error.kind === 'edge' ? 'Edge' : 'Workflow'} ID: {error.id}
|
||||
</div>
|
||||
)}
|
||||
{error.field && (
|
||||
<div className="text-sm mt-1">
|
||||
Field: {error.field}
|
||||
</div>
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button onClick={() => setValidationDialogOpen(false)}>
|
||||
Close
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* Phone Call Dialog */}
|
||||
<Dialog open={dialogOpen} onOpenChange={handleDialogOpenChange}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Phone Call</DialogTitle>
|
||||
<DialogDescription>
|
||||
Enter the phone number to call. The number will be saved automatically.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<PhoneInput
|
||||
defaultCountry="in"
|
||||
value={phoneNumber}
|
||||
onChange={handlePhoneInputChange}
|
||||
/>
|
||||
<DialogFooter className="flex-col sm:flex-row gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
setDialogOpen(false);
|
||||
router.push(`/configure-telephony?returnTo=/workflow/${workflowId}`);
|
||||
}}
|
||||
>
|
||||
Configure Telephony
|
||||
</Button>
|
||||
<div className="flex gap-2 flex-1 justify-end">
|
||||
<DialogClose asChild>
|
||||
<Button variant="outline">Cancel</Button>
|
||||
</DialogClose>
|
||||
{!callSuccessMsg ? (
|
||||
<Button
|
||||
onClick={handleStartCall}
|
||||
disabled={callLoading || !phoneNumber}
|
||||
>
|
||||
{callLoading ? "Calling..." : "Start Call"}
|
||||
</Button>
|
||||
) : (
|
||||
<Button onClick={() => setDialogOpen(false)}>
|
||||
Close
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</DialogFooter>
|
||||
{callError && <div className="text-red-500 text-sm mt-2">{callError}</div>}
|
||||
{callSuccessMsg && <div className="text-green-600 text-sm mt-2">{callSuccessMsg}</div>}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* Configure Telephony Dialog */}
|
||||
<Dialog open={configureDialogOpen} onOpenChange={setConfigureDialogOpen}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Configure Telephony</DialogTitle>
|
||||
<DialogDescription>
|
||||
You need to configure your telephony settings before making phone calls.
|
||||
You will be redirected to the telephony configuration page.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogFooter>
|
||||
<Button variant="ghost" onClick={() => setConfigureDialogOpen(false)}>
|
||||
Do it Later
|
||||
</Button>
|
||||
<Button onClick={handleConfigureContinue}>
|
||||
Continue
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* Onboarding Tooltip */}
|
||||
<OnboardingTooltip
|
||||
title='Test your Voice Agent'
|
||||
targetRef={webCallButtonRef}
|
||||
message="Test this workflow now in your browser using Web Call"
|
||||
onDismiss={() => markTooltipSeen('web_call')}
|
||||
showNext={false}
|
||||
isVisible={!hasSeenTooltip('web_call') && !hasValidationErrors}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default WorkflowHeader;
|
||||
|
|
@ -1,45 +0,0 @@
|
|||
"use client";
|
||||
|
||||
import { useRouter } from "next/navigation";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
interface WorkflowTabsProps {
|
||||
workflowId: number;
|
||||
currentTab: 'editor' | 'executions';
|
||||
}
|
||||
|
||||
export const WorkflowTabs = ({ workflowId, currentTab }: WorkflowTabsProps) => {
|
||||
const router = useRouter();
|
||||
|
||||
const handleTabChange = (tab: 'editor' | 'executions') => {
|
||||
router.push(`/workflow/${workflowId}?tab=${tab}`, { scroll: false });
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={() => handleTabChange('editor')}
|
||||
className={cn(
|
||||
"px-6 py-2.5 text-sm font-medium transition-all relative cursor-pointer rounded-md",
|
||||
currentTab === 'editor'
|
||||
? "text-white bg-[#3d4451]"
|
||||
: "text-gray-300 hover:text-white hover:bg-[#343842]"
|
||||
)}
|
||||
>
|
||||
Editor
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleTabChange('executions')}
|
||||
className={cn(
|
||||
"px-6 py-2.5 text-sm font-medium transition-all relative cursor-pointer rounded-md",
|
||||
currentTab === 'executions'
|
||||
? "text-white bg-[#3d4451]"
|
||||
: "text-gray-300 hover:text-white hover:bg-[#343842]"
|
||||
)}
|
||||
>
|
||||
Executions
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
@ -1 +0,0 @@
|
|||
export * from './WorkflowHeader';
|
||||
|
|
@ -431,6 +431,7 @@ export const useWorkflowState = ({
|
|||
templateContextVariables,
|
||||
workflowConfigurations,
|
||||
setNodes,
|
||||
setIsDirty,
|
||||
setIsAddNodePanelOpen,
|
||||
handleNodeSelect,
|
||||
handleNameChange,
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
'use client';
|
||||
|
||||
import { useParams, useSearchParams } from 'next/navigation';
|
||||
import { useParams } from 'next/navigation';
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
|
||||
import RenderWorkflow from '@/app/workflow/[workflowId]/RenderWorkflow';
|
||||
|
|
@ -10,23 +10,17 @@ import { FlowEdge, FlowNode } from '@/components/flow/types';
|
|||
import SpinLoader from '@/components/SpinLoader';
|
||||
import { useAuth } from '@/lib/auth';
|
||||
import logger from '@/lib/logger';
|
||||
import { DEFAULT_WORKFLOW_CONFIGURATIONS,WorkflowConfigurations } from '@/types/workflow-configurations';
|
||||
import { DEFAULT_WORKFLOW_CONFIGURATIONS, WorkflowConfigurations } from '@/types/workflow-configurations';
|
||||
|
||||
import WorkflowLayout from '../WorkflowLayout';
|
||||
import { WorkflowExecutions } from './components/WorkflowExecutions';
|
||||
import { WorkflowTabs } from './components/WorkflowTabs';
|
||||
|
||||
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);
|
||||
const { user, getAccessToken, redirectToLogin, loading: authLoading } = useAuth();
|
||||
|
||||
// Get current tab from URL, default to 'editor'
|
||||
const currentTab = (searchParams.get('tab') as 'editor' | 'executions') || 'editor';
|
||||
|
||||
// Redirect if not authenticated
|
||||
useEffect(() => {
|
||||
if (!authLoading && !user) {
|
||||
|
|
@ -62,59 +56,41 @@ export default function WorkflowDetailPage() {
|
|||
}
|
||||
}, [params.workflowId, user, getAccessToken]);
|
||||
|
||||
const stickyTabs = workflow ? <WorkflowTabs workflowId={workflow.id} currentTab={currentTab} /> : null;
|
||||
|
||||
// Memoize user and getAccessToken to prevent unnecessary re-renders
|
||||
const stableUser = useMemo(() => user, [user]);
|
||||
const stableGetAccessToken = useMemo(() => getAccessToken, [getAccessToken]);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<WorkflowLayout stickyTabs={stickyTabs}>
|
||||
<WorkflowLayout>
|
||||
<SpinLoader />
|
||||
</WorkflowLayout>
|
||||
);
|
||||
}
|
||||
else if (error || !workflow) {
|
||||
return (
|
||||
<WorkflowLayout showFeaturesNav={false} stickyTabs={stickyTabs}>
|
||||
<WorkflowLayout showFeaturesNav={false}>
|
||||
<div className="flex items-center justify-center min-h-screen">
|
||||
<div className="text-lg text-red-500">{error || 'Workflow not found'}</div>
|
||||
<div className="text-lg text-destructive">{error || 'Workflow not found'}</div>
|
||||
</div>
|
||||
</WorkflowLayout>
|
||||
);
|
||||
}
|
||||
else {
|
||||
// Render both views but hide the inactive one using absolute positioning
|
||||
// This preserves state when switching tabs
|
||||
return (
|
||||
<>
|
||||
{/* Editor view */}
|
||||
<div className={currentTab === 'editor' ? 'block' : 'hidden'} aria-hidden={currentTab !== 'editor'}>
|
||||
{stableUser && (
|
||||
<RenderWorkflow
|
||||
initialWorkflowName={workflow.name}
|
||||
workflowId={workflow.id}
|
||||
initialFlow={{
|
||||
nodes: workflow.workflow_definition.nodes as FlowNode[],
|
||||
edges: workflow.workflow_definition.edges as FlowEdge[],
|
||||
viewport: { x: 0, y: 0, zoom: 0 }
|
||||
}}
|
||||
initialTemplateContextVariables={workflow.template_context_variables as Record<string, string> || {}}
|
||||
initialWorkflowConfigurations={(workflow.workflow_configurations as WorkflowConfigurations) || DEFAULT_WORKFLOW_CONFIGURATIONS}
|
||||
user={stableUser}
|
||||
getAccessToken={stableGetAccessToken}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Executions view */}
|
||||
<div className={currentTab === 'executions' ? 'block' : 'hidden'} aria-hidden={currentTab !== 'executions'}>
|
||||
<WorkflowLayout stickyTabs={stickyTabs} showFeaturesNav={false}>
|
||||
<WorkflowExecutions workflowId={workflow.id} searchParams={searchParams} />
|
||||
</WorkflowLayout>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
return stableUser ? (
|
||||
<RenderWorkflow
|
||||
initialWorkflowName={workflow.name}
|
||||
workflowId={workflow.id}
|
||||
initialFlow={{
|
||||
nodes: workflow.workflow_definition.nodes as FlowNode[],
|
||||
edges: workflow.workflow_definition.edges as FlowEdge[],
|
||||
viewport: { x: 0, y: 0, zoom: 0 }
|
||||
}}
|
||||
initialTemplateContextVariables={workflow.template_context_variables as Record<string, string> || {}}
|
||||
initialWorkflowConfigurations={(workflow.workflow_configurations as WorkflowConfigurations) || DEFAULT_WORKFLOW_CONFIGURATIONS}
|
||||
user={stableUser}
|
||||
getAccessToken={stableGetAccessToken}
|
||||
/>
|
||||
) : null;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -101,10 +101,10 @@ const BrowserCall = ({ workflowId, workflowRunId, accessToken, initialContextVar
|
|||
<CardContent>
|
||||
{isCompleted && checkingForRecording ? (
|
||||
<div className="flex flex-col items-center justify-center space-y-4 p-8">
|
||||
<Loader2 className="h-8 w-8 animate-spin text-blue-600" />
|
||||
<Loader2 className="h-8 w-8 animate-spin text-primary" />
|
||||
<div className="text-center space-y-2">
|
||||
<p className="text-gray-700 font-medium">Processing your call</p>
|
||||
<p className="text-sm text-gray-500">Fetching transcript and recording...</p>
|
||||
<p className="text-foreground font-medium">Processing your call</p>
|
||||
<p className="text-sm text-muted-foreground">Fetching transcript and recording...</p>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
|
|
|
|||
|
|
@ -63,13 +63,12 @@ export const AudioControls = ({
|
|||
return (
|
||||
<div className="flex flex-col items-center justify-center space-y-4 p-8">
|
||||
<div className="text-center space-y-2">
|
||||
<p className="text-gray-700 font-medium">Audio permissions required</p>
|
||||
<p className="text-sm text-gray-500">Click below to grant microphone access</p>
|
||||
<p className="text-foreground font-medium">Audio permissions required</p>
|
||||
<p className="text-sm text-muted-foreground">Click below to grant microphone access</p>
|
||||
</div>
|
||||
<Button
|
||||
onClick={requestAudioPermissions}
|
||||
size="lg"
|
||||
className="bg-blue-600 hover:bg-blue-700 text-white"
|
||||
>
|
||||
<Mic className="h-5 w-5 mr-2" />
|
||||
Grant Audio Permissions
|
||||
|
|
@ -85,33 +84,33 @@ export const AudioControls = ({
|
|||
<button
|
||||
onClick={start}
|
||||
disabled={isStarting}
|
||||
className="group relative h-20 w-20 rounded-full bg-green-600 hover:bg-green-700 transition-all duration-200 shadow-lg hover:shadow-xl disabled:opacity-50 disabled:cursor-not-allowed cursor-pointer"
|
||||
className="group relative h-20 w-20 rounded-full bg-emerald-600 hover:bg-emerald-700 transition-all duration-200 shadow-lg hover:shadow-xl disabled:opacity-50 disabled:cursor-not-allowed cursor-pointer"
|
||||
aria-label="Start Call"
|
||||
>
|
||||
<div className="absolute inset-0 rounded-full bg-green-600 animate-ping opacity-25"></div>
|
||||
<div className="absolute inset-0 rounded-full bg-emerald-600 animate-ping opacity-25"></div>
|
||||
<div className="relative flex items-center justify-center h-full">
|
||||
<Phone className="h-8 w-8 text-white" />
|
||||
</div>
|
||||
</button>
|
||||
<p className="text-sm font-medium text-gray-700">Start Call</p>
|
||||
<p className="text-sm font-medium text-foreground">Start Call</p>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<p className="text-sm text-gray-600">Call in progress</p>
|
||||
<p className="text-sm text-muted-foreground">Call in progress</p>
|
||||
<button
|
||||
onClick={stop}
|
||||
className="group relative h-20 w-20 rounded-full bg-red-600 hover:bg-red-700 transition-all duration-200 shadow-lg hover:shadow-xl"
|
||||
className="group relative h-20 w-20 rounded-full bg-destructive hover:bg-destructive/90 transition-all duration-200 shadow-lg hover:shadow-xl"
|
||||
aria-label="End Call"
|
||||
>
|
||||
<div className="relative flex items-center justify-center h-full">
|
||||
<PhoneOff className="h-8 w-8 text-white" />
|
||||
<PhoneOff className="h-8 w-8 text-destructive-foreground" />
|
||||
</div>
|
||||
</button>
|
||||
<p className="text-sm font-medium text-gray-700">End Call</p>
|
||||
<p className="text-sm font-medium text-foreground">End Call</p>
|
||||
</>
|
||||
)}
|
||||
{permissionError && (
|
||||
<p className="text-sm text-red-500 text-center">{permissionError}</p>
|
||||
<p className="text-sm text-destructive text-center">{permissionError}</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -27,11 +27,11 @@ export const ContextDisplay = ({ title, context }: ContextDisplayProps) => {
|
|||
<CardContent className="space-y-3">
|
||||
{Object.entries(context).map(([key, value]) => (
|
||||
<div key={key} className="space-y-1">
|
||||
<label className="text-sm font-medium text-gray-700">
|
||||
<label className="text-sm font-medium text-muted-foreground">
|
||||
{key}
|
||||
</label>
|
||||
<div className="p-3 bg-gray-50 border rounded-md">
|
||||
<p className="text-sm text-gray-900 whitespace-pre-wrap">
|
||||
<div className="p-3 bg-muted border rounded-md">
|
||||
<p className="text-sm whitespace-pre-wrap">
|
||||
{typeof value === 'object' && value !== null ? JSON.stringify(value, null, 2) : (value || 'No value')}
|
||||
</p>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -49,9 +49,9 @@ export const ContextVariablesSection = ({
|
|||
<div className="space-y-2">
|
||||
<Label className="text-sm font-medium">Current Variables</Label>
|
||||
{Object.entries(initialContext).map(([key, value]) => (
|
||||
<div key={key} className="flex items-center gap-2 p-3 border rounded-md bg-gray-50">
|
||||
<div key={key} className="flex items-center gap-2 p-3 border rounded-md bg-muted">
|
||||
<div className="flex-1">
|
||||
<Label className="text-xs text-gray-600">{key}</Label>
|
||||
<Label className="text-xs text-muted-foreground">{key}</Label>
|
||||
<Input
|
||||
value={value}
|
||||
onChange={(e) => handleUpdateContextVar(key, e.target.value)}
|
||||
|
|
|
|||
|
|
@ -84,7 +84,7 @@ export default function WorkflowRunPage() {
|
|||
|
||||
if (isLoading) {
|
||||
returnValue = (
|
||||
<div className="min-h-screen flex mt-40 justify-center">
|
||||
<div className="h-full flex items-center justify-center">
|
||||
<div className="w-full max-w-4xl p-6">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
|
|
@ -106,14 +106,14 @@ export default function WorkflowRunPage() {
|
|||
}
|
||||
else if (workflowRun?.is_completed) {
|
||||
returnValue = (
|
||||
<div className="min-h-screen flex mt-40 justify-center p-6">
|
||||
<div className="h-full flex items-center justify-center p-6">
|
||||
<div className="w-full max-w-4xl space-y-6">
|
||||
<Card className="border-gray-100">
|
||||
<Card className="border-border">
|
||||
<CardHeader className="flex flex-row items-center justify-between">
|
||||
<div className="flex items-center gap-4">
|
||||
<CardTitle className="text-2xl">Agent Run Completed</CardTitle>
|
||||
<div className="h-8 w-8 bg-green-100 rounded-full flex items-center justify-center">
|
||||
<svg className="h-5 w-5 text-green-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<div className="h-8 w-8 bg-emerald-500/20 rounded-full flex items-center justify-center">
|
||||
<svg className="h-5 w-5 text-emerald-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M5 13l4 4L19 7" />
|
||||
</svg>
|
||||
</div>
|
||||
|
|
@ -137,11 +137,11 @@ export default function WorkflowRunPage() {
|
|||
</Link>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p className="text-gray-600 mb-8">Your voice agent run has been completed successfully. You can preview or download the transcript and recording.</p>
|
||||
<p className="text-muted-foreground mb-8">Your voice agent run has been completed successfully. You can preview or download the transcript and recording.</p>
|
||||
|
||||
<div className="flex flex-wrap gap-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm text-gray-600">Preview:</span>
|
||||
<span className="text-sm text-muted-foreground">Preview:</span>
|
||||
<MediaPreviewButtons
|
||||
recordingUrl={workflowRun?.recording_url}
|
||||
transcriptUrl={workflowRun?.transcript_url}
|
||||
|
|
@ -150,8 +150,8 @@ export default function WorkflowRunPage() {
|
|||
onOpenTranscript={openTranscriptModal}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 border-l pl-4">
|
||||
<span className="text-sm text-gray-600">Download:</span>
|
||||
<div className="flex items-center gap-2 border-l border-border pl-4">
|
||||
<span className="text-sm text-muted-foreground">Download:</span>
|
||||
<Button
|
||||
onClick={() => downloadFile(workflowRun?.transcript_url, accessToken!)}
|
||||
disabled={!workflowRun?.transcript_url || !accessToken}
|
||||
|
|
@ -191,7 +191,7 @@ export default function WorkflowRunPage() {
|
|||
}
|
||||
else {
|
||||
returnValue =
|
||||
<div className="min-h-screen mt-40">
|
||||
<div className="h-full flex items-center justify-center">
|
||||
<BrowserCall
|
||||
workflowId={Number(params.workflowId)}
|
||||
workflowRunId={Number(params.runId)}
|
||||
|
|
|
|||
|
|
@ -1,19 +1,20 @@
|
|||
"use client";
|
||||
|
||||
import { useParams, useRouter, useSearchParams } from "next/navigation";
|
||||
import { useEffect } from "react";
|
||||
import { useParams, useSearchParams } from "next/navigation";
|
||||
|
||||
import WorkflowLayout from "../../WorkflowLayout";
|
||||
import { WorkflowExecutions } from "../components/WorkflowExecutions";
|
||||
|
||||
export default function WorkflowRunsPage() {
|
||||
const { workflowId } = useParams();
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
|
||||
// Redirect to main workflow page with executions tab
|
||||
useEffect(() => {
|
||||
const params = new URLSearchParams(searchParams.toString());
|
||||
params.set('tab', 'executions');
|
||||
router.replace(`/workflow/${workflowId}?${params.toString()}`);
|
||||
}, [workflowId, router, searchParams]);
|
||||
|
||||
return null;
|
||||
return (
|
||||
<WorkflowLayout showFeaturesNav={false}>
|
||||
<WorkflowExecutions
|
||||
workflowId={Number(workflowId)}
|
||||
searchParams={searchParams}
|
||||
/>
|
||||
</WorkflowLayout>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -10,7 +10,10 @@ export const layoutNodes = (
|
|||
rfInstance: React.RefObject<ReactFlowInstance<FlowNode, FlowEdge> | null>
|
||||
) => {
|
||||
const g = new dagre.graphlib.Graph();
|
||||
g.setGraph({ rankdir, nodesep: 250, ranksep: 250 });
|
||||
// For TB (top-to-bottom) layout:
|
||||
// - nodesep: horizontal spacing between nodes at the same depth level
|
||||
// - ranksep: vertical spacing between depth levels
|
||||
g.setGraph({ rankdir, nodesep: 400, ranksep: 300 });
|
||||
g.setDefaultEdgeLabel(() => ({}));
|
||||
|
||||
// Sort nodes so startCall nodes come first and endCall nodes come last
|
||||
|
|
@ -22,8 +25,10 @@ export const layoutNodes = (
|
|||
return 0;
|
||||
});
|
||||
|
||||
// Use larger node dimensions to account for actual rendered size
|
||||
// This prevents overlapping when dagre calculates positions
|
||||
sortedNodes.forEach((node) => {
|
||||
g.setNode(node.id, { width: 180, height: 60 });
|
||||
g.setNode(node.id, { width: 350, height: 120 });
|
||||
});
|
||||
|
||||
edges.forEach((edge) => {
|
||||
|
|
@ -32,11 +37,45 @@ export const layoutNodes = (
|
|||
|
||||
dagre.layout(g);
|
||||
|
||||
// Group nodes by their Y position (rank/depth level)
|
||||
const nodesByRank = new Map<number, { node: FlowNode; dagreNode: dagre.Node }[]>();
|
||||
sortedNodes.forEach((node) => {
|
||||
const dagreNode = g.node(node.id);
|
||||
const rankY = Math.round(dagreNode.y / 50) * 50; // Round to group nearby Y values
|
||||
if (!nodesByRank.has(rankY)) {
|
||||
nodesByRank.set(rankY, []);
|
||||
}
|
||||
nodesByRank.get(rankY)!.push({ node, dagreNode });
|
||||
});
|
||||
|
||||
// Calculate horizontal offset for zigzag pattern
|
||||
// Nodes at each rank level get staggered left/right
|
||||
const horizontalStagger = 600; // How much to offset alternating ranks
|
||||
const ranks = Array.from(nodesByRank.keys()).sort((a, b) => a - b);
|
||||
|
||||
const newNodes = sortedNodes.map((node) => {
|
||||
const nodeWithPosition = g.node(node.id);
|
||||
const dagreNode = g.node(node.id);
|
||||
const rankY = Math.round(dagreNode.y / 50) * 50;
|
||||
const rankIndex = ranks.indexOf(rankY);
|
||||
const nodesAtRank = nodesByRank.get(rankY)!;
|
||||
|
||||
let xOffset = 0;
|
||||
|
||||
// Apply zigzag pattern: alternate ranks offset left/right
|
||||
// But only if there's a single node at this rank (linear chain)
|
||||
if (nodesAtRank.length === 1) {
|
||||
// Skip startCall (keep centered) and endCall (keep centered)
|
||||
if (node.type !== 'startCall' && node.type !== 'endCall' && node.type !== 'global') {
|
||||
xOffset = (rankIndex % 2 === 0) ? -horizontalStagger : horizontalStagger;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
...node,
|
||||
position: { x: nodeWithPosition.x, y: nodeWithPosition.y }
|
||||
position: {
|
||||
x: dagreNode.x + xOffset,
|
||||
y: dagreNode.y
|
||||
}
|
||||
};
|
||||
});
|
||||
|
||||
|
|
|
|||
253
ui/src/app/workflow/create/page.tsx
Normal file
253
ui/src/app/workflow/create/page.tsx
Normal file
|
|
@ -0,0 +1,253 @@
|
|||
'use client';
|
||||
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { useState } from 'react';
|
||||
|
||||
import { createWorkflowFromTemplateApiV1WorkflowCreateTemplatePost, createWorkflowRunApiV1WorkflowWorkflowIdRunsPost } from '@/client/sdk.gen';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
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();
|
||||
const { user, getAccessToken } = useAuth();
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [showSuccessModal, setShowSuccessModal] = useState(false);
|
||||
const [workflowId, setWorkflowId] = useState<string | null>(null);
|
||||
|
||||
const [callType, setCallType] = useState<'INBOUND' | 'OUTBOUND'>('INBOUND');
|
||||
const [useCase, setUseCase] = useState('');
|
||||
const [activityDescription, setActivityDescription] = useState('');
|
||||
|
||||
const handleCreateWorkflow = async () => {
|
||||
if (!useCase || !activityDescription) {
|
||||
setError('Please fill in all fields');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!user) {
|
||||
setError('You must be logged in to create a workflow');
|
||||
return;
|
||||
}
|
||||
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const accessToken = await getAccessToken();
|
||||
|
||||
// Call the API to create workflow from template
|
||||
const response = await createWorkflowFromTemplateApiV1WorkflowCreateTemplatePost({
|
||||
body: {
|
||||
call_type: callType,
|
||||
use_case: useCase,
|
||||
activity_description: activityDescription,
|
||||
},
|
||||
headers: {
|
||||
'Authorization': `Bearer ${accessToken}`,
|
||||
},
|
||||
});
|
||||
|
||||
if (response.data?.id) {
|
||||
setWorkflowId(String(response.data.id));
|
||||
setShowSuccessModal(true);
|
||||
}
|
||||
} catch (err) {
|
||||
setError('Failed to create workflow. Please try again.');
|
||||
logger.error(`Error creating workflow: ${err}`);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
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}`);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-background">
|
||||
<div className="container mx-auto px-4 py-8 max-w-2xl">
|
||||
<div className="mb-6">
|
||||
<h1 className="text-3xl font-bold mb-2">Create Voice Agent</h1>
|
||||
<p className="text-muted-foreground">
|
||||
Tell us about your use case and we'll create a customized voice agent for you
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Agent Details</CardTitle>
|
||||
<CardDescription>
|
||||
Configure your voice agent settings
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-6">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="call-type">Call Type</Label>
|
||||
<Select value={callType} onValueChange={(value) => setCallType(value as 'INBOUND' | 'OUTBOUND')}>
|
||||
<SelectTrigger id="call-type">
|
||||
<SelectValue placeholder="Select type" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="INBOUND">
|
||||
Inbound (Users call AI)
|
||||
</SelectItem>
|
||||
<SelectItem value="OUTBOUND">
|
||||
Outbound (AI calls users)
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Choose whether users will call your AI or your AI will call users
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="use-case">Use Case</Label>
|
||||
<Input
|
||||
id="use-case"
|
||||
placeholder="e.g., Lead Qualification, HR Screening, Customer Support"
|
||||
value={useCase}
|
||||
onChange={(e) => setUseCase(e.target.value)}
|
||||
/>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Describe the primary purpose of your voice agent
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="activity-description">Activity Description</Label>
|
||||
<Textarea
|
||||
id="activity-description"
|
||||
placeholder="Describe briefly what your voice agent will do (e.g., Qualify leads for real estate, Screen candidates for roles, Handle customer support). This will be a prompt to an LLM."
|
||||
value={activityDescription}
|
||||
onChange={(e) => setActivityDescription(e.target.value)}
|
||||
className="min-h-[100px]"
|
||||
/>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
This description will be used to generate the AI prompt for your voice agent
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<p className="text-sm text-red-500">{error}</p>
|
||||
)}
|
||||
|
||||
<div className="pt-4">
|
||||
<Button
|
||||
onClick={handleCreateWorkflow}
|
||||
disabled={isLoading || !useCase || !activityDescription}
|
||||
className="w-full"
|
||||
>
|
||||
{isLoading ? 'Creating...' : 'Create Agent'}
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Loading Overlay */}
|
||||
{isLoading && (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm">
|
||||
<Card className="w-full max-w-md p-8">
|
||||
<div className="flex flex-col items-center space-y-6">
|
||||
{/* Animated spinner */}
|
||||
<div className="relative">
|
||||
<div className="w-16 h-16 border-4 border-muted rounded-full"></div>
|
||||
<div className="absolute top-0 left-0 w-16 h-16 border-4 border-transparent border-t-primary rounded-full animate-spin"></div>
|
||||
</div>
|
||||
|
||||
<div className="text-center space-y-2">
|
||||
<h3 className="text-lg font-semibold">
|
||||
Creating Your Workflow
|
||||
</h3>
|
||||
<p className="text-sm text-muted-foreground max-w-xs">
|
||||
We're setting up your voice agent with your specifications. This will just take a moment...
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Success Modal */}
|
||||
<Dialog open={showSuccessModal} onOpenChange={setShowSuccessModal}>
|
||||
<DialogContent className="sm:max-w-lg">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<svg className="w-5 h-5 text-green-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
Workflow Created Successfully!
|
||||
</DialogTitle>
|
||||
<DialogDescription asChild>
|
||||
<div className="mt-4 space-y-3">
|
||||
<p>
|
||||
A voice agent workflow has been generated for your use case, with some artificial data and sample actions.
|
||||
</p>
|
||||
<p>
|
||||
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.
|
||||
</p>
|
||||
</div>
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogFooter className="mt-6">
|
||||
<Button
|
||||
onClick={handleModalContinue}
|
||||
className="w-full"
|
||||
>
|
||||
Start Web Call
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,11 +1,7 @@
|
|||
import { Settings } from 'lucide-react';
|
||||
import Link from 'next/link';
|
||||
import { Suspense } from 'react';
|
||||
|
||||
import { getWorkflowsApiV1WorkflowFetchGet, getWorkflowTemplatesApiV1WorkflowTemplatesGet } from '@/client/sdk.gen';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { getWorkflowsApiV1WorkflowFetchGet } from '@/client/sdk.gen';
|
||||
import { CreateWorkflowButton } from "@/components/workflow/CreateWorkflowButton";
|
||||
import { DuplicateWorkflowTemplate } from "@/components/workflow/TemplateCard";
|
||||
import { UploadWorkflowButton } from '@/components/workflow/UploadWorkflowButton';
|
||||
import { WorkflowTable } from "@/components/workflow/WorkflowTable";
|
||||
import { getServerAccessToken, getServerAuthProvider } from '@/lib/auth/server';
|
||||
|
|
@ -15,42 +11,6 @@ import WorkflowLayout from "./WorkflowLayout";
|
|||
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
// Server component for workflow templates
|
||||
async function WorkflowTemplatesList() {
|
||||
try {
|
||||
const response = await getWorkflowTemplatesApiV1WorkflowTemplatesGet();
|
||||
// Log request URL if available
|
||||
if (response.request?.url) {
|
||||
logger.info(`Template Request URL: ${response.request.url}`);
|
||||
}
|
||||
const templates = response.data || [];
|
||||
|
||||
// Get access token on server side to pass to client component
|
||||
const accessToken = await getServerAccessToken();
|
||||
|
||||
return (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||
{templates.map((template) => (
|
||||
<DuplicateWorkflowTemplate
|
||||
key={template.id}
|
||||
id={template.id}
|
||||
title={template.template_name}
|
||||
description={template.template_description}
|
||||
serverAccessToken={accessToken}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
} catch (err) {
|
||||
logger.error(`Error fetching workflow templates: ${err}`);
|
||||
return (
|
||||
<div className="text-red-500">
|
||||
Failed to load Workflow Templates. Please Try Again Later.
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Server component for workflow list
|
||||
async function WorkflowList() {
|
||||
const authProvider = getServerAuthProvider();
|
||||
|
|
@ -99,11 +59,11 @@ async function WorkflowList() {
|
|||
<>
|
||||
{/* Active Workflows Section */}
|
||||
<div className="mb-8">
|
||||
<h2 className="text-xl font-semibold mb-4">Active Workflows</h2>
|
||||
<h2 className="text-xl font-semibold mb-4">Active Agents</h2>
|
||||
{activeWorkflows.length > 0 ? (
|
||||
<WorkflowTable workflows={activeWorkflows} showArchived={false} />
|
||||
) : (
|
||||
<div className="text-gray-500 bg-gray-50 rounded-lg p-8 text-center">
|
||||
<div className="text-muted-foreground bg-muted rounded-lg p-8 text-center">
|
||||
No active workflows found. Create your first workflow to get started.
|
||||
</div>
|
||||
)}
|
||||
|
|
@ -112,7 +72,7 @@ async function WorkflowList() {
|
|||
{/* Archived Workflows Section */}
|
||||
{archivedWorkflows.length > 0 && (
|
||||
<div className="mb-8">
|
||||
<h2 className="text-xl font-semibold mb-4 text-gray-600">Archived Workflows</h2>
|
||||
<h2 className="text-xl font-semibold mb-4 text-muted-foreground">Archived Workflows</h2>
|
||||
<WorkflowTable workflows={archivedWorkflows} showArchived={true} />
|
||||
</div>
|
||||
)}
|
||||
|
|
@ -134,41 +94,10 @@ async function PageContent() {
|
|||
|
||||
return (
|
||||
<div className="container mx-auto px-4 py-8">
|
||||
{/* Get Started Section */}
|
||||
<div className="mb-12">
|
||||
<div className="flex justify-between items-center px-4">
|
||||
<h2 className="text-2xl font-bold mb-6">Get Started</h2>
|
||||
<div className="flex gap-2">
|
||||
<Link href="/service-configurations">
|
||||
<Button className="flex items-center gap-2 mb-6">
|
||||
<Settings size={16} />
|
||||
Configure Services
|
||||
</Button>
|
||||
</Link>
|
||||
<Link href="/integrations">
|
||||
<Button className="flex items-center gap-2 mb-6">
|
||||
<Settings size={16} />
|
||||
Integrations
|
||||
</Button>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Suspense fallback={
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||
{Array.from({ length: 3 }, (_, i) => (
|
||||
<div key={i} className="bg-gray-200 rounded-lg h-40"></div>
|
||||
))}
|
||||
</div>
|
||||
}>
|
||||
<WorkflowTemplatesList />
|
||||
</Suspense>
|
||||
</div>
|
||||
|
||||
{/* Your Workflows Section */}
|
||||
<div className="mb-6">
|
||||
<div className="flex justify-between items-center mb-6">
|
||||
<h1 className="text-2xl font-bold">Your Workflows</h1>
|
||||
<h1 className="text-2xl font-bold">Your Agents</h1>
|
||||
<div className="flex gap-2">
|
||||
<UploadWorkflowButton />
|
||||
<CreateWorkflowButton />
|
||||
|
|
@ -185,10 +114,10 @@ function WorkflowsLoading() {
|
|||
<div className="container mx-auto px-4 py-8">
|
||||
{/* Get Started Section Loading */}
|
||||
<div className="mb-12">
|
||||
<div className="h-8 w-48 bg-gray-200 rounded mb-6"></div>
|
||||
<div className="h-8 w-48 bg-muted rounded mb-6"></div>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||
{Array.from({ length: 3 }, (_, i) => (
|
||||
<div key={i} className="bg-gray-200 rounded-lg h-40"></div>
|
||||
<div key={i} className="bg-muted rounded-lg h-40"></div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -196,10 +125,10 @@ function WorkflowsLoading() {
|
|||
{/* Your Workflows Section Loading */}
|
||||
<div className="mb-6">
|
||||
<div className="flex justify-between items-center mb-6">
|
||||
<div className="h-8 w-48 bg-gray-200 rounded"></div>
|
||||
<div className="h-10 w-32 bg-gray-200 rounded"></div>
|
||||
<div className="h-8 w-48 bg-muted rounded"></div>
|
||||
<div className="h-10 w-32 bg-muted rounded"></div>
|
||||
</div>
|
||||
<div className="bg-gray-200 rounded-lg h-96"></div>
|
||||
<div className="bg-muted rounded-lg h-96"></div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -1,8 +1,9 @@
|
|||
// This file is auto-generated by @hey-api/openapi-ts
|
||||
|
||||
import type { ClientOptions } from './types.gen';
|
||||
import { type Config, type ClientOptions as DefaultClientOptions, createClient, createConfig } from '@hey-api/client-fetch';
|
||||
import { type ClientOptions as DefaultClientOptions, type Config, createClient, createConfig } from '@hey-api/client-fetch';
|
||||
|
||||
import { createClientConfig } from '../lib/apiClient';
|
||||
import type { ClientOptions } from './types.gen';
|
||||
|
||||
/**
|
||||
* The `createClientConfig()` function will be called on client initialization
|
||||
|
|
@ -16,4 +17,4 @@ export type CreateClientConfig<T extends DefaultClientOptions = ClientOptions> =
|
|||
|
||||
export const client = createClient(createClientConfig(createConfig<ClientOptions>({
|
||||
baseUrl: 'http://127.0.0.1:8000'
|
||||
})));
|
||||
})));
|
||||
|
|
|
|||
|
|
@ -1,3 +1,3 @@
|
|||
// This file is auto-generated by @hey-api/openapi-ts
|
||||
export * from './sdk.gen';
|
||||
export * from './types.gen';
|
||||
export * from './sdk.gen';
|
||||
File diff suppressed because one or more lines are too long
|
|
@ -3274,4 +3274,4 @@ export type HealthApiV1HealthGetResponses = {
|
|||
|
||||
export type ClientOptions = {
|
||||
baseUrl: 'http://127.0.0.1:8000' | (string & {});
|
||||
};
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
'use client';
|
||||
|
||||
import { createContext, ReactNode, useCallback, useContext, useEffect, useState } from 'react';
|
||||
import { createContext, ReactNode, useCallback, useContext, useEffect, useRef, useState } from 'react';
|
||||
|
||||
import { getUserConfigurationsApiV1UserConfigurationsUserGet, updateUserConfigurationsApiV1UserConfigurationsUserPut } from '@/client/sdk.gen';
|
||||
import type { UserConfigurationRequestResponseSchema } from '@/client/types.gen';
|
||||
|
|
@ -41,7 +41,7 @@ interface UserConfigContextType {
|
|||
refreshConfig: () => Promise<void>;
|
||||
permissions: TeamPermission[];
|
||||
accessToken: string | null;
|
||||
user: AuthUser | null; // Now properly typed as CurrentUser | LocalUser
|
||||
user: AuthUser | null;
|
||||
organizationPricing: OrganizationPricing | null;
|
||||
}
|
||||
|
||||
|
|
@ -53,17 +53,32 @@ export function UserConfigProvider({ children }: { children: ReactNode }) {
|
|||
const [error, setError] = useState<Error | null>(null);
|
||||
const [accessToken, setAccessToken] = useState<string | null>(null);
|
||||
const [organizationPricing, setOrganizationPricing] = useState<OrganizationPricing | null>(null);
|
||||
const auth = useAuth();
|
||||
const [permissions, setPermissions] = useState<TeamPermission[]>([]);
|
||||
|
||||
const auth = useAuth();
|
||||
|
||||
// Store auth functions in refs to avoid dependency issues
|
||||
const authRef = useRef(auth);
|
||||
authRef.current = auth;
|
||||
|
||||
// Track initialization
|
||||
const hasFetchedConfig = useRef(false);
|
||||
const hasFetchedPermissions = useRef(false);
|
||||
|
||||
// Fetch permissions once when auth is ready
|
||||
useEffect(() => {
|
||||
if (auth.loading || hasFetchedPermissions.current) {
|
||||
return;
|
||||
}
|
||||
hasFetchedPermissions.current = true;
|
||||
|
||||
const fetchPermissions = async () => {
|
||||
if (auth.provider === 'stack') {
|
||||
const selectedTeam = auth.getSelectedTeam();
|
||||
const currentAuth = authRef.current;
|
||||
if (currentAuth.provider === 'stack' && currentAuth.getSelectedTeam && currentAuth.listPermissions) {
|
||||
const selectedTeam = currentAuth.getSelectedTeam();
|
||||
if (selectedTeam) {
|
||||
try {
|
||||
const perms = await auth.listPermissions(selectedTeam);
|
||||
const perms = await currentAuth.listPermissions(selectedTeam);
|
||||
setPermissions(Array.isArray(perms) ? perms : []);
|
||||
} catch {
|
||||
setPermissions([]);
|
||||
|
|
@ -72,16 +87,55 @@ export function UserConfigProvider({ children }: { children: ReactNode }) {
|
|||
setPermissions([]);
|
||||
}
|
||||
} else {
|
||||
// For non-Stack providers, set default permissions
|
||||
setPermissions([{ id: 'admin' }]);
|
||||
}
|
||||
};
|
||||
|
||||
if (!auth.loading) {
|
||||
fetchPermissions();
|
||||
}
|
||||
}, [auth.loading, auth.provider, auth.getSelectedTeam, auth.listPermissions]);
|
||||
fetchPermissions();
|
||||
}, [auth.loading, auth.provider]);
|
||||
|
||||
// Fetch user config once when auth is ready
|
||||
useEffect(() => {
|
||||
if (auth.loading || !auth.isAuthenticated || hasFetchedConfig.current) {
|
||||
return;
|
||||
}
|
||||
hasFetchedConfig.current = true;
|
||||
|
||||
const fetchUserConfig = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const token = await authRef.current.getAccessToken();
|
||||
setAccessToken(token);
|
||||
|
||||
const response = await getUserConfigurationsApiV1UserConfigurationsUserGet({
|
||||
headers: {
|
||||
'Authorization': `Bearer ${token}`,
|
||||
},
|
||||
});
|
||||
|
||||
if (response.data) {
|
||||
setUserConfig(response.data);
|
||||
if (response.data.organization_pricing) {
|
||||
setOrganizationPricing({
|
||||
price_per_second_usd: response.data.organization_pricing.price_per_second_usd as number | null,
|
||||
currency: response.data.organization_pricing.currency as string || 'USD',
|
||||
billing_enabled: response.data.organization_pricing.billing_enabled as boolean || false
|
||||
});
|
||||
} else {
|
||||
setOrganizationPricing(null);
|
||||
}
|
||||
}
|
||||
setError(null);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err : new Error('Failed to fetch user configuration'));
|
||||
setAccessToken(null);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
fetchUserConfig();
|
||||
}, [auth.loading, auth.isAuthenticated]);
|
||||
|
||||
const saveUserConfig = useCallback(async (userConfigRequest: SaveUserConfigFunctionParams) => {
|
||||
if (!accessToken) throw new Error('No authentication token available');
|
||||
|
|
@ -93,11 +147,9 @@ export function UserConfigProvider({ children }: { children: ReactNode }) {
|
|||
headers: { 'Authorization': `Bearer ${accessToken}` },
|
||||
});
|
||||
if (response.error) {
|
||||
// Try to pull out a JSON array of { model, message } from response.error.detail
|
||||
let msg = 'Failed to save user configuration';
|
||||
const detail = (response.error as unknown as { detail?: { errors: { model: string; message: string }[] } }).detail;
|
||||
if (Array.isArray(detail)) {
|
||||
// Map each entry to "model: message" and join with \n
|
||||
msg = detail
|
||||
.map((e: { model: string; message: string }) => `${e.model}: ${e.message}`)
|
||||
.join('\n');
|
||||
|
|
@ -106,7 +158,6 @@ export function UserConfigProvider({ children }: { children: ReactNode }) {
|
|||
}
|
||||
setUserConfig(response.data!);
|
||||
|
||||
// Update organization pricing if available
|
||||
if (response.data?.organization_pricing) {
|
||||
setOrganizationPricing({
|
||||
price_per_second_usd: response.data.organization_pricing.price_per_second_usd as number | null,
|
||||
|
|
@ -116,12 +167,14 @@ export function UserConfigProvider({ children }: { children: ReactNode }) {
|
|||
}
|
||||
}, [accessToken, userConfig]);
|
||||
|
||||
const fetchUserConfig = useCallback(async () => {
|
||||
const refreshConfig = useCallback(async () => {
|
||||
const currentAuth = authRef.current;
|
||||
if (!currentAuth.isAuthenticated) return;
|
||||
|
||||
setLoading(true);
|
||||
try {
|
||||
if (auth.loading || !auth.isAuthenticated) return;
|
||||
const token = await auth.getAccessToken();
|
||||
setAccessToken(token); // Set token when fetching config
|
||||
const token = await currentAuth.getAccessToken();
|
||||
setAccessToken(token);
|
||||
|
||||
const response = await getUserConfigurationsApiV1UserConfigurationsUserGet({
|
||||
headers: {
|
||||
|
|
@ -131,32 +184,21 @@ export function UserConfigProvider({ children }: { children: ReactNode }) {
|
|||
|
||||
if (response.data) {
|
||||
setUserConfig(response.data);
|
||||
|
||||
// Extract organization pricing if available
|
||||
if (response.data.organization_pricing) {
|
||||
setOrganizationPricing({
|
||||
price_per_second_usd: response.data.organization_pricing.price_per_second_usd as number | null,
|
||||
currency: response.data.organization_pricing.currency as string || 'USD',
|
||||
billing_enabled: response.data.organization_pricing.billing_enabled as boolean || false
|
||||
});
|
||||
} else {
|
||||
setOrganizationPricing(null);
|
||||
}
|
||||
}
|
||||
setError(null);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err : new Error('Failed to fetch user configuration'));
|
||||
setAccessToken(null);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [auth.loading, auth.isAuthenticated, auth.getAccessToken]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!auth.loading && auth.isAuthenticated) {
|
||||
fetchUserConfig();
|
||||
}
|
||||
}, [fetchUserConfig, auth.loading, auth.isAuthenticated]);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<UserConfigContext.Provider
|
||||
|
|
@ -165,10 +207,10 @@ export function UserConfigProvider({ children }: { children: ReactNode }) {
|
|||
saveUserConfig,
|
||||
loading,
|
||||
error,
|
||||
refreshConfig: fetchUserConfig,
|
||||
refreshConfig,
|
||||
permissions,
|
||||
accessToken,
|
||||
user: auth.user, // Pass the AuthUser (CurrentUser | LocalUser)
|
||||
user: auth.user,
|
||||
organizationPricing,
|
||||
}}
|
||||
>
|
||||
|
|
|
|||
|
|
@ -1,83 +1,7 @@
|
|||
'use client';
|
||||
|
||||
import React from 'react';
|
||||
|
||||
import logger from '@/lib/logger';
|
||||
|
||||
import { useAuthContext } from '../providers/AuthProvider';
|
||||
|
||||
export function useAuth() {
|
||||
const renderCount = React.useRef(0);
|
||||
renderCount.current++;
|
||||
|
||||
const context = useAuthContext();
|
||||
|
||||
logger.debug('[useAuth] Hook called', {
|
||||
renderCount: renderCount.current,
|
||||
hasUser: !!context.user,
|
||||
userId: context.user?.id,
|
||||
isAuthenticated: context.isAuthenticated,
|
||||
loading: context.loading,
|
||||
provider: context.provider
|
||||
});
|
||||
|
||||
// Memoize functions that are recreated on every render
|
||||
const logout = React.useCallback(() => context.service.logout(), [context.service]);
|
||||
const redirectToLogin = React.useCallback(() => context.service.redirectToLogin(), [context.service]);
|
||||
const getSelectedTeam = React.useCallback(() => context.service.getSelectedTeam?.(), [context.service]);
|
||||
const listPermissions = React.useCallback(
|
||||
(team?: unknown) => context.service.listPermissions?.(team) || Promise.resolve([]),
|
||||
[context.service]
|
||||
);
|
||||
|
||||
return React.useMemo(() => ({
|
||||
// Core functionality
|
||||
getAccessToken: context.getAccessToken,
|
||||
user: context.user, // This is now AuthUser (CurrentUser | LocalUser)
|
||||
isAuthenticated: context.isAuthenticated,
|
||||
loading: context.loading,
|
||||
|
||||
// Service methods
|
||||
logout,
|
||||
redirectToLogin,
|
||||
|
||||
// Provider info
|
||||
provider: context.provider,
|
||||
|
||||
// Stack-specific methods (optional)
|
||||
getSelectedTeam,
|
||||
listPermissions,
|
||||
}), [
|
||||
context.getAccessToken,
|
||||
context.user,
|
||||
context.isAuthenticated,
|
||||
context.loading,
|
||||
context.provider,
|
||||
logout,
|
||||
redirectToLogin,
|
||||
getSelectedTeam,
|
||||
listPermissions,
|
||||
]);
|
||||
return useAuthContext();
|
||||
}
|
||||
|
||||
// Compatibility wrapper for gradual migration from useUser
|
||||
export function useUser(options?: { or?: 'redirect' }) {
|
||||
const auth = useAuth();
|
||||
|
||||
// Handle redirect option
|
||||
if (options?.or === 'redirect' && !auth.isAuthenticated && !auth.loading) {
|
||||
auth.redirectToLogin();
|
||||
}
|
||||
|
||||
// Return Stack-compatible interface
|
||||
return {
|
||||
...auth.user,
|
||||
getAuthJson: async () => ({
|
||||
accessToken: await auth.getAccessToken(),
|
||||
}),
|
||||
selectedTeam: auth.getSelectedTeam(),
|
||||
listPermissions: auth.listPermissions,
|
||||
signOut: auth.logout,
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
export { useAuth, useUser } from './hooks/useAuth';
|
||||
export { useAuth } from './hooks/useAuth';
|
||||
export { AuthProvider } from './providers/AuthProvider';
|
||||
export type {
|
||||
AuthProvider as AuthProviderType,
|
||||
|
|
|
|||
|
|
@ -3,24 +3,24 @@
|
|||
import { Loader2 } from 'lucide-react';
|
||||
import React, { createContext, lazy, Suspense, useContext, useEffect, useMemo, useState } from 'react';
|
||||
|
||||
import { createAuthService, IAuthService, StackAuthService } from '../services';
|
||||
import { createAuthService } from '../services';
|
||||
import type { AuthUser } from '../types';
|
||||
|
||||
interface AuthContextType {
|
||||
service: IAuthService;
|
||||
user: AuthUser | null; // Union type: CurrentUser | LocalUser
|
||||
// Shared context type for both Stack and Local providers
|
||||
export interface AuthContextType {
|
||||
user: AuthUser | null;
|
||||
isAuthenticated: boolean;
|
||||
loading: boolean;
|
||||
getAccessToken: () => Promise<string>;
|
||||
redirectToLogin: () => void;
|
||||
logout: () => Promise<void>;
|
||||
provider: string;
|
||||
// Stack-specific (optional)
|
||||
getSelectedTeam?: () => unknown;
|
||||
listPermissions?: (team?: unknown) => Promise<Array<{ id: string }>>;
|
||||
}
|
||||
|
||||
const AuthContext = createContext<AuthContextType | null>(null);
|
||||
|
||||
interface AuthContextProviderProps {
|
||||
service: IAuthService;
|
||||
children: React.ReactNode;
|
||||
}
|
||||
export const AuthContext = createContext<AuthContextType | null>(null);
|
||||
|
||||
// Lazy load Stack components only when needed
|
||||
const StackProviderWrapper = lazy(() =>
|
||||
|
|
@ -29,15 +29,14 @@ const StackProviderWrapper = lazy(() =>
|
|||
}))
|
||||
);
|
||||
|
||||
// Generic context provider for non-Stack providers
|
||||
function GenericAuthContextProvider({ service, children }: AuthContextProviderProps) {
|
||||
// Generic context provider for non-Stack providers (local/OSS)
|
||||
function LocalAuthContextProvider({ children }: { children: React.ReactNode }) {
|
||||
const [user, setUser] = useState<AuthUser | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const service = useMemo(() => createAuthService('local'), []);
|
||||
|
||||
useEffect(() => {
|
||||
// Fetch current user
|
||||
const fetchUser = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const currentUser = await service.getCurrentUser();
|
||||
setUser(currentUser);
|
||||
|
|
@ -48,20 +47,22 @@ function GenericAuthContextProvider({ service, children }: AuthContextProviderPr
|
|||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
fetchUser();
|
||||
}, [service]);
|
||||
|
||||
const getAccessToken = React.useCallback(() => service.getAccessToken(), [service]);
|
||||
const redirectToLogin = React.useCallback(() => service.redirectToLogin(), [service]);
|
||||
const logout = React.useCallback(() => service.logout(), [service]);
|
||||
|
||||
const contextValue: AuthContextType = React.useMemo(() => ({
|
||||
service,
|
||||
const contextValue: AuthContextType = useMemo(() => ({
|
||||
user,
|
||||
isAuthenticated: service.isAuthenticated(),
|
||||
isAuthenticated: !!user,
|
||||
loading,
|
||||
getAccessToken,
|
||||
provider: service.getProviderName(),
|
||||
}), [service, user, loading, getAccessToken]);
|
||||
redirectToLogin,
|
||||
logout,
|
||||
provider: 'local',
|
||||
}), [user, loading, getAccessToken, redirectToLogin, logout]);
|
||||
|
||||
return (
|
||||
<AuthContext.Provider value={contextValue}>
|
||||
|
|
@ -72,62 +73,27 @@ function GenericAuthContextProvider({ service, children }: AuthContextProviderPr
|
|||
|
||||
export function AuthProvider({ children }: { children: React.ReactNode }) {
|
||||
const authProvider = process.env.NEXT_PUBLIC_AUTH_PROVIDER || 'stack';
|
||||
const authService = useMemo(() => createAuthService(authProvider), [authProvider]);
|
||||
|
||||
// For Stack provider, wrap with StackProvider and use Stack-specific context
|
||||
if (authProvider === 'stack' && authService instanceof StackAuthService) {
|
||||
// For Stack provider, use the dedicated wrapper
|
||||
if (authProvider === 'stack') {
|
||||
return (
|
||||
<Suspense fallback={
|
||||
<div className="flex items-center justify-center min-h-screen">
|
||||
<Loader2 className="w-8 h-8 animate-spin text-gray-600" />
|
||||
<Loader2 className="w-8 h-8 animate-spin" />
|
||||
</div>
|
||||
}>
|
||||
<StackProviderWrapper service={authService}>
|
||||
<StackProviderWrapper>
|
||||
{children}
|
||||
</StackProviderWrapper>
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
|
||||
// For other providers, use generic context provider
|
||||
// For local/OSS provider
|
||||
return (
|
||||
<GenericAuthContextProvider service={authService}>
|
||||
<LocalAuthContextProvider>
|
||||
{children}
|
||||
</GenericAuthContextProvider>
|
||||
);
|
||||
}
|
||||
|
||||
// Export the context for Stack-specific provider
|
||||
export { AuthContext };
|
||||
|
||||
// Stack-specific context provider that uses the useUser hook
|
||||
export function StackAuthContextProvider({ service, children }: AuthContextProviderProps) {
|
||||
const [loading, setLoading] = useState(true);
|
||||
const stackUser: AuthUser | null = null;
|
||||
|
||||
useEffect(() => {
|
||||
// For Stack provider, we'll get the user from the StackProviderWrapper
|
||||
// This is a placeholder that will be overridden by the actual implementation
|
||||
if (service instanceof StackAuthService) {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [service]);
|
||||
|
||||
const getAccessToken = React.useCallback(() => service.getAccessToken(), [service]);
|
||||
|
||||
const contextValue: AuthContextType = React.useMemo(() => ({
|
||||
service,
|
||||
user: stackUser,
|
||||
isAuthenticated: service.isAuthenticated(),
|
||||
loading,
|
||||
getAccessToken,
|
||||
provider: service.getProviderName(),
|
||||
}), [service, loading, getAccessToken]);
|
||||
|
||||
return (
|
||||
<AuthContext.Provider value={contextValue}>
|
||||
{children}
|
||||
</AuthContext.Provider>
|
||||
</LocalAuthContextProvider>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,11 +1,8 @@
|
|||
'use client';
|
||||
|
||||
import { StackClientApp,StackProvider, StackTheme, useUser as useStackUser } from '@stackframe/stack';
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { StackClientApp, StackProvider, StackTheme, useUser as useStackUser } from '@stackframe/stack';
|
||||
import React, { useMemo, useRef } from 'react';
|
||||
|
||||
import logger from '@/lib/logger';
|
||||
|
||||
import { StackAuthService } from '../services';
|
||||
import type { AuthUser } from '../types';
|
||||
import { AuthContext } from './AuthProvider';
|
||||
|
||||
|
|
@ -14,7 +11,6 @@ let stackClientAppInstance: StackClientApp<true, string> | null = null;
|
|||
|
||||
function getStackClientApp(): StackClientApp<true, string> {
|
||||
if (!stackClientAppInstance) {
|
||||
logger.debug('[StackProviderWrapper] Creating singleton StackClientApp instance');
|
||||
stackClientAppInstance = new StackClientApp({
|
||||
tokenStore: "nextjs-cookie",
|
||||
urls: {
|
||||
|
|
@ -26,90 +22,82 @@ function getStackClientApp(): StackClientApp<true, string> {
|
|||
}
|
||||
|
||||
interface StackProviderWrapperProps {
|
||||
service: StackAuthService;
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
// Stack-specific context provider that uses the useUser hook
|
||||
function StackAuthContextProvider({ service, children }: { service: StackAuthService; children: React.ReactNode }) {
|
||||
const renderCount = React.useRef(0);
|
||||
const lastUserId = React.useRef<string | undefined>(undefined);
|
||||
renderCount.current++;
|
||||
// Simple context provider that uses Stack's useUser directly
|
||||
function StackAuthContextProvider({ children }: { children: React.ReactNode }) {
|
||||
const stackUser = useStackUser();
|
||||
|
||||
logger.debug(`[StackAuthContextProvider] Render #${renderCount.current} - Starting`);
|
||||
// Store user in ref for callbacks to access latest value without creating new callbacks
|
||||
const userRef = useRef(stackUser);
|
||||
userRef.current = stackUser;
|
||||
|
||||
const stackUser = useStackUser(); // Always call the hook
|
||||
const [isInitialized, setIsInitialized] = useState(false);
|
||||
// Derive loading state: loading if we don't have a user yet
|
||||
const isLoading = stackUser === null;
|
||||
|
||||
// Track if user actually changed
|
||||
const userChanged = lastUserId.current !== stackUser?.id;
|
||||
if (userChanged) {
|
||||
lastUserId.current = stackUser?.id;
|
||||
}
|
||||
|
||||
logger.debug(`[StackAuthContextProvider] Render #${renderCount.current} - stackUser:`, {
|
||||
hasUser: !!stackUser,
|
||||
userId: stackUser?.id,
|
||||
isInitialized,
|
||||
userChanged
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
// Only log and update if user actually changed
|
||||
if (!userChanged && isInitialized) {
|
||||
return;
|
||||
// Stable callbacks that use ref to access current user
|
||||
const getAccessToken = React.useCallback(async () => {
|
||||
const user = userRef.current;
|
||||
if (!user) {
|
||||
throw new Error('User not authenticated');
|
||||
}
|
||||
|
||||
logger.debug('[StackAuthContextProvider] useEffect triggered (user changed)', {
|
||||
hasUser: !!stackUser,
|
||||
userId: stackUser?.id,
|
||||
isInitialized,
|
||||
isStackAuthService: service instanceof StackAuthService
|
||||
});
|
||||
|
||||
// Only update the service once when user becomes available
|
||||
if (!isInitialized && service instanceof StackAuthService && stackUser) {
|
||||
logger.debug('[StackAuthContextProvider] Setting user instance in service', {
|
||||
userId: stackUser.id
|
||||
});
|
||||
service.setUserInstance(stackUser);
|
||||
setIsInitialized(true);
|
||||
const authJson = await user.getAuthJson();
|
||||
if (!authJson.accessToken) {
|
||||
throw new Error('No access token available');
|
||||
}
|
||||
}, [service, stackUser, isInitialized, userChanged]);
|
||||
return authJson.accessToken;
|
||||
}, []);
|
||||
|
||||
const getAccessToken = React.useCallback(() => {
|
||||
logger.debug('[StackAuthContextProvider] getAccessToken called');
|
||||
return service.getAccessToken();
|
||||
}, [service]);
|
||||
const redirectToLogin = React.useCallback(() => {
|
||||
if (typeof window !== 'undefined') {
|
||||
window.location.href = '/handler/sign-in';
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Stabilize the context value to prevent unnecessary re-renders
|
||||
const contextValue = React.useMemo(() => {
|
||||
const isAuth = service.isAuthenticated();
|
||||
// IMPORTANT: Stay in loading state until service is initialized (has user set)
|
||||
// Even if stackUser exists, we're still loading until setUserInstance is called
|
||||
const loadingState = !isInitialized;
|
||||
const logout = React.useCallback(async () => {
|
||||
const user = userRef.current;
|
||||
if (user?.signOut) {
|
||||
await user.signOut();
|
||||
}
|
||||
}, []);
|
||||
|
||||
const value = {
|
||||
service,
|
||||
user: stackUser as AuthUser, // Pass the actual Stack CurrentUser
|
||||
isAuthenticated: isAuth,
|
||||
loading: loadingState, // Loading until service is initialized
|
||||
getAccessToken,
|
||||
provider: service.getProviderName(),
|
||||
};
|
||||
const getSelectedTeam = React.useCallback(() => {
|
||||
return userRef.current?.selectedTeam ?? null;
|
||||
}, []);
|
||||
|
||||
logger.debug('[StackAuthContextProvider] Context value created', {
|
||||
isAuthenticated: isAuth,
|
||||
loading: loadingState,
|
||||
hasUser: !!value.user,
|
||||
userId: stackUser?.id,
|
||||
isInitialized,
|
||||
provider: value.provider,
|
||||
serviceHasUser: isAuth
|
||||
});
|
||||
const listPermissions = React.useCallback(async (team?: unknown) => {
|
||||
const user = userRef.current;
|
||||
if (!user?.listPermissions) {
|
||||
return [];
|
||||
}
|
||||
const targetTeam = team || user.selectedTeam;
|
||||
if (!targetTeam) {
|
||||
return [];
|
||||
}
|
||||
try {
|
||||
const perms = await user.listPermissions(targetTeam);
|
||||
return Array.isArray(perms) ? perms : [];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}, []);
|
||||
|
||||
return value;
|
||||
}, [service, stackUser, isInitialized, getAccessToken]);
|
||||
// IMPORTANT: Use primitive values (userId, isLoading) in deps, NOT stackUser object
|
||||
// Stack's useUser() returns a new object reference on every render, which would cause infinite re-renders
|
||||
const userId = stackUser?.id;
|
||||
|
||||
const contextValue = useMemo(() => ({
|
||||
user: userRef.current as AuthUser,
|
||||
isAuthenticated: !!userId,
|
||||
loading: isLoading,
|
||||
getAccessToken,
|
||||
redirectToLogin,
|
||||
logout,
|
||||
provider: 'stack' as const,
|
||||
getSelectedTeam,
|
||||
listPermissions,
|
||||
}), [userId, isLoading, getAccessToken, redirectToLogin, logout, getSelectedTeam, listPermissions]);
|
||||
|
||||
return (
|
||||
<AuthContext.Provider value={contextValue}>
|
||||
|
|
@ -118,16 +106,13 @@ function StackAuthContextProvider({ service, children }: { service: StackAuthSer
|
|||
);
|
||||
}
|
||||
|
||||
export function StackProviderWrapper({ service, children }: StackProviderWrapperProps) {
|
||||
logger.debug('[StackProviderWrapper] Rendering wrapper');
|
||||
|
||||
// Use the singleton instance
|
||||
export function StackProviderWrapper({ children }: StackProviderWrapperProps) {
|
||||
const stackClientApp = getStackClientApp();
|
||||
|
||||
return (
|
||||
<StackProvider app={stackClientApp}>
|
||||
<StackTheme>
|
||||
<StackAuthContextProvider service={service}>
|
||||
<StackAuthContextProvider>
|
||||
{children}
|
||||
</StackAuthContextProvider>
|
||||
</StackTheme>
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@ export interface BaseUser {
|
|||
export interface LocalUser extends BaseUser {
|
||||
provider: 'local';
|
||||
organizationId?: string;
|
||||
displayName?: string;
|
||||
}
|
||||
|
||||
// Union type for all user types
|
||||
|
|
|
|||
|
|
@ -91,14 +91,14 @@ export async function getRedirectUrl(token: string, permissions: { id: string }[
|
|||
console.log('[getRedirectUrl] User has workflows, redirecting to /workflow');
|
||||
return "/workflow";
|
||||
} else {
|
||||
console.log('[getRedirectUrl] No workflows found, redirecting to /create-workflow');
|
||||
return "/create-workflow";
|
||||
console.log('[getRedirectUrl] No workflows found, redirecting to /workflow/create');
|
||||
return "/workflow/create";
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[getRedirectUrl] Error checking workflows:', error);
|
||||
// If we can't check workflows, default to create-workflow
|
||||
console.log('[getRedirectUrl] Defaulting to /create-workflow due to error');
|
||||
return "/create-workflow";
|
||||
// If we can't check workflows, default to /workflow/create
|
||||
console.log('[getRedirectUrl] Defaulting to /workflow/create due to error');
|
||||
return "/workflow/create";
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("[getRedirectUrl] Failed to fetch auth user:", error);
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue