mirror of
https://github.com/dograh-hq/dograh.git
synced 2026-06-07 07:55:16 +02:00
feat: agent versioning and model configurations override (#227)
* feat: add tests and migrations * feat: workflow versioning among published and draft * feat: add a new settings page to simplify workflow detail page * fix: fix tsclient generation
This commit is contained in:
parent
f5fa9ce717
commit
38d1d928b7
62 changed files with 10158 additions and 3131 deletions
|
|
@ -0,0 +1,132 @@
|
|||
"""add versioning in workflow definitions
|
||||
|
||||
Revision ID: a399b39479fe
|
||||
Revises: c71db647d354
|
||||
Create Date: 2026-04-07 14:43:50.042973
|
||||
|
||||
"""
|
||||
|
||||
from typing import Sequence, Union
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = "a399b39479fe"
|
||||
down_revision: Union[str, None] = "c71db647d354"
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
op.drop_index(op.f("ix_api_keys_key_hash"), table_name="api_keys")
|
||||
op.create_index(op.f("ix_api_keys_key_hash"), "api_keys", ["key_hash"], unique=True)
|
||||
op.add_column(
|
||||
"workflow_definitions",
|
||||
sa.Column(
|
||||
"status", sa.String(), server_default=sa.text("'published'"), nullable=False
|
||||
),
|
||||
)
|
||||
op.add_column(
|
||||
"workflow_definitions", sa.Column("version_number", sa.Integer(), nullable=True)
|
||||
)
|
||||
op.add_column(
|
||||
"workflow_definitions",
|
||||
sa.Column("published_at", sa.DateTime(timezone=True), nullable=True),
|
||||
)
|
||||
op.add_column(
|
||||
"workflow_definitions",
|
||||
sa.Column(
|
||||
"workflow_configurations",
|
||||
sa.JSON(),
|
||||
server_default=sa.text("'{}'::json"),
|
||||
nullable=False,
|
||||
),
|
||||
)
|
||||
op.add_column(
|
||||
"workflow_definitions",
|
||||
sa.Column(
|
||||
"template_context_variables",
|
||||
sa.JSON(),
|
||||
server_default=sa.text("'{}'::json"),
|
||||
nullable=False,
|
||||
),
|
||||
)
|
||||
op.add_column(
|
||||
"workflow_definitions",
|
||||
sa.Column(
|
||||
"call_disposition_codes",
|
||||
sa.JSON(),
|
||||
server_default=sa.text("'{}'::json"),
|
||||
nullable=False,
|
||||
),
|
||||
)
|
||||
op.alter_column(
|
||||
"workflow_definitions",
|
||||
"workflow_hash",
|
||||
existing_type=sa.VARCHAR(),
|
||||
nullable=True,
|
||||
)
|
||||
op.drop_index(
|
||||
op.f("ix_workflow_hash_workflow_id"), table_name="workflow_definitions"
|
||||
)
|
||||
op.drop_constraint(
|
||||
op.f("uq_workflow_hash_workflow_id"), "workflow_definitions", type_="unique"
|
||||
)
|
||||
op.create_index(
|
||||
"ix_workflow_definitions_workflow_status",
|
||||
"workflow_definitions",
|
||||
["workflow_id", "status"],
|
||||
unique=False,
|
||||
)
|
||||
op.add_column(
|
||||
"workflows", sa.Column("released_definition_id", sa.Integer(), nullable=True)
|
||||
)
|
||||
op.create_foreign_key(
|
||||
None,
|
||||
"workflows",
|
||||
"workflow_definitions",
|
||||
["released_definition_id"],
|
||||
["id"],
|
||||
use_alter=True,
|
||||
)
|
||||
# ### end Alembic commands ###
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
op.drop_constraint(None, "workflows", type_="foreignkey")
|
||||
op.drop_column("workflows", "released_definition_id")
|
||||
op.drop_index(
|
||||
"ix_workflow_definitions_workflow_status", table_name="workflow_definitions"
|
||||
)
|
||||
op.create_unique_constraint(
|
||||
op.f("uq_workflow_hash_workflow_id"),
|
||||
"workflow_definitions",
|
||||
["workflow_hash", "workflow_id"],
|
||||
postgresql_nulls_not_distinct=False,
|
||||
)
|
||||
op.create_index(
|
||||
op.f("ix_workflow_hash_workflow_id"),
|
||||
"workflow_definitions",
|
||||
["workflow_hash", "workflow_id"],
|
||||
unique=False,
|
||||
)
|
||||
op.alter_column(
|
||||
"workflow_definitions",
|
||||
"workflow_hash",
|
||||
existing_type=sa.VARCHAR(),
|
||||
nullable=False,
|
||||
)
|
||||
op.drop_column("workflow_definitions", "call_disposition_codes")
|
||||
op.drop_column("workflow_definitions", "template_context_variables")
|
||||
op.drop_column("workflow_definitions", "workflow_configurations")
|
||||
op.drop_column("workflow_definitions", "published_at")
|
||||
op.drop_column("workflow_definitions", "version_number")
|
||||
op.drop_column("workflow_definitions", "status")
|
||||
op.drop_index(op.f("ix_api_keys_key_hash"), table_name="api_keys")
|
||||
op.create_index(
|
||||
op.f("ix_api_keys_key_hash"), "api_keys", ["key_hash"], unique=False
|
||||
)
|
||||
# ### end Alembic commands ###
|
||||
|
|
@ -0,0 +1,89 @@
|
|||
"""backfill workflow definition versioning
|
||||
|
||||
Copy workflow_configurations, template_context_variables, call_disposition_codes
|
||||
from the workflows table into the is_current=True definition for each workflow.
|
||||
Set that definition as status='published', version_number=1.
|
||||
Set all other definitions to status='archived'.
|
||||
Point workflows.released_definition_id to the published definition.
|
||||
|
||||
Revision ID: d688d0da1123
|
||||
Revises: a399b39479fe
|
||||
Create Date: 2026-04-07 15:00:00.000000
|
||||
|
||||
"""
|
||||
|
||||
from typing import Sequence, Union
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = "d688d0da1123"
|
||||
down_revision: Union[str, None] = "a399b39479fe"
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
conn = op.get_bind()
|
||||
|
||||
# Step 1: For each workflow's is_current=True definition, copy configs from
|
||||
# the workflow table and mark as published with version_number=1.
|
||||
conn.execute(
|
||||
sa.text("""
|
||||
UPDATE workflow_definitions wd
|
||||
SET
|
||||
workflow_configurations = w.workflow_configurations,
|
||||
template_context_variables = w.template_context_variables,
|
||||
status = 'published',
|
||||
version_number = 1,
|
||||
published_at = wd.created_at
|
||||
FROM workflows w
|
||||
WHERE wd.workflow_id = w.id
|
||||
AND wd.is_current = true
|
||||
""")
|
||||
)
|
||||
|
||||
# Step 2: Mark all pre-versioning non-current definitions as legacy.
|
||||
conn.execute(
|
||||
sa.text("""
|
||||
UPDATE workflow_definitions
|
||||
SET status = 'legacy'
|
||||
WHERE is_current = false
|
||||
""")
|
||||
)
|
||||
|
||||
# Step 3: Set released_definition_id on workflows to their published definition.
|
||||
conn.execute(
|
||||
sa.text("""
|
||||
UPDATE workflows w
|
||||
SET released_definition_id = wd.id
|
||||
FROM workflow_definitions wd
|
||||
WHERE wd.workflow_id = w.id
|
||||
AND wd.is_current = true
|
||||
""")
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
conn = op.get_bind()
|
||||
|
||||
# Clear the released pointer
|
||||
conn.execute(
|
||||
sa.text("""
|
||||
UPDATE workflows SET released_definition_id = NULL
|
||||
""")
|
||||
)
|
||||
|
||||
# Reset all definitions back to server defaults
|
||||
conn.execute(
|
||||
sa.text("""
|
||||
UPDATE workflow_definitions
|
||||
SET
|
||||
status = 'published',
|
||||
version_number = NULL,
|
||||
published_at = NULL,
|
||||
workflow_configurations = '{}',
|
||||
template_context_variables = '{}'
|
||||
""")
|
||||
)
|
||||
|
|
@ -9,6 +9,7 @@ from api.db.base_client import BaseDBClient
|
|||
from api.db.models import (
|
||||
LoopTalkConversation,
|
||||
LoopTalkTestSession,
|
||||
WorkflowModel,
|
||||
)
|
||||
|
||||
|
||||
|
|
@ -50,8 +51,12 @@ class LoopTalkClient(BaseDBClient):
|
|||
result = await session.execute(
|
||||
select(LoopTalkTestSession)
|
||||
.options(
|
||||
selectinload(LoopTalkTestSession.actor_workflow),
|
||||
selectinload(LoopTalkTestSession.adversary_workflow),
|
||||
selectinload(LoopTalkTestSession.actor_workflow).selectinload(
|
||||
WorkflowModel.released_definition
|
||||
),
|
||||
selectinload(LoopTalkTestSession.adversary_workflow).selectinload(
|
||||
WorkflowModel.released_definition
|
||||
),
|
||||
selectinload(LoopTalkTestSession.conversations),
|
||||
)
|
||||
.where(
|
||||
|
|
|
|||
|
|
@ -1,7 +1,6 @@
|
|||
import uuid
|
||||
from datetime import UTC, datetime
|
||||
|
||||
from loguru import logger
|
||||
from pgvector.sqlalchemy import Vector
|
||||
from sqlalchemy import (
|
||||
JSON,
|
||||
|
|
@ -199,7 +198,7 @@ class IntegrationModel(Base):
|
|||
class WorkflowDefinitionModel(Base):
|
||||
__tablename__ = "workflow_definitions"
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
workflow_hash = Column(String, nullable=False)
|
||||
workflow_hash = Column(String, nullable=True) # Legacy, no longer used
|
||||
workflow_json = Column(JSON, nullable=False, default=dict)
|
||||
workflow_id = Column(Integer, ForeignKey("workflows.id"), nullable=True)
|
||||
is_current = Column(
|
||||
|
|
@ -207,12 +206,29 @@ class WorkflowDefinitionModel(Base):
|
|||
)
|
||||
created_at = Column(DateTime(timezone=True), default=lambda: datetime.now(UTC))
|
||||
|
||||
# Table constraints and indexes
|
||||
# Versioning columns
|
||||
status = Column(
|
||||
String,
|
||||
nullable=False,
|
||||
default="published",
|
||||
server_default=text("'published'"),
|
||||
) # draft | published | archived
|
||||
version_number = Column(
|
||||
Integer, nullable=True
|
||||
) # Sequential per workflow, display only
|
||||
published_at = Column(DateTime(timezone=True), nullable=True)
|
||||
|
||||
# Full behavioral snapshot (moved from WorkflowModel to enable versioning)
|
||||
workflow_configurations = Column(
|
||||
JSON, nullable=False, default=dict, server_default=text("'{}'::json")
|
||||
)
|
||||
template_context_variables = Column(
|
||||
JSON, nullable=False, default=dict, server_default=text("'{}'::json")
|
||||
)
|
||||
|
||||
# Table constraints and indexes — unique hash constraint removed (no more dedup)
|
||||
__table_args__ = (
|
||||
UniqueConstraint(
|
||||
"workflow_hash", "workflow_id", name="uq_workflow_hash_workflow_id"
|
||||
),
|
||||
Index("ix_workflow_hash_workflow_id", "workflow_hash", "workflow_id"),
|
||||
Index("ix_workflow_definitions_workflow_status", "workflow_id", "status"),
|
||||
)
|
||||
|
||||
# Relationships
|
||||
|
|
@ -247,6 +263,19 @@ class WorkflowModel(Base):
|
|||
runs = relationship("WorkflowRunModel", back_populates="workflow")
|
||||
created_at = Column(DateTime(timezone=True), default=lambda: datetime.now(UTC))
|
||||
|
||||
# Pointer to the currently-live (published) version
|
||||
released_definition_id = Column(
|
||||
Integer,
|
||||
ForeignKey("workflow_definitions.id", use_alter=True),
|
||||
nullable=True,
|
||||
)
|
||||
released_definition = relationship(
|
||||
"WorkflowDefinitionModel",
|
||||
foreign_keys=[released_definition_id],
|
||||
uselist=False,
|
||||
viewonly=True,
|
||||
)
|
||||
|
||||
# All versions / historical definitions of this workflow
|
||||
definitions = relationship(
|
||||
"WorkflowDefinitionModel",
|
||||
|
|
@ -255,6 +284,7 @@ class WorkflowModel(Base):
|
|||
)
|
||||
|
||||
# Relationship to fetch the current (is_current=True) definition
|
||||
# Kept for backward compatibility during transition
|
||||
current_definition = relationship(
|
||||
"WorkflowDefinitionModel",
|
||||
primaryjoin=lambda: and_(
|
||||
|
|
@ -277,36 +307,6 @@ class WorkflowModel(Base):
|
|||
# that scenario so callers can handle the absence explicitly.
|
||||
return None
|
||||
|
||||
@property
|
||||
def workflow_definition_with_fallback(self):
|
||||
"""
|
||||
Get workflow definition with fallback to legacy workflow_definition field.
|
||||
|
||||
Returns:
|
||||
dict: The workflow definition JSON
|
||||
"""
|
||||
# Access the relationship only if it has ALREADY been eagerly loaded on this
|
||||
# instance to avoid triggering an implicit lazy load once the SQLAlchemy
|
||||
# Session has been closed (which would raise a DetachedInstanceError).
|
||||
|
||||
# ``__dict__`` will contain "current_definition" **only** when the attribute
|
||||
# has been populated (e.g. via `selectinload` or an explicit access while
|
||||
# the session was still open). Using ``__dict__.get`` guarantees that we
|
||||
# do not accidentally issue a lazy load query on a detached instance.
|
||||
|
||||
current_definition = self.__dict__.get("current_definition")
|
||||
|
||||
if current_definition is not None:
|
||||
return current_definition.workflow_json
|
||||
|
||||
# Fallback for backwards-compatibility when the relationship is not (yet)
|
||||
# loaded. In this case we fall back to the legacy ``workflow_definition``
|
||||
# column that always contains the most recent definition JSON.
|
||||
logger.warning(
|
||||
f"Workflow {self.id} has no loaded current definition, using workflow_definition as fallback",
|
||||
)
|
||||
return self.workflow_definition
|
||||
|
||||
|
||||
class WorkflowTemplates(Base):
|
||||
__tablename__ = "workflow_templates"
|
||||
|
|
|
|||
|
|
@ -1,5 +1,4 @@
|
|||
import hashlib
|
||||
import json
|
||||
from datetime import UTC, datetime
|
||||
from typing import Optional
|
||||
|
||||
from loguru import logger
|
||||
|
|
@ -12,41 +11,15 @@ from api.db.models import WorkflowDefinitionModel, WorkflowModel, WorkflowRunMod
|
|||
|
||||
|
||||
class WorkflowClient(BaseDBClient):
|
||||
def _generate_workflow_hash(self, workflow_definition: dict) -> str:
|
||||
"""Generate a consistent hash for workflow definition."""
|
||||
# Convert to JSON with sorted keys for consistent hashing
|
||||
json_str = json.dumps(
|
||||
workflow_definition, sort_keys=True, separators=(",", ":")
|
||||
)
|
||||
return hashlib.sha256(json_str.encode()).hexdigest()
|
||||
|
||||
async def _get_or_create_workflow_definition(
|
||||
self, workflow_definition: dict, session, workflow_id: int = None
|
||||
) -> WorkflowDefinitionModel:
|
||||
"""Get existing workflow definition by hash or create a new one."""
|
||||
workflow_hash = self._generate_workflow_hash(workflow_definition)
|
||||
|
||||
# Try to find existing definition
|
||||
async def _next_version_number(self, session, workflow_id: int) -> int:
|
||||
"""Get the next version number for a workflow."""
|
||||
result = await session.execute(
|
||||
select(WorkflowDefinitionModel).where(
|
||||
WorkflowDefinitionModel.workflow_hash == workflow_hash,
|
||||
select(func.max(WorkflowDefinitionModel.version_number)).where(
|
||||
WorkflowDefinitionModel.workflow_id == workflow_id,
|
||||
)
|
||||
)
|
||||
existing_definition = result.scalars().first()
|
||||
|
||||
if existing_definition:
|
||||
return existing_definition
|
||||
|
||||
# Create new definition if it doesn't exist
|
||||
new_definition = WorkflowDefinitionModel(
|
||||
workflow_hash=workflow_hash,
|
||||
workflow_json=workflow_definition,
|
||||
workflow_id=workflow_id,
|
||||
)
|
||||
session.add(new_definition)
|
||||
await session.flush() # Flush to get the ID without committing
|
||||
return new_definition
|
||||
current_max = result.scalar()
|
||||
return (current_max or 0) + 1
|
||||
|
||||
async def create_workflow(
|
||||
self,
|
||||
|
|
@ -66,21 +39,23 @@ class WorkflowClient(BaseDBClient):
|
|||
session.add(new_workflow)
|
||||
await session.flush() # Flush to get the workflow ID
|
||||
|
||||
# Now get or create workflow definition with the workflow_id
|
||||
definition = await self._get_or_create_workflow_definition(
|
||||
workflow_definition, session, new_workflow.id
|
||||
# Create the first definition as V1 published
|
||||
definition = WorkflowDefinitionModel(
|
||||
workflow_json=workflow_definition,
|
||||
workflow_id=new_workflow.id,
|
||||
is_current=True,
|
||||
status="published",
|
||||
version_number=1,
|
||||
published_at=datetime.now(UTC),
|
||||
workflow_configurations=new_workflow.workflow_configurations or {},
|
||||
template_context_variables=new_workflow.template_context_variables
|
||||
or {},
|
||||
)
|
||||
session.add(definition)
|
||||
await session.flush()
|
||||
|
||||
# Mark this definition as the current one and unset others
|
||||
definition.is_current = True
|
||||
await session.execute(
|
||||
update(WorkflowDefinitionModel)
|
||||
.where(
|
||||
WorkflowDefinitionModel.workflow_id == new_workflow.id,
|
||||
WorkflowDefinitionModel.id != definition.id,
|
||||
)
|
||||
.values(is_current=False)
|
||||
)
|
||||
# Set the released pointer
|
||||
new_workflow.released_definition_id = definition.id
|
||||
|
||||
await session.commit()
|
||||
except Exception as e:
|
||||
|
|
@ -89,6 +64,257 @@ class WorkflowClient(BaseDBClient):
|
|||
await session.refresh(new_workflow)
|
||||
return new_workflow
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Versioning methods
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
async def save_workflow_draft(
|
||||
self,
|
||||
workflow_id: int,
|
||||
workflow_definition: dict | None = None,
|
||||
workflow_configurations: dict | None = None,
|
||||
template_context_variables: dict | None = None,
|
||||
) -> WorkflowDefinitionModel:
|
||||
"""Create or update a draft version for this workflow.
|
||||
|
||||
If a draft already exists, it is updated in place.
|
||||
If no draft exists, a new one is created with the next version number.
|
||||
"""
|
||||
async with self.async_session() as session:
|
||||
# Check for existing draft
|
||||
result = await session.execute(
|
||||
select(WorkflowDefinitionModel).where(
|
||||
WorkflowDefinitionModel.workflow_id == workflow_id,
|
||||
WorkflowDefinitionModel.status == "draft",
|
||||
)
|
||||
)
|
||||
draft = result.scalars().first()
|
||||
|
||||
if draft:
|
||||
# Update existing draft in place
|
||||
if workflow_definition is not None:
|
||||
draft.workflow_json = workflow_definition
|
||||
if workflow_configurations is not None:
|
||||
draft.workflow_configurations = workflow_configurations
|
||||
if template_context_variables is not None:
|
||||
draft.template_context_variables = template_context_variables
|
||||
else:
|
||||
# Get current published to use as base for unspecified fields
|
||||
pub_result = await session.execute(
|
||||
select(WorkflowDefinitionModel).where(
|
||||
WorkflowDefinitionModel.workflow_id == workflow_id,
|
||||
WorkflowDefinitionModel.status == "published",
|
||||
)
|
||||
)
|
||||
published = pub_result.scalars().first()
|
||||
|
||||
next_version = await self._next_version_number(session, workflow_id)
|
||||
|
||||
draft = WorkflowDefinitionModel(
|
||||
workflow_id=workflow_id,
|
||||
workflow_json=workflow_definition
|
||||
if workflow_definition is not None
|
||||
else (published.workflow_json if published else {}),
|
||||
workflow_configurations=workflow_configurations
|
||||
if workflow_configurations is not None
|
||||
else (published.workflow_configurations if published else {}),
|
||||
template_context_variables=template_context_variables
|
||||
if template_context_variables is not None
|
||||
else (published.template_context_variables if published else {}),
|
||||
status="draft",
|
||||
version_number=next_version,
|
||||
is_current=False,
|
||||
)
|
||||
session.add(draft)
|
||||
|
||||
# Keep legacy columns on workflows table in sync with draft
|
||||
wf_result = await session.execute(
|
||||
select(WorkflowModel).where(WorkflowModel.id == workflow_id)
|
||||
)
|
||||
workflow = wf_result.scalars().first()
|
||||
if workflow:
|
||||
workflow.workflow_definition = draft.workflow_json
|
||||
workflow.workflow_configurations = draft.workflow_configurations
|
||||
workflow.template_context_variables = draft.template_context_variables
|
||||
|
||||
try:
|
||||
await session.commit()
|
||||
except Exception as e:
|
||||
await session.rollback()
|
||||
raise e
|
||||
await session.refresh(draft)
|
||||
return draft
|
||||
|
||||
async def publish_workflow_draft(
|
||||
self,
|
||||
workflow_id: int,
|
||||
) -> WorkflowDefinitionModel:
|
||||
"""Promote the current draft to published.
|
||||
|
||||
- Draft → published
|
||||
- Previous published → archived
|
||||
- Updates released_definition_id on the workflow
|
||||
- Sets is_current for backward compatibility
|
||||
"""
|
||||
async with self.async_session() as session:
|
||||
# Find the draft
|
||||
result = await session.execute(
|
||||
select(WorkflowDefinitionModel).where(
|
||||
WorkflowDefinitionModel.workflow_id == workflow_id,
|
||||
WorkflowDefinitionModel.status == "draft",
|
||||
)
|
||||
)
|
||||
draft = result.scalars().first()
|
||||
if not draft:
|
||||
raise ValueError(f"No draft exists for workflow {workflow_id}")
|
||||
|
||||
# Archive the current published version
|
||||
await session.execute(
|
||||
update(WorkflowDefinitionModel)
|
||||
.where(
|
||||
WorkflowDefinitionModel.workflow_id == workflow_id,
|
||||
WorkflowDefinitionModel.status == "published",
|
||||
)
|
||||
.values(status="archived", is_current=False)
|
||||
)
|
||||
|
||||
# Promote draft → published
|
||||
draft.status = "published"
|
||||
draft.published_at = datetime.now(UTC)
|
||||
draft.is_current = True
|
||||
|
||||
# Update workflow's released pointer + legacy fields
|
||||
wf_result = await session.execute(
|
||||
select(WorkflowModel).where(WorkflowModel.id == workflow_id)
|
||||
)
|
||||
workflow = wf_result.scalars().first()
|
||||
workflow.released_definition_id = draft.id
|
||||
workflow.workflow_definition = draft.workflow_json
|
||||
workflow.workflow_configurations = draft.workflow_configurations
|
||||
workflow.template_context_variables = draft.template_context_variables
|
||||
|
||||
try:
|
||||
await session.commit()
|
||||
except Exception as e:
|
||||
await session.rollback()
|
||||
raise e
|
||||
await session.refresh(draft)
|
||||
return draft
|
||||
|
||||
async def discard_workflow_draft(
|
||||
self,
|
||||
workflow_id: int,
|
||||
) -> None:
|
||||
"""Delete the current draft version."""
|
||||
async with self.async_session() as session:
|
||||
result = await session.execute(
|
||||
select(WorkflowDefinitionModel).where(
|
||||
WorkflowDefinitionModel.workflow_id == workflow_id,
|
||||
WorkflowDefinitionModel.status == "draft",
|
||||
)
|
||||
)
|
||||
draft = result.scalars().first()
|
||||
if not draft:
|
||||
raise ValueError(f"No draft exists for workflow {workflow_id}")
|
||||
|
||||
await session.delete(draft)
|
||||
|
||||
try:
|
||||
await session.commit()
|
||||
except Exception as e:
|
||||
await session.rollback()
|
||||
raise e
|
||||
|
||||
async def revert_to_version(
|
||||
self,
|
||||
workflow_id: int,
|
||||
definition_id: int,
|
||||
) -> WorkflowDefinitionModel:
|
||||
"""Create a new draft from an archived version's snapshot.
|
||||
|
||||
Raises ValueError if a draft already exists (must discard first).
|
||||
"""
|
||||
async with self.async_session() as session:
|
||||
# Ensure no existing draft
|
||||
draft_result = await session.execute(
|
||||
select(WorkflowDefinitionModel).where(
|
||||
WorkflowDefinitionModel.workflow_id == workflow_id,
|
||||
WorkflowDefinitionModel.status == "draft",
|
||||
)
|
||||
)
|
||||
if draft_result.scalars().first():
|
||||
raise ValueError(
|
||||
f"Draft already exists for workflow {workflow_id}. "
|
||||
"Discard it before reverting."
|
||||
)
|
||||
|
||||
# Fetch the source version
|
||||
source_result = await session.execute(
|
||||
select(WorkflowDefinitionModel).where(
|
||||
WorkflowDefinitionModel.id == definition_id,
|
||||
WorkflowDefinitionModel.workflow_id == workflow_id,
|
||||
)
|
||||
)
|
||||
source = source_result.scalars().first()
|
||||
if not source:
|
||||
raise ValueError(
|
||||
f"Version {definition_id} not found for workflow {workflow_id}"
|
||||
)
|
||||
|
||||
next_version = await self._next_version_number(session, workflow_id)
|
||||
|
||||
# Create new draft from the source snapshot
|
||||
draft = WorkflowDefinitionModel(
|
||||
workflow_id=workflow_id,
|
||||
workflow_json=source.workflow_json,
|
||||
workflow_configurations=source.workflow_configurations,
|
||||
template_context_variables=source.template_context_variables,
|
||||
status="draft",
|
||||
version_number=next_version,
|
||||
is_current=False,
|
||||
)
|
||||
session.add(draft)
|
||||
|
||||
try:
|
||||
await session.commit()
|
||||
except Exception as e:
|
||||
await session.rollback()
|
||||
raise e
|
||||
await session.refresh(draft)
|
||||
return draft
|
||||
|
||||
async def get_draft_version(
|
||||
self,
|
||||
workflow_id: int,
|
||||
) -> WorkflowDefinitionModel | None:
|
||||
"""Get the draft version for a workflow, or None if no draft exists."""
|
||||
async with self.async_session() as session:
|
||||
result = await session.execute(
|
||||
select(WorkflowDefinitionModel).where(
|
||||
WorkflowDefinitionModel.workflow_id == workflow_id,
|
||||
WorkflowDefinitionModel.status == "draft",
|
||||
)
|
||||
)
|
||||
return result.scalars().first()
|
||||
|
||||
async def get_workflow_versions(
|
||||
self,
|
||||
workflow_id: int,
|
||||
) -> list[WorkflowDefinitionModel]:
|
||||
"""List all versions for a workflow, newest first."""
|
||||
async with self.async_session() as session:
|
||||
result = await session.execute(
|
||||
select(WorkflowDefinitionModel)
|
||||
.where(
|
||||
WorkflowDefinitionModel.workflow_id == workflow_id,
|
||||
WorkflowDefinitionModel.status.in_(
|
||||
["published", "draft", "archived"]
|
||||
),
|
||||
)
|
||||
.order_by(WorkflowDefinitionModel.version_number.desc())
|
||||
)
|
||||
return result.scalars().all()
|
||||
|
||||
async def get_all_workflows(
|
||||
self, user_id: int = None, organization_id: int = None, status: str = None
|
||||
) -> list[WorkflowModel]:
|
||||
|
|
@ -191,7 +417,10 @@ class WorkflowClient(BaseDBClient):
|
|||
async with self.async_session() as session:
|
||||
query = (
|
||||
select(WorkflowModel)
|
||||
.options(selectinload(WorkflowModel.current_definition))
|
||||
.options(
|
||||
selectinload(WorkflowModel.current_definition),
|
||||
selectinload(WorkflowModel.released_definition),
|
||||
)
|
||||
.where(WorkflowModel.id == workflow_id)
|
||||
)
|
||||
|
||||
|
|
@ -209,7 +438,10 @@ class WorkflowClient(BaseDBClient):
|
|||
async with self.async_session() as session:
|
||||
result = await session.execute(
|
||||
select(WorkflowModel)
|
||||
.options(selectinload(WorkflowModel.current_definition))
|
||||
.options(
|
||||
selectinload(WorkflowModel.current_definition),
|
||||
selectinload(WorkflowModel.released_definition),
|
||||
)
|
||||
.where(WorkflowModel.id == workflow_id)
|
||||
)
|
||||
return result.scalars().first()
|
||||
|
|
@ -227,11 +459,16 @@ class WorkflowClient(BaseDBClient):
|
|||
"""
|
||||
Update an existing workflow in the database.
|
||||
|
||||
Name changes are applied directly to the workflow.
|
||||
Definition/config/template_var changes are saved as a draft version
|
||||
via save_workflow_draft, keeping the published version unchanged.
|
||||
|
||||
Args:
|
||||
workflow_id: The ID of the workflow to update
|
||||
name: The new name for the workflow
|
||||
workflow_definition: The new workflow definition
|
||||
template_context_variables: The template context variables
|
||||
workflow_configurations: The workflow configurations
|
||||
user_id: The user ID (for backwards compatibility)
|
||||
organization_id: The organization ID
|
||||
|
||||
|
|
@ -249,10 +486,8 @@ class WorkflowClient(BaseDBClient):
|
|||
)
|
||||
|
||||
if organization_id:
|
||||
# Filter by organization_id when provided
|
||||
query = query.where(WorkflowModel.organization_id == organization_id)
|
||||
elif user_id:
|
||||
# Fallback to user_id for backwards compatibility
|
||||
query = query.where(WorkflowModel.user_id == user_id)
|
||||
|
||||
result = await session.execute(query)
|
||||
|
|
@ -260,42 +495,38 @@ class WorkflowClient(BaseDBClient):
|
|||
if not workflow:
|
||||
raise ValueError(f"Workflow with ID {workflow_id} not found")
|
||||
|
||||
# Name is a workflow-level field, not versioned
|
||||
if name is not None:
|
||||
workflow.name = name
|
||||
|
||||
if template_context_variables is not None:
|
||||
workflow.template_context_variables = template_context_variables
|
||||
|
||||
if workflow_configurations is not None:
|
||||
workflow.workflow_configurations = workflow_configurations
|
||||
|
||||
# In case of only name update, the workflow_definition can be None
|
||||
if workflow_definition:
|
||||
# Get or create new workflow definition
|
||||
definition = await self._get_or_create_workflow_definition(
|
||||
workflow_definition, session, workflow_id
|
||||
)
|
||||
|
||||
# Update legacy field for backwards compatibility
|
||||
workflow.workflow_definition = workflow_definition
|
||||
|
||||
# Mark new definition as current and reset others
|
||||
definition.is_current = True
|
||||
await session.execute(
|
||||
update(WorkflowDefinitionModel)
|
||||
.where(
|
||||
WorkflowDefinitionModel.workflow_id == workflow_id,
|
||||
WorkflowDefinitionModel.id != definition.id,
|
||||
)
|
||||
.values(is_current=False)
|
||||
)
|
||||
|
||||
try:
|
||||
await session.commit()
|
||||
except Exception as e:
|
||||
await session.rollback()
|
||||
raise e
|
||||
await session.refresh(workflow)
|
||||
|
||||
# Save versioned changes as a draft
|
||||
has_versioned_changes = any(
|
||||
v is not None
|
||||
for v in [
|
||||
workflow_definition,
|
||||
workflow_configurations,
|
||||
template_context_variables,
|
||||
]
|
||||
)
|
||||
if has_versioned_changes:
|
||||
await self.save_workflow_draft(
|
||||
workflow_id=workflow_id,
|
||||
workflow_definition=workflow_definition,
|
||||
workflow_configurations=workflow_configurations,
|
||||
template_context_variables=template_context_variables,
|
||||
)
|
||||
# Re-fetch with updated state
|
||||
workflow = await self.get_workflow(
|
||||
workflow_id, user_id=user_id, organization_id=organization_id
|
||||
)
|
||||
|
||||
return workflow
|
||||
|
||||
async def get_workflows_by_ids(
|
||||
|
|
@ -353,7 +584,10 @@ class WorkflowClient(BaseDBClient):
|
|||
async with self.async_session() as session:
|
||||
query = (
|
||||
select(WorkflowModel)
|
||||
.options(selectinload(WorkflowModel.current_definition))
|
||||
.options(
|
||||
selectinload(WorkflowModel.current_definition),
|
||||
selectinload(WorkflowModel.released_definition),
|
||||
)
|
||||
.where(WorkflowModel.id == workflow_id)
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -30,6 +30,7 @@ class WorkflowRunClient(BaseDBClient):
|
|||
gathered_context: dict = None,
|
||||
campaign_id: int = None,
|
||||
queued_run_id: int = None,
|
||||
use_draft: bool = False,
|
||||
) -> WorkflowRunModel:
|
||||
async with self.async_session() as session:
|
||||
# Get workflow and user to check organization
|
||||
|
|
@ -44,41 +45,51 @@ class WorkflowRunClient(BaseDBClient):
|
|||
if not workflow:
|
||||
raise ValueError(f"Workflow with ID {workflow_id} not found")
|
||||
|
||||
# # Check quota if user has an organization
|
||||
# if workflow.user and workflow.user.selected_organization_id:
|
||||
# # Import here to avoid circular dependency
|
||||
# from api.db.organization_usage_client import OrganizationUsageClient
|
||||
# Resolve which definition to bind to this run
|
||||
target_def = None
|
||||
|
||||
# usage_client = OrganizationUsageClient()
|
||||
|
||||
# # Check quota (no reservation for now, actual cost will be added after completion)
|
||||
# has_quota = await usage_client.check_and_reserve_quota(
|
||||
# workflow.user.selected_organization_id, estimated_tokens=0
|
||||
# )
|
||||
|
||||
# if not has_quota:
|
||||
# raise ValueError(
|
||||
# "Organization quota exceeded. Please contact your administrator."
|
||||
# )
|
||||
|
||||
# Fetch the current definition for this workflow
|
||||
current_def_result = await session.execute(
|
||||
select(WorkflowDefinitionModel).where(
|
||||
WorkflowDefinitionModel.workflow_id == workflow.id,
|
||||
WorkflowDefinitionModel.is_current == True,
|
||||
if use_draft:
|
||||
# For test calls: prefer draft if it exists, fall back to published
|
||||
draft_result = await session.execute(
|
||||
select(WorkflowDefinitionModel).where(
|
||||
WorkflowDefinitionModel.workflow_id == workflow.id,
|
||||
WorkflowDefinitionModel.status == "draft",
|
||||
)
|
||||
)
|
||||
)
|
||||
current_def = current_def_result.scalars().first()
|
||||
target_def = draft_result.scalars().first()
|
||||
|
||||
if target_def is None:
|
||||
# Use the published version via released_definition_id (preferred)
|
||||
# or fall back to is_current for backward compatibility
|
||||
if workflow.released_definition_id:
|
||||
target_def = await session.get(
|
||||
WorkflowDefinitionModel, workflow.released_definition_id
|
||||
)
|
||||
else:
|
||||
pub_result = await session.execute(
|
||||
select(WorkflowDefinitionModel).where(
|
||||
WorkflowDefinitionModel.workflow_id == workflow.id,
|
||||
WorkflowDefinitionModel.is_current == True,
|
||||
)
|
||||
)
|
||||
target_def = pub_result.scalars().first()
|
||||
|
||||
# Get the current storage backend based on ENABLE_AWS_S3 flag
|
||||
current_backend = StorageBackend.get_current_backend()
|
||||
|
||||
# Use initial_context from the version if available, else from workflow
|
||||
default_context = (
|
||||
target_def.template_context_variables
|
||||
if target_def and target_def.template_context_variables
|
||||
else workflow.template_context_variables
|
||||
)
|
||||
|
||||
new_run = WorkflowRunModel(
|
||||
name=name,
|
||||
workflow=workflow,
|
||||
mode=mode,
|
||||
definition_id=current_def.id if current_def else None,
|
||||
initial_context=initial_context or workflow.template_context_variables,
|
||||
definition_id=target_def.id if target_def else None,
|
||||
initial_context=initial_context or default_context,
|
||||
gathered_context=gathered_context or {},
|
||||
campaign_id=campaign_id,
|
||||
queued_run_id=queued_run_id,
|
||||
|
|
@ -189,7 +200,11 @@ class WorkflowRunClient(BaseDBClient):
|
|||
self, run_id: int, user_id: int = None, organization_id: int = None
|
||||
) -> WorkflowRunModel | None:
|
||||
async with self.async_session() as session:
|
||||
query = select(WorkflowRunModel).join(WorkflowRunModel.workflow)
|
||||
query = (
|
||||
select(WorkflowRunModel)
|
||||
.options(selectinload(WorkflowRunModel.definition))
|
||||
.join(WorkflowRunModel.workflow)
|
||||
)
|
||||
|
||||
if organization_id:
|
||||
# Filter by organization_id when provided
|
||||
|
|
|
|||
|
|
@ -295,7 +295,7 @@ async def create_campaign(
|
|||
from api.services.workflow.dto import ReactFlowDTO
|
||||
from api.services.workflow.workflow import WorkflowGraph
|
||||
|
||||
workflow_def = workflow.workflow_definition_with_fallback
|
||||
workflow_def = workflow.released_definition.workflow_json
|
||||
if workflow_def:
|
||||
try:
|
||||
dto = ReactFlowDTO(**workflow_def)
|
||||
|
|
|
|||
|
|
@ -103,14 +103,13 @@ async def initiate_call(
|
|||
if not workflow:
|
||||
raise HTTPException(status_code=404, detail="Workflow not found")
|
||||
|
||||
# Get workflow definition (with fallback to legacy field)
|
||||
workflow_definition = workflow.workflow_definition_with_fallback
|
||||
workflow_definition = workflow.released_definition.workflow_json
|
||||
|
||||
# Validate trigger node still exists in the workflow definition
|
||||
if not trigger_exists_in_workflow(workflow_definition, uuid):
|
||||
raise HTTPException(
|
||||
status_code=404,
|
||||
detail="Trigger not found or has been removed from workflow",
|
||||
detail="Trigger not found in the published Agent",
|
||||
)
|
||||
|
||||
# 6. Get telephony provider for the organization
|
||||
|
|
|
|||
|
|
@ -143,7 +143,8 @@ class StatusCallbackRequest(BaseModel):
|
|||
async def initiate_call(
|
||||
request: InitiateCallRequest, user: UserModel = Depends(get_user)
|
||||
):
|
||||
"""Initiate a call using the configured telephony provider."""
|
||||
"""Initiate a call using the configured telephony provider from web browser. This is
|
||||
supposed to be a test call method for the draft version of the agent."""
|
||||
|
||||
# Get the telephony provider for the organization
|
||||
provider = await get_telephony_provider(user.selected_organization_id)
|
||||
|
|
@ -190,6 +191,7 @@ async def initiate_call(
|
|||
"called_number": phone_number,
|
||||
"provider": provider.PROVIDER_NAME,
|
||||
},
|
||||
use_draft=True,
|
||||
)
|
||||
workflow_run_id = workflow_run.id
|
||||
else:
|
||||
|
|
|
|||
|
|
@ -15,10 +15,12 @@ from api.db.workflow_template_client import WorkflowTemplateClient
|
|||
from api.enums import CallType
|
||||
from api.schemas.workflow import WorkflowRunResponseSchema
|
||||
from api.services.auth.depends import get_user
|
||||
from api.services.configuration.check_validity import UserConfigurationValidator
|
||||
from api.services.configuration.masking import (
|
||||
mask_workflow_definition,
|
||||
merge_workflow_api_keys,
|
||||
)
|
||||
from api.services.configuration.resolve import resolve_effective_config
|
||||
from api.services.mps_service_key_client import mps_service_key_client
|
||||
from api.services.workflow.dto import ReactFlowDTO
|
||||
from api.services.workflow.duplicate import duplicate_workflow
|
||||
|
|
@ -104,6 +106,8 @@ class WorkflowResponse(BaseModel):
|
|||
call_disposition_codes: CallDispositionCodes | None = None
|
||||
total_runs: int | None = None
|
||||
workflow_configurations: dict | None = None
|
||||
version_number: int | None = None
|
||||
version_status: str | None = None
|
||||
|
||||
|
||||
class WorkflowListResponse(BaseModel):
|
||||
|
|
@ -149,6 +153,17 @@ class UpdateWorkflowRequest(BaseModel):
|
|||
workflow_configurations: dict | None = None
|
||||
|
||||
|
||||
class WorkflowVersionResponse(BaseModel):
|
||||
id: int
|
||||
version_number: int
|
||||
status: str
|
||||
created_at: datetime
|
||||
published_at: datetime | None = None
|
||||
workflow_json: dict
|
||||
workflow_configurations: dict | None = None
|
||||
template_context_variables: dict | None = None
|
||||
|
||||
|
||||
class UpdateWorkflowStatusRequest(BaseModel):
|
||||
status: str # "active" or "archived"
|
||||
|
||||
|
|
@ -200,8 +215,11 @@ async def validate_workflow(
|
|||
|
||||
errors: list[WorkflowError] = []
|
||||
|
||||
# Get workflow definition from WorkflowDefinition table, fallback to workflow_definition field
|
||||
workflow_definition = workflow.workflow_definition_with_fallback
|
||||
# Validate draft if it exists (user is editing), else validate published
|
||||
draft = await db_client.get_draft_version(workflow_id)
|
||||
workflow_definition = (
|
||||
draft.workflow_json if draft else workflow.released_definition.workflow_json
|
||||
)
|
||||
|
||||
# ----------- DTO Validation ------------
|
||||
dto: Optional[ReactFlowDTO] = None
|
||||
|
|
@ -282,9 +300,7 @@ async def create_workflow(
|
|||
"name": workflow.name,
|
||||
"status": workflow.status,
|
||||
"created_at": workflow.created_at,
|
||||
"workflow_definition": mask_workflow_definition(
|
||||
workflow.workflow_definition_with_fallback
|
||||
),
|
||||
"workflow_definition": mask_workflow_definition(request.workflow_definition),
|
||||
"current_definition_id": workflow.current_definition_id,
|
||||
"template_context_variables": workflow.template_context_variables,
|
||||
"call_disposition_codes": workflow.call_disposition_codes,
|
||||
|
|
@ -362,9 +378,7 @@ async def create_workflow_from_template(
|
|||
"name": workflow.name,
|
||||
"status": workflow.status,
|
||||
"created_at": workflow.created_at,
|
||||
"workflow_definition": mask_workflow_definition(
|
||||
workflow.workflow_definition_with_fallback
|
||||
),
|
||||
"workflow_definition": mask_workflow_definition(workflow_def),
|
||||
"current_definition_id": workflow.current_definition_id,
|
||||
"template_context_variables": workflow.template_context_variables,
|
||||
"call_disposition_codes": workflow.call_disposition_codes,
|
||||
|
|
@ -461,7 +475,11 @@ async def get_workflow(
|
|||
workflow_id: int,
|
||||
user: UserModel = Depends(get_user),
|
||||
) -> WorkflowResponse:
|
||||
"""Get a single workflow by ID"""
|
||||
"""Get a single workflow by ID.
|
||||
|
||||
If a draft version exists, returns the draft content for editing.
|
||||
Otherwise returns the published version's content.
|
||||
"""
|
||||
workflow = await db_client.get_workflow(
|
||||
workflow_id, organization_id=user.selected_organization_id
|
||||
)
|
||||
|
|
@ -470,21 +488,123 @@ async def get_workflow(
|
|||
status_code=404, detail=f"Workflow with id {workflow_id} not found"
|
||||
)
|
||||
|
||||
# Check for draft — editor should show draft content if it exists
|
||||
draft = await db_client.get_draft_version(workflow_id)
|
||||
|
||||
if draft:
|
||||
workflow_def = draft.workflow_json
|
||||
workflow_configs = draft.workflow_configurations
|
||||
template_vars = draft.template_context_variables
|
||||
else:
|
||||
published = workflow.released_definition
|
||||
workflow_def = published.workflow_json
|
||||
workflow_configs = published.workflow_configurations
|
||||
template_vars = published.template_context_variables
|
||||
|
||||
active_def = draft or workflow.released_definition
|
||||
return {
|
||||
"id": workflow.id,
|
||||
"name": workflow.name,
|
||||
"status": workflow.status,
|
||||
"created_at": workflow.created_at,
|
||||
"workflow_definition": mask_workflow_definition(
|
||||
workflow.workflow_definition_with_fallback
|
||||
),
|
||||
"workflow_definition": mask_workflow_definition(workflow_def),
|
||||
"current_definition_id": workflow.current_definition_id,
|
||||
"template_context_variables": workflow.template_context_variables,
|
||||
"template_context_variables": template_vars,
|
||||
"call_disposition_codes": workflow.call_disposition_codes,
|
||||
"workflow_configurations": workflow.workflow_configurations,
|
||||
"workflow_configurations": workflow_configs,
|
||||
"version_number": active_def.version_number if active_def else None,
|
||||
"version_status": active_def.status if active_def else None,
|
||||
}
|
||||
|
||||
|
||||
@router.get("/{workflow_id}/versions")
|
||||
async def get_workflow_versions(
|
||||
workflow_id: int,
|
||||
user: UserModel = Depends(get_user),
|
||||
) -> list[WorkflowVersionResponse]:
|
||||
"""List all versions for a workflow, newest first."""
|
||||
workflow = await db_client.get_workflow(
|
||||
workflow_id, organization_id=user.selected_organization_id
|
||||
)
|
||||
if workflow is None:
|
||||
raise HTTPException(
|
||||
status_code=404, detail=f"Workflow with id {workflow_id} not found"
|
||||
)
|
||||
|
||||
versions = await db_client.get_workflow_versions(workflow_id)
|
||||
return [
|
||||
WorkflowVersionResponse(
|
||||
id=v.id,
|
||||
version_number=v.version_number,
|
||||
status=v.status,
|
||||
created_at=v.created_at,
|
||||
published_at=v.published_at,
|
||||
workflow_json=mask_workflow_definition(v.workflow_json),
|
||||
workflow_configurations=v.workflow_configurations,
|
||||
template_context_variables=v.template_context_variables,
|
||||
)
|
||||
for v in versions
|
||||
if v.version_number is not None
|
||||
]
|
||||
|
||||
|
||||
@router.post("/{workflow_id}/publish")
|
||||
async def publish_workflow(
|
||||
workflow_id: int,
|
||||
user: UserModel = Depends(get_user),
|
||||
):
|
||||
"""Publish the current draft version of a workflow."""
|
||||
workflow = await db_client.get_workflow(
|
||||
workflow_id, organization_id=user.selected_organization_id
|
||||
)
|
||||
if workflow is None:
|
||||
raise HTTPException(
|
||||
status_code=404, detail=f"Workflow with id {workflow_id} not found"
|
||||
)
|
||||
|
||||
try:
|
||||
published = await db_client.publish_workflow_draft(workflow_id)
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
|
||||
return {
|
||||
"id": published.id,
|
||||
"version_number": published.version_number,
|
||||
"status": published.status,
|
||||
"published_at": published.published_at,
|
||||
}
|
||||
|
||||
|
||||
@router.post("/{workflow_id}/create-draft")
|
||||
async def create_workflow_draft(
|
||||
workflow_id: int,
|
||||
user: UserModel = Depends(get_user),
|
||||
) -> WorkflowVersionResponse:
|
||||
"""Create a draft version from the current published version.
|
||||
|
||||
If a draft already exists, returns the existing draft.
|
||||
"""
|
||||
workflow = await db_client.get_workflow(
|
||||
workflow_id, organization_id=user.selected_organization_id
|
||||
)
|
||||
if workflow is None:
|
||||
raise HTTPException(
|
||||
status_code=404, detail=f"Workflow with id {workflow_id} not found"
|
||||
)
|
||||
|
||||
draft = await db_client.save_workflow_draft(workflow_id)
|
||||
return WorkflowVersionResponse(
|
||||
id=draft.id,
|
||||
version_number=draft.version_number,
|
||||
status=draft.status,
|
||||
created_at=draft.created_at,
|
||||
published_at=draft.published_at,
|
||||
workflow_json=mask_workflow_definition(draft.workflow_json),
|
||||
workflow_configurations=draft.workflow_configurations,
|
||||
template_context_variables=draft.template_context_variables,
|
||||
)
|
||||
|
||||
|
||||
@router.get("/summary")
|
||||
async def get_workflows_summary(
|
||||
user: UserModel = Depends(get_user),
|
||||
|
|
@ -528,7 +648,7 @@ async def update_workflow_status(
|
|||
"status": workflow.status,
|
||||
"created_at": workflow.created_at,
|
||||
"workflow_definition": mask_workflow_definition(
|
||||
workflow.workflow_definition_with_fallback
|
||||
workflow.released_definition.workflow_json
|
||||
),
|
||||
"current_definition_id": workflow.current_definition_id,
|
||||
"template_context_variables": workflow.template_context_variables,
|
||||
|
|
@ -569,11 +689,37 @@ async def update_workflow(
|
|||
workflow_id, organization_id=user.selected_organization_id
|
||||
)
|
||||
if existing_workflow:
|
||||
# Merge against what the user was editing (draft or published)
|
||||
existing_draft = await db_client.get_draft_version(workflow_id)
|
||||
existing_def = (
|
||||
existing_draft.workflow_json
|
||||
if existing_draft
|
||||
else existing_workflow.released_definition.workflow_json
|
||||
)
|
||||
workflow_definition = merge_workflow_api_keys(
|
||||
workflow_definition,
|
||||
existing_workflow.workflow_definition_with_fallback,
|
||||
existing_def,
|
||||
)
|
||||
|
||||
# Validate model_overrides: resolve onto global config, then
|
||||
# run the same validator used by the user-configurations endpoint.
|
||||
if request.workflow_configurations and request.workflow_configurations.get(
|
||||
"model_overrides"
|
||||
):
|
||||
user_config = await db_client.get_user_configurations(user.id)
|
||||
try:
|
||||
effective = resolve_effective_config(
|
||||
user_config,
|
||||
request.workflow_configurations["model_overrides"],
|
||||
)
|
||||
await UserConfigurationValidator().validate(
|
||||
effective,
|
||||
organization_id=user.selected_organization_id,
|
||||
created_by=user.provider_id,
|
||||
)
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=422, detail=str(e))
|
||||
|
||||
workflow = await db_client.update_workflow(
|
||||
workflow_id=workflow_id,
|
||||
name=request.name,
|
||||
|
|
@ -592,19 +738,35 @@ async def update_workflow(
|
|||
trigger_paths=trigger_paths,
|
||||
)
|
||||
|
||||
# Return draft content if one exists (save creates a draft)
|
||||
draft = await db_client.get_draft_version(workflow_id)
|
||||
if draft:
|
||||
workflow_def = draft.workflow_json
|
||||
workflow_configs = draft.workflow_configurations
|
||||
template_vars = draft.template_context_variables
|
||||
else:
|
||||
published = workflow.released_definition
|
||||
workflow_def = published.workflow_json
|
||||
workflow_configs = published.workflow_configurations
|
||||
template_vars = published.template_context_variables
|
||||
|
||||
# Include version info from the active definition (draft or published)
|
||||
active_def = draft or workflow.released_definition
|
||||
return {
|
||||
"id": workflow.id,
|
||||
"name": workflow.name,
|
||||
"status": workflow.status,
|
||||
"created_at": workflow.created_at,
|
||||
"workflow_definition": mask_workflow_definition(
|
||||
workflow.workflow_definition_with_fallback
|
||||
),
|
||||
"workflow_definition": mask_workflow_definition(workflow_def),
|
||||
"current_definition_id": workflow.current_definition_id,
|
||||
"template_context_variables": workflow.template_context_variables,
|
||||
"template_context_variables": template_vars,
|
||||
"call_disposition_codes": workflow.call_disposition_codes,
|
||||
"workflow_configurations": workflow.workflow_configurations,
|
||||
"workflow_configurations": workflow_configs,
|
||||
"version_number": active_def.version_number if active_def else None,
|
||||
"version_status": active_def.status if active_def else None,
|
||||
}
|
||||
except HTTPException:
|
||||
raise
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=404, detail=str(e))
|
||||
except Exception as e:
|
||||
|
|
@ -629,7 +791,7 @@ async def duplicate_workflow_endpoint(
|
|||
"status": workflow.status,
|
||||
"created_at": workflow.created_at,
|
||||
"workflow_definition": mask_workflow_definition(
|
||||
workflow.workflow_definition_with_fallback
|
||||
workflow.released_definition.workflow_json
|
||||
),
|
||||
"current_definition_id": workflow.current_definition_id,
|
||||
"template_context_variables": workflow.template_context_variables,
|
||||
|
|
@ -658,7 +820,7 @@ async def create_workflow_run(
|
|||
user: The user to create the workflow run for
|
||||
"""
|
||||
run = await db_client.create_workflow_run(
|
||||
request.name, workflow_id, request.mode, user.id
|
||||
request.name, workflow_id, request.mode, user.id, use_draft=True
|
||||
)
|
||||
return {
|
||||
"id": run.id,
|
||||
|
|
@ -862,9 +1024,7 @@ async def duplicate_workflow_template(
|
|||
"name": workflow.name,
|
||||
"status": workflow.status,
|
||||
"created_at": workflow.created_at,
|
||||
"workflow_definition": mask_workflow_definition(
|
||||
workflow.workflow_definition_with_fallback
|
||||
),
|
||||
"workflow_definition": mask_workflow_definition(workflow_def),
|
||||
"current_definition_id": workflow.current_definition_id,
|
||||
"template_context_variables": workflow.template_context_variables,
|
||||
"call_disposition_codes": workflow.call_disposition_codes,
|
||||
|
|
|
|||
83
api/services/configuration/resolve.py
Normal file
83
api/services/configuration/resolve.py
Normal file
|
|
@ -0,0 +1,83 @@
|
|||
"""Resolve effective config by merging per-workflow model overrides onto global config."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from api.schemas.user_configuration import UserConfiguration
|
||||
from api.services.configuration.registry import (
|
||||
REGISTRY,
|
||||
ServiceType,
|
||||
)
|
||||
|
||||
# Maps override key → (UserConfiguration field, ServiceType for registry lookup)
|
||||
_SECTION_MAP: dict[str, ServiceType] = {
|
||||
"llm": ServiceType.LLM,
|
||||
"tts": ServiceType.TTS,
|
||||
"stt": ServiceType.STT,
|
||||
"realtime": ServiceType.REALTIME,
|
||||
}
|
||||
|
||||
|
||||
def _build_section_from_override(service_type: ServiceType, override: dict):
|
||||
"""Construct a typed config object from a raw override dict using the registry."""
|
||||
provider = override.get("provider")
|
||||
if not provider:
|
||||
return None
|
||||
registry = REGISTRY.get(service_type, {})
|
||||
config_cls = registry.get(provider)
|
||||
if config_cls is None:
|
||||
return None
|
||||
return config_cls(**override)
|
||||
|
||||
|
||||
def resolve_effective_config(
|
||||
user_config: UserConfiguration,
|
||||
model_overrides: dict | None,
|
||||
) -> UserConfiguration:
|
||||
"""Deep-merge workflow model_overrides onto global user config.
|
||||
|
||||
- If model_overrides is None or empty, returns a copy of user_config unchanged.
|
||||
- For each section (llm, tts, stt, realtime), if the override contains that key:
|
||||
- If the global section is None, construct a new config from the override.
|
||||
- If the provider changes, construct a new config from the override.
|
||||
- Otherwise, merge override fields onto the existing config (model_copy).
|
||||
- is_realtime is a simple boolean override.
|
||||
- Sections not in the override are inherited from global unchanged.
|
||||
- The original user_config is never mutated.
|
||||
"""
|
||||
if not model_overrides:
|
||||
return user_config.model_copy(deep=True)
|
||||
|
||||
effective = user_config.model_copy(deep=True)
|
||||
|
||||
# Handle is_realtime boolean
|
||||
if "is_realtime" in model_overrides:
|
||||
effective.is_realtime = model_overrides["is_realtime"]
|
||||
|
||||
# Handle service sections
|
||||
for section_key, service_type in _SECTION_MAP.items():
|
||||
if section_key not in model_overrides:
|
||||
continue
|
||||
|
||||
override = model_overrides[section_key]
|
||||
base = getattr(effective, section_key)
|
||||
|
||||
if base is None:
|
||||
# No global config for this section — build from override
|
||||
setattr(
|
||||
effective,
|
||||
section_key,
|
||||
_build_section_from_override(service_type, override),
|
||||
)
|
||||
elif "provider" in override and override["provider"] != base.provider:
|
||||
# Provider changed — must construct new typed object
|
||||
setattr(
|
||||
effective,
|
||||
section_key,
|
||||
_build_section_from_override(service_type, override),
|
||||
)
|
||||
else:
|
||||
# Same provider — merge fields onto existing config
|
||||
merged = base.model_copy(update=override)
|
||||
setattr(effective, section_key, merged)
|
||||
|
||||
return effective
|
||||
|
|
@ -76,13 +76,15 @@ class LoopTalkPipelineBuilder:
|
|||
pipeline_sample_rate=16000,
|
||||
)
|
||||
|
||||
# Use published definition for graph + configs
|
||||
released_def = workflow.released_definition
|
||||
wf_json = released_def.workflow_json
|
||||
wf_configs = released_def.workflow_configurations or {}
|
||||
|
||||
# Extract keyterms from workflow configurations
|
||||
keyterms = None
|
||||
if (
|
||||
workflow.workflow_configurations
|
||||
and "dictionary" in workflow.workflow_configurations
|
||||
):
|
||||
dictionary = workflow.workflow_configurations["dictionary"]
|
||||
if wf_configs and "dictionary" in wf_configs:
|
||||
dictionary = wf_configs["dictionary"]
|
||||
if dictionary and isinstance(dictionary, str):
|
||||
keyterms = [
|
||||
term.strip() for term in dictionary.split(",") if term.strip()
|
||||
|
|
@ -90,6 +92,12 @@ class LoopTalkPipelineBuilder:
|
|||
if keyterms:
|
||||
logger.info(f"Using {len(keyterms)} keyterms for STT: {keyterms}")
|
||||
|
||||
# Resolve model overrides from the version onto global user config
|
||||
from api.services.configuration.resolve import resolve_effective_config
|
||||
|
||||
model_overrides = wf_configs.get("model_overrides")
|
||||
user_config = resolve_effective_config(user_config, model_overrides)
|
||||
|
||||
# Create services
|
||||
stt = create_stt_service(user_config, audio_config, keyterms=keyterms)
|
||||
llm = create_llm_service(user_config)
|
||||
|
|
@ -98,9 +106,7 @@ class LoopTalkPipelineBuilder:
|
|||
logger.debug(f"Created services for {role}: STT={stt}, LLM={llm}, TTS={tts}")
|
||||
|
||||
# Get workflow graph
|
||||
workflow_graph = WorkflowGraph(
|
||||
ReactFlowDTO.model_validate(workflow.workflow_definition_with_fallback)
|
||||
)
|
||||
workflow_graph = WorkflowGraph(ReactFlowDTO.model_validate(wf_json))
|
||||
|
||||
# Create engine first (needed for create_pipeline_components)
|
||||
engine = PipecatEngine(
|
||||
|
|
|
|||
|
|
@ -562,50 +562,49 @@ async def _run_pipeline(
|
|||
# Get user configuration
|
||||
user_config = await db_client.get_user_configurations(user_id)
|
||||
|
||||
# Get workflow first so we can extract configurations before creating services
|
||||
# Get workflow for metadata (name, organization_id, call_disposition_codes)
|
||||
workflow = await db_client.get_workflow(workflow_id, user_id)
|
||||
if not workflow:
|
||||
raise HTTPException(status_code=404, detail="Workflow not found")
|
||||
|
||||
# Extract configurations from workflow configurations
|
||||
# Use the run's pinned definition for graph + configs (not the workflow's current)
|
||||
run_definition = workflow_run.definition
|
||||
run_workflow_json = run_definition.workflow_json
|
||||
run_configs = run_definition.workflow_configurations or {}
|
||||
|
||||
# Extract configurations from the version's workflow_configurations
|
||||
max_call_duration_seconds = 300 # Default 5 minutes
|
||||
max_user_idle_timeout = 10.0 # Default 10 seconds
|
||||
smart_turn_stop_secs = 2.0 # Default 2 seconds for incomplete turn timeout
|
||||
turn_stop_strategy = "transcription" # Default to transcription-based detection
|
||||
keyterms = None # Dictionary words for STT boosting
|
||||
|
||||
if workflow.workflow_configurations:
|
||||
# Use workflow-specific max call duration if provided
|
||||
if "max_call_duration" in workflow.workflow_configurations:
|
||||
max_call_duration_seconds = workflow.workflow_configurations[
|
||||
"max_call_duration"
|
||||
]
|
||||
if run_configs:
|
||||
if "max_call_duration" in run_configs:
|
||||
max_call_duration_seconds = run_configs["max_call_duration"]
|
||||
|
||||
# Use workflow-specific max user idle timeout if provided
|
||||
if "max_user_idle_timeout" in workflow.workflow_configurations:
|
||||
max_user_idle_timeout = workflow.workflow_configurations[
|
||||
"max_user_idle_timeout"
|
||||
]
|
||||
if "max_user_idle_timeout" in run_configs:
|
||||
max_user_idle_timeout = run_configs["max_user_idle_timeout"]
|
||||
|
||||
# Use workflow-specific smart turn stop timeout if provided
|
||||
if "smart_turn_stop_secs" in workflow.workflow_configurations:
|
||||
smart_turn_stop_secs = workflow.workflow_configurations[
|
||||
"smart_turn_stop_secs"
|
||||
]
|
||||
if "smart_turn_stop_secs" in run_configs:
|
||||
smart_turn_stop_secs = run_configs["smart_turn_stop_secs"]
|
||||
|
||||
# Use workflow-specific turn stop strategy if provided
|
||||
if "turn_stop_strategy" in workflow.workflow_configurations:
|
||||
turn_stop_strategy = workflow.workflow_configurations["turn_stop_strategy"]
|
||||
if "turn_stop_strategy" in run_configs:
|
||||
turn_stop_strategy = run_configs["turn_stop_strategy"]
|
||||
|
||||
# Extract dictionary words and convert to keyterms list
|
||||
if "dictionary" in workflow.workflow_configurations:
|
||||
dictionary = workflow.workflow_configurations["dictionary"]
|
||||
if "dictionary" in run_configs:
|
||||
dictionary = run_configs["dictionary"]
|
||||
if dictionary and isinstance(dictionary, str):
|
||||
# Split by comma and strip whitespace from each term
|
||||
keyterms = [
|
||||
term.strip() for term in dictionary.split(",") if term.strip()
|
||||
]
|
||||
|
||||
# Resolve model overrides from the version onto global user config
|
||||
from api.services.configuration.resolve import resolve_effective_config
|
||||
|
||||
model_overrides = run_configs.get("model_overrides")
|
||||
user_config = resolve_effective_config(user_config, model_overrides)
|
||||
|
||||
# Detect realtime mode (speech-to-speech services like OpenAI Realtime, Gemini Live)
|
||||
is_realtime = user_config.is_realtime and user_config.realtime is not None
|
||||
|
||||
|
|
@ -619,9 +618,7 @@ async def _run_pipeline(
|
|||
tts = create_tts_service(user_config, audio_config)
|
||||
llm = create_llm_service(user_config)
|
||||
|
||||
workflow_graph = WorkflowGraph(
|
||||
ReactFlowDTO.model_validate(workflow.workflow_definition_with_fallback)
|
||||
)
|
||||
workflow_graph = WorkflowGraph(ReactFlowDTO.model_validate(run_workflow_json))
|
||||
|
||||
# Pre-call fetch: fire early so it runs concurrently with remaining setup
|
||||
pre_call_fetch_task = None
|
||||
|
|
|
|||
|
|
@ -325,8 +325,6 @@ def create_tts_service(user_config, audio_config: "AudioConfig"):
|
|||
silence_time_s=1.0,
|
||||
)
|
||||
elif user_config.tts.provider == ServiceProviders.RIME.value:
|
||||
from pipecat.transcriptions.language import Language
|
||||
|
||||
speed = getattr(user_config.tts, "speed", None)
|
||||
language_code = getattr(user_config.tts, "language", None) or "en"
|
||||
rime_language_mapping = {
|
||||
|
|
|
|||
|
|
@ -74,13 +74,17 @@ async def duplicate_workflow(
|
|||
if source is None:
|
||||
raise ValueError(f"Workflow with id {workflow_id} not found")
|
||||
|
||||
workflow_definition = copy.deepcopy(source.workflow_definition_with_fallback)
|
||||
# 2. Prefer draft over released definition (duplicate latest state)
|
||||
draft = await db_client.get_draft_version(workflow_id)
|
||||
source_def = draft if draft else source.released_definition
|
||||
|
||||
# 2. Regenerate trigger UUIDs to avoid conflicts
|
||||
workflow_definition = copy.deepcopy(source_def.workflow_json)
|
||||
|
||||
# 3. Regenerate trigger UUIDs to avoid conflicts
|
||||
if workflow_definition:
|
||||
workflow_definition = _regenerate_trigger_uuids(workflow_definition)
|
||||
|
||||
# 3. Create the new workflow
|
||||
# 4. Create the new workflow
|
||||
new_name = f"{source.name} - Duplicate"
|
||||
new_workflow = await db_client.create_workflow(
|
||||
name=new_name,
|
||||
|
|
@ -89,21 +93,20 @@ async def duplicate_workflow(
|
|||
organization_id=organization_id,
|
||||
)
|
||||
|
||||
# 4. Copy template_context_variables and workflow_configurations
|
||||
has_extra_fields = (
|
||||
source.template_context_variables or source.workflow_configurations
|
||||
)
|
||||
if has_extra_fields:
|
||||
# 5. Copy template_context_variables and workflow_configurations from source definition
|
||||
source_tcv = source_def.template_context_variables
|
||||
source_wc = source_def.workflow_configurations
|
||||
if source_tcv or source_wc:
|
||||
new_workflow = await db_client.update_workflow(
|
||||
workflow_id=new_workflow.id,
|
||||
name=None,
|
||||
workflow_definition=None,
|
||||
template_context_variables=copy.deepcopy(source.template_context_variables),
|
||||
workflow_configurations=copy.deepcopy(source.workflow_configurations),
|
||||
template_context_variables=copy.deepcopy(source_tcv),
|
||||
workflow_configurations=copy.deepcopy(source_wc),
|
||||
organization_id=organization_id,
|
||||
)
|
||||
|
||||
# 5. Copy recordings with new IDs and storage paths scoped to new workflow
|
||||
# 6. Copy recordings with new IDs and storage paths scoped to new workflow
|
||||
recording_id_map = await _duplicate_recordings(
|
||||
source_workflow_id=workflow_id,
|
||||
new_workflow_id=new_workflow.id,
|
||||
|
|
@ -111,7 +114,7 @@ async def duplicate_workflow(
|
|||
user_id=user_id,
|
||||
)
|
||||
|
||||
# 6. Replace old recording IDs with new ones in the workflow definition
|
||||
# 7. Replace old recording IDs with new ones in the workflow definition
|
||||
if recording_id_map:
|
||||
workflow_definition = _replace_recording_ids(
|
||||
workflow_definition, recording_id_map
|
||||
|
|
@ -125,7 +128,7 @@ async def duplicate_workflow(
|
|||
organization_id=organization_id,
|
||||
)
|
||||
|
||||
# 7. Sync triggers for the new workflow
|
||||
# 8. Sync triggers for the new workflow
|
||||
if workflow_definition:
|
||||
trigger_paths = _extract_trigger_paths(workflow_definition)
|
||||
if trigger_paths:
|
||||
|
|
|
|||
|
|
@ -187,15 +187,9 @@ async def run_integrations_post_workflow_run(_ctx, workflow_run_id: int):
|
|||
secret_key=langfuse_config.get("secret_key"),
|
||||
)
|
||||
|
||||
# Step 2: Get workflow definition (prefer the run-specific definition)
|
||||
if workflow_run.definition:
|
||||
workflow_definition = workflow_run.definition.workflow_json
|
||||
definition_id = workflow_run.definition.id
|
||||
else:
|
||||
workflow_definition = (
|
||||
workflow_run.workflow.workflow_definition_with_fallback
|
||||
)
|
||||
definition_id = workflow_run.workflow.current_definition_id
|
||||
# Step 2: Get workflow definition from the run's pinned version
|
||||
workflow_definition = workflow_run.definition.workflow_json
|
||||
definition_id = workflow_run.definition.id
|
||||
|
||||
if not workflow_definition:
|
||||
logger.debug("No workflow definition, skipping integrations")
|
||||
|
|
|
|||
|
|
@ -80,11 +80,6 @@ class MockWorkflowModel:
|
|||
workflow_id: int = 1
|
||||
organization_id: int = 1
|
||||
workflow_configurations: Dict[str, Any] = field(default_factory=dict)
|
||||
workflow_definition_with_fallback: Dict[str, Any] = field(default_factory=dict)
|
||||
|
||||
def __post_init__(self):
|
||||
if not self.workflow_definition_with_fallback:
|
||||
self.workflow_definition_with_fallback = DEFAULT_WORKFLOW_DEFINITION.copy()
|
||||
|
||||
|
||||
@dataclass
|
||||
|
|
@ -120,6 +115,7 @@ class MockToolModel:
|
|||
name: str
|
||||
description: str
|
||||
definition: Dict[str, Any]
|
||||
category: str = "http_api"
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
|
|
|
|||
353
api/tests/test_resolve_effective_config.py
Normal file
353
api/tests/test_resolve_effective_config.py
Normal file
|
|
@ -0,0 +1,353 @@
|
|||
"""
|
||||
TDD tests for resolve_effective_config().
|
||||
|
||||
This function deep-merges workflow-level model_overrides onto the global
|
||||
UserConfiguration. Fields not overridden inherit from global.
|
||||
|
||||
Module under test: api.services.configuration.resolve
|
||||
"""
|
||||
|
||||
import pytest
|
||||
|
||||
from api.schemas.user_configuration import UserConfiguration
|
||||
from api.services.configuration.registry import (
|
||||
DeepgramSTTConfiguration,
|
||||
ElevenlabsTTSConfiguration,
|
||||
GoogleRealtimeLLMConfiguration,
|
||||
OpenAILLMService,
|
||||
)
|
||||
from api.services.configuration.resolve import resolve_effective_config
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Fixtures
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def global_config() -> UserConfiguration:
|
||||
"""A realistic global user configuration."""
|
||||
return UserConfiguration(
|
||||
llm=OpenAILLMService(
|
||||
provider="openai", api_key="sk-global-llm", model="gpt-4.1"
|
||||
),
|
||||
tts=ElevenlabsTTSConfiguration(
|
||||
provider="elevenlabs",
|
||||
api_key="el-global-tts",
|
||||
voice="Rachel",
|
||||
model="eleven_flash_v2_5",
|
||||
),
|
||||
stt=DeepgramSTTConfiguration(
|
||||
provider="deepgram",
|
||||
api_key="dg-global-stt",
|
||||
model="nova-3-general",
|
||||
language="multi",
|
||||
),
|
||||
is_realtime=False,
|
||||
realtime=None,
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def global_config_realtime() -> UserConfiguration:
|
||||
"""Global config with realtime enabled."""
|
||||
return UserConfiguration(
|
||||
llm=OpenAILLMService(
|
||||
provider="openai", api_key="sk-global-llm", model="gpt-4.1"
|
||||
),
|
||||
tts=ElevenlabsTTSConfiguration(
|
||||
provider="elevenlabs",
|
||||
api_key="el-global-tts",
|
||||
voice="Rachel",
|
||||
model="eleven_flash_v2_5",
|
||||
),
|
||||
stt=DeepgramSTTConfiguration(
|
||||
provider="deepgram",
|
||||
api_key="dg-global-stt",
|
||||
model="nova-3-general",
|
||||
language="multi",
|
||||
),
|
||||
is_realtime=True,
|
||||
realtime=GoogleRealtimeLLMConfiguration(
|
||||
provider="google_realtime",
|
||||
api_key="goog-global-rt",
|
||||
model="gemini-3.1-flash-live-preview",
|
||||
voice="Puck",
|
||||
language="en",
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# No overrides → global returned unchanged
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestNoOverrides:
|
||||
def test_none_overrides_returns_global(self, global_config):
|
||||
result = resolve_effective_config(global_config, None)
|
||||
assert result.llm.model == "gpt-4.1"
|
||||
assert result.tts.voice == "Rachel"
|
||||
assert result.stt.model == "nova-3-general"
|
||||
assert result.is_realtime is False
|
||||
|
||||
def test_empty_dict_overrides_returns_global(self, global_config):
|
||||
result = resolve_effective_config(global_config, {})
|
||||
assert result.llm.model == "gpt-4.1"
|
||||
assert result.tts.voice == "Rachel"
|
||||
|
||||
def test_does_not_mutate_original(self, global_config):
|
||||
"""The original config object must not be modified."""
|
||||
resolve_effective_config(global_config, {"llm": {"model": "gpt-4.1-mini"}})
|
||||
assert global_config.llm.model == "gpt-4.1"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Single-field overrides within a section (same provider)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestSingleFieldOverride:
|
||||
def test_override_llm_model_only(self, global_config):
|
||||
result = resolve_effective_config(
|
||||
global_config, {"llm": {"model": "gpt-4.1-mini"}}
|
||||
)
|
||||
assert result.llm.model == "gpt-4.1-mini"
|
||||
assert result.llm.provider == "openai" # inherited
|
||||
assert result.llm.api_key == "sk-global-llm" # inherited
|
||||
|
||||
def test_override_tts_voice_only(self, global_config):
|
||||
result = resolve_effective_config(global_config, {"tts": {"voice": "shimmer"}})
|
||||
assert result.tts.voice == "shimmer"
|
||||
assert result.tts.provider == "elevenlabs" # inherited
|
||||
assert result.tts.api_key == "el-global-tts" # inherited
|
||||
|
||||
def test_override_stt_language_only(self, global_config):
|
||||
result = resolve_effective_config(global_config, {"stt": {"language": "en"}})
|
||||
assert result.stt.language == "en"
|
||||
assert result.stt.model == "nova-3-general" # inherited
|
||||
assert result.stt.provider == "deepgram" # inherited
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Provider change (requires full section replacement)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestProviderChange:
|
||||
def test_override_llm_to_different_provider(self, global_config):
|
||||
result = resolve_effective_config(
|
||||
global_config,
|
||||
{
|
||||
"llm": {
|
||||
"provider": "groq",
|
||||
"api_key": "groq-key",
|
||||
"model": "llama-3.3-70b-versatile",
|
||||
}
|
||||
},
|
||||
)
|
||||
assert result.llm.provider == "groq"
|
||||
assert result.llm.model == "llama-3.3-70b-versatile"
|
||||
assert result.llm.api_key == "groq-key"
|
||||
|
||||
def test_provider_change_does_not_affect_other_sections(self, global_config):
|
||||
result = resolve_effective_config(
|
||||
global_config,
|
||||
{
|
||||
"llm": {
|
||||
"provider": "groq",
|
||||
"api_key": "groq-key",
|
||||
"model": "llama-3.3-70b-versatile",
|
||||
}
|
||||
},
|
||||
)
|
||||
# TTS and STT unchanged
|
||||
assert result.tts.provider == "elevenlabs"
|
||||
assert result.stt.provider == "deepgram"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# API key inheritance
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestAPIKeyInheritance:
|
||||
def test_no_api_key_in_override_inherits_global(self, global_config):
|
||||
"""When override omits api_key, global key is used."""
|
||||
result = resolve_effective_config(
|
||||
global_config, {"llm": {"model": "gpt-4.1-mini"}}
|
||||
)
|
||||
assert result.llm.api_key == "sk-global-llm"
|
||||
|
||||
def test_explicit_api_key_in_override_wins(self, global_config):
|
||||
"""When override includes api_key, it takes precedence."""
|
||||
result = resolve_effective_config(
|
||||
global_config,
|
||||
{"llm": {"model": "gpt-4.1-mini", "api_key": "sk-override-key"}},
|
||||
)
|
||||
assert result.llm.api_key == "sk-override-key"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# is_realtime override
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestRealtimeOverride:
|
||||
def test_enable_realtime_on_non_realtime_global(self, global_config):
|
||||
result = resolve_effective_config(
|
||||
global_config,
|
||||
{
|
||||
"is_realtime": True,
|
||||
"realtime": {
|
||||
"provider": "google_realtime",
|
||||
"api_key": "goog-override",
|
||||
"model": "gemini-3.1-flash-live-preview",
|
||||
"voice": "Charon",
|
||||
"language": "en",
|
||||
},
|
||||
},
|
||||
)
|
||||
assert result.is_realtime is True
|
||||
assert result.realtime.provider == "google_realtime"
|
||||
assert result.realtime.voice == "Charon"
|
||||
|
||||
def test_disable_realtime_on_realtime_global(self, global_config_realtime):
|
||||
result = resolve_effective_config(
|
||||
global_config_realtime, {"is_realtime": False}
|
||||
)
|
||||
assert result.is_realtime is False
|
||||
# Realtime config may still be present but is_realtime flag controls usage
|
||||
|
||||
def test_override_realtime_voice_only(self, global_config_realtime):
|
||||
result = resolve_effective_config(
|
||||
global_config_realtime, {"realtime": {"voice": "Kore"}}
|
||||
)
|
||||
assert result.realtime.voice == "Kore"
|
||||
assert result.realtime.provider == "google_realtime" # inherited
|
||||
assert result.realtime.api_key == "goog-global-rt" # inherited
|
||||
|
||||
def test_override_is_realtime_only_without_realtime_section(self, global_config):
|
||||
"""Override is_realtime=True but provide no realtime config.
|
||||
Should set the flag; realtime section stays None from global."""
|
||||
result = resolve_effective_config(global_config, {"is_realtime": True})
|
||||
assert result.is_realtime is True
|
||||
assert result.realtime is None # no config provided
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Section override when global has None for that section
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestOverrideOnNullGlobal:
|
||||
def test_override_stt_when_global_is_none(self):
|
||||
"""When global has no STT config, override creates one from scratch."""
|
||||
config = UserConfiguration(
|
||||
llm=OpenAILLMService(provider="openai", api_key="sk-key", model="gpt-4.1"),
|
||||
stt=None,
|
||||
tts=None,
|
||||
is_realtime=False,
|
||||
)
|
||||
result = resolve_effective_config(
|
||||
config,
|
||||
{
|
||||
"stt": {
|
||||
"provider": "deepgram",
|
||||
"api_key": "dg-new",
|
||||
"model": "nova-3-general",
|
||||
"language": "en",
|
||||
}
|
||||
},
|
||||
)
|
||||
assert result.stt is not None
|
||||
assert result.stt.provider == "deepgram"
|
||||
assert result.stt.model == "nova-3-general"
|
||||
|
||||
def test_override_realtime_when_global_is_none(self):
|
||||
"""Realtime section can be created from override even if global has none."""
|
||||
config = UserConfiguration(
|
||||
llm=OpenAILLMService(provider="openai", api_key="sk-key", model="gpt-4.1"),
|
||||
is_realtime=False,
|
||||
realtime=None,
|
||||
)
|
||||
result = resolve_effective_config(
|
||||
config,
|
||||
{
|
||||
"is_realtime": True,
|
||||
"realtime": {
|
||||
"provider": "google_realtime",
|
||||
"api_key": "goog-new",
|
||||
"model": "gemini-3.1-flash-live-preview",
|
||||
"voice": "Puck",
|
||||
"language": "en",
|
||||
},
|
||||
},
|
||||
)
|
||||
assert result.is_realtime is True
|
||||
assert result.realtime.provider == "google_realtime"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Multi-section overrides
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestMultiSectionOverride:
|
||||
def test_override_llm_and_tts_not_stt(self, global_config):
|
||||
result = resolve_effective_config(
|
||||
global_config,
|
||||
{
|
||||
"llm": {"model": "gpt-4.1-mini"},
|
||||
"tts": {"voice": "shimmer"},
|
||||
},
|
||||
)
|
||||
assert result.llm.model == "gpt-4.1-mini"
|
||||
assert result.tts.voice == "shimmer"
|
||||
# STT untouched
|
||||
assert result.stt.model == "nova-3-general"
|
||||
assert result.stt.language == "multi"
|
||||
|
||||
def test_override_all_sections(self, global_config):
|
||||
result = resolve_effective_config(
|
||||
global_config,
|
||||
{
|
||||
"llm": {"model": "gpt-4.1-mini"},
|
||||
"tts": {"voice": "shimmer"},
|
||||
"stt": {"language": "en"},
|
||||
"is_realtime": True,
|
||||
"realtime": {
|
||||
"provider": "google_realtime",
|
||||
"api_key": "goog-key",
|
||||
"model": "gemini-3.1-flash-live-preview",
|
||||
"voice": "Fenrir",
|
||||
"language": "en",
|
||||
},
|
||||
},
|
||||
)
|
||||
assert result.llm.model == "gpt-4.1-mini"
|
||||
assert result.tts.voice == "shimmer"
|
||||
assert result.stt.language == "en"
|
||||
assert result.is_realtime is True
|
||||
assert result.realtime.voice == "Fenrir"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Ignored / unknown keys
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestUnknownKeys:
|
||||
def test_unknown_section_in_overrides_is_ignored(self, global_config):
|
||||
"""Override with a key that doesn't map to any section should not crash."""
|
||||
result = resolve_effective_config(
|
||||
global_config, {"unknown_section": {"foo": "bar"}}
|
||||
)
|
||||
assert result.llm.model == "gpt-4.1"
|
||||
|
||||
def test_embeddings_not_overridable(self, global_config):
|
||||
"""Embeddings stay global — overrides for embeddings should be ignored."""
|
||||
result = resolve_effective_config(
|
||||
global_config,
|
||||
{"embeddings": {"provider": "openai", "model": "text-embedding-3-small"}},
|
||||
)
|
||||
assert result.embeddings is None # was None in global, stays None
|
||||
|
|
@ -1,960 +0,0 @@
|
|||
"""Tests validating user turn stop strategy behavior during bot speaking scenarios.
|
||||
|
||||
These tests validate the scenarios described in scenarios.md. They demonstrate
|
||||
how the ExternalUserTurnStopStrategy and UserTurnController interact when frames
|
||||
are suppressed (muted) during bot speaking.
|
||||
|
||||
Key concepts:
|
||||
- When the bot is speaking, AlwaysUserMuteStrategy causes the LLMUserAggregator
|
||||
to suppress user frames (UserStartedSpeaking, UserStoppedSpeaking, Transcription, VAD).
|
||||
- The ExternalUserTurnStopStrategy accumulates _text from TranscriptionFrames and
|
||||
triggers a stop when _user_speaking is False and _text is truthy.
|
||||
- The UserTurnController only allows a stop if _user_turn is True (a start must
|
||||
have occurred first). When a stop is rejected, the controller unconditionally
|
||||
resets all stop strategies, clearing any dangling state (e.g. _text).
|
||||
- This unconditional reset prevents stale _text from causing premature stops
|
||||
or contaminating subsequent turns.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
|
||||
import pytest
|
||||
|
||||
from pipecat.frames.frames import (
|
||||
BotStartedSpeakingFrame,
|
||||
BotStoppedSpeakingFrame,
|
||||
EndTaskFrame,
|
||||
Frame,
|
||||
TranscriptionFrame,
|
||||
UserStartedSpeakingFrame,
|
||||
UserStoppedSpeakingFrame,
|
||||
VADUserStartedSpeakingFrame,
|
||||
VADUserStoppedSpeakingFrame,
|
||||
)
|
||||
from pipecat.pipeline.pipeline import Pipeline
|
||||
from pipecat.pipeline.runner import PipelineRunner
|
||||
from pipecat.pipeline.task import PipelineParams, PipelineTask
|
||||
from pipecat.processors.aggregators.llm_context import LLMContext
|
||||
from pipecat.processors.aggregators.llm_response_universal import (
|
||||
LLMAssistantAggregatorParams,
|
||||
LLMContextAggregatorPair,
|
||||
LLMUserAggregatorParams,
|
||||
)
|
||||
from pipecat.processors.frame_processor import FrameDirection, FrameProcessor
|
||||
from pipecat.tests import MockLLMService
|
||||
from pipecat.turns.user_mute import AlwaysUserMuteStrategy
|
||||
from pipecat.turns.user_start import VADUserTurnStartStrategy
|
||||
from pipecat.turns.user_stop import ExternalUserTurnStopStrategy
|
||||
from pipecat.turns.user_turn_strategies import UserTurnStrategies
|
||||
from pipecat.utils.time import time_now_iso8601
|
||||
|
||||
# Short timeout for faster tests
|
||||
STOP_STRATEGY_TIMEOUT = 0.15
|
||||
# Delay to allow async processing
|
||||
ASYNC_DELAY = 0.05
|
||||
# Delay to wait for stop strategy timeout to fire
|
||||
TIMEOUT_WAIT = STOP_STRATEGY_TIMEOUT + 0.1
|
||||
|
||||
|
||||
class FrameInjector(FrameProcessor):
|
||||
"""Simple processor that can inject frames into the pipeline."""
|
||||
|
||||
async def process_frame(self, frame: Frame, direction: FrameDirection):
|
||||
await super().process_frame(frame, direction)
|
||||
await self.push_frame(frame, direction)
|
||||
|
||||
async def inject(
|
||||
self, frame: Frame, direction: FrameDirection = FrameDirection.DOWNSTREAM
|
||||
):
|
||||
"""Inject a frame into the pipeline."""
|
||||
await self.push_frame(frame, direction)
|
||||
|
||||
|
||||
def _build_components(llm_steps=None):
|
||||
"""Build pipeline components for testing.
|
||||
|
||||
Uses:
|
||||
- VADUserTurnStartStrategy: turn starts only when VADUserStartedSpeakingFrame arrives
|
||||
- ExternalUserTurnStopStrategy: turn stops based on UserStoppedSpeakingFrame + _text
|
||||
- AlwaysUserMuteStrategy: suppresses user frames while bot is speaking
|
||||
|
||||
Returns a tuple of (injector, user_aggregator, stop_strategy, turn_controller, mock_llm, pipeline).
|
||||
"""
|
||||
context = LLMContext()
|
||||
|
||||
stop_strategy = ExternalUserTurnStopStrategy(timeout=STOP_STRATEGY_TIMEOUT)
|
||||
|
||||
user_turn_strategies = UserTurnStrategies(
|
||||
start=[VADUserTurnStartStrategy()],
|
||||
stop=[stop_strategy],
|
||||
)
|
||||
|
||||
user_params = LLMUserAggregatorParams(
|
||||
user_turn_strategies=user_turn_strategies,
|
||||
user_mute_strategies=[AlwaysUserMuteStrategy()],
|
||||
)
|
||||
assistant_params = LLMAssistantAggregatorParams(expect_stripped_words=True)
|
||||
|
||||
context_aggregator = LLMContextAggregatorPair(
|
||||
context, assistant_params=assistant_params, user_params=user_params
|
||||
)
|
||||
user_agg = context_aggregator.user()
|
||||
assistant_agg = context_aggregator.assistant()
|
||||
|
||||
if llm_steps is None:
|
||||
llm_steps = [
|
||||
MockLLMService.create_text_chunks(text="Response 1"),
|
||||
MockLLMService.create_text_chunks(text="Response 2"),
|
||||
MockLLMService.create_text_chunks(text="Response 3"),
|
||||
]
|
||||
mock_llm = MockLLMService(mock_steps=llm_steps, chunk_delay=0.001)
|
||||
|
||||
injector = FrameInjector()
|
||||
pipeline = Pipeline([injector, user_agg, mock_llm, assistant_agg])
|
||||
|
||||
turn_controller = user_agg._user_turn_controller
|
||||
|
||||
return (
|
||||
injector,
|
||||
user_agg,
|
||||
stop_strategy,
|
||||
turn_controller,
|
||||
mock_llm,
|
||||
context,
|
||||
pipeline,
|
||||
)
|
||||
|
||||
|
||||
async def _run_scenario(pipeline, inject_fn):
|
||||
"""Run a pipeline with a frame injection coroutine."""
|
||||
task = PipelineTask(pipeline, params=PipelineParams(), enable_rtvi=False)
|
||||
runner = PipelineRunner()
|
||||
|
||||
async def run():
|
||||
await runner.run(task)
|
||||
|
||||
async def inject():
|
||||
# Wait for pipeline to start (StartFrame to propagate)
|
||||
await asyncio.sleep(ASYNC_DELAY)
|
||||
await inject_fn()
|
||||
|
||||
await asyncio.gather(run(), inject())
|
||||
|
||||
|
||||
async def _inject_user_turn(injector, text, delay=ASYNC_DELAY):
|
||||
"""Inject a complete user turn: VAD start + external start + transcription + external stop.
|
||||
|
||||
This simulates what happens in a real pipeline when the user speaks:
|
||||
1. VAD detects speech -> VADUserStartedSpeakingFrame (triggers turn start)
|
||||
2. External processor sends UserStartedSpeakingFrame (stop strategy tracks _user_speaking)
|
||||
3. STT produces TranscriptionFrame (stop strategy accumulates _text)
|
||||
4. External processor sends UserStoppedSpeakingFrame (stop strategy triggers stop)
|
||||
"""
|
||||
await injector.inject(VADUserStartedSpeakingFrame())
|
||||
await asyncio.sleep(0)
|
||||
await injector.inject(UserStartedSpeakingFrame())
|
||||
await asyncio.sleep(0)
|
||||
await injector.inject(UserStoppedSpeakingFrame())
|
||||
await asyncio.sleep(delay)
|
||||
await injector.inject(TranscriptionFrame(text, "user-1", time_now_iso8601()))
|
||||
|
||||
|
||||
class TestUserTurnStopScenarios:
|
||||
"""Test scenarios from scenarios.md.
|
||||
|
||||
Each test simulates a specific frame ordering to validate the interaction
|
||||
between ExternalUserTurnStopStrategy and UserTurnController, particularly
|
||||
around frame suppression during bot speaking.
|
||||
"""
|
||||
|
||||
# =========================================================================
|
||||
# Scenario 1 (✅): All frames suppressed during bot speaking
|
||||
#
|
||||
# BotStartedSpeaking (muted)
|
||||
# UserStartedSpeaking (suppressed)
|
||||
# TranscriptionFrame (suppressed)
|
||||
# UserStoppedSpeaking (suppressed)
|
||||
# BotStoppedSpeaking (unmuted)
|
||||
#
|
||||
# Stop strategy _text is empty because TranscriptionFrame was suppressed.
|
||||
# =========================================================================
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_scenario_1_all_suppressed_then_bot_stops(self):
|
||||
"""All user frames suppressed during bot speaking, then bot stops.
|
||||
|
||||
Expected: _text is empty, no turn triggered, clean state.
|
||||
Second turn works correctly.
|
||||
"""
|
||||
injector, user_agg, stop_strategy, turn_ctrl, mock_llm, context, pipeline = (
|
||||
_build_components()
|
||||
)
|
||||
|
||||
async def inject():
|
||||
# === Turn 1: Bot speaking, all user frames suppressed ===
|
||||
await injector.inject(BotStartedSpeakingFrame())
|
||||
await asyncio.sleep(ASYNC_DELAY)
|
||||
|
||||
# These are all suppressed by mute
|
||||
await injector.inject(VADUserStartedSpeakingFrame())
|
||||
await asyncio.sleep(0)
|
||||
await injector.inject(UserStartedSpeakingFrame())
|
||||
await asyncio.sleep(0)
|
||||
await injector.inject(
|
||||
TranscriptionFrame("hello", "user-1", time_now_iso8601())
|
||||
)
|
||||
await asyncio.sleep(0)
|
||||
await injector.inject(UserStoppedSpeakingFrame())
|
||||
await asyncio.sleep(0)
|
||||
await injector.inject(VADUserStoppedSpeakingFrame())
|
||||
await asyncio.sleep(ASYNC_DELAY)
|
||||
|
||||
await injector.inject(BotStoppedSpeakingFrame())
|
||||
await asyncio.sleep(TIMEOUT_WAIT)
|
||||
|
||||
# Assert: _text should be empty (all frames suppressed)
|
||||
assert stop_strategy._text == "", (
|
||||
f"Expected empty _text after all frames suppressed, got '{stop_strategy._text}'"
|
||||
)
|
||||
assert not turn_ctrl._user_turn, "Expected _user_turn to be False"
|
||||
|
||||
# === Turn 2: Normal turn should work correctly ===
|
||||
await _inject_user_turn(injector, "second turn text")
|
||||
await asyncio.sleep(TIMEOUT_WAIT)
|
||||
|
||||
# Assert: turn completed, _text cleared by reset
|
||||
assert stop_strategy._text == "", (
|
||||
f"Expected empty _text after clean turn, got '{stop_strategy._text}'"
|
||||
)
|
||||
assert not turn_ctrl._user_turn, (
|
||||
"Expected _user_turn to be False after turn"
|
||||
)
|
||||
assert mock_llm.get_current_step() == 1, (
|
||||
f"Expected 1 LLM call (turn 2 only), got {mock_llm.get_current_step()}"
|
||||
)
|
||||
|
||||
await injector.inject(EndTaskFrame(), direction=FrameDirection.UPSTREAM)
|
||||
|
||||
await _run_scenario(pipeline, inject)
|
||||
|
||||
# =========================================================================
|
||||
# Scenario 2 (✅): User frames suppressed, user stops after bot stops
|
||||
#
|
||||
# BotStartedSpeaking (muted)
|
||||
# UserStartedSpeaking (suppressed)
|
||||
# TranscriptionFrame (suppressed)
|
||||
# BotStoppedSpeaking (unmuted)
|
||||
# UserStoppedSpeaking (stop strategy has no _text -> no trigger)
|
||||
# =========================================================================
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_scenario_2_user_stops_after_bot_stops_no_text(self):
|
||||
"""User stops speaking after bot stops, but transcription was suppressed.
|
||||
|
||||
Expected: _text is empty because transcription was suppressed.
|
||||
UserStoppedSpeaking doesn't trigger stop (no _text).
|
||||
"""
|
||||
injector, user_agg, stop_strategy, turn_ctrl, mock_llm, context, pipeline = (
|
||||
_build_components()
|
||||
)
|
||||
|
||||
async def inject():
|
||||
# === Turn 1: Bot speaking, user frames partially suppressed ===
|
||||
await injector.inject(BotStartedSpeakingFrame())
|
||||
await asyncio.sleep(ASYNC_DELAY)
|
||||
|
||||
# Suppressed during bot speaking
|
||||
await injector.inject(VADUserStartedSpeakingFrame())
|
||||
await asyncio.sleep(0)
|
||||
await injector.inject(UserStartedSpeakingFrame())
|
||||
await asyncio.sleep(0)
|
||||
await injector.inject(
|
||||
TranscriptionFrame("hello", "user-1", time_now_iso8601())
|
||||
)
|
||||
await asyncio.sleep(ASYNC_DELAY)
|
||||
|
||||
# Bot stops -> unmuted
|
||||
await injector.inject(BotStoppedSpeakingFrame())
|
||||
await asyncio.sleep(ASYNC_DELAY)
|
||||
|
||||
# UserStoppedSpeaking arrives after unmute, but _text is empty
|
||||
await injector.inject(UserStoppedSpeakingFrame())
|
||||
await asyncio.sleep(TIMEOUT_WAIT)
|
||||
|
||||
# Assert: _text empty (TranscriptionFrame was suppressed)
|
||||
assert stop_strategy._text == "", (
|
||||
f"Expected empty _text, got '{stop_strategy._text}'"
|
||||
)
|
||||
assert not turn_ctrl._user_turn, "Expected _user_turn to be False"
|
||||
|
||||
# === Turn 2: Normal turn should work ===
|
||||
await _inject_user_turn(injector, "second turn")
|
||||
await asyncio.sleep(TIMEOUT_WAIT)
|
||||
|
||||
assert stop_strategy._text == "", "Expected clean _text after turn 2"
|
||||
assert mock_llm.get_current_step() == 1, (
|
||||
f"Expected 1 LLM call, got {mock_llm.get_current_step()}"
|
||||
)
|
||||
|
||||
await injector.inject(EndTaskFrame(), direction=FrameDirection.UPSTREAM)
|
||||
|
||||
await _run_scenario(pipeline, inject)
|
||||
|
||||
# =========================================================================
|
||||
# Scenario 3 (✅ after fix): Transcription arrives after unmute
|
||||
#
|
||||
# BotStartedSpeaking (muted)
|
||||
# UserStartedSpeaking (suppressed)
|
||||
# BotStoppedSpeaking (unmuted)
|
||||
# TranscriptionFrame -> stop strategy _text = "hello"
|
||||
# UserStoppedSpeaking -> stop strategy triggers (text truthy, not speaking)
|
||||
# Turn controller ignores (user_turn is False), BUT unconditionally
|
||||
# resets stop strategies -> _text cleared. No dangling state.
|
||||
# =========================================================================
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_scenario_3_transcription_after_unmute_text_cleared(self):
|
||||
"""Transcription arrives after bot stops but turn was never started.
|
||||
|
||||
The VADUserStartedSpeakingFrame was suppressed, so no turn started.
|
||||
But TranscriptionFrame arrives after unmute and accumulates _text.
|
||||
The stop strategy triggers, but the turn controller rejects it
|
||||
(no active turn). The unconditional reset clears _text, preventing
|
||||
any dangling state from contaminating subsequent turns.
|
||||
"""
|
||||
injector, user_agg, stop_strategy, turn_ctrl, mock_llm, context, pipeline = (
|
||||
_build_components()
|
||||
)
|
||||
|
||||
async def inject():
|
||||
# === Turn 1: Rejected stop with unconditional reset ===
|
||||
await injector.inject(BotStartedSpeakingFrame())
|
||||
await asyncio.sleep(ASYNC_DELAY)
|
||||
|
||||
# Suppressed: VAD and UserStartedSpeaking
|
||||
await injector.inject(VADUserStartedSpeakingFrame())
|
||||
await asyncio.sleep(0)
|
||||
await injector.inject(UserStartedSpeakingFrame())
|
||||
await asyncio.sleep(ASYNC_DELAY)
|
||||
|
||||
# Bot stops -> unmuted
|
||||
await injector.inject(BotStoppedSpeakingFrame())
|
||||
await asyncio.sleep(ASYNC_DELAY)
|
||||
|
||||
# Install spy on trigger_user_turn_stopped to track every call
|
||||
# and the _user_turn state at the time of each call.
|
||||
trigger_stop_calls = []
|
||||
original_trigger_stop = stop_strategy.trigger_user_turn_stopped
|
||||
|
||||
async def spy_trigger_stop():
|
||||
trigger_stop_calls.append(turn_ctrl._user_turn)
|
||||
await original_trigger_stop()
|
||||
|
||||
stop_strategy.trigger_user_turn_stopped = spy_trigger_stop
|
||||
|
||||
# TranscriptionFrame arrives AFTER unmute -> reaches stop strategy
|
||||
await injector.inject(
|
||||
TranscriptionFrame("hello", "user-1", time_now_iso8601())
|
||||
)
|
||||
await asyncio.sleep(ASYNC_DELAY)
|
||||
|
||||
# UserStoppedSpeaking arrives AFTER unmute
|
||||
# Stop strategy: _user_speaking is False (UserStartedSpeaking was suppressed),
|
||||
# _text is "hello" -> triggers stop via _handle_user_stopped_speaking
|
||||
# Turn controller: _user_turn is False -> rejects, but resets -> _text cleared
|
||||
await injector.inject(UserStoppedSpeakingFrame())
|
||||
await asyncio.sleep(ASYNC_DELAY)
|
||||
|
||||
# Call #1: _handle_user_stopped_speaking -> _maybe_trigger_user_turn_stopped
|
||||
assert len(trigger_stop_calls) == 1, (
|
||||
f"Expected exactly 1 trigger_user_turn_stopped call from "
|
||||
f"_handle_user_stopped_speaking, got {len(trigger_stop_calls)}"
|
||||
)
|
||||
assert trigger_stop_calls[0] is False, (
|
||||
"Expected _user_turn=False when _handle_user_stopped_speaking triggered stop"
|
||||
)
|
||||
|
||||
# Wait for _task_handler timeout period
|
||||
await asyncio.sleep(TIMEOUT_WAIT)
|
||||
|
||||
# The unconditional reset cleared _text after the rejected stop,
|
||||
# so the timeout's _maybe_trigger_user_turn_stopped sees _text="" and
|
||||
# does NOT call trigger_user_turn_stopped again.
|
||||
assert len(trigger_stop_calls) == 1, (
|
||||
f"Expected no additional trigger_user_turn_stopped calls after "
|
||||
f"reset cleared _text, but got {len(trigger_stop_calls)} total call(s)"
|
||||
)
|
||||
|
||||
# Restore original method
|
||||
stop_strategy.trigger_user_turn_stopped = original_trigger_stop
|
||||
|
||||
# Transcript is not suppressed, so we should have hello in user aggregator
|
||||
assert user_agg._aggregation[0].text == "hello"
|
||||
|
||||
# Assert: _text is cleared by the unconditional reset (no dangling state)
|
||||
assert stop_strategy._text == "", (
|
||||
f"Expected empty _text after unconditional reset, got '{stop_strategy._text}'"
|
||||
)
|
||||
assert not turn_ctrl._user_turn, (
|
||||
"Expected _user_turn to be False (turn was never started)"
|
||||
)
|
||||
# No LLM call should have happened
|
||||
assert mock_llm.get_current_step() == 0, (
|
||||
f"Expected 0 LLM calls, got {mock_llm.get_current_step()}"
|
||||
)
|
||||
|
||||
# === Turn 2: No premature stop, normal flow ===
|
||||
# _text is clean, so UserStoppedSpeaking won't trigger a premature stop.
|
||||
# The turn completes normally when the timeout fires after TranscriptionFrame.
|
||||
# The aggregator still has dangling "hello" from turn 1, which gets
|
||||
# combined with turn 2's "world" — this is acceptable behavior.
|
||||
await _inject_user_turn(injector, "world")
|
||||
await asyncio.sleep(TIMEOUT_WAIT)
|
||||
|
||||
assert stop_strategy._text == "", (
|
||||
f"Expected clean _text after normal turn, got '{stop_strategy._text}'"
|
||||
)
|
||||
assert mock_llm.get_current_step() == 1, (
|
||||
f"Expected 1 LLM call (normal turn), got {mock_llm.get_current_step()}"
|
||||
)
|
||||
|
||||
# The LLM received both "hello" (dangling in aggregator from turn 1)
|
||||
# and "world" (from turn 2). This is acceptable — the aggregator's
|
||||
# _aggregation is a separate concern from the stop strategy's _text.
|
||||
messages = context.messages
|
||||
user_messages = [m for m in messages if m.get("role") == "user"]
|
||||
assert len(user_messages) == 1, (
|
||||
f"Expected 1 user message, got {len(user_messages)}"
|
||||
)
|
||||
user_text = user_messages[0]["content"]
|
||||
assert "hello" in user_text, (
|
||||
f"Expected 'hello' (from aggregator) in user message, got: '{user_text}'"
|
||||
)
|
||||
assert "world" in user_text, (
|
||||
f"Expected 'world' (from turn 2) in user message, got: '{user_text}'"
|
||||
)
|
||||
|
||||
await injector.inject(EndTaskFrame(), direction=FrameDirection.UPSTREAM)
|
||||
|
||||
await _run_scenario(pipeline, inject)
|
||||
|
||||
# =========================================================================
|
||||
# Scenario 4 (✅): User speaks after bot stops -> normal flow
|
||||
#
|
||||
# BotStartedSpeaking (muted)
|
||||
# BotStoppedSpeaking (unmuted)
|
||||
# UserStartedSpeaking (triggers interruption/turn start)
|
||||
# TranscriptionFrame
|
||||
# UserStoppedSpeaking
|
||||
#
|
||||
# Turn starts because VAD frame is not suppressed. Everything works.
|
||||
# =========================================================================
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_scenario_4_user_speaks_after_bot_stops(self):
|
||||
"""User speaks after bot stops speaking. Normal flow, everything works.
|
||||
|
||||
All frames arrive after unmute, so VAD triggers turn start normally.
|
||||
"""
|
||||
injector, user_agg, stop_strategy, turn_ctrl, mock_llm, context, pipeline = (
|
||||
_build_components()
|
||||
)
|
||||
|
||||
async def inject():
|
||||
# === Turn 1: Bot speaks, then user speaks after bot stops ===
|
||||
await injector.inject(BotStartedSpeakingFrame())
|
||||
await asyncio.sleep(ASYNC_DELAY)
|
||||
await injector.inject(BotStoppedSpeakingFrame())
|
||||
await asyncio.sleep(ASYNC_DELAY)
|
||||
|
||||
# Normal user turn after bot stopped
|
||||
await _inject_user_turn(injector, "hello after bot")
|
||||
await asyncio.sleep(TIMEOUT_WAIT)
|
||||
|
||||
# Assert: clean state
|
||||
assert stop_strategy._text == "", (
|
||||
f"Expected empty _text after clean turn, got '{stop_strategy._text}'"
|
||||
)
|
||||
assert not turn_ctrl._user_turn, "Expected _user_turn False after turn"
|
||||
assert mock_llm.get_current_step() == 1, (
|
||||
f"Expected 1 LLM call, got {mock_llm.get_current_step()}"
|
||||
)
|
||||
|
||||
# === Turn 2: Another normal turn ===
|
||||
await _inject_user_turn(injector, "second turn")
|
||||
await asyncio.sleep(TIMEOUT_WAIT)
|
||||
|
||||
assert stop_strategy._text == "", "Expected clean _text after turn 2"
|
||||
assert mock_llm.get_current_step() == 2, (
|
||||
f"Expected 2 LLM calls, got {mock_llm.get_current_step()}"
|
||||
)
|
||||
|
||||
# Verify clean context - each turn should be separate
|
||||
user_messages = [m for m in context.messages if m.get("role") == "user"]
|
||||
assert len(user_messages) == 2, (
|
||||
f"Expected 2 user messages (one per turn), got {len(user_messages)}"
|
||||
)
|
||||
|
||||
await injector.inject(EndTaskFrame(), direction=FrameDirection.UPSTREAM)
|
||||
|
||||
await _run_scenario(pipeline, inject)
|
||||
|
||||
# =========================================================================
|
||||
# Scenario 5 (✅): Late transcription - all suppressed
|
||||
#
|
||||
# BotStartedSpeaking (muted)
|
||||
# UserStartedSpeaking (suppressed)
|
||||
# UserStoppedSpeaking (suppressed)
|
||||
# TranscriptionFrame (suppressed) <- late, but still during bot speaking
|
||||
# BotStoppedSpeaking (unmuted)
|
||||
#
|
||||
# Everything suppressed, _text empty. Clean state.
|
||||
# =========================================================================
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_scenario_5_late_transcription_all_suppressed(self):
|
||||
"""Late transcription arrives during bot speaking. All suppressed.
|
||||
|
||||
Even though transcription is late, it still arrives before BotStoppedSpeaking
|
||||
so it's still muted. Clean state.
|
||||
"""
|
||||
injector, user_agg, stop_strategy, turn_ctrl, mock_llm, context, pipeline = (
|
||||
_build_components()
|
||||
)
|
||||
|
||||
async def inject():
|
||||
# === Turn 1: Late transcription, but all still suppressed ===
|
||||
await injector.inject(BotStartedSpeakingFrame())
|
||||
await asyncio.sleep(ASYNC_DELAY)
|
||||
|
||||
await injector.inject(VADUserStartedSpeakingFrame())
|
||||
await asyncio.sleep(0)
|
||||
await injector.inject(UserStartedSpeakingFrame())
|
||||
await asyncio.sleep(0)
|
||||
await injector.inject(UserStoppedSpeakingFrame())
|
||||
await asyncio.sleep(0)
|
||||
await injector.inject(VADUserStoppedSpeakingFrame())
|
||||
await asyncio.sleep(0)
|
||||
# Late transcription - but still during bot speaking
|
||||
await injector.inject(
|
||||
TranscriptionFrame("late hello", "user-1", time_now_iso8601())
|
||||
)
|
||||
await asyncio.sleep(ASYNC_DELAY)
|
||||
|
||||
await injector.inject(BotStoppedSpeakingFrame())
|
||||
await asyncio.sleep(TIMEOUT_WAIT)
|
||||
|
||||
# Assert: all suppressed, clean state
|
||||
assert stop_strategy._text == "", (
|
||||
f"Expected empty _text, got '{stop_strategy._text}'"
|
||||
)
|
||||
assert not turn_ctrl._user_turn
|
||||
|
||||
# === Turn 2: Normal turn works ===
|
||||
await _inject_user_turn(injector, "clean turn")
|
||||
await asyncio.sleep(TIMEOUT_WAIT)
|
||||
|
||||
assert stop_strategy._text == ""
|
||||
assert mock_llm.get_current_step() == 1
|
||||
|
||||
await injector.inject(EndTaskFrame(), direction=FrameDirection.UPSTREAM)
|
||||
|
||||
await _run_scenario(pipeline, inject)
|
||||
|
||||
# =========================================================================
|
||||
# Scenario 6 (✅ after fix): Late transcription arrives after bot stops
|
||||
#
|
||||
# BotStartedSpeaking (muted)
|
||||
# UserStartedSpeaking (suppressed)
|
||||
# UserStoppedSpeaking (suppressed)
|
||||
# BotStoppedSpeaking (unmuted)
|
||||
# TranscriptionFrame -> reaches stop strategy, _text = "late hello"
|
||||
#
|
||||
# Stop strategy timeout fires: _user_speaking is False (from initial state,
|
||||
# UserStartedSpeaking was suppressed), _text truthy -> triggers stop.
|
||||
# Turn controller: _user_turn False -> rejects, but unconditionally resets
|
||||
# -> _text cleared. No dangling state.
|
||||
# =========================================================================
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_scenario_6_late_transcription_after_unmute_text_cleared(self):
|
||||
"""Late transcription arrives after bot stops. No turn was started.
|
||||
|
||||
UserStartedSpeaking was suppressed so _user_turn never started.
|
||||
The late TranscriptionFrame accumulates _text after unmute.
|
||||
The stop strategy timeout triggers, but controller rejects it.
|
||||
The unconditional reset clears _text, preventing dangling state.
|
||||
"""
|
||||
injector, user_agg, stop_strategy, turn_ctrl, mock_llm, context, pipeline = (
|
||||
_build_components()
|
||||
)
|
||||
|
||||
async def inject():
|
||||
# === Turn 1: Late transcription scenario ===
|
||||
await injector.inject(BotStartedSpeakingFrame())
|
||||
await asyncio.sleep(ASYNC_DELAY)
|
||||
|
||||
# Suppressed
|
||||
await injector.inject(VADUserStartedSpeakingFrame())
|
||||
await asyncio.sleep(0)
|
||||
await injector.inject(UserStartedSpeakingFrame())
|
||||
await asyncio.sleep(0)
|
||||
await injector.inject(UserStoppedSpeakingFrame())
|
||||
await asyncio.sleep(0)
|
||||
await injector.inject(VADUserStoppedSpeakingFrame())
|
||||
await asyncio.sleep(ASYNC_DELAY)
|
||||
|
||||
# Bot stops -> unmuted
|
||||
await injector.inject(BotStoppedSpeakingFrame())
|
||||
await asyncio.sleep(ASYNC_DELAY)
|
||||
|
||||
# Install spy on trigger_user_turn_stopped to track calls
|
||||
trigger_stop_calls = []
|
||||
original_trigger_stop = stop_strategy.trigger_user_turn_stopped
|
||||
|
||||
async def spy_trigger_stop():
|
||||
trigger_stop_calls.append(turn_ctrl._user_turn)
|
||||
await original_trigger_stop()
|
||||
|
||||
stop_strategy.trigger_user_turn_stopped = spy_trigger_stop
|
||||
|
||||
# Late transcription arrives after unmute
|
||||
await injector.inject(
|
||||
TranscriptionFrame("late hello", "user-1", time_now_iso8601())
|
||||
)
|
||||
|
||||
# No UserStoppedSpeakingFrame in this scenario — the stop is
|
||||
# triggered ONLY by the _task_handler timeout path.
|
||||
await asyncio.sleep(TIMEOUT_WAIT)
|
||||
|
||||
# The _task_handler timeout fired _maybe_trigger_user_turn_stopped:
|
||||
# _user_speaking=False (UserStartedSpeaking was suppressed),
|
||||
# _text="late hello" -> trigger_user_turn_stopped called
|
||||
# Turn controller: _user_turn=False -> rejects, but resets -> _text cleared
|
||||
assert len(trigger_stop_calls) == 1, (
|
||||
f"Expected exactly 1 trigger_user_turn_stopped call from "
|
||||
f"_task_handler timeout, got {len(trigger_stop_calls)}"
|
||||
)
|
||||
assert trigger_stop_calls[0] is False, (
|
||||
"Expected _user_turn=False when timeout triggered stop"
|
||||
)
|
||||
|
||||
# Restore original method
|
||||
stop_strategy.trigger_user_turn_stopped = original_trigger_stop
|
||||
|
||||
# Transcript is not suppressed, so we should have late hello in user aggregator
|
||||
assert user_agg._aggregation[0].text == "late hello"
|
||||
|
||||
# Assert: _text is cleared by the unconditional reset (no dangling state)
|
||||
assert stop_strategy._text == "", (
|
||||
f"Expected empty _text after unconditional reset, got '{stop_strategy._text}'"
|
||||
)
|
||||
assert not turn_ctrl._user_turn, "Turn should not have started"
|
||||
assert mock_llm.get_current_step() == 0, "No LLM call expected"
|
||||
|
||||
# === Turn 2: No premature stop, normal flow ===
|
||||
# _text is clean, so no premature stop occurs.
|
||||
# The turn completes normally when the timeout fires after TranscriptionFrame.
|
||||
# The aggregator still has dangling "late hello" from turn 1, which gets
|
||||
# combined with turn 2's "real speech" — this is acceptable behavior.
|
||||
await _inject_user_turn(injector, "real speech")
|
||||
await asyncio.sleep(TIMEOUT_WAIT)
|
||||
|
||||
assert stop_strategy._text == "", (
|
||||
f"Expected clean _text after normal turn, got '{stop_strategy._text}'"
|
||||
)
|
||||
assert mock_llm.get_current_step() == 1, (
|
||||
f"Expected 1 LLM call (normal turn), got {mock_llm.get_current_step()}"
|
||||
)
|
||||
|
||||
# The LLM received both "late hello" (dangling in aggregator from turn 1)
|
||||
# and "real speech" (from turn 2).
|
||||
user_messages = [m for m in context.messages if m.get("role") == "user"]
|
||||
assert len(user_messages) == 1, (
|
||||
f"Expected 1 user message, got {len(user_messages)}"
|
||||
)
|
||||
user_text = user_messages[0]["content"]
|
||||
assert "late hello" in user_text, (
|
||||
f"Expected 'late hello' (from aggregator) in user message, got: '{user_text}'"
|
||||
)
|
||||
assert "real speech" in user_text, (
|
||||
f"Expected 'real speech' (from turn 2) in user message, got: '{user_text}'"
|
||||
)
|
||||
|
||||
await injector.inject(EndTaskFrame(), direction=FrameDirection.UPSTREAM)
|
||||
|
||||
await _run_scenario(pipeline, inject)
|
||||
|
||||
# =========================================================================
|
||||
# Scenario 7 (✅ after fix): Late transcription - user stops before transcription
|
||||
#
|
||||
# BotStartedSpeaking (muted)
|
||||
# UserStartedSpeaking (suppressed)
|
||||
# BotStoppedSpeaking (unmuted)
|
||||
# UserStoppedSpeaking (no _text yet -> no trigger from _handle_user_stopped)
|
||||
# TranscriptionFrame -> _text = "late", timeout triggers stop
|
||||
#
|
||||
# Turn controller: _user_turn False -> rejects, but unconditionally resets
|
||||
# -> _text cleared. No dangling state.
|
||||
# =========================================================================
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_scenario_7_late_transcription_after_user_stops_text_cleared(self):
|
||||
"""User stops speaking, then late transcription arrives. No turn started.
|
||||
|
||||
UserStoppedSpeaking arrives first (no _text yet, so no trigger).
|
||||
Then TranscriptionFrame arrives (sets _text). The timeout fires and
|
||||
triggers stop, but controller rejects it. The unconditional reset
|
||||
clears _text, preventing dangling state.
|
||||
"""
|
||||
injector, user_agg, stop_strategy, turn_ctrl, mock_llm, context, pipeline = (
|
||||
_build_components()
|
||||
)
|
||||
|
||||
async def inject():
|
||||
# === Turn 1: Late transcription after user stops ===
|
||||
await injector.inject(BotStartedSpeakingFrame())
|
||||
await asyncio.sleep(ASYNC_DELAY)
|
||||
|
||||
# Suppressed
|
||||
await injector.inject(VADUserStartedSpeakingFrame())
|
||||
await asyncio.sleep(0)
|
||||
await injector.inject(UserStartedSpeakingFrame())
|
||||
await asyncio.sleep(ASYNC_DELAY)
|
||||
|
||||
# Bot stops -> unmuted
|
||||
await injector.inject(BotStoppedSpeakingFrame())
|
||||
await asyncio.sleep(ASYNC_DELAY)
|
||||
|
||||
# UserStoppedSpeaking arrives after unmute, but _text is still empty
|
||||
# -> _maybe_trigger_user_turn_stopped: _text is "" -> no trigger
|
||||
await injector.inject(UserStoppedSpeakingFrame())
|
||||
await asyncio.sleep(ASYNC_DELAY)
|
||||
|
||||
# Late transcription arrives AFTER user stopped
|
||||
await injector.inject(
|
||||
TranscriptionFrame("late text", "user-1", time_now_iso8601())
|
||||
)
|
||||
# Wait for timeout to fire
|
||||
await asyncio.sleep(TIMEOUT_WAIT)
|
||||
|
||||
# Transcript is not suppressed, so we should have late text in user aggregator
|
||||
assert user_agg._aggregation[0].text == "late text"
|
||||
|
||||
# Assert: _text is cleared by the unconditional reset
|
||||
# The timeout fired _maybe_trigger_user_turn_stopped:
|
||||
# _user_speaking=False (was never set, UserStartedSpeaking suppressed),
|
||||
# _text="late text" -> triggers stop
|
||||
# Turn controller: _user_turn=False -> rejects, but resets -> _text cleared
|
||||
assert stop_strategy._text == "", (
|
||||
f"Expected empty _text after unconditional reset, got '{stop_strategy._text}'"
|
||||
)
|
||||
assert not turn_ctrl._user_turn
|
||||
assert mock_llm.get_current_step() == 0
|
||||
|
||||
# === Turn 2: No premature stop, normal flow ===
|
||||
# _text is clean, so no premature stop occurs.
|
||||
# The turn completes normally when the timeout fires after TranscriptionFrame.
|
||||
# The aggregator still has dangling "late text" from turn 1, which gets
|
||||
# combined with turn 2's "next speech" — this is acceptable behavior.
|
||||
await _inject_user_turn(injector, "next speech")
|
||||
await asyncio.sleep(TIMEOUT_WAIT)
|
||||
|
||||
assert stop_strategy._text == "", (
|
||||
f"Expected clean _text after normal turn, got '{stop_strategy._text}'"
|
||||
)
|
||||
assert mock_llm.get_current_step() == 1
|
||||
|
||||
# The LLM received both "late text" (dangling in aggregator from turn 1)
|
||||
# and "next speech" (from turn 2).
|
||||
user_messages = [m for m in context.messages if m.get("role") == "user"]
|
||||
assert len(user_messages) == 1
|
||||
user_text = user_messages[0]["content"]
|
||||
assert "late text" in user_text, (
|
||||
f"Expected 'late text' (from aggregator) in context, got: '{user_text}'"
|
||||
)
|
||||
assert "next speech" in user_text, (
|
||||
f"Expected 'next speech' (from turn 2) in context, got: '{user_text}'"
|
||||
)
|
||||
|
||||
await injector.inject(EndTaskFrame(), direction=FrameDirection.UPSTREAM)
|
||||
|
||||
await _run_scenario(pipeline, inject)
|
||||
|
||||
# =========================================================================
|
||||
# Scenario 8 (✅): Late transcription - user speaks after bot stops
|
||||
#
|
||||
# BotStartedSpeaking (muted)
|
||||
# BotStoppedSpeaking (unmuted)
|
||||
# UserStartedSpeaking (not suppressed -> turn starts, start strategies reset)
|
||||
# UserStoppedSpeaking (no _text -> no trigger)
|
||||
# TranscriptionFrame (timeout triggers stop)
|
||||
#
|
||||
# Turn controller: _user_turn IS True -> allows stop -> resets strategies
|
||||
# Clean state!
|
||||
# =========================================================================
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_scenario_8_late_transcription_user_speaks_after_bot_stops(self):
|
||||
"""User speaks after bot stops, then late transcription arrives.
|
||||
|
||||
Because user spoke after unmute, VAD triggers turn start -> _user_turn=True.
|
||||
When the late transcription triggers the stop, controller allows it and
|
||||
resets strategies. Clean state.
|
||||
"""
|
||||
injector, user_agg, stop_strategy, turn_ctrl, mock_llm, context, pipeline = (
|
||||
_build_components()
|
||||
)
|
||||
|
||||
async def inject():
|
||||
# === Turn 1: Late transcription but user spoke after unmute ===
|
||||
await injector.inject(BotStartedSpeakingFrame())
|
||||
await asyncio.sleep(ASYNC_DELAY)
|
||||
await injector.inject(BotStoppedSpeakingFrame())
|
||||
await asyncio.sleep(ASYNC_DELAY)
|
||||
|
||||
# User speaks AFTER bot stops -> not suppressed
|
||||
await injector.inject(VADUserStartedSpeakingFrame())
|
||||
await asyncio.sleep(0)
|
||||
await injector.inject(UserStartedSpeakingFrame())
|
||||
await asyncio.sleep(ASYNC_DELAY)
|
||||
|
||||
# User stops speaking (no _text yet, so stop strategy doesn't trigger)
|
||||
await injector.inject(UserStoppedSpeakingFrame())
|
||||
await asyncio.sleep(0)
|
||||
await injector.inject(VADUserStoppedSpeakingFrame())
|
||||
await asyncio.sleep(ASYNC_DELAY)
|
||||
|
||||
# Late transcription arrives
|
||||
await injector.inject(
|
||||
TranscriptionFrame("late but ok", "user-1", time_now_iso8601())
|
||||
)
|
||||
# Wait for timeout to trigger stop
|
||||
await asyncio.sleep(TIMEOUT_WAIT)
|
||||
|
||||
# Assert: turn controller allowed the stop, strategies were reset
|
||||
assert stop_strategy._text == "", (
|
||||
f"Expected clean _text after allowed stop, got '{stop_strategy._text}'"
|
||||
)
|
||||
assert not turn_ctrl._user_turn, "Turn should have stopped"
|
||||
assert mock_llm.get_current_step() == 1, (
|
||||
f"Expected 1 LLM call, got {mock_llm.get_current_step()}"
|
||||
)
|
||||
|
||||
# === Turn 2: Clean subsequent turn ===
|
||||
await _inject_user_turn(injector, "clean turn")
|
||||
await asyncio.sleep(TIMEOUT_WAIT)
|
||||
|
||||
assert stop_strategy._text == ""
|
||||
assert mock_llm.get_current_step() == 2
|
||||
|
||||
# Verify each turn is separate in context
|
||||
user_messages = [m for m in context.messages if m.get("role") == "user"]
|
||||
assert len(user_messages) == 2, (
|
||||
f"Expected 2 separate user messages, got {len(user_messages)}"
|
||||
)
|
||||
|
||||
await injector.inject(EndTaskFrame(), direction=FrameDirection.UPSTREAM)
|
||||
|
||||
await _run_scenario(pipeline, inject)
|
||||
|
||||
# =========================================================================
|
||||
# Combined test: validates _text is cleared independently after each
|
||||
# rejected stop, preventing accumulation across muted periods.
|
||||
# =========================================================================
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_text_cleared_independently_across_failed_stops(self):
|
||||
"""Validates _text does not accumulate across multiple failed stop attempts.
|
||||
|
||||
Two consecutive muted periods with late transcriptions each trigger
|
||||
a rejected stop. The unconditional reset clears _text after each
|
||||
rejection, so no accumulation occurs. The subsequent normal turn
|
||||
completes correctly.
|
||||
"""
|
||||
injector, user_agg, stop_strategy, turn_ctrl, mock_llm, context, pipeline = (
|
||||
_build_components()
|
||||
)
|
||||
|
||||
async def inject():
|
||||
# === Muted period 1: _text cleared after rejected stop ===
|
||||
await injector.inject(BotStartedSpeakingFrame())
|
||||
await asyncio.sleep(ASYNC_DELAY)
|
||||
await injector.inject(VADUserStartedSpeakingFrame()) # suppressed
|
||||
await asyncio.sleep(0)
|
||||
await injector.inject(UserStartedSpeakingFrame()) # suppressed
|
||||
await asyncio.sleep(ASYNC_DELAY)
|
||||
await injector.inject(BotStoppedSpeakingFrame())
|
||||
await asyncio.sleep(ASYNC_DELAY)
|
||||
|
||||
# Late transcription after unmute
|
||||
await injector.inject(
|
||||
TranscriptionFrame("first", "user-1", time_now_iso8601())
|
||||
)
|
||||
await asyncio.sleep(0)
|
||||
await injector.inject(UserStoppedSpeakingFrame())
|
||||
await asyncio.sleep(TIMEOUT_WAIT)
|
||||
|
||||
# Transcript is not suppressed, so we should have first in user aggregator
|
||||
assert user_agg._aggregation[0].text == "first"
|
||||
|
||||
# _text is cleared by the unconditional reset after rejected stop
|
||||
assert stop_strategy._text == "", (
|
||||
f"Expected empty _text after unconditional reset, got '{stop_strategy._text}'"
|
||||
)
|
||||
|
||||
# === Muted period 2: _text cleared independently, no accumulation ===
|
||||
await injector.inject(BotStartedSpeakingFrame())
|
||||
await asyncio.sleep(ASYNC_DELAY)
|
||||
await injector.inject(VADUserStartedSpeakingFrame()) # suppressed
|
||||
await asyncio.sleep(0)
|
||||
await injector.inject(UserStartedSpeakingFrame()) # suppressed
|
||||
await asyncio.sleep(ASYNC_DELAY)
|
||||
await injector.inject(BotStoppedSpeakingFrame())
|
||||
await asyncio.sleep(ASYNC_DELAY)
|
||||
|
||||
await injector.inject(
|
||||
TranscriptionFrame("second", "user-1", time_now_iso8601())
|
||||
)
|
||||
await asyncio.sleep(0)
|
||||
await injector.inject(UserStoppedSpeakingFrame())
|
||||
await asyncio.sleep(TIMEOUT_WAIT)
|
||||
|
||||
# _text is cleared again — no accumulation of "first" + "second"
|
||||
assert stop_strategy._text == "", (
|
||||
f"Expected empty _text after second unconditional reset, got '{stop_strategy._text}'"
|
||||
)
|
||||
# Aggregator accumulated both (separate concern, acceptable)
|
||||
assert len(user_agg._aggregation) == 2
|
||||
assert user_agg._aggregation[0].text == "first"
|
||||
assert user_agg._aggregation[1].text == "second"
|
||||
|
||||
# === Turn 3: No premature stop, normal flow ===
|
||||
# _text is clean, so no premature stop occurs.
|
||||
# The turn completes normally when the timeout fires after TranscriptionFrame.
|
||||
# The aggregator has dangling "first" + "second" from muted periods,
|
||||
# which get combined with turn 3's "actual speech".
|
||||
await _inject_user_turn(injector, "actual speech")
|
||||
await asyncio.sleep(TIMEOUT_WAIT)
|
||||
|
||||
assert stop_strategy._text == "", (
|
||||
f"Expected clean _text after normal turn, got '{stop_strategy._text}'"
|
||||
)
|
||||
assert mock_llm.get_current_step() == 1
|
||||
|
||||
# The LLM received all three: "first" + "second" (from aggregator)
|
||||
# and "actual speech" (from turn 3).
|
||||
user_messages = [m for m in context.messages if m.get("role") == "user"]
|
||||
assert len(user_messages) == 1, (
|
||||
f"Expected 1 user message, got {len(user_messages)}"
|
||||
)
|
||||
user_text = user_messages[0]["content"]
|
||||
assert "first" in user_text, f"Expected 'first' in '{user_text}'"
|
||||
assert "second" in user_text, f"Expected 'second' in '{user_text}'"
|
||||
assert "actual speech" in user_text, (
|
||||
f"Expected 'actual speech' in '{user_text}'"
|
||||
)
|
||||
|
||||
await injector.inject(EndTaskFrame(), direction=FrameDirection.UPSTREAM)
|
||||
|
||||
await _run_scenario(pipeline, inject)
|
||||
608
api/tests/test_workflow_versioning.py
Normal file
608
api/tests/test_workflow_versioning.py
Normal file
|
|
@ -0,0 +1,608 @@
|
|||
"""
|
||||
TDD tests for workflow versioning lifecycle.
|
||||
|
||||
Tests the version lifecycle on WorkflowDefinitionModel:
|
||||
- status: draft / published / archived
|
||||
- version_number: sequential per workflow
|
||||
- released_definition_id on WorkflowModel
|
||||
|
||||
Modules under test:
|
||||
- api.db.workflow_client (new versioning methods)
|
||||
- api.db.models (new columns on WorkflowDefinitionModel, WorkflowModel)
|
||||
|
||||
These are DB integration tests using the transactional test session.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
|
||||
from api.db.models import (
|
||||
OrganizationModel,
|
||||
UserModel,
|
||||
)
|
||||
|
||||
# Sample workflow definitions (graph JSON)
|
||||
GRAPH_V1 = {
|
||||
"nodes": [
|
||||
{"id": "1", "type": "startCall", "data": {"name": "Start", "prompt": "Hello"}},
|
||||
{"id": "2", "type": "endCall", "data": {"name": "End", "prompt": "Bye"}},
|
||||
],
|
||||
"edges": [{"id": "e1", "source": "1", "target": "2", "data": {"label": "End"}}],
|
||||
}
|
||||
|
||||
GRAPH_V2 = {
|
||||
"nodes": [
|
||||
{
|
||||
"id": "1",
|
||||
"type": "startCall",
|
||||
"data": {"name": "Start", "prompt": "Hello v2"},
|
||||
},
|
||||
{
|
||||
"id": "2",
|
||||
"type": "agentNode",
|
||||
"data": {"name": "Agent", "prompt": "Collect info"},
|
||||
},
|
||||
{"id": "3", "type": "endCall", "data": {"name": "End", "prompt": "Bye"}},
|
||||
],
|
||||
"edges": [
|
||||
{"id": "e1", "source": "1", "target": "2", "data": {"label": "Collect"}},
|
||||
{"id": "e2", "source": "2", "target": "3", "data": {"label": "End"}},
|
||||
],
|
||||
}
|
||||
|
||||
GRAPH_V3 = {
|
||||
"nodes": [
|
||||
{
|
||||
"id": "1",
|
||||
"type": "startCall",
|
||||
"data": {"name": "Start", "prompt": "Hello v3"},
|
||||
},
|
||||
{"id": "2", "type": "endCall", "data": {"name": "End", "prompt": "Goodbye"}},
|
||||
],
|
||||
"edges": [{"id": "e1", "source": "1", "target": "2", "data": {"label": "End"}}],
|
||||
}
|
||||
|
||||
CONFIG_V1 = {"max_call_duration": 300}
|
||||
CONFIG_V2 = {
|
||||
"max_call_duration": 600,
|
||||
"model_overrides": {"llm": {"model": "gpt-4.1-mini"}},
|
||||
}
|
||||
TEMPLATE_VARS_V1 = {"company_name": "Acme"}
|
||||
TEMPLATE_VARS_V2 = {"company_name": "Acme Inc"}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Fixtures
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def org_and_user(async_session):
|
||||
"""Create an organization and user for workflow tests."""
|
||||
org = OrganizationModel(provider_id="test-org-versioning")
|
||||
async_session.add(org)
|
||||
await async_session.flush()
|
||||
|
||||
user = UserModel(
|
||||
provider_id="test-user-versioning", selected_organization_id=org.id
|
||||
)
|
||||
async_session.add(user)
|
||||
await async_session.flush()
|
||||
|
||||
return org, user
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def workflow_with_v1(db_session, org_and_user):
|
||||
"""Create a workflow — should produce V1 as published."""
|
||||
org, user = org_and_user
|
||||
workflow = await db_session.create_workflow(
|
||||
name="Test Workflow",
|
||||
workflow_definition=GRAPH_V1,
|
||||
user_id=user.id,
|
||||
organization_id=org.id,
|
||||
)
|
||||
return workflow, user
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Workflow creation → V1 published
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestWorkflowCreation:
|
||||
async def test_create_workflow_produces_published_v1(
|
||||
self, db_session, org_and_user
|
||||
):
|
||||
"""Creating a new workflow should produce exactly one definition
|
||||
with status='published' and version_number=1."""
|
||||
org, user = org_and_user
|
||||
workflow = await db_session.create_workflow(
|
||||
name="New Workflow",
|
||||
workflow_definition=GRAPH_V1,
|
||||
user_id=user.id,
|
||||
organization_id=org.id,
|
||||
)
|
||||
|
||||
versions = await db_session.get_workflow_versions(workflow.id)
|
||||
assert len(versions) == 1
|
||||
|
||||
v1 = versions[0]
|
||||
assert v1.status == "published"
|
||||
assert v1.version_number == 1
|
||||
assert v1.workflow_json == GRAPH_V1
|
||||
|
||||
async def test_create_workflow_sets_released_pointer(
|
||||
self, db_session, org_and_user
|
||||
):
|
||||
"""The workflow's released_definition_id should point to V1."""
|
||||
org, user = org_and_user
|
||||
workflow = await db_session.create_workflow(
|
||||
name="Pointer Test",
|
||||
workflow_definition=GRAPH_V1,
|
||||
user_id=user.id,
|
||||
organization_id=org.id,
|
||||
)
|
||||
|
||||
versions = await db_session.get_workflow_versions(workflow.id)
|
||||
assert workflow.released_definition_id == versions[0].id
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Saving a draft
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestSaveDraft:
|
||||
async def test_save_draft_creates_draft_version(self, db_session, workflow_with_v1):
|
||||
"""Saving changes to a published workflow creates a draft version."""
|
||||
workflow, user = workflow_with_v1
|
||||
|
||||
draft = await db_session.save_workflow_draft(
|
||||
workflow_id=workflow.id,
|
||||
workflow_definition=GRAPH_V2,
|
||||
workflow_configurations=CONFIG_V2,
|
||||
template_context_variables=TEMPLATE_VARS_V2,
|
||||
)
|
||||
|
||||
assert draft.status == "draft"
|
||||
assert draft.version_number == 2
|
||||
assert draft.workflow_json == GRAPH_V2
|
||||
assert draft.workflow_configurations == CONFIG_V2
|
||||
assert draft.template_context_variables == TEMPLATE_VARS_V2
|
||||
|
||||
async def test_save_draft_does_not_change_released_pointer(
|
||||
self, db_session, workflow_with_v1
|
||||
):
|
||||
"""Creating a draft must not move the released pointer."""
|
||||
workflow, user = workflow_with_v1
|
||||
original_released_id = workflow.released_definition_id
|
||||
|
||||
await db_session.save_workflow_draft(
|
||||
workflow_id=workflow.id,
|
||||
workflow_definition=GRAPH_V2,
|
||||
)
|
||||
|
||||
refreshed = await db_session.get_workflow(workflow.id)
|
||||
assert refreshed.released_definition_id == original_released_id
|
||||
|
||||
async def test_save_draft_twice_updates_in_place(
|
||||
self, db_session, workflow_with_v1
|
||||
):
|
||||
"""Saving a second draft should update the existing draft, not create a new row."""
|
||||
workflow, user = workflow_with_v1
|
||||
|
||||
draft1 = await db_session.save_workflow_draft(
|
||||
workflow_id=workflow.id,
|
||||
workflow_definition=GRAPH_V2,
|
||||
)
|
||||
|
||||
draft2 = await db_session.save_workflow_draft(
|
||||
workflow_id=workflow.id,
|
||||
workflow_definition=GRAPH_V3,
|
||||
)
|
||||
|
||||
assert draft1.id == draft2.id # same row
|
||||
assert draft2.workflow_json == GRAPH_V3
|
||||
assert draft2.version_number == 2 # unchanged
|
||||
|
||||
versions = await db_session.get_workflow_versions(workflow.id)
|
||||
assert len(versions) == 2 # V1 published + V2 draft, no extras
|
||||
|
||||
async def test_save_draft_with_only_config_change(
|
||||
self, db_session, workflow_with_v1
|
||||
):
|
||||
"""A draft can change only configs, keeping the same graph."""
|
||||
workflow, user = workflow_with_v1
|
||||
|
||||
draft = await db_session.save_workflow_draft(
|
||||
workflow_id=workflow.id,
|
||||
workflow_definition=GRAPH_V1, # same graph
|
||||
workflow_configurations=CONFIG_V2, # different config
|
||||
)
|
||||
|
||||
assert draft.status == "draft"
|
||||
assert draft.workflow_json == GRAPH_V1
|
||||
assert draft.workflow_configurations == CONFIG_V2
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Publishing a draft
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestPublishDraft:
|
||||
async def test_publish_promotes_draft_to_published(
|
||||
self, db_session, workflow_with_v1
|
||||
):
|
||||
"""Publishing moves draft → published and old published → archived."""
|
||||
workflow, user = workflow_with_v1
|
||||
|
||||
await db_session.save_workflow_draft(
|
||||
workflow_id=workflow.id,
|
||||
workflow_definition=GRAPH_V2,
|
||||
workflow_configurations=CONFIG_V2,
|
||||
)
|
||||
|
||||
published = await db_session.publish_workflow_draft(workflow.id)
|
||||
|
||||
assert published.status == "published"
|
||||
assert published.workflow_json == GRAPH_V2
|
||||
|
||||
versions = await db_session.get_workflow_versions(workflow.id)
|
||||
statuses = {v.version_number: v.status for v in versions}
|
||||
assert statuses[1] == "archived"
|
||||
assert statuses[2] == "published"
|
||||
|
||||
async def test_publish_updates_released_pointer(self, db_session, workflow_with_v1):
|
||||
"""After publishing, released_definition_id should point to the new version."""
|
||||
workflow, user = workflow_with_v1
|
||||
|
||||
draft = await db_session.save_workflow_draft(
|
||||
workflow_id=workflow.id,
|
||||
workflow_definition=GRAPH_V2,
|
||||
)
|
||||
|
||||
await db_session.publish_workflow_draft(workflow.id)
|
||||
|
||||
refreshed = await db_session.get_workflow(workflow.id)
|
||||
assert refreshed.released_definition_id == draft.id
|
||||
|
||||
async def test_publish_sets_published_at(self, db_session, workflow_with_v1):
|
||||
"""Published version should have a published_at timestamp."""
|
||||
workflow, user = workflow_with_v1
|
||||
|
||||
await db_session.save_workflow_draft(
|
||||
workflow_id=workflow.id,
|
||||
workflow_definition=GRAPH_V2,
|
||||
)
|
||||
|
||||
published = await db_session.publish_workflow_draft(workflow.id)
|
||||
assert published.published_at is not None
|
||||
|
||||
async def test_publish_with_no_draft_raises(self, db_session, workflow_with_v1):
|
||||
"""Publishing when no draft exists should raise an error."""
|
||||
workflow, user = workflow_with_v1
|
||||
|
||||
with pytest.raises(ValueError, match="[Nn]o draft"):
|
||||
await db_session.publish_workflow_draft(workflow.id)
|
||||
|
||||
async def test_exactly_one_published_after_multiple_cycles(
|
||||
self, db_session, workflow_with_v1
|
||||
):
|
||||
"""After several draft/publish cycles, exactly one version is published."""
|
||||
workflow, user = workflow_with_v1
|
||||
|
||||
# Cycle 1
|
||||
await db_session.save_workflow_draft(
|
||||
workflow_id=workflow.id,
|
||||
workflow_definition=GRAPH_V2,
|
||||
)
|
||||
await db_session.publish_workflow_draft(workflow.id)
|
||||
|
||||
# Cycle 2
|
||||
await db_session.save_workflow_draft(
|
||||
workflow_id=workflow.id,
|
||||
workflow_definition=GRAPH_V3,
|
||||
)
|
||||
await db_session.publish_workflow_draft(workflow.id)
|
||||
|
||||
versions = await db_session.get_workflow_versions(workflow.id)
|
||||
published = [v for v in versions if v.status == "published"]
|
||||
assert len(published) == 1
|
||||
assert published[0].version_number == 3
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Discarding a draft
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestDiscardDraft:
|
||||
async def test_discard_removes_draft(self, db_session, workflow_with_v1):
|
||||
"""Discarding a draft should delete the draft row."""
|
||||
workflow, user = workflow_with_v1
|
||||
|
||||
await db_session.save_workflow_draft(
|
||||
workflow_id=workflow.id,
|
||||
workflow_definition=GRAPH_V2,
|
||||
)
|
||||
|
||||
await db_session.discard_workflow_draft(workflow.id)
|
||||
|
||||
versions = await db_session.get_workflow_versions(workflow.id)
|
||||
assert len(versions) == 1
|
||||
assert versions[0].status == "published"
|
||||
|
||||
async def test_discard_does_not_affect_published(
|
||||
self, db_session, workflow_with_v1
|
||||
):
|
||||
"""Published version and released pointer are unchanged after discard."""
|
||||
workflow, user = workflow_with_v1
|
||||
original_released_id = workflow.released_definition_id
|
||||
|
||||
await db_session.save_workflow_draft(
|
||||
workflow_id=workflow.id,
|
||||
workflow_definition=GRAPH_V2,
|
||||
)
|
||||
await db_session.discard_workflow_draft(workflow.id)
|
||||
|
||||
refreshed = await db_session.get_workflow(workflow.id)
|
||||
assert refreshed.released_definition_id == original_released_id
|
||||
|
||||
async def test_discard_when_no_draft_raises(self, db_session, workflow_with_v1):
|
||||
"""Discarding when no draft exists should raise an error."""
|
||||
workflow, user = workflow_with_v1
|
||||
|
||||
with pytest.raises(ValueError, match="[Nn]o draft"):
|
||||
await db_session.discard_workflow_draft(workflow.id)
|
||||
|
||||
async def test_new_draft_after_discard_gets_next_version_number(
|
||||
self, db_session, workflow_with_v1
|
||||
):
|
||||
"""After discarding V2 draft, the next draft should still be V2
|
||||
(since V2 was deleted and never published)."""
|
||||
workflow, user = workflow_with_v1
|
||||
|
||||
await db_session.save_workflow_draft(
|
||||
workflow_id=workflow.id,
|
||||
workflow_definition=GRAPH_V2,
|
||||
)
|
||||
await db_session.discard_workflow_draft(workflow.id)
|
||||
|
||||
new_draft = await db_session.save_workflow_draft(
|
||||
workflow_id=workflow.id,
|
||||
workflow_definition=GRAPH_V3,
|
||||
)
|
||||
# Version number reuse is acceptable since V2 was never published
|
||||
assert new_draft.version_number == 2
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Reverting to an archived version
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestRevert:
|
||||
async def _publish_v2(self, db_session, workflow):
|
||||
"""Helper: create and publish V2, making V1 archived."""
|
||||
await db_session.save_workflow_draft(
|
||||
workflow_id=workflow.id,
|
||||
workflow_definition=GRAPH_V2,
|
||||
workflow_configurations=CONFIG_V2,
|
||||
template_context_variables=TEMPLATE_VARS_V2,
|
||||
)
|
||||
return await db_session.publish_workflow_draft(workflow.id)
|
||||
|
||||
async def test_revert_creates_draft_from_archived(
|
||||
self, db_session, workflow_with_v1
|
||||
):
|
||||
"""Reverting copies the archived version's full snapshot into a new draft."""
|
||||
workflow, user = workflow_with_v1
|
||||
|
||||
# Get V1's definition ID before it gets archived
|
||||
versions_before = await db_session.get_workflow_versions(workflow.id)
|
||||
v1_id = versions_before[0].id
|
||||
|
||||
# Publish V2, archiving V1
|
||||
await self._publish_v2(db_session, workflow)
|
||||
|
||||
# Revert to V1
|
||||
draft = await db_session.revert_to_version(workflow.id, v1_id)
|
||||
|
||||
assert draft.status == "draft"
|
||||
assert draft.workflow_json == GRAPH_V1
|
||||
|
||||
async def test_revert_preserves_all_snapshot_fields(
|
||||
self, db_session, workflow_with_v1
|
||||
):
|
||||
"""Revert should copy graph, configs, and template vars."""
|
||||
workflow, user = workflow_with_v1
|
||||
|
||||
# Publish V2 with full config
|
||||
v2 = await self._publish_v2(db_session, workflow)
|
||||
|
||||
# Publish V3, archiving V2
|
||||
await db_session.save_workflow_draft(
|
||||
workflow_id=workflow.id,
|
||||
workflow_definition=GRAPH_V3,
|
||||
)
|
||||
await db_session.publish_workflow_draft(workflow.id)
|
||||
|
||||
# Revert to V2
|
||||
draft = await db_session.revert_to_version(workflow.id, v2.id)
|
||||
|
||||
assert draft.workflow_json == GRAPH_V2
|
||||
assert draft.workflow_configurations == CONFIG_V2
|
||||
assert draft.template_context_variables == TEMPLATE_VARS_V2
|
||||
|
||||
async def test_revert_when_draft_exists_raises(self, db_session, workflow_with_v1):
|
||||
"""Cannot revert when a draft already exists — must discard first."""
|
||||
workflow, user = workflow_with_v1
|
||||
versions = await db_session.get_workflow_versions(workflow.id)
|
||||
v1_id = versions[0].id
|
||||
|
||||
await self._publish_v2(db_session, workflow)
|
||||
|
||||
# Create a draft
|
||||
await db_session.save_workflow_draft(
|
||||
workflow_id=workflow.id,
|
||||
workflow_definition=GRAPH_V3,
|
||||
)
|
||||
|
||||
with pytest.raises(ValueError, match="[Dd]raft.*exists"):
|
||||
await db_session.revert_to_version(workflow.id, v1_id)
|
||||
|
||||
async def test_revert_does_not_change_released_pointer(
|
||||
self, db_session, workflow_with_v1
|
||||
):
|
||||
"""Revert creates a draft — the released pointer stays on the published version."""
|
||||
workflow, user = workflow_with_v1
|
||||
versions = await db_session.get_workflow_versions(workflow.id)
|
||||
v1_id = versions[0].id
|
||||
|
||||
v2 = await self._publish_v2(db_session, workflow)
|
||||
|
||||
await db_session.revert_to_version(workflow.id, v1_id)
|
||||
|
||||
refreshed = await db_session.get_workflow(workflow.id)
|
||||
assert refreshed.released_definition_id == v2.id # still V2
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Version listing & ordering
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestVersionListing:
|
||||
async def test_versions_ordered_by_version_number_desc(
|
||||
self, db_session, workflow_with_v1
|
||||
):
|
||||
"""Versions should be returned newest first."""
|
||||
workflow, user = workflow_with_v1
|
||||
|
||||
await db_session.save_workflow_draft(
|
||||
workflow_id=workflow.id,
|
||||
workflow_definition=GRAPH_V2,
|
||||
)
|
||||
await db_session.publish_workflow_draft(workflow.id)
|
||||
|
||||
await db_session.save_workflow_draft(
|
||||
workflow_id=workflow.id,
|
||||
workflow_definition=GRAPH_V3,
|
||||
)
|
||||
|
||||
versions = await db_session.get_workflow_versions(workflow.id)
|
||||
version_numbers = [v.version_number for v in versions]
|
||||
assert version_numbers == sorted(version_numbers, reverse=True)
|
||||
|
||||
async def test_versions_include_status(self, db_session, workflow_with_v1):
|
||||
"""Each version should have an explicit status."""
|
||||
workflow, user = workflow_with_v1
|
||||
|
||||
await db_session.save_workflow_draft(
|
||||
workflow_id=workflow.id,
|
||||
workflow_definition=GRAPH_V2,
|
||||
)
|
||||
await db_session.publish_workflow_draft(workflow.id)
|
||||
|
||||
await db_session.save_workflow_draft(
|
||||
workflow_id=workflow.id,
|
||||
workflow_definition=GRAPH_V3,
|
||||
)
|
||||
|
||||
versions = await db_session.get_workflow_versions(workflow.id)
|
||||
statuses = {v.version_number: v.status for v in versions}
|
||||
assert statuses == {1: "archived", 2: "published", 3: "draft"}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Version data stored on definition, not workflow
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestVersionDataOnDefinition:
|
||||
async def test_configs_stored_on_definition(self, db_session, workflow_with_v1):
|
||||
"""workflow_configurations should be on the definition, not just the workflow."""
|
||||
workflow, user = workflow_with_v1
|
||||
|
||||
draft = await db_session.save_workflow_draft(
|
||||
workflow_id=workflow.id,
|
||||
workflow_definition=GRAPH_V2,
|
||||
workflow_configurations=CONFIG_V2,
|
||||
template_context_variables=TEMPLATE_VARS_V2,
|
||||
)
|
||||
|
||||
assert draft.workflow_configurations == CONFIG_V2
|
||||
assert draft.template_context_variables == TEMPLATE_VARS_V2
|
||||
|
||||
async def test_different_versions_have_different_configs(
|
||||
self, db_session, workflow_with_v1
|
||||
):
|
||||
"""V1 and V2 can have different configs stored independently."""
|
||||
workflow, user = workflow_with_v1
|
||||
|
||||
await db_session.save_workflow_draft(
|
||||
workflow_id=workflow.id,
|
||||
workflow_definition=GRAPH_V2,
|
||||
workflow_configurations=CONFIG_V2,
|
||||
)
|
||||
await db_session.publish_workflow_draft(workflow.id)
|
||||
|
||||
versions = await db_session.get_workflow_versions(workflow.id)
|
||||
configs_by_version = {
|
||||
v.version_number: v.workflow_configurations for v in versions
|
||||
}
|
||||
|
||||
assert configs_by_version[1] != configs_by_version[2]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Run creation uses published (or draft for testing)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestRunDefinitionBinding:
|
||||
async def test_campaign_run_uses_published_version(
|
||||
self, db_session, workflow_with_v1
|
||||
):
|
||||
"""A campaign-initiated run should use the published version, not draft."""
|
||||
workflow, user = workflow_with_v1
|
||||
|
||||
# Create a draft (unpublished)
|
||||
await db_session.save_workflow_draft(
|
||||
workflow_id=workflow.id,
|
||||
workflow_definition=GRAPH_V2,
|
||||
)
|
||||
|
||||
# Create a run (simulating campaign dispatch)
|
||||
run = await db_session.create_workflow_run(
|
||||
name="Campaign Run",
|
||||
workflow_id=workflow.id,
|
||||
mode="webrtc",
|
||||
user_id=user.id,
|
||||
)
|
||||
|
||||
# Run should be bound to the published V1, not the draft V2
|
||||
versions = await db_session.get_workflow_versions(workflow.id)
|
||||
published = next(v for v in versions if v.status == "published")
|
||||
assert run.definition_id == published.id
|
||||
|
||||
async def test_test_run_uses_draft_if_exists(self, db_session, workflow_with_v1):
|
||||
"""A test/phone call should use the draft version for pre-publish testing."""
|
||||
workflow, user = workflow_with_v1
|
||||
|
||||
draft = await db_session.save_workflow_draft(
|
||||
workflow_id=workflow.id,
|
||||
workflow_definition=GRAPH_V2,
|
||||
)
|
||||
|
||||
# Create a test run
|
||||
run = await db_session.create_workflow_run(
|
||||
name="Test Run",
|
||||
workflow_id=workflow.id,
|
||||
mode="webrtc", # test mode
|
||||
user_id=user.id,
|
||||
use_draft=True,
|
||||
)
|
||||
|
||||
assert run.definition_id == draft.id
|
||||
|
|
@ -12,6 +12,8 @@ const compat = new FlatCompat({
|
|||
});
|
||||
|
||||
const eslintConfig = [
|
||||
// Ignore auto-generated hey-api client files
|
||||
{ ignores: ["src/client/client/", "src/client/core/", "src/client/client.gen.ts", "src/client/sdk.gen.ts", "src/client/types.gen.ts", "src/client/index.ts"] },
|
||||
...compat.extends("next/core-web-vitals", "next/typescript"),
|
||||
{
|
||||
plugins: {
|
||||
|
|
|
|||
|
|
@ -5,6 +5,6 @@ export default defineConfig({
|
|||
output: 'src/client',
|
||||
plugins: [{
|
||||
name: '@hey-api/client-fetch',
|
||||
runtimeConfigPath: './src/lib/apiClient.ts',
|
||||
runtimeConfigPath: '../lib/apiClient',
|
||||
}],
|
||||
});
|
||||
|
|
|
|||
63
ui/package-lock.json
generated
63
ui/package-lock.json
generated
|
|
@ -9,7 +9,6 @@
|
|||
"version": "1.20.0",
|
||||
"dependencies": {
|
||||
"@dagrejs/dagre": "^1.1.4",
|
||||
"@hey-api/client-fetch": "^0.10.0",
|
||||
"@nangohq/frontend": "^0.69.47",
|
||||
"@radix-ui/react-alert-dialog": "^1.1.15",
|
||||
"@radix-ui/react-checkbox": "^1.3.2",
|
||||
|
|
@ -1756,22 +1755,11 @@
|
|||
"integrity": "sha512-MDWhGtE+eHw5JW7lq4qhc5yRLS11ERl1c7Z6Xd0a58DozHES6EnNNwUWbMiG4J9Cgj053Bhk8zvlhFYKVhULwg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@hey-api/client-fetch": {
|
||||
"version": "0.10.0",
|
||||
"resolved": "https://registry.npmjs.org/@hey-api/client-fetch/-/client-fetch-0.10.0.tgz",
|
||||
"integrity": "sha512-C7vzj4t52qPiHCqjn1l8cRTI2p4pZCd7ViLtJDTHr5ZwI4sWOYC1tmv6bd529qqY6HFFbhGCz4TAZSwKAMJncg==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/hey-api"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@hey-api/openapi-ts": "< 2"
|
||||
}
|
||||
},
|
||||
"node_modules/@hey-api/codegen-core": {
|
||||
"version": "0.7.4",
|
||||
"resolved": "https://registry.npmjs.org/@hey-api/codegen-core/-/codegen-core-0.7.4.tgz",
|
||||
"integrity": "sha512-DGd9yeSQzflOWO3Y5mt1GRXkXH9O/yIMgbxPjwLI3jwu/3nAjoXXD26lEeFb6tclYlg0JAqTIs5d930G/qxHeA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@hey-api/types": "0.1.4",
|
||||
|
|
@ -1790,6 +1778,7 @@
|
|||
"version": "1.3.1",
|
||||
"resolved": "https://registry.npmjs.org/@hey-api/json-schema-ref-parser/-/json-schema-ref-parser-1.3.1.tgz",
|
||||
"integrity": "sha512-7atnpUkT8TyUPHYPLk91j/GyaqMuwTEHanLOe50Dlx0EEvNuQqFD52Yjg8x4KU0UFL1mWlyhE+sUE/wAtQ1N2A==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@jsdevtools/ono": "7.1.3",
|
||||
|
|
@ -1807,6 +1796,7 @@
|
|||
"version": "0.95.0",
|
||||
"resolved": "https://registry.npmjs.org/@hey-api/openapi-ts/-/openapi-ts-0.95.0.tgz",
|
||||
"integrity": "sha512-lk5C+WKl5yqEmliQihEyhX/jNcWlAykTSEqkDeKa9xSq5YDAzOFvx7oos8YTqiIzdc4TemtlEaB8Rns7+8A0qg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
|
|
@ -1837,6 +1827,7 @@
|
|||
"version": "0.3.0",
|
||||
"resolved": "https://registry.npmjs.org/@hey-api/shared/-/shared-0.3.0.tgz",
|
||||
"integrity": "sha512-G+4GPojdLEh9bUwRG88teMPM1HdqMm/IsJ38cbnNxhyDu1FkFGwilkA1EqnULCzfTam/ZoZkaLdmAd8xEh4Xsw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@hey-api/codegen-core": "0.7.4",
|
||||
|
|
@ -1859,6 +1850,7 @@
|
|||
"version": "0.1.0",
|
||||
"resolved": "https://registry.npmjs.org/@hey-api/spec-types/-/spec-types-0.1.0.tgz",
|
||||
"integrity": "sha512-StS4RrAO5pyJCBwe6uF9MAuPflkztriW+FPnVb7oEjzDYv1sxPwP+f7fL6u6D+UVrKpZ/9bPNx/xXVdkeWPU6A==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@hey-api/types": "0.1.4"
|
||||
|
|
@ -1871,6 +1863,7 @@
|
|||
"version": "0.1.4",
|
||||
"resolved": "https://registry.npmjs.org/@hey-api/types/-/types-0.1.4.tgz",
|
||||
"integrity": "sha512-thWfawrDIP7wSI9ioT13I5soaaqB5vAPIiZmgD8PbeEVKNrkonc0N/Sjj97ezl7oQgusZmaNphGdMKipPO6IBg==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@hookform/resolvers": {
|
||||
|
|
@ -2476,6 +2469,7 @@
|
|||
"version": "7.1.3",
|
||||
"resolved": "https://registry.npmjs.org/@jsdevtools/ono/-/ono-7.1.3.tgz",
|
||||
"integrity": "sha512-4JQNk+3mVzK3xh2rqd6RB4J46qUR19azEHBneZyTZM+c456qOrbbM/5xcR8huNCCcbVt7+UmizG6GuUvPvKUYg==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@nangohq/frontend": {
|
||||
|
|
@ -11402,6 +11396,7 @@
|
|||
"version": "4.1.3",
|
||||
"resolved": "https://registry.npmjs.org/ansi-colors/-/ansi-colors-4.1.3.tgz",
|
||||
"integrity": "sha512-/6w/C21Pm1A7aZitlI5Ni/2J6FFQN8i1Cvz3kHABAAbw93v/NlvKdVOqz7CCWz/3iv/JplRSEEZ83XION15ovw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=6"
|
||||
|
|
@ -11448,6 +11443,7 @@
|
|||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz",
|
||||
"integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==",
|
||||
"dev": true,
|
||||
"license": "Python-2.0"
|
||||
},
|
||||
"node_modules/aria-hidden": {
|
||||
|
|
@ -11875,6 +11871,7 @@
|
|||
"version": "4.1.0",
|
||||
"resolved": "https://registry.npmjs.org/bundle-name/-/bundle-name-4.1.0.tgz",
|
||||
"integrity": "sha512-tjwM5exMg6BGRI+kNmTntNsvdZS1X8BFYS6tnJ2hdH0kVxM6/eVZ2xy+FqStSWvYmtfFMDLIxurorHwDKfDz5Q==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"run-applescript": "^7.0.0"
|
||||
|
|
@ -11890,6 +11887,7 @@
|
|||
"version": "3.3.3",
|
||||
"resolved": "https://registry.npmjs.org/c12/-/c12-3.3.3.tgz",
|
||||
"integrity": "sha512-750hTRvgBy5kcMNPdh95Qo+XUBeGo8C7nsKSmedDmaQI+E0r82DwHeM6vBewDe4rGFbnxoa4V9pw+sPh5+Iz8Q==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"chokidar": "^5.0.0",
|
||||
|
|
@ -11918,6 +11916,7 @@
|
|||
"version": "17.4.1",
|
||||
"resolved": "https://registry.npmjs.org/dotenv/-/dotenv-17.4.1.tgz",
|
||||
"integrity": "sha512-k8DaKGP6r1G30Lx8V4+pCsLzKr8vLmV2paqEj1Y55GdAgJuIqpRp5FfajGF8KtwMxCz9qJc6wUIJnm053d/WCw==",
|
||||
"dev": true,
|
||||
"license": "BSD-2-Clause",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
|
|
@ -12034,6 +12033,7 @@
|
|||
"version": "5.0.0",
|
||||
"resolved": "https://registry.npmjs.org/chokidar/-/chokidar-5.0.0.tgz",
|
||||
"integrity": "sha512-TQMmc3w+5AxjpL8iIiwebF73dRDF4fBIieAqGn9RGCWaEVwQ6Fb2cGe31Yns0RRIzii5goJ1Y7xbMwo1TxMplw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"readdirp": "^5.0.0"
|
||||
|
|
@ -12058,6 +12058,7 @@
|
|||
"version": "0.1.6",
|
||||
"resolved": "https://registry.npmjs.org/citty/-/citty-0.1.6.tgz",
|
||||
"integrity": "sha512-tskPPKEs8D2KPafUypv2gxwJP8h/OaJmC82QQGGDQcHvXX43xF2VDACcJVmZ0EuSxkpO9Kc4MlrA3q0+FG58AQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"consola": "^3.2.3"
|
||||
|
|
@ -12199,6 +12200,7 @@
|
|||
"version": "1.1.3",
|
||||
"resolved": "https://registry.npmjs.org/color-support/-/color-support-1.1.3.tgz",
|
||||
"integrity": "sha512-qiBjkpbMLO/HL68y+lh4q0/O1MZFj2RX6X/KmMa3+gJD3z+WwI1ZzDHysvqHGS3mP6mznPckpXmw1nI9cJjyRg==",
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"bin": {
|
||||
"color-support": "bin.js"
|
||||
|
|
@ -12247,6 +12249,7 @@
|
|||
"version": "14.0.3",
|
||||
"resolved": "https://registry.npmjs.org/commander/-/commander-14.0.3.tgz",
|
||||
"integrity": "sha512-H+y0Jo/T1RZ9qPP4Eh1pkcQcLRglraJaSLoyOtHxu6AapkjWVCy2Sit1QQ4x3Dng8qDlSsZEet7g5Pq06MvTgw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
|
|
@ -12269,12 +12272,14 @@
|
|||
"version": "0.2.4",
|
||||
"resolved": "https://registry.npmjs.org/confbox/-/confbox-0.2.4.tgz",
|
||||
"integrity": "sha512-ysOGlgTFbN2/Y6Cg3Iye8YKulHw+R2fNXHrgSmXISQdMnomY6eNDprVdW9R5xBguEqI954+S6709UyiO7B+6OQ==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/consola": {
|
||||
"version": "3.4.2",
|
||||
"resolved": "https://registry.npmjs.org/consola/-/consola-3.4.2.tgz",
|
||||
"integrity": "sha512-5IKcdX0nnYavi6G7TtOhwkYzyjfJlatbjMjuLSfE2kYT5pMDOilZ4OvMhi637CcDICTmz3wARPoyhqyX1Y+XvA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": "^14.18.0 || >=16.10.0"
|
||||
|
|
@ -12366,6 +12371,7 @@
|
|||
"version": "7.0.6",
|
||||
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
|
||||
"integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"path-key": "^3.1.0",
|
||||
|
|
@ -12699,6 +12705,7 @@
|
|||
"version": "5.5.0",
|
||||
"resolved": "https://registry.npmjs.org/default-browser/-/default-browser-5.5.0.tgz",
|
||||
"integrity": "sha512-H9LMLr5zwIbSxrmvikGuI/5KGhZ8E2zH3stkMgM5LpOWDutGM2JZaj460Udnf1a+946zc7YBgrqEWwbk7zHvGw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"bundle-name": "^4.1.0",
|
||||
|
|
@ -12715,6 +12722,7 @@
|
|||
"version": "5.0.1",
|
||||
"resolved": "https://registry.npmjs.org/default-browser-id/-/default-browser-id-5.0.1.tgz",
|
||||
"integrity": "sha512-x1VCxdX4t+8wVfd1so/9w+vQ4vx7lKd2Qp5tDRutErwmR85OgmfX7RlLRMWafRMY7hbEiXIbudNrjOAPa/hL8Q==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
|
|
@ -12745,6 +12753,7 @@
|
|||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/define-lazy-prop/-/define-lazy-prop-3.0.0.tgz",
|
||||
"integrity": "sha512-N+MeXYoqr3pOgn8xfyRPREN7gHakLYjhsHhWGT3fWAiL4IkAt0iDw14QiiEm2bE30c5XX5q0FtAA3CK5f9/BUg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
|
|
@ -12775,6 +12784,7 @@
|
|||
"version": "6.1.6",
|
||||
"resolved": "https://registry.npmjs.org/defu/-/defu-6.1.6.tgz",
|
||||
"integrity": "sha512-f8mefEW4WIVg4LckePx3mALjQSPQgFlg9U8yaPdlsbdYcHQyj9n2zL2LJEA52smeYxOvmd/nB7TpMtHGMTHcug==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/delayed-stream": {
|
||||
|
|
@ -12790,6 +12800,7 @@
|
|||
"version": "2.0.5",
|
||||
"resolved": "https://registry.npmjs.org/destr/-/destr-2.0.5.tgz",
|
||||
"integrity": "sha512-ugFTXCtDZunbzasqBxrK93Ik/DRYsO6S/fedkWEMKqt04xZ4csmnmwGDBAb07QWNaGMAmnTIemsYZCksjATwsA==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/detect-libc": {
|
||||
|
|
@ -13673,6 +13684,7 @@
|
|||
"version": "1.0.8",
|
||||
"resolved": "https://registry.npmjs.org/exsolve/-/exsolve-1.0.8.tgz",
|
||||
"integrity": "sha512-LmDxfWXwcTArk8fUEnOfSZpHOJ6zOMUJKOtFLFqJLoKJetuQG874Uc7/Kki7zFLzYybmZhp1M7+98pfMqeX8yA==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/fast-copy": {
|
||||
|
|
@ -14097,6 +14109,7 @@
|
|||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/giget/-/giget-2.0.0.tgz",
|
||||
"integrity": "sha512-L5bGsVkxJbJgdnwyuheIunkGatUF/zssUoxxjACCseZYAVbaqdh9Tsmmlkl8vYan09H7sbvKt4pS8GqKLBrEzA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"citty": "^0.1.6",
|
||||
|
|
@ -14594,6 +14607,7 @@
|
|||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/is-docker/-/is-docker-3.0.0.tgz",
|
||||
"integrity": "sha512-eljcgEDlEns/7AXFosB5K/2nCM4P7FQPkGc/DWLy5rmFEWvZayGrik1d9/QIY5nJ4f9YsVvBkA6kJpHn9rISdQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"bin": {
|
||||
"is-docker": "cli.js"
|
||||
|
|
@ -14674,6 +14688,7 @@
|
|||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/is-in-ssh/-/is-in-ssh-1.0.0.tgz",
|
||||
"integrity": "sha512-jYa6Q9rH90kR1vKB6NM7qqd1mge3Fx4Dhw5TVlK1MUBqhEOuCagrEHMevNuCcbECmXZ0ThXkRm+Ymr51HwEPAw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
|
|
@ -14686,6 +14701,7 @@
|
|||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/is-inside-container/-/is-inside-container-1.0.0.tgz",
|
||||
"integrity": "sha512-KIYLCCJghfHZxqjYBE7rEy0OBuTd5xCHS7tHVgvCLkx7StIoaxwNW3hCALgEUjFfeRk+MG/Qxmp/vtETEF3tRA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"is-docker": "^3.0.0"
|
||||
|
|
@ -14897,6 +14913,7 @@
|
|||
"version": "3.1.1",
|
||||
"resolved": "https://registry.npmjs.org/is-wsl/-/is-wsl-3.1.1.tgz",
|
||||
"integrity": "sha512-e6rvdUCiQCAuumZslxRJWR/Doq4VpPR82kqclvcS0efgt430SlGIk05vdCN58+VrzgtIcfNODjozVielycD4Sw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"is-inside-container": "^1.0.0"
|
||||
|
|
@ -14972,6 +14989,7 @@
|
|||
"version": "2.6.1",
|
||||
"resolved": "https://registry.npmjs.org/jiti/-/jiti-2.6.1.tgz",
|
||||
"integrity": "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"bin": {
|
||||
"jiti": "lib/jiti-cli.mjs"
|
||||
|
|
@ -15014,6 +15032,7 @@
|
|||
"version": "4.1.1",
|
||||
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz",
|
||||
"integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"argparse": "^2.0.1"
|
||||
|
|
@ -15729,6 +15748,7 @@
|
|||
"version": "1.6.7",
|
||||
"resolved": "https://registry.npmjs.org/node-fetch-native/-/node-fetch-native-1.6.7.tgz",
|
||||
"integrity": "sha512-g9yhqoedzIUm0nTnTqAQvueMPVOuIY16bqgAJJC8XOOubYFNwz6IER9qs0Gq2Xd0+CecCKFjtdDTMA4u4xG06Q==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/node-releases": {
|
||||
|
|
@ -15756,6 +15776,7 @@
|
|||
"version": "0.6.5",
|
||||
"resolved": "https://registry.npmjs.org/nypm/-/nypm-0.6.5.tgz",
|
||||
"integrity": "sha512-K6AJy1GMVyfyMXRVB88700BJqNUkByijGJM8kEHpLdcAt+vSQAVfkWWHYzuRXHSY6xA2sNc5RjTj0p9rE2izVQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"citty": "^0.2.0",
|
||||
|
|
@ -15773,6 +15794,7 @@
|
|||
"version": "0.2.2",
|
||||
"resolved": "https://registry.npmjs.org/citty/-/citty-0.2.2.tgz",
|
||||
"integrity": "sha512-+6vJA3L98yv+IdfKGZHBNiGW5KHn22e/JwID0Strsz8h4S/csAu/OuICwxrg44k5MRiZHWIo8XXuJgQTriRP4w==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/oauth4webapi": {
|
||||
|
|
@ -15910,6 +15932,7 @@
|
|||
"version": "2.0.11",
|
||||
"resolved": "https://registry.npmjs.org/ohash/-/ohash-2.0.11.tgz",
|
||||
"integrity": "sha512-RdR9FQrFwNBNXAr4GixM8YaRZRJ5PUWbKYbE5eOsrwAjJW0q2REGcf79oYPsLyskQCZG1PLN+S/K1V00joZAoQ==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/on-exit-leak-free": {
|
||||
|
|
@ -15934,6 +15957,7 @@
|
|||
"version": "11.0.0",
|
||||
"resolved": "https://registry.npmjs.org/open/-/open-11.0.0.tgz",
|
||||
"integrity": "sha512-smsWv2LzFjP03xmvFoJ331ss6h+jixfA4UUV/Bsiyuu4YJPfN+FIQGOIiv4w9/+MoHkfkJ22UIaQWRVFRfH6Vw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"default-browser": "^5.4.0",
|
||||
|
|
@ -16083,6 +16107,7 @@
|
|||
"version": "3.1.1",
|
||||
"resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz",
|
||||
"integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
|
|
@ -16123,12 +16148,14 @@
|
|||
"version": "2.0.3",
|
||||
"resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz",
|
||||
"integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/perfect-debounce": {
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/perfect-debounce/-/perfect-debounce-2.1.0.tgz",
|
||||
"integrity": "sha512-LjgdTytVFXeUgtHZr9WYViYSM/g8MkcTPYDlPa3cDqMirHjKiSZPYd6DoL7pK8AJQr+uWkQvCjHNdiMqsrJs+g==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/pg-int8": {
|
||||
|
|
@ -16257,6 +16284,7 @@
|
|||
"version": "2.3.0",
|
||||
"resolved": "https://registry.npmjs.org/pkg-types/-/pkg-types-2.3.0.tgz",
|
||||
"integrity": "sha512-SIqCzDRg0s9npO5XQ3tNZioRY1uK06lA41ynBC1YmFTmnY6FjUjVt6s4LoADmwoig1qqD0oK8h1p/8mlMx8Oig==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"confbox": "^0.2.2",
|
||||
|
|
@ -16388,6 +16416,7 @@
|
|||
"version": "0.1.0",
|
||||
"resolved": "https://registry.npmjs.org/powershell-utils/-/powershell-utils-0.1.0.tgz",
|
||||
"integrity": "sha512-dM0jVuXJPsDN6DvRpea484tCUaMiXWjuCn++HGTqUWzGDjv5tZkEZldAJ/UMlqRYGFrD/etByo4/xOuC/snX2A==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
|
|
@ -16532,6 +16561,7 @@
|
|||
"version": "2.1.2",
|
||||
"resolved": "https://registry.npmjs.org/rc9/-/rc9-2.1.2.tgz",
|
||||
"integrity": "sha512-btXCnMmRIBINM2LDZoEmOogIZU7Qe7zn4BpomSKZ/ykbLObuBdvG+mFq11DL6fjH1DRwHhrlgtYWG96bJiC7Cg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"defu": "^6.1.4",
|
||||
|
|
@ -16789,6 +16819,7 @@
|
|||
"version": "5.0.0",
|
||||
"resolved": "https://registry.npmjs.org/readdirp/-/readdirp-5.0.0.tgz",
|
||||
"integrity": "sha512-9u/XQ1pvrQtYyMpZe7DXKv2p5CNvyVwzUB6uhLAnQwHMSgKMBR62lc7AHljaeteeHXn11XTAaLLUVZYVZyuRBQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 20.19.0"
|
||||
|
|
@ -17062,6 +17093,7 @@
|
|||
"version": "7.1.0",
|
||||
"resolved": "https://registry.npmjs.org/run-applescript/-/run-applescript-7.1.0.tgz",
|
||||
"integrity": "sha512-DPe5pVFaAsinSaV6QjQ6gdiedWDcRCbUuiQfQa2wmWV7+xC9bGulGI8+TdRmoFkAPaBXk8CrAbnlY2ISniJ47Q==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
|
|
@ -17374,6 +17406,7 @@
|
|||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz",
|
||||
"integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"shebang-regex": "^3.0.0"
|
||||
|
|
@ -17386,6 +17419,7 @@
|
|||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz",
|
||||
"integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
|
|
@ -17923,6 +17957,7 @@
|
|||
"version": "1.0.4",
|
||||
"resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.0.4.tgz",
|
||||
"integrity": "sha512-u9r3uZC0bdpGOXtlxUIdwf9pkmvhqJdrVCH9fapQtgy/OeTTMZ1nqH7agtvEfmGui6e1XxjcdrlxvxJvc3sMqw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
|
|
@ -18165,6 +18200,7 @@
|
|||
"version": "5.8.2",
|
||||
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.8.2.tgz",
|
||||
"integrity": "sha512-aJn6wq13/afZp/jT9QZmwEjDqqvSGp1VT5GVg+f/t6/oVyrgXM6BY1h9BRh/O5p3PlUPAe+WuiEZOmb/49RqoQ==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"peer": true,
|
||||
"bin": {
|
||||
|
|
@ -18684,6 +18720,7 @@
|
|||
"version": "0.3.1",
|
||||
"resolved": "https://registry.npmjs.org/wsl-utils/-/wsl-utils-0.3.1.tgz",
|
||||
"integrity": "sha512-g/eziiSUNBSsdDJtCLB8bdYEUMj4jR7AGeUo96p/3dTafgjHhpF4RiCFPiRILwjQoDXx5MqkBr4fwWtR3Ky4Wg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"is-wsl": "^3.1.0",
|
||||
|
|
|
|||
|
|
@ -12,7 +12,6 @@
|
|||
},
|
||||
"dependencies": {
|
||||
"@dagrejs/dagre": "^1.1.4",
|
||||
"@hey-api/client-fetch": "^0.10.0",
|
||||
"@nangohq/frontend": "^0.69.47",
|
||||
"@radix-ui/react-alert-dialog": "^1.1.15",
|
||||
"@radix-ui/react-checkbox": "^1.3.2",
|
||||
|
|
|
|||
|
|
@ -88,7 +88,7 @@ export default function GoogleSheetSelector({ accessToken, onSheetSelected, sele
|
|||
|
||||
if (response.data) {
|
||||
const integrations = Array.isArray(response.data) ? response.data : [response.data];
|
||||
const googleSheet = integrations.find(i => i.provider === 'google-sheet');
|
||||
const googleSheet = integrations.find((i: IntegrationResponse) => i.provider === 'google-sheet');
|
||||
setGoogleIntegration(googleSheet || null);
|
||||
}
|
||||
} catch (error) {
|
||||
|
|
|
|||
|
|
@ -106,7 +106,7 @@ export default function EditCampaignPage() {
|
|||
setScheduleEnabled(c.schedule_config.enabled);
|
||||
setScheduleTimezone(c.schedule_config.timezone);
|
||||
if (c.schedule_config.slots.length > 0) {
|
||||
setTimeSlots(c.schedule_config.slots.map(s => ({ ...s })));
|
||||
setTimeSlots(c.schedule_config.slots.map((s: TimeSlot) => ({ ...s })));
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ import {
|
|||
getDailyRunsDetailApiV1OrganizationsReportsDailyRunsGet,
|
||||
getWorkflowOptionsApiV1OrganizationsReportsWorkflowsGet
|
||||
} from '@/client/sdk.gen';
|
||||
import type { WorkflowRunDetail } from '@/client/types.gen';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Calendar as CalendarPicker } from '@/components/ui/calendar';
|
||||
import { Card } from '@/components/ui/card';
|
||||
|
|
@ -140,7 +141,7 @@ export default function ReportsPage() {
|
|||
if (response.data && response.data.length > 0) {
|
||||
// Prepare CSV content
|
||||
const headers = ['Phone Number', 'Disposition', 'Duration (seconds)', 'Workflow Run URL'];
|
||||
const rows = response.data.map(run => {
|
||||
const rows = response.data.map((run: WorkflowRunDetail) => {
|
||||
const url = `${window.location.origin}/workflow/${run.workflow_id}/run/${run.run_id}`;
|
||||
return [
|
||||
run.phone_number || '',
|
||||
|
|
@ -153,7 +154,7 @@ export default function ReportsPage() {
|
|||
// Create CSV content
|
||||
const csvContent = [
|
||||
headers.join(','),
|
||||
...rows.map(row => row.map(cell => `"${cell}"`).join(','))
|
||||
...rows.map((row: string[]) => row.map((cell: string) => `"${cell}"`).join(','))
|
||||
].join('\n');
|
||||
|
||||
// Create blob and download
|
||||
|
|
|
|||
|
|
@ -6,10 +6,11 @@ import {
|
|||
Panel,
|
||||
ReactFlow,
|
||||
} from "@xyflow/react";
|
||||
import { BookA, BrushCleaning, Maximize2, Mic, Minus, PhoneOff, Plus, Rocket, Settings, Variable } from 'lucide-react';
|
||||
import React, { useEffect, useMemo, useState } from 'react';
|
||||
import { BrushCleaning, Maximize2, Minus, Plus, Settings } from 'lucide-react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
|
||||
import { listDocumentsApiV1KnowledgeBaseDocumentsGet, listRecordingsApiV1WorkflowRecordingsGet, listToolsApiV1ToolsGet } from '@/client';
|
||||
import { createWorkflowDraftApiV1WorkflowWorkflowIdCreateDraftPost, getWorkflowVersionsApiV1WorkflowWorkflowIdVersionsGet, listDocumentsApiV1KnowledgeBaseDocumentsGet, listRecordingsApiV1WorkflowRecordingsGet, listToolsApiV1ToolsGet } from '@/client';
|
||||
import type { DocumentResponseSchema, RecordingResponseSchema, ToolResponse } from '@/client/types.gen';
|
||||
import { FlowEdge, FlowNode, NodeType } from "@/components/flow/types";
|
||||
import { Button } from '@/components/ui/button';
|
||||
|
|
@ -20,13 +21,8 @@ import { WorkflowConfigurations } from '@/types/workflow-configurations';
|
|||
import AddNodePanel from "../../../components/flow/AddNodePanel";
|
||||
import CustomEdge from "../../../components/flow/edges/CustomEdge";
|
||||
import { AgentNode, EndCall, GlobalNode, QANode, StartCall, TriggerNode, WebhookNode } from "../../../components/flow/nodes";
|
||||
import { ConfigurationsDialog } from './components/ConfigurationsDialog';
|
||||
import { DictionaryDialog } from './components/DictionaryDialog';
|
||||
import { EmbedDialog } from './components/EmbedDialog';
|
||||
import { PhoneCallDialog } from './components/PhoneCallDialog';
|
||||
import { RecordingsDialog } from './components/RecordingsDialog';
|
||||
import { TemplateContextVariablesDialog } from './components/TemplateContextVariablesDialog';
|
||||
import { VoicemailDetectionDialog } from './components/VoicemailDetectionDialog';
|
||||
import { VersionHistoryPanel, WorkflowVersion } from './components/VersionHistoryPanel';
|
||||
import { WorkflowEditorHeader } from "./components/WorkflowEditorHeader";
|
||||
import { WorkflowProvider } from "./contexts/WorkflowContext";
|
||||
import { useWorkflowState } from "./hooks/useWorkflowState";
|
||||
|
|
@ -61,22 +57,27 @@ interface RenderWorkflowProps {
|
|||
};
|
||||
initialTemplateContextVariables?: Record<string, string>;
|
||||
initialWorkflowConfigurations?: WorkflowConfigurations;
|
||||
initialVersionNumber?: number | null;
|
||||
initialVersionStatus?: string | null;
|
||||
user: { id: string; email?: string };
|
||||
}
|
||||
|
||||
function RenderWorkflow({ initialWorkflowName, workflowId, initialFlow, initialTemplateContextVariables, initialWorkflowConfigurations, user }: RenderWorkflowProps) {
|
||||
function RenderWorkflow({ initialWorkflowName, workflowId, initialFlow, initialTemplateContextVariables, initialWorkflowConfigurations, initialVersionNumber, initialVersionStatus, user }: RenderWorkflowProps) {
|
||||
const router = useRouter();
|
||||
const { userConfig } = useUserConfig();
|
||||
const ttsProvider = (userConfig?.tts?.provider as string) ?? "";
|
||||
const ttsModel = (userConfig?.tts?.model as string) ?? "";
|
||||
const ttsVoiceId = (userConfig?.tts?.voice as string) ?? "";
|
||||
|
||||
const [isContextVarsDialogOpen, setIsContextVarsDialogOpen] = useState(false);
|
||||
const [isConfigurationsDialogOpen, setIsConfigurationsDialogOpen] = useState(false);
|
||||
const [isDictionaryDialogOpen, setIsDictionaryDialogOpen] = useState(false);
|
||||
const [isEmbedDialogOpen, setIsEmbedDialogOpen] = useState(false);
|
||||
const [isPhoneCallDialogOpen, setIsPhoneCallDialogOpen] = useState(false);
|
||||
const [isRecordingsDialogOpen, setIsRecordingsDialogOpen] = useState(false);
|
||||
const [isVoicemailDialogOpen, setIsVoicemailDialogOpen] = useState(false);
|
||||
const [isVersionPanelOpen, setIsVersionPanelOpen] = useState(false);
|
||||
const [versions, setVersions] = useState<WorkflowVersion[]>([]);
|
||||
const [versionsLoading, setVersionsLoading] = useState(false);
|
||||
const [activeVersionId, setActiveVersionId] = useState<number | null>(null);
|
||||
// Version info that updates immediately from the GET/save/publish responses.
|
||||
const [currentVersionNumber, setCurrentVersionNumber] = useState<number | null>(initialVersionNumber ?? null);
|
||||
const [currentVersionStatus, setCurrentVersionStatus] = useState<string | null>(initialVersionStatus ?? null);
|
||||
const versionsFetched = useRef(false);
|
||||
const [documents, setDocuments] = useState<DocumentResponseSchema[] | undefined>(undefined);
|
||||
const [tools, setTools] = useState<ToolResponse[] | undefined>(undefined);
|
||||
const [recordings, setRecordings] = useState<RecordingResponseSchema[]>([]);
|
||||
|
|
@ -89,9 +90,8 @@ function RenderWorkflow({ initialWorkflowName, workflowId, initialFlow, initialT
|
|||
workflowName,
|
||||
isDirty,
|
||||
workflowValidationErrors,
|
||||
templateContextVariables,
|
||||
workflowConfigurations,
|
||||
setNodes,
|
||||
setEdges,
|
||||
setIsDirty,
|
||||
setIsAddNodePanelOpen,
|
||||
handleNodeSelect,
|
||||
|
|
@ -100,10 +100,6 @@ function RenderWorkflow({ initialWorkflowName, workflowId, initialFlow, initialT
|
|||
onEdgesChange,
|
||||
onNodesChange,
|
||||
onRun,
|
||||
saveTemplateContextVariables,
|
||||
saveWorkflowConfigurations,
|
||||
dictionary,
|
||||
saveDictionary
|
||||
} = useWorkflowState({
|
||||
initialWorkflowName,
|
||||
workflowId,
|
||||
|
|
@ -113,6 +109,123 @@ function RenderWorkflow({ initialWorkflowName, workflowId, initialFlow, initialT
|
|||
user,
|
||||
});
|
||||
|
||||
// Derive hasDraft from the current version status
|
||||
const hasDraft = currentVersionStatus === "draft";
|
||||
|
||||
// Fetch workflow versions, optionally forcing a refresh
|
||||
const fetchVersions = useCallback(async (force = false) => {
|
||||
if (versionsFetched.current && !force) return;
|
||||
setVersionsLoading(true);
|
||||
try {
|
||||
const response = await getWorkflowVersionsApiV1WorkflowWorkflowIdVersionsGet({
|
||||
path: { workflow_id: workflowId },
|
||||
});
|
||||
const data = response.data as WorkflowVersion[] | undefined;
|
||||
if (data) {
|
||||
setVersions(data);
|
||||
// Set active version to draft if exists, else published
|
||||
const current = data.find((v) => v.status === "draft") ?? data.find((v) => v.status === "published");
|
||||
if (current) {
|
||||
setActiveVersionId(current.id);
|
||||
setCurrentVersionNumber(current.version_number);
|
||||
setCurrentVersionStatus(current.status);
|
||||
}
|
||||
}
|
||||
versionsFetched.current = true;
|
||||
} finally {
|
||||
setVersionsLoading(false);
|
||||
}
|
||||
}, [workflowId]);
|
||||
|
||||
const handleOpenVersionPanel = useCallback(() => {
|
||||
setIsVersionPanelOpen(true);
|
||||
fetchVersions();
|
||||
}, [fetchVersions]);
|
||||
|
||||
const handleSelectVersion = useCallback((version: WorkflowVersion) => {
|
||||
setActiveVersionId(version.id);
|
||||
const wfJson = version.workflow_json;
|
||||
const flowNodes = (wfJson.nodes ?? []) as FlowNode[];
|
||||
const flowEdges = (wfJson.edges ?? []) as FlowEdge[];
|
||||
|
||||
// Update the Zustand store directly instead of rfInstance.current.setNodes().
|
||||
// This keeps data flow unidirectional (store → props → ReactFlow) and avoids
|
||||
// xyflow's d3 event handlers interfering with React's event delegation.
|
||||
// The key={activeVersionId} on <ReactFlow> forces a clean remount.
|
||||
setNodes(flowNodes);
|
||||
setEdges(flowEdges);
|
||||
// Never mark dirty when switching versions — historical versions are
|
||||
// read-only, and loading the draft is restoring the saved state.
|
||||
setIsDirty(false);
|
||||
setIsVersionPanelOpen(false);
|
||||
}, [setNodes, setEdges, setIsDirty]);
|
||||
|
||||
// Determine if we are viewing a historical (non-current) version.
|
||||
// The "current" version is the draft if one exists, otherwise the published version.
|
||||
// Anything else (archived, or published while a draft exists) is historical.
|
||||
const isViewingHistoricalVersion = useMemo(() => {
|
||||
if (!activeVersionId || versions.length === 0) return false;
|
||||
const activeVersion = versions.find((v) => v.id === activeVersionId);
|
||||
if (!activeVersion) return false;
|
||||
if (activeVersion.status === "draft") return false;
|
||||
if (activeVersion.status === "published" && !hasDraft) return false;
|
||||
return true;
|
||||
}, [activeVersionId, versions, hasDraft]);
|
||||
|
||||
// Return to the draft version, creating one from published if needed
|
||||
const handleBackToDraft = useCallback(async () => {
|
||||
const existingDraft = versions.find((v) => v.status === "draft");
|
||||
if (existingDraft) {
|
||||
handleSelectVersion(existingDraft);
|
||||
return;
|
||||
}
|
||||
|
||||
// No draft exists — ask the backend to create one from published
|
||||
const response = await createWorkflowDraftApiV1WorkflowWorkflowIdCreateDraftPost({
|
||||
path: { workflow_id: workflowId },
|
||||
});
|
||||
const draft = response.data;
|
||||
if (draft) {
|
||||
setCurrentVersionNumber(draft.version_number);
|
||||
setCurrentVersionStatus(draft.status);
|
||||
// Load draft nodes/edges via the Zustand store (same approach as handleSelectVersion)
|
||||
const flowNodes = (draft.workflow_json?.nodes ?? []) as FlowNode[];
|
||||
const flowEdges = (draft.workflow_json?.edges ?? []) as FlowEdge[];
|
||||
setNodes(flowNodes);
|
||||
setEdges(flowEdges);
|
||||
setActiveVersionId(draft.id);
|
||||
setIsDirty(false);
|
||||
// Refresh the version list so the new draft appears
|
||||
fetchVersions(true);
|
||||
}
|
||||
}, [versions, handleSelectVersion, workflowId, setNodes, setEdges, setIsDirty, fetchVersions]);
|
||||
|
||||
// After a successful publish, refresh the version list and update status
|
||||
const handlePublished = useCallback(() => {
|
||||
setCurrentVersionStatus("published");
|
||||
fetchVersions(true);
|
||||
}, [fetchVersions]);
|
||||
|
||||
// Compute version label for the header.
|
||||
// Uses currentVersionNumber/Status which update immediately from save responses,
|
||||
// falling back to the versions list for history navigation.
|
||||
const activeVersionLabel = useMemo(() => {
|
||||
// When viewing a version from the history panel, use the versions list
|
||||
if (activeVersionId && versions.length > 0) {
|
||||
const v = versions.find((ver) => ver.id === activeVersionId);
|
||||
if (v) {
|
||||
const statusSuffix = v.status === "draft" ? " (Draft)" : v.status === "published" ? " (Published)" : "";
|
||||
return `v${v.version_number}${statusSuffix}`;
|
||||
}
|
||||
}
|
||||
// Otherwise use the immediately-available version info from save responses
|
||||
if (currentVersionNumber != null) {
|
||||
const statusSuffix = currentVersionStatus === "draft" ? " (Draft)" : currentVersionStatus === "published" ? " (Published)" : "";
|
||||
return `v${currentVersionNumber}${statusSuffix}`;
|
||||
}
|
||||
return undefined;
|
||||
}, [activeVersionId, versions, currentVersionNumber, currentVersionStatus]);
|
||||
|
||||
// Fetch documents, tools, and recordings once for the entire workflow
|
||||
useEffect(() => {
|
||||
const fetchData = async () => {
|
||||
|
|
@ -161,13 +274,37 @@ function RenderWorkflow({ initialWorkflowName, workflowId, initialFlow, initialT
|
|||
type: "custom"
|
||||
}), []);
|
||||
|
||||
// Guard saveWorkflow so it's a no-op when viewing a historical version.
|
||||
// This is the single safety net that covers every save path: header button,
|
||||
// Cmd+S, node edit dialogs, stale doc/tool cleanup, etc.
|
||||
// Uses the save response to immediately update version label and hasDraft.
|
||||
const guardedSaveWorkflow = useCallback(async (updateWorkflowDefinition?: boolean) => {
|
||||
if (isViewingHistoricalVersion) return;
|
||||
const result = await saveWorkflow(updateWorkflowDefinition);
|
||||
if (result) {
|
||||
// If the versions list has been fetched (user interacted with versioning
|
||||
// or published), refresh it so that activeVersionId points to the correct
|
||||
// version. This is critical when a save creates a new draft from a
|
||||
// published version: without refreshing, activeVersionId would still
|
||||
// point to the old published version, causing isViewingHistoricalVersion
|
||||
// to incorrectly return true and lock the editor into read-only mode.
|
||||
if (versionsFetched.current) {
|
||||
await fetchVersions(true);
|
||||
} else {
|
||||
if (result.versionNumber != null) setCurrentVersionNumber(result.versionNumber);
|
||||
if (result.versionStatus) setCurrentVersionStatus(result.versionStatus);
|
||||
}
|
||||
}
|
||||
}, [saveWorkflow, isViewingHistoricalVersion, fetchVersions]);
|
||||
|
||||
// Memoize the context value to prevent unnecessary re-renders
|
||||
const workflowContextValue = useMemo(() => ({
|
||||
saveWorkflow,
|
||||
saveWorkflow: guardedSaveWorkflow,
|
||||
documents,
|
||||
tools,
|
||||
recordings,
|
||||
}), [saveWorkflow, documents, tools, recordings]);
|
||||
readOnly: isViewingHistoricalVersion,
|
||||
}), [guardedSaveWorkflow, documents, tools, recordings, isViewingHistoricalVersion]);
|
||||
|
||||
return (
|
||||
<WorkflowProvider value={workflowContextValue}>
|
||||
|
|
@ -180,21 +317,29 @@ function RenderWorkflow({ initialWorkflowName, workflowId, initialFlow, initialT
|
|||
rfInstance={rfInstance}
|
||||
onRun={onRun}
|
||||
workflowId={workflowId}
|
||||
saveWorkflow={saveWorkflow}
|
||||
saveWorkflow={guardedSaveWorkflow}
|
||||
user={user}
|
||||
onPhoneCallClick={() => setIsPhoneCallDialogOpen(true)}
|
||||
onHistoryClick={handleOpenVersionPanel}
|
||||
activeVersionLabel={activeVersionLabel}
|
||||
isViewingHistoricalVersion={isViewingHistoricalVersion}
|
||||
onBackToDraft={handleBackToDraft}
|
||||
hasDraft={hasDraft}
|
||||
onPublished={handlePublished}
|
||||
/>
|
||||
|
||||
{/* Workflow Canvas */}
|
||||
<div className="flex-1 relative">
|
||||
<ReactFlow
|
||||
key={activeVersionId ?? 'current'}
|
||||
nodes={nodes}
|
||||
edges={edges}
|
||||
onNodesChange={onNodesChange}
|
||||
onEdgesChange={onEdgesChange}
|
||||
nodeTypes={nodeTypes}
|
||||
edgeTypes={edgeTypes}
|
||||
onConnect={onConnect}
|
||||
onConnect={isViewingHistoricalVersion ? undefined : onConnect}
|
||||
minZoom={0.4}
|
||||
onInit={(instance) => {
|
||||
rfInstance.current = instance;
|
||||
// Center the workflow on load
|
||||
|
|
@ -204,6 +349,11 @@ function RenderWorkflow({ initialWorkflowName, workflowId, initialFlow, initialT
|
|||
}}
|
||||
defaultEdgeOptions={defaultEdgeOptions}
|
||||
defaultViewport={initialFlow?.viewport}
|
||||
nodesDraggable={!isViewingHistoricalVersion}
|
||||
nodesConnectable={!isViewingHistoricalVersion}
|
||||
edgesReconnectable={!isViewingHistoricalVersion}
|
||||
zoomOnDoubleClick={false}
|
||||
deleteKeyCode={isViewingHistoricalVersion ? null : "Backspace"}
|
||||
>
|
||||
<Background
|
||||
variant={BackgroundVariant.Dots}
|
||||
|
|
@ -212,124 +362,46 @@ function RenderWorkflow({ initialWorkflowName, workflowId, initialFlow, initialT
|
|||
color="#94a3b8"
|
||||
/>
|
||||
|
||||
{/* Top-right controls - vertical layout */}
|
||||
<Panel position="top-right">
|
||||
<TooltipProvider>
|
||||
<div className="flex flex-col gap-2">
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="default"
|
||||
size="icon"
|
||||
onClick={() => setIsAddNodePanelOpen(true)}
|
||||
className="shadow-md hover:shadow-lg"
|
||||
>
|
||||
<Plus className="h-4 w-4" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="left">
|
||||
<p>Add node</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
{/* Top-right controls - vertical layout (hidden when viewing history) */}
|
||||
{!isViewingHistoricalVersion && (
|
||||
<Panel position="top-right">
|
||||
<TooltipProvider>
|
||||
<div className="flex flex-col gap-2">
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="default"
|
||||
size="icon"
|
||||
onClick={() => setIsAddNodePanelOpen(true)}
|
||||
className="shadow-md hover:shadow-lg"
|
||||
>
|
||||
<Plus className="h-4 w-4" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="left">
|
||||
<p>Add node</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
onClick={() => setIsConfigurationsDialogOpen(true)}
|
||||
className="bg-white shadow-sm hover:shadow-md"
|
||||
>
|
||||
<Settings className="h-4 w-4" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="left">
|
||||
<p>Configurations</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
onClick={() => setIsContextVarsDialogOpen(true)}
|
||||
className="bg-white shadow-sm hover:shadow-md"
|
||||
>
|
||||
<Variable className="h-4 w-4" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="left">
|
||||
<p>Template Context Variables</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
onClick={() => setIsDictionaryDialogOpen(true)}
|
||||
className="bg-white shadow-sm hover:shadow-md"
|
||||
>
|
||||
<BookA className="h-4 w-4" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="left">
|
||||
<p>Dictionary</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
onClick={() => setIsRecordingsDialogOpen(true)}
|
||||
className="bg-white shadow-sm hover:shadow-md"
|
||||
>
|
||||
<Mic className="h-4 w-4" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="left">
|
||||
<p>Recordings</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
onClick={() => setIsVoicemailDialogOpen(true)}
|
||||
className="bg-white shadow-sm hover:shadow-md"
|
||||
>
|
||||
<PhoneOff className="h-4 w-4" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="left">
|
||||
<p>Voicemail Detection</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
onClick={() => setIsEmbedDialogOpen(true)}
|
||||
className="bg-white shadow-sm hover:shadow-md"
|
||||
>
|
||||
<Rocket className="h-4 w-4" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="left">
|
||||
<p>Deploy Workflow</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</TooltipProvider>
|
||||
</Panel>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
onClick={() => router.push(`/workflow/${workflowId}/settings`)}
|
||||
className="bg-white shadow-sm hover:shadow-md"
|
||||
>
|
||||
<Settings className="h-4 w-4" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="left">
|
||||
<p>Workflow settings</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</TooltipProvider>
|
||||
</Panel>
|
||||
)}
|
||||
</ReactFlow>
|
||||
|
||||
{/* Bottom-left controls - horizontal layout with custom buttons */}
|
||||
|
|
@ -386,25 +458,27 @@ function RenderWorkflow({ initialWorkflowName, workflowId, initialFlow, initialT
|
|||
</TooltipContent>
|
||||
</Tooltip>
|
||||
|
||||
{/* Tidy/Arrange Nodes */}
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
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" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top">
|
||||
<p>Tidy Up</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
{/* Tidy/Arrange Nodes (hidden when viewing history) */}
|
||||
{!isViewingHistoricalVersion && (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
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" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top">
|
||||
<p>Tidy Up</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
</TooltipProvider>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -415,33 +489,13 @@ function RenderWorkflow({ initialWorkflowName, workflowId, initialFlow, initialT
|
|||
onClose={() => setIsAddNodePanelOpen(false)}
|
||||
/>
|
||||
|
||||
<ConfigurationsDialog
|
||||
open={isConfigurationsDialogOpen}
|
||||
onOpenChange={setIsConfigurationsDialogOpen}
|
||||
workflowConfigurations={workflowConfigurations}
|
||||
workflowName={workflowName}
|
||||
onSave={saveWorkflowConfigurations}
|
||||
/>
|
||||
|
||||
<TemplateContextVariablesDialog
|
||||
open={isContextVarsDialogOpen}
|
||||
onOpenChange={setIsContextVarsDialogOpen}
|
||||
templateContextVariables={templateContextVariables}
|
||||
onSave={saveTemplateContextVariables}
|
||||
/>
|
||||
|
||||
<DictionaryDialog
|
||||
open={isDictionaryDialogOpen}
|
||||
onOpenChange={setIsDictionaryDialogOpen}
|
||||
dictionary={dictionary}
|
||||
onSave={saveDictionary}
|
||||
/>
|
||||
|
||||
<EmbedDialog
|
||||
open={isEmbedDialogOpen}
|
||||
onOpenChange={setIsEmbedDialogOpen}
|
||||
workflowId={workflowId}
|
||||
workflowName={workflowName}
|
||||
<VersionHistoryPanel
|
||||
isOpen={isVersionPanelOpen}
|
||||
onClose={() => setIsVersionPanelOpen(false)}
|
||||
versions={versions}
|
||||
loading={versionsLoading}
|
||||
activeVersionId={activeVersionId}
|
||||
onSelectVersion={handleSelectVersion}
|
||||
/>
|
||||
|
||||
<PhoneCallDialog
|
||||
|
|
@ -450,22 +504,6 @@ function RenderWorkflow({ initialWorkflowName, workflowId, initialFlow, initialT
|
|||
workflowId={workflowId}
|
||||
user={user}
|
||||
/>
|
||||
|
||||
<RecordingsDialog
|
||||
open={isRecordingsDialogOpen}
|
||||
onOpenChange={setIsRecordingsDialogOpen}
|
||||
workflowId={workflowId}
|
||||
onRecordingsChange={setRecordings}
|
||||
/>
|
||||
|
||||
{workflowConfigurations && (
|
||||
<VoicemailDetectionDialog
|
||||
open={isVoicemailDialogOpen}
|
||||
onOpenChange={setIsVoicemailDialogOpen}
|
||||
workflowConfigurations={workflowConfigurations}
|
||||
onSave={(configurations) => saveWorkflowConfigurations(configurations, workflowName)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</WorkflowProvider>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -0,0 +1,54 @@
|
|||
import { ServiceConfigurationForm } from "@/components/ServiceConfigurationForm";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import type { WorkflowConfigurations } from "@/types/workflow-configurations";
|
||||
|
||||
interface ModelConfigurationDialogProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
workflowConfigurations: WorkflowConfigurations | null;
|
||||
workflowName: string;
|
||||
onSave: (configurations: WorkflowConfigurations, workflowName: string) => Promise<void>;
|
||||
}
|
||||
|
||||
export const ModelConfigurationDialog = ({
|
||||
open,
|
||||
onOpenChange,
|
||||
workflowConfigurations,
|
||||
workflowName,
|
||||
onSave,
|
||||
}: ModelConfigurationDialogProps) => {
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="max-w-2xl max-h-[85vh] overflow-y-auto">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Model Configuration</DialogTitle>
|
||||
<DialogDescription>
|
||||
Override global model settings for this workflow. Toggle individual services to customize.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<ServiceConfigurationForm
|
||||
mode="override"
|
||||
currentOverrides={workflowConfigurations?.model_overrides}
|
||||
submitLabel="Save"
|
||||
onSave={async (config) => {
|
||||
await onSave(
|
||||
{
|
||||
...workflowConfigurations,
|
||||
model_overrides: config.model_overrides as WorkflowConfigurations["model_overrides"],
|
||||
} as WorkflowConfigurations,
|
||||
workflowName,
|
||||
);
|
||||
onOpenChange(false);
|
||||
}}
|
||||
/>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
|
|
@ -9,7 +9,7 @@ import {
|
|||
listRecordingsApiV1WorkflowRecordingsGet,
|
||||
transcribeAudioApiV1WorkflowRecordingsTranscribePost,
|
||||
} from "@/client";
|
||||
import type { RecordingResponseSchema } from "@/client/types.gen";
|
||||
import type { RecordingResponseSchema, RecordingUploadResponseSchema } from "@/client/types.gen";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
|
|
@ -294,7 +294,7 @@ export const RecordingsDialog = ({
|
|||
|
||||
// Step 2: Upload all files to storage in parallel
|
||||
await Promise.all(
|
||||
items.map(async (item, idx) => {
|
||||
items.map(async (item: RecordingUploadResponseSchema, idx: number) => {
|
||||
const file = ready[idx].file;
|
||||
const uploadResponse = await fetch(item.upload_url, {
|
||||
method: "PUT",
|
||||
|
|
@ -312,7 +312,7 @@ export const RecordingsDialog = ({
|
|||
// Step 3: Create all recording records
|
||||
await createRecordingsApiV1WorkflowRecordingsPost({
|
||||
body: {
|
||||
recordings: items.map((item, idx) => ({
|
||||
recordings: items.map((item: RecordingUploadResponseSchema, idx: number) => ({
|
||||
recording_id: item.recording_id,
|
||||
workflow_id: workflowId,
|
||||
tts_provider: ttsProvider,
|
||||
|
|
|
|||
|
|
@ -0,0 +1,133 @@
|
|||
"use client";
|
||||
|
||||
import { formatDistanceToNow } from "date-fns";
|
||||
import { FileText, LoaderCircle, X } from "lucide-react";
|
||||
import { useEffect } from "react";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
|
||||
export interface WorkflowVersion {
|
||||
id: number;
|
||||
version_number: number;
|
||||
status: string;
|
||||
created_at: string;
|
||||
published_at: string | null;
|
||||
workflow_json: { nodes?: unknown[]; edges?: unknown[]; viewport?: unknown };
|
||||
workflow_configurations: Record<string, unknown> | null;
|
||||
template_context_variables: Record<string, string> | null;
|
||||
}
|
||||
|
||||
interface VersionHistoryPanelProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
versions: WorkflowVersion[];
|
||||
loading: boolean;
|
||||
activeVersionId: number | null;
|
||||
onSelectVersion: (version: WorkflowVersion) => void;
|
||||
}
|
||||
|
||||
const statusLabel: Record<string, string> = {
|
||||
draft: "Draft",
|
||||
published: "Published",
|
||||
archived: "Archived",
|
||||
};
|
||||
|
||||
const statusColor: Record<string, string> = {
|
||||
draft: "bg-yellow-500/20 text-yellow-400 border-yellow-500/30",
|
||||
published: "bg-green-500/20 text-green-400 border-green-500/30",
|
||||
archived: "bg-gray-500/20 text-gray-400 border-gray-500/30",
|
||||
};
|
||||
|
||||
export const VersionHistoryPanel = ({
|
||||
isOpen,
|
||||
onClose,
|
||||
versions,
|
||||
loading,
|
||||
activeVersionId,
|
||||
onSelectVersion,
|
||||
}: VersionHistoryPanelProps) => {
|
||||
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-[#1a1a1a] border-l border-[#2a2a2a] shadow-lg transform transition-transform duration-300 ease-in-out ${
|
||||
isOpen ? "translate-x-0" : "translate-x-full"
|
||||
}`}
|
||||
>
|
||||
<div className="p-4 h-full overflow-y-auto">
|
||||
<div className="flex justify-between items-center mb-6">
|
||||
<h2 className="text-lg font-semibold text-white">
|
||||
Version History
|
||||
</h2>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={onClose}
|
||||
className="text-gray-400 hover:text-white hover:bg-[#2a2a2a]"
|
||||
>
|
||||
<X className="w-5 h-5" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<LoaderCircle className="w-6 h-6 text-gray-400 animate-spin" />
|
||||
</div>
|
||||
) : versions.length === 0 ? (
|
||||
<p className="text-sm text-gray-500 text-center py-8">
|
||||
No versions found.
|
||||
</p>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{versions.map((version) => {
|
||||
const isActive = version.id === activeVersionId;
|
||||
const date = version.published_at || version.created_at;
|
||||
return (
|
||||
<button
|
||||
key={version.id}
|
||||
onClick={() => onSelectVersion(version)}
|
||||
className={`w-full text-left p-3 rounded-lg border transition-colors cursor-pointer ${
|
||||
isActive
|
||||
? "border-teal-500/50 bg-teal-500/10"
|
||||
: "border-[#2a2a2a] bg-[#222] hover:bg-[#2a2a2a]"
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center justify-between mb-1.5">
|
||||
<div className="flex items-center gap-2">
|
||||
<FileText className="w-4 h-4 text-gray-400" />
|
||||
<span className="text-sm font-medium text-white">
|
||||
v{version.version_number}
|
||||
</span>
|
||||
</div>
|
||||
{version.status !== "archived" && (
|
||||
<span
|
||||
className={`text-xs px-2 py-0.5 rounded-full border ${
|
||||
statusColor[version.status] ?? ""
|
||||
}`}
|
||||
>
|
||||
{statusLabel[version.status] ?? version.status}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-xs text-gray-500">
|
||||
{formatDistanceToNow(new Date(date), {
|
||||
addSuffix: true,
|
||||
})}
|
||||
</p>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
@ -1,12 +1,15 @@
|
|||
"use client";
|
||||
|
||||
import { ReactFlowInstance } from "@xyflow/react";
|
||||
import { AlertCircle, ArrowLeft, ChevronDown, Copy, Download, History, LoaderCircle, MoreVertical, Phone } from "lucide-react";
|
||||
import { AlertCircle, ArrowLeft, ChevronDown, Copy, Download, Eye, History, LoaderCircle, MoreVertical, Phone, Rocket } from "lucide-react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import { duplicateWorkflowEndpointApiV1WorkflowWorkflowIdDuplicatePost } from "@/client/sdk.gen";
|
||||
import {
|
||||
duplicateWorkflowEndpointApiV1WorkflowWorkflowIdDuplicatePost,
|
||||
publishWorkflowApiV1WorkflowWorkflowIdPublishPost,
|
||||
} from "@/client/sdk.gen";
|
||||
import { WorkflowError } from "@/client/types.gen";
|
||||
import { FlowEdge, FlowNode } from "@/components/flow/types";
|
||||
import { Button } from "@/components/ui/button";
|
||||
|
|
@ -33,6 +36,12 @@ interface WorkflowEditorHeaderProps {
|
|||
saveWorkflow: (updateWorkflowDefinition?: boolean) => Promise<void>;
|
||||
user: { id: string; email?: string };
|
||||
onPhoneCallClick: () => void;
|
||||
onHistoryClick: () => void;
|
||||
activeVersionLabel?: string;
|
||||
isViewingHistoricalVersion: boolean;
|
||||
onBackToDraft: () => void;
|
||||
hasDraft: boolean;
|
||||
onPublished: () => void;
|
||||
}
|
||||
|
||||
export const WorkflowEditorHeader = ({
|
||||
|
|
@ -43,11 +52,18 @@ export const WorkflowEditorHeader = ({
|
|||
saveWorkflow,
|
||||
onRun,
|
||||
onPhoneCallClick,
|
||||
onHistoryClick,
|
||||
activeVersionLabel,
|
||||
isViewingHistoricalVersion,
|
||||
onBackToDraft,
|
||||
hasDraft,
|
||||
onPublished,
|
||||
workflowId,
|
||||
}: WorkflowEditorHeaderProps) => {
|
||||
const router = useRouter();
|
||||
const [savingWorkflow, setSavingWorkflow] = useState(false);
|
||||
const [duplicating, setDuplicating] = useState(false);
|
||||
const [publishing, setPublishing] = useState(false);
|
||||
|
||||
const hasValidationErrors = workflowValidationErrors.length > 0;
|
||||
const isCallDisabled = isDirty || hasValidationErrors;
|
||||
|
|
@ -58,6 +74,25 @@ export const WorkflowEditorHeader = ({
|
|||
setSavingWorkflow(false);
|
||||
};
|
||||
|
||||
const handlePublish = async () => {
|
||||
if (publishing) return;
|
||||
setPublishing(true);
|
||||
const promise = publishWorkflowApiV1WorkflowWorkflowIdPublishPost({
|
||||
path: { workflow_id: workflowId },
|
||||
});
|
||||
toast.promise(promise, {
|
||||
loading: "Publishing...",
|
||||
success: "Workflow published successfully",
|
||||
error: "Failed to publish workflow",
|
||||
});
|
||||
try {
|
||||
await promise;
|
||||
onPublished();
|
||||
} finally {
|
||||
setPublishing(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleBack = () => {
|
||||
router.push("/workflow");
|
||||
};
|
||||
|
|
@ -121,10 +156,41 @@ export const WorkflowEditorHeader = ({
|
|||
</div>
|
||||
</div>
|
||||
|
||||
{/* Right section: Unsaved indicator + Call button + Save button */}
|
||||
{/* Right section: Version + Unsaved indicator + Call button + Save button */}
|
||||
<div className="flex items-center gap-3">
|
||||
{/* Unsaved changes indicator */}
|
||||
{isDirty && (
|
||||
{/* Read-only banner when viewing a historical version */}
|
||||
{isViewingHistoricalVersion && (
|
||||
<div className="flex items-center gap-2 px-3 py-1.5 rounded-md border border-blue-500/30 bg-blue-500/10">
|
||||
<Eye className="w-4 h-4 text-blue-400" />
|
||||
<span className="text-sm text-blue-400">
|
||||
Viewing {activeVersionLabel} — Read only
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Back to Draft button when viewing history */}
|
||||
{isViewingHistoricalVersion && (
|
||||
<Button
|
||||
onClick={onBackToDraft}
|
||||
className="bg-teal-600 hover:bg-teal-700 text-white px-4"
|
||||
>
|
||||
Back to Draft
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{/* Version history button */}
|
||||
<button
|
||||
onClick={onHistoryClick}
|
||||
className="flex items-center gap-2 px-3 py-1.5 rounded-md border border-[#3a3a3a] hover:bg-[#2a2a2a] transition-colors cursor-pointer"
|
||||
>
|
||||
<History className="w-4 h-4 text-gray-400" />
|
||||
{activeVersionLabel && !isViewingHistoricalVersion && (
|
||||
<span className="text-sm text-gray-300">{activeVersionLabel}</span>
|
||||
)}
|
||||
</button>
|
||||
|
||||
{/* Unsaved changes indicator (hidden when viewing history) */}
|
||||
{isDirty && !isViewingHistoricalVersion && (
|
||||
<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>
|
||||
|
|
@ -177,57 +243,83 @@ export const WorkflowEditorHeader = ({
|
|||
</Popover>
|
||||
)}
|
||||
|
||||
{/* 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={() => {
|
||||
// Delay opening dialog to next event cycle to allow DropdownMenu
|
||||
// to clean up first, preventing pointer-events: none stuck on body
|
||||
// See: https://github.com/radix-ui/primitives/issues/1241
|
||||
setTimeout(onPhoneCallClick, 0);
|
||||
}}
|
||||
className="text-white hover:bg-[#2a2a2a] cursor-pointer"
|
||||
>
|
||||
<Phone className="w-4 h-4 mr-2" />
|
||||
Phone Call
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
{/* Call button with dropdown (hidden when viewing history) */}
|
||||
{!isViewingHistoricalVersion && (
|
||||
<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={() => {
|
||||
// Delay opening dialog to next event cycle to allow DropdownMenu
|
||||
// to clean up first, preventing pointer-events: none stuck on body
|
||||
// See: https://github.com/radix-ui/primitives/issues/1241
|
||||
setTimeout(onPhoneCallClick, 0);
|
||||
}}
|
||||
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>
|
||||
{/* Save button (only shown when editing the draft) */}
|
||||
{!isViewingHistoricalVersion && (
|
||||
<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>
|
||||
)}
|
||||
|
||||
{/* Publish button (only when on draft with no unsaved changes) */}
|
||||
{!isViewingHistoricalVersion && hasDraft && (
|
||||
<Button
|
||||
onClick={handlePublish}
|
||||
disabled={isDirty || publishing || hasValidationErrors}
|
||||
variant="outline"
|
||||
className="border-[#3a3a3a] bg-transparent hover:bg-[#2a2a2a] text-white px-4"
|
||||
>
|
||||
{publishing ? (
|
||||
<>
|
||||
<LoaderCircle className="w-4 h-4 mr-2 animate-spin" />
|
||||
Publishing...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Rocket className="w-4 h-4 mr-2" />
|
||||
Publish
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{/* More options dropdown */}
|
||||
<DropdownMenu>
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ interface WorkflowContextType {
|
|||
documents?: DocumentResponseSchema[];
|
||||
tools?: ToolResponse[];
|
||||
recordings?: RecordingResponseSchema[];
|
||||
readOnly?: boolean;
|
||||
}
|
||||
|
||||
const WorkflowContext = createContext<WorkflowContextType | undefined>(undefined);
|
||||
|
|
|
|||
|
|
@ -165,7 +165,7 @@ interface UseWorkflowStateProps {
|
|||
};
|
||||
initialTemplateContextVariables?: Record<string, string>;
|
||||
initialWorkflowConfigurations?: WorkflowConfigurations;
|
||||
user: { id: string; email?: string }; // Minimal user type needed
|
||||
user: { id: string; email?: string } | null;
|
||||
}
|
||||
|
||||
export const useWorkflowState = ({
|
||||
|
|
@ -300,7 +300,7 @@ export const useWorkflowState = ({
|
|||
|
||||
// Validate workflow function
|
||||
const validateWorkflow = useCallback(async () => {
|
||||
if (!user) return;
|
||||
if (!user?.id) return;
|
||||
try {
|
||||
const response = await validateWorkflowApiV1WorkflowWorkflowIdValidatePost({
|
||||
path: {
|
||||
|
|
@ -342,7 +342,7 @@ export const useWorkflowState = ({
|
|||
if (response.data.is_valid === false && response.data.errors) {
|
||||
const errors = response.data.errors;
|
||||
|
||||
errors.forEach((error) => {
|
||||
errors.forEach((error: WorkflowError) => {
|
||||
if (error.kind === 'node' && error.id) {
|
||||
markNodeAsInvalid(error.id, error.message);
|
||||
} else if (error.kind === 'edge' && error.id) {
|
||||
|
|
@ -355,14 +355,14 @@ export const useWorkflowState = ({
|
|||
logger.info('Workflow is valid');
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
} catch (error: unknown) {
|
||||
logger.error(`Unexpected validation error: ${error}`);
|
||||
}
|
||||
}, [workflowId, user, clearValidationErrors, markNodeAsInvalid, markEdgeAsInvalid, setWorkflowValidationErrors]);
|
||||
|
||||
// Save workflow function
|
||||
const saveWorkflow = useCallback(async (updateWorkflowDefinition: boolean = true) => {
|
||||
if (!user || !rfInstance.current) return;
|
||||
// Save workflow function. Returns version info from the API response.
|
||||
const saveWorkflow = useCallback(async (updateWorkflowDefinition: boolean = true): Promise<{ versionNumber?: number; versionStatus?: string } | undefined> => {
|
||||
if (!user?.id || !rfInstance.current) return;
|
||||
// Read nodes/edges from the Zustand store (synchronously up-to-date)
|
||||
// and viewport from the ReactFlow instance to build the flow object.
|
||||
// This avoids a race condition where rfInstance.toObject() may return
|
||||
|
|
@ -370,8 +370,9 @@ export const useWorkflowState = ({
|
|||
const { nodes: currentNodes, edges: currentEdges } = useWorkflowStore.getState();
|
||||
const viewport = rfInstance.current.getViewport();
|
||||
const flow = { nodes: currentNodes, edges: currentEdges, viewport };
|
||||
let result: { versionNumber?: number; versionStatus?: string } | undefined;
|
||||
try {
|
||||
await updateWorkflowApiV1WorkflowWorkflowIdPut({
|
||||
const response = await updateWorkflowApiV1WorkflowWorkflowIdPut({
|
||||
path: {
|
||||
workflow_id: workflowId,
|
||||
},
|
||||
|
|
@ -381,12 +382,19 @@ export const useWorkflowState = ({
|
|||
},
|
||||
});
|
||||
setIsDirty(false);
|
||||
if (response.data) {
|
||||
result = {
|
||||
versionNumber: response.data.version_number ?? undefined,
|
||||
versionStatus: response.data.version_status ?? undefined,
|
||||
};
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error(`Error saving workflow: ${error}`);
|
||||
}
|
||||
|
||||
// Validate after saving
|
||||
await validateWorkflow();
|
||||
return result;
|
||||
}, [workflowId, workflowName, setIsDirty, user, validateWorkflow]);
|
||||
|
||||
// Set up keyboard shortcut for save (Cmd/Ctrl + S)
|
||||
|
|
@ -394,7 +402,9 @@ export const useWorkflowState = ({
|
|||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if ((e.metaKey || e.ctrlKey) && e.key === 's') {
|
||||
e.preventDefault();
|
||||
saveWorkflow();
|
||||
if (useWorkflowStore.getState().isDirty) {
|
||||
saveWorkflow();
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
|
|
@ -439,7 +449,7 @@ export const useWorkflowState = ({
|
|||
);
|
||||
|
||||
const onRun = async (mode: string) => {
|
||||
if (!user) return;
|
||||
if (!user?.id) return;
|
||||
const workflowRunName = `WR-${getRandomId()}`;
|
||||
const response = await createWorkflowRunApiV1WorkflowWorkflowIdRunsPost({
|
||||
path: {
|
||||
|
|
@ -455,7 +465,7 @@ export const useWorkflowState = ({
|
|||
|
||||
// Save template context variables
|
||||
const saveTemplateContextVariables = useCallback(async (variables: Record<string, string>) => {
|
||||
if (!user) return;
|
||||
if (!user?.id) return;
|
||||
try {
|
||||
await updateWorkflowApiV1WorkflowWorkflowIdPut({
|
||||
path: {
|
||||
|
|
@ -477,12 +487,12 @@ export const useWorkflowState = ({
|
|||
|
||||
// Save workflow configurations
|
||||
const saveWorkflowConfigurations = useCallback(async (configurations: WorkflowConfigurations, newWorkflowName: string) => {
|
||||
if (!user) return;
|
||||
if (!user?.id) return;
|
||||
// Preserve the current dictionary when saving other configurations
|
||||
const currentDictionary = useWorkflowStore.getState().dictionary;
|
||||
const configurationsWithDictionary: WorkflowConfigurations = { ...configurations, dictionary: currentDictionary };
|
||||
try {
|
||||
await updateWorkflowApiV1WorkflowWorkflowIdPut({
|
||||
const response = await updateWorkflowApiV1WorkflowWorkflowIdPut({
|
||||
path: {
|
||||
workflow_id: workflowId,
|
||||
},
|
||||
|
|
@ -492,6 +502,22 @@ export const useWorkflowState = ({
|
|||
workflow_configurations: configurationsWithDictionary as Record<string, unknown>,
|
||||
},
|
||||
});
|
||||
|
||||
if (response.error) {
|
||||
const detail = (response.error as { detail?: unknown }).detail;
|
||||
let msg = 'Failed to save workflow configurations';
|
||||
if (typeof detail === 'string') {
|
||||
msg = detail;
|
||||
} else if (Array.isArray(detail)) {
|
||||
msg = detail
|
||||
.map((e: { model?: string; message?: string; msg?: string }) =>
|
||||
e.model && e.message ? `${e.model}: ${e.message}` : (e.msg || JSON.stringify(e))
|
||||
)
|
||||
.join('\n');
|
||||
}
|
||||
throw new Error(msg);
|
||||
}
|
||||
|
||||
setWorkflowConfigurations(configurationsWithDictionary);
|
||||
// Set name directly in the store to avoid setWorkflowName which marks isDirty: true
|
||||
useWorkflowStore.setState({ workflowName: newWorkflowName });
|
||||
|
|
@ -550,6 +576,7 @@ export const useWorkflowState = ({
|
|||
workflowConfigurations,
|
||||
dictionary,
|
||||
setNodes,
|
||||
setEdges,
|
||||
setIsDirty,
|
||||
setIsAddNodePanelOpen,
|
||||
handleNodeSelect,
|
||||
|
|
|
|||
|
|
@ -82,6 +82,8 @@ export default function WorkflowDetailPage() {
|
|||
}}
|
||||
initialTemplateContextVariables={workflow.template_context_variables as Record<string, string> || {}}
|
||||
initialWorkflowConfigurations={(workflow.workflow_configurations as WorkflowConfigurations) || DEFAULT_WORKFLOW_CONFIGURATIONS}
|
||||
initialVersionNumber={workflow.version_number ?? null}
|
||||
initialVersionStatus={workflow.version_status ?? null}
|
||||
user={stableUser}
|
||||
/>
|
||||
) : null;
|
||||
|
|
|
|||
|
|
@ -526,7 +526,7 @@ export const useWebSocketRTC = ({ workflowId, workflowRunId, accessToken, initia
|
|||
});
|
||||
if (turnResponse.data) {
|
||||
turnCredentialsRef.current = turnResponse.data;
|
||||
logger.info(`TURN credentials obtained, TTL: ${turnCredentialsRef.current.ttl}s`);
|
||||
logger.info(`TURN credentials obtained, TTL: ${turnResponse.data.ttl}s`);
|
||||
} else if (turnResponse.response.status === 503) {
|
||||
// TURN not configured on server - this is OK, we'll use STUN only
|
||||
logger.info('TURN server not configured, using STUN only');
|
||||
|
|
|
|||
926
ui/src/app/workflow/[workflowId]/settings/page.tsx
Normal file
926
ui/src/app/workflow/[workflowId]/settings/page.tsx
Normal file
|
|
@ -0,0 +1,926 @@
|
|||
"use client";
|
||||
|
||||
import { ArrowLeft, BookA, Brain, ExternalLink, Mic, PhoneOff, Rocket, Settings, Trash2Icon, Variable } from "lucide-react";
|
||||
import { useParams, useRouter } from "next/navigation";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
|
||||
import { getWorkflowApiV1WorkflowFetchWorkflowIdGet } from "@/client/sdk.gen";
|
||||
import type { WorkflowResponse } from "@/client/types.gen";
|
||||
import { FlowEdge, FlowNode } from "@/components/flow/types";
|
||||
import { LLMConfigSelector } from "@/components/LLMConfigSelector";
|
||||
import { ServiceConfigurationForm } from "@/components/ServiceConfigurationForm";
|
||||
import SpinLoader from "@/components/SpinLoader";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } 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 { Separator } from "@/components/ui/separator";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { SETTINGS_DOCUMENTATION_URLS } from "@/constants/documentation";
|
||||
import { useAuth } from "@/lib/auth";
|
||||
import logger from "@/lib/logger";
|
||||
import {
|
||||
type AmbientNoiseConfiguration,
|
||||
DEFAULT_VOICEMAIL_DETECTION_CONFIGURATION,
|
||||
DEFAULT_WORKFLOW_CONFIGURATIONS,
|
||||
type TurnStopStrategy,
|
||||
type VoicemailDetectionConfiguration,
|
||||
type WorkflowConfigurations,
|
||||
} from "@/types/workflow-configurations";
|
||||
|
||||
import { EmbedDialog } from "../components/EmbedDialog";
|
||||
import { RecordingsDialog } from "../components/RecordingsDialog";
|
||||
import { useWorkflowState } from "../hooks/useWorkflowState";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Constants
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const DEFAULT_AMBIENT_NOISE_CONFIG: AmbientNoiseConfiguration = {
|
||||
enabled: false,
|
||||
volume: 0.3,
|
||||
};
|
||||
|
||||
const DEFAULT_VOICEMAIL_SYSTEM_PROMPT = `You are a voicemail detection classifier for an OUTBOUND calling system. A bot has called a phone number and you need to determine if a human answered or if the call went to voicemail based on the provided text.
|
||||
|
||||
HUMAN ANSWERED - LIVE CONVERSATION (respond "CONVERSATION"):
|
||||
- Personal greetings: "Hello?", "Hi", "Yeah?", "John speaking"
|
||||
- Interactive responses: "Who is this?", "What do you want?", "Can I help you?"
|
||||
- Conversational tone expecting back-and-forth dialogue
|
||||
- Questions directed at the caller: "Hello? Anyone there?"
|
||||
- Informal responses: "Yep", "What's up?", "Speaking"
|
||||
- Natural, spontaneous speech patterns
|
||||
- Immediate acknowledgment of the call
|
||||
|
||||
VOICEMAIL SYSTEM (respond "VOICEMAIL"):
|
||||
- Automated voicemail greetings: "Hi, you've reached [name], please leave a message"
|
||||
- Phone carrier messages: "The number you have dialed is not in service", "Please leave a message", "All circuits are busy"
|
||||
- Professional voicemail: "This is [name], I'm not available right now"
|
||||
- Instructions about leaving messages: "leave a message", "leave your name and number"
|
||||
- References to callback or messaging: "call me back", "I'll get back to you"
|
||||
- Carrier system messages: "mailbox is full", "has not been set up"
|
||||
- Business hours messages: "our office is currently closed"
|
||||
|
||||
Respond with ONLY "CONVERSATION" if a person answered, or "VOICEMAIL" if it's voicemail/recording.`;
|
||||
|
||||
// Sidebar navigation items
|
||||
const NAV_ITEMS = [
|
||||
{ id: "general", label: "General", icon: Settings },
|
||||
{ id: "models", label: "Model Overrides", icon: Brain },
|
||||
{ id: "variables", label: "Template Variables", icon: Variable },
|
||||
{ id: "dictionary", label: "Dictionary", icon: BookA },
|
||||
{ id: "voicemail", label: "Voicemail Detection", icon: PhoneOff },
|
||||
{ id: "recordings", label: "Recordings", icon: Mic },
|
||||
{ id: "deployment", label: "Deployment", icon: Rocket },
|
||||
];
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Section: General
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function GeneralSection({
|
||||
workflowConfigurations,
|
||||
workflowName,
|
||||
onSave,
|
||||
}: {
|
||||
workflowConfigurations: WorkflowConfigurations;
|
||||
workflowName: string;
|
||||
onSave: (configurations: WorkflowConfigurations, workflowName: string) => Promise<void>;
|
||||
}) {
|
||||
const [name, setName] = useState(workflowName);
|
||||
const [ambientNoiseConfig, setAmbientNoiseConfig] = useState<AmbientNoiseConfiguration>(
|
||||
workflowConfigurations.ambient_noise_configuration || DEFAULT_AMBIENT_NOISE_CONFIG,
|
||||
);
|
||||
const [maxCallDuration, setMaxCallDuration] = useState(workflowConfigurations.max_call_duration || 600);
|
||||
const [maxUserIdleTimeout, setMaxUserIdleTimeout] = useState(workflowConfigurations.max_user_idle_timeout || 10);
|
||||
const [smartTurnStopSecs, setSmartTurnStopSecs] = useState(workflowConfigurations.smart_turn_stop_secs || 2);
|
||||
const [turnStopStrategy, setTurnStopStrategy] = useState<TurnStopStrategy>(
|
||||
workflowConfigurations.turn_stop_strategy || "transcription",
|
||||
);
|
||||
const [contextCompactionEnabled, setContextCompactionEnabled] = useState(
|
||||
workflowConfigurations.context_compaction_enabled ?? false,
|
||||
);
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
|
||||
const handleSave = async () => {
|
||||
setIsSaving(true);
|
||||
try {
|
||||
await onSave(
|
||||
{
|
||||
...workflowConfigurations,
|
||||
ambient_noise_configuration: ambientNoiseConfig,
|
||||
max_call_duration: maxCallDuration,
|
||||
max_user_idle_timeout: maxUserIdleTimeout,
|
||||
smart_turn_stop_secs: smartTurnStopSecs,
|
||||
turn_stop_strategy: turnStopStrategy,
|
||||
context_compaction_enabled: contextCompactionEnabled,
|
||||
},
|
||||
name,
|
||||
);
|
||||
} catch (error) {
|
||||
console.error("Failed to save general settings:", error);
|
||||
} finally {
|
||||
setIsSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Card id="general">
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2 text-base">
|
||||
<Settings className="h-4 w-4" />
|
||||
General
|
||||
</CardTitle>
|
||||
<CardDescription>Agent name, call behavior, and turn detection.{" "}
|
||||
<a href={SETTINGS_DOCUMENTATION_URLS.general} target="_blank" rel="noopener noreferrer" className="inline-flex items-center gap-0.5 underline">Learn more <ExternalLink className="h-3 w-3" /></a>
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-6">
|
||||
{/* Agent Name */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="workflow_name" className="text-sm font-medium">Agent Name</Label>
|
||||
<Input
|
||||
id="workflow_name"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
placeholder="Enter Agent name"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
{/* Ambient Noise */}
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<h3 className="text-sm font-medium">Ambient Noise</h3>
|
||||
<p className="text-xs text-muted-foreground mt-0.5">
|
||||
Add background office ambient noise to make the conversation sound more natural.
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<Label htmlFor="ambient-noise-enabled" className="text-sm">Use Ambient Noise</Label>
|
||||
<Switch
|
||||
id="ambient-noise-enabled"
|
||||
checked={ambientNoiseConfig.enabled}
|
||||
onCheckedChange={(checked) =>
|
||||
setAmbientNoiseConfig((prev) => ({ ...prev, enabled: checked }))
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
{ambientNoiseConfig.enabled && (
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="ambient-volume" className="text-xs">Volume</Label>
|
||||
<Input
|
||||
id="ambient-volume"
|
||||
type="number"
|
||||
step="0.1"
|
||||
min="0"
|
||||
max="1"
|
||||
value={ambientNoiseConfig.volume}
|
||||
onChange={(e) => {
|
||||
const value = parseFloat(e.target.value);
|
||||
if (!isNaN(value)) setAmbientNoiseConfig((prev) => ({ ...prev, volume: value }));
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
{/* Turn Detection */}
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<h3 className="text-sm font-medium">Turn Detection</h3>
|
||||
<p className="text-xs text-muted-foreground mt-0.5">
|
||||
Configure how the agent detects when the user has finished speaking.
|
||||
</p>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="turn_stop_strategy" className="text-xs">Detection Strategy</Label>
|
||||
<Select
|
||||
value={turnStopStrategy}
|
||||
onValueChange={(value: TurnStopStrategy) => setTurnStopStrategy(value)}
|
||||
>
|
||||
<SelectTrigger id="turn_stop_strategy">
|
||||
<SelectValue placeholder="Select strategy" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="transcription">Transcription-based</SelectItem>
|
||||
<SelectItem value="turn_analyzer">Smart Turn Analyzer</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{turnStopStrategy === "transcription"
|
||||
? "Best for short responses (1-2 word statements). Ends turn when transcription indicates completion."
|
||||
: "Best for longer responses with natural pauses. Uses ML model to detect end of turn."}
|
||||
</p>
|
||||
</div>
|
||||
{turnStopStrategy === "turn_analyzer" && (
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="smart_turn_stop_secs" className="text-xs">
|
||||
Incomplete Turn Timeout (seconds)
|
||||
</Label>
|
||||
<Input
|
||||
id="smart_turn_stop_secs"
|
||||
type="number"
|
||||
step="0.5"
|
||||
min="0.5"
|
||||
max="10"
|
||||
value={smartTurnStopSecs}
|
||||
onChange={(e) => {
|
||||
const value = parseFloat(e.target.value);
|
||||
if (!isNaN(value) && value >= 0.5) setSmartTurnStopSecs(value);
|
||||
}}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Max silence duration before ending an incomplete turn. Default: 2 seconds
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
{/* Context Compaction */}
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<h3 className="text-sm font-medium">Context Compaction</h3>
|
||||
<p className="text-xs text-muted-foreground mt-0.5">
|
||||
Automatically summarize conversation context when transitioning between nodes.
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<Label htmlFor="context-compaction-enabled" className="text-sm">
|
||||
Enable Context Compaction
|
||||
</Label>
|
||||
<Switch
|
||||
id="context-compaction-enabled"
|
||||
checked={contextCompactionEnabled}
|
||||
onCheckedChange={setContextCompactionEnabled}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
{/* Call Management */}
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<h3 className="text-sm font-medium">Call Management</h3>
|
||||
<p className="text-xs text-muted-foreground mt-0.5">
|
||||
Configure call duration limits and idle timeout settings.
|
||||
</p>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="max_call_duration" className="text-xs">Max Call Duration (seconds)</Label>
|
||||
<Input
|
||||
id="max_call_duration"
|
||||
type="number"
|
||||
min="1"
|
||||
value={maxCallDuration}
|
||||
onChange={(e) => {
|
||||
const value = parseInt(e.target.value);
|
||||
if (!isNaN(value) && value > 0) setMaxCallDuration(value);
|
||||
}}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">Default: 600 (10 minutes)</p>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="max_user_idle_timeout" className="text-xs">
|
||||
Max User Idle Timeout (seconds)
|
||||
</Label>
|
||||
<Input
|
||||
id="max_user_idle_timeout"
|
||||
type="number"
|
||||
min="1"
|
||||
value={maxUserIdleTimeout}
|
||||
onChange={(e) => {
|
||||
const value = parseInt(e.target.value);
|
||||
if (!isNaN(value) && value > 0) setMaxUserIdleTimeout(value);
|
||||
}}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">Default: 10 seconds</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
<CardFooter className="justify-end border-t pt-6">
|
||||
<Button onClick={handleSave} disabled={isSaving}>
|
||||
{isSaving ? "Saving..." : "Save General Settings"}
|
||||
</Button>
|
||||
</CardFooter>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Section: Template Variables
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function TemplateVariablesSection({
|
||||
templateContextVariables,
|
||||
onSave,
|
||||
}: {
|
||||
templateContextVariables: Record<string, string>;
|
||||
onSave: (variables: Record<string, string>) => Promise<void>;
|
||||
}) {
|
||||
const [contextVars, setContextVars] = useState<Record<string, string>>(templateContextVariables);
|
||||
const [newKey, setNewKey] = useState("");
|
||||
const [newValue, setNewValue] = useState("");
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
|
||||
const handleAdd = () => {
|
||||
if (newKey && newValue) {
|
||||
setContextVars((prev) => ({ ...prev, [newKey]: newValue }));
|
||||
}
|
||||
setNewKey("");
|
||||
setNewValue("");
|
||||
};
|
||||
|
||||
const handleRemove = (key: string) => {
|
||||
setContextVars((prev) => {
|
||||
const next = { ...prev };
|
||||
delete next[key];
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
const handleSave = async () => {
|
||||
setIsSaving(true);
|
||||
try {
|
||||
let varsToSave = contextVars;
|
||||
if (newKey && newValue) {
|
||||
varsToSave = { ...varsToSave, [newKey]: newValue };
|
||||
}
|
||||
await onSave(varsToSave);
|
||||
} catch (error) {
|
||||
console.error("Failed to save variables:", error);
|
||||
} finally {
|
||||
setIsSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Card id="variables">
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2 text-base">
|
||||
<Variable className="h-4 w-4" />
|
||||
Template Variables
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
Variables available in workflow prompts via {`{{variable_name}}`} syntax for testing the workflow.{" "}
|
||||
<a href={SETTINGS_DOCUMENTATION_URLS.templateVariables} target="_blank" rel="noopener noreferrer" className="inline-flex items-center gap-0.5 underline">Learn more <ExternalLink className="h-3 w-3" /></a>
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
{/* Existing Variables */}
|
||||
{Object.entries(contextVars).length > 0 && (
|
||||
<div className="space-y-2">
|
||||
<Label className="text-sm font-medium">Current Variables</Label>
|
||||
{Object.entries(contextVars).map(([key, value]) => (
|
||||
<div key={key} className="flex items-center gap-2 rounded-md border p-2">
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="text-sm font-medium">{key}</div>
|
||||
<div className="text-xs text-muted-foreground truncate">{value}</div>
|
||||
</div>
|
||||
<Button size="sm" variant="ghost" onClick={() => handleRemove(key)}>
|
||||
<Trash2Icon className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Add New Variable */}
|
||||
<div className="space-y-3">
|
||||
<Label className="text-sm font-medium">Add New Variable</Label>
|
||||
<div className="flex gap-2">
|
||||
<div className="flex-1 space-y-1">
|
||||
<Label htmlFor="var-key" className="text-xs">Key</Label>
|
||||
<Input
|
||||
id="var-key"
|
||||
placeholder="Enter variable key"
|
||||
value={newKey}
|
||||
onChange={(e) => setNewKey(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex-1 space-y-1">
|
||||
<Label htmlFor="var-value" className="text-xs">Value</Label>
|
||||
<Input
|
||||
id="var-value"
|
||||
placeholder="Enter variable value"
|
||||
value={newValue}
|
||||
onChange={(e) => setNewValue(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<Button size="sm" onClick={handleAdd} disabled={!newKey || !newValue}>
|
||||
Add Variable
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
<CardFooter className="justify-end border-t pt-6">
|
||||
<Button onClick={handleSave} disabled={isSaving}>
|
||||
{isSaving ? "Saving..." : "Save Variables"}
|
||||
</Button>
|
||||
</CardFooter>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Section: Dictionary
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function DictionarySection({
|
||||
dictionary,
|
||||
onSave,
|
||||
}: {
|
||||
dictionary: string;
|
||||
onSave: (dictionary: string) => Promise<void>;
|
||||
}) {
|
||||
const [dictionaryValue, setDictionaryValue] = useState(dictionary);
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
|
||||
const handleSave = async () => {
|
||||
setIsSaving(true);
|
||||
try {
|
||||
await onSave(dictionaryValue);
|
||||
} catch (error) {
|
||||
console.error("Failed to save dictionary:", error);
|
||||
} finally {
|
||||
setIsSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Card id="dictionary">
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2 text-base">
|
||||
<BookA className="h-4 w-4" />
|
||||
Dictionary
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
Add words the agent should actively listen for — company jargon, names,
|
||||
industry terms. May incur extra cost depending on provider.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Textarea
|
||||
placeholder="Enter words separated by comma (e.g. billing department, tretinoin)"
|
||||
value={dictionaryValue}
|
||||
onChange={(e) => setDictionaryValue(e.target.value)}
|
||||
rows={4}
|
||||
className="resize-none"
|
||||
/>
|
||||
</CardContent>
|
||||
<CardFooter className="justify-end border-t pt-6">
|
||||
<Button onClick={handleSave} disabled={isSaving}>
|
||||
{isSaving ? "Saving..." : "Save Dictionary"}
|
||||
</Button>
|
||||
</CardFooter>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Section: Voicemail Detection
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function VoicemailSection({
|
||||
workflowConfigurations,
|
||||
workflowName,
|
||||
onSave,
|
||||
}: {
|
||||
workflowConfigurations: WorkflowConfigurations;
|
||||
workflowName: string;
|
||||
onSave: (configurations: WorkflowConfigurations, workflowName: string) => Promise<void>;
|
||||
}) {
|
||||
const getConfig = (): VoicemailDetectionConfiguration => ({
|
||||
...DEFAULT_VOICEMAIL_DETECTION_CONFIGURATION,
|
||||
...workflowConfigurations.voicemail_detection,
|
||||
});
|
||||
|
||||
const [enabled, setEnabled] = useState(getConfig().enabled);
|
||||
const [useWorkflowLlm, setUseWorkflowLlm] = useState(getConfig().use_workflow_llm);
|
||||
const [provider, setProvider] = useState(getConfig().provider || "openai");
|
||||
const [model, setModel] = useState(getConfig().model || "gpt-4.1");
|
||||
const [apiKey, setApiKey] = useState(getConfig().api_key || "");
|
||||
const [systemPrompt, setSystemPrompt] = useState(getConfig().system_prompt || DEFAULT_VOICEMAIL_SYSTEM_PROMPT);
|
||||
const [longSpeechTimeout, setLongSpeechTimeout] = useState(getConfig().long_speech_timeout);
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
|
||||
const handleSave = async () => {
|
||||
setIsSaving(true);
|
||||
try {
|
||||
const voicemailConfig: VoicemailDetectionConfiguration = {
|
||||
enabled,
|
||||
use_workflow_llm: useWorkflowLlm,
|
||||
provider: useWorkflowLlm ? undefined : provider,
|
||||
model: useWorkflowLlm ? undefined : model,
|
||||
api_key: useWorkflowLlm ? undefined : apiKey,
|
||||
system_prompt:
|
||||
systemPrompt && systemPrompt !== DEFAULT_VOICEMAIL_SYSTEM_PROMPT ? systemPrompt : undefined,
|
||||
long_speech_timeout: longSpeechTimeout,
|
||||
};
|
||||
await onSave(
|
||||
{ ...workflowConfigurations, voicemail_detection: voicemailConfig },
|
||||
workflowName,
|
||||
);
|
||||
} catch (error) {
|
||||
console.error("Failed to save voicemail settings:", error);
|
||||
} finally {
|
||||
setIsSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Card id="voicemail">
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2 text-base">
|
||||
<PhoneOff className="h-4 w-4" />
|
||||
Voicemail Detection
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
Automatically detect and end calls when a voicemail system is reached.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="flex items-center space-x-2 rounded-md border bg-muted/20 p-2">
|
||||
<Switch id="voicemail-enabled" checked={enabled} onCheckedChange={setEnabled} />
|
||||
<Label htmlFor="voicemail-enabled">Enable Voicemail Detection</Label>
|
||||
</div>
|
||||
|
||||
{enabled && (
|
||||
<>
|
||||
{/* LLM Configuration */}
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center space-x-2 rounded-md border bg-muted/20 p-2">
|
||||
<Switch
|
||||
id="voicemail-use-workflow-llm"
|
||||
checked={useWorkflowLlm}
|
||||
onCheckedChange={setUseWorkflowLlm}
|
||||
/>
|
||||
<Label htmlFor="voicemail-use-workflow-llm">Use Workflow LLM</Label>
|
||||
<Label className="ml-2 text-xs text-muted-foreground">
|
||||
Use the LLM configured in your account settings.
|
||||
</Label>
|
||||
</div>
|
||||
|
||||
{!useWorkflowLlm && (
|
||||
<LLMConfigSelector
|
||||
provider={provider}
|
||||
onProviderChange={setProvider}
|
||||
model={model}
|
||||
onModelChange={setModel}
|
||||
apiKey={apiKey}
|
||||
onApiKeyChange={setApiKey}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* System Prompt */}
|
||||
<div className="space-y-2">
|
||||
<Label>System Prompt</Label>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
The LLM must respond with either "CONVERSATION" or "VOICEMAIL".
|
||||
</p>
|
||||
<Textarea
|
||||
value={systemPrompt}
|
||||
onChange={(e) => setSystemPrompt(e.target.value)}
|
||||
className="min-h-[200px] font-mono text-xs"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Timing */}
|
||||
<div className="space-y-2 rounded-md border bg-muted/10 p-3">
|
||||
<Label className="font-medium">Timing</Label>
|
||||
<div className="space-y-2">
|
||||
<Label className="text-sm">Speech Cutoff (seconds)</Label>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Trigger classification early if first turn speech exceeds this duration.
|
||||
</p>
|
||||
<Input
|
||||
type="number"
|
||||
step="0.5"
|
||||
min="1"
|
||||
max="30"
|
||||
value={longSpeechTimeout}
|
||||
onChange={(e) => setLongSpeechTimeout(parseFloat(e.target.value) || 8.0)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</CardContent>
|
||||
<CardFooter className="justify-end border-t pt-6">
|
||||
<Button onClick={handleSave} disabled={isSaving}>
|
||||
{isSaving ? "Saving..." : "Save Voicemail Settings"}
|
||||
</Button>
|
||||
</CardFooter>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Main Page
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Page wrapper — handles auth & data fetching, then mounts the content
|
||||
// component only when everything is loaded. This avoids useWorkflowState
|
||||
// running with empty initial values and overwriting the Zustand store.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export default function WorkflowSettingsPage() {
|
||||
const params = useParams();
|
||||
const { user, redirectToLogin, loading: authLoading } = useAuth();
|
||||
const [workflow, setWorkflow] = useState<WorkflowResponse | undefined>(undefined);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!authLoading && !user) {
|
||||
redirectToLogin();
|
||||
}
|
||||
}, [authLoading, user, redirectToLogin]);
|
||||
|
||||
useEffect(() => {
|
||||
const fetchWorkflow = async () => {
|
||||
if (!user) return;
|
||||
try {
|
||||
const response = await getWorkflowApiV1WorkflowFetchWorkflowIdGet({
|
||||
path: { workflow_id: Number(params.workflowId) },
|
||||
});
|
||||
setWorkflow(response.data);
|
||||
} catch (err) {
|
||||
setError("Failed to fetch workflow");
|
||||
logger.error(`Error fetching workflow settings: ${err}`);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
if (user) fetchWorkflow();
|
||||
}, [params.workflowId, user]);
|
||||
|
||||
if (loading || authLoading) return <SpinLoader />;
|
||||
|
||||
if (error || !workflow) {
|
||||
return (
|
||||
<div className="flex min-h-screen items-center justify-center">
|
||||
<div className="text-lg text-destructive">{error || "Workflow not found"}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!user) return null;
|
||||
|
||||
return <WorkflowSettingsContent workflow={workflow} user={user} />;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Content — only mounts once the workflow API response is available, so
|
||||
// useWorkflowState always initialises with real data.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function WorkflowSettingsContent({
|
||||
workflow,
|
||||
user,
|
||||
}: {
|
||||
workflow: WorkflowResponse;
|
||||
user: { id: string; email?: string };
|
||||
}) {
|
||||
const router = useRouter();
|
||||
|
||||
const [isRecordingsDialogOpen, setIsRecordingsDialogOpen] = useState(false);
|
||||
const [isEmbedDialogOpen, setIsEmbedDialogOpen] = useState(false);
|
||||
const [activeSection, setActiveSection] = useState("general");
|
||||
|
||||
const workflowId = workflow.id;
|
||||
|
||||
const initialFlow = useMemo(
|
||||
() => ({
|
||||
nodes: workflow.workflow_definition.nodes as FlowNode[],
|
||||
edges: workflow.workflow_definition.edges as FlowEdge[],
|
||||
viewport: { x: 0, y: 0, zoom: 0 },
|
||||
}),
|
||||
[workflow],
|
||||
);
|
||||
|
||||
const initialTemplateContextVariables = useMemo(
|
||||
() => (workflow.template_context_variables as Record<string, string>) || {},
|
||||
[workflow],
|
||||
);
|
||||
|
||||
const initialWorkflowConfigurations = useMemo(
|
||||
() => (workflow.workflow_configurations as WorkflowConfigurations) || DEFAULT_WORKFLOW_CONFIGURATIONS,
|
||||
[workflow],
|
||||
);
|
||||
|
||||
const {
|
||||
workflowName,
|
||||
workflowConfigurations,
|
||||
templateContextVariables,
|
||||
dictionary,
|
||||
saveWorkflowConfigurations,
|
||||
saveTemplateContextVariables,
|
||||
saveDictionary,
|
||||
} = useWorkflowState({
|
||||
initialWorkflowName: workflow.name,
|
||||
workflowId,
|
||||
initialFlow,
|
||||
initialTemplateContextVariables,
|
||||
initialWorkflowConfigurations,
|
||||
user,
|
||||
});
|
||||
|
||||
// Intersection observer for active sidebar link
|
||||
useEffect(() => {
|
||||
const ids = NAV_ITEMS.map((n) => n.id);
|
||||
const observer = new IntersectionObserver(
|
||||
(entries) => {
|
||||
for (const entry of entries) {
|
||||
if (entry.isIntersecting) {
|
||||
setActiveSection(entry.target.id);
|
||||
break;
|
||||
}
|
||||
}
|
||||
},
|
||||
{ rootMargin: "-20% 0px -60% 0px" },
|
||||
);
|
||||
ids.forEach((id) => {
|
||||
const el = document.getElementById(id);
|
||||
if (el) observer.observe(el);
|
||||
});
|
||||
return () => observer.disconnect();
|
||||
}, []);
|
||||
|
||||
// Sections are gated on configurations being present in the store.
|
||||
// After mount, initializeWorkflow runs in a useEffect — the first render
|
||||
// may still have stale store data, but the next tick corrects it.
|
||||
const dataReady = !!workflowConfigurations;
|
||||
|
||||
return (
|
||||
<div className="min-h-screen">
|
||||
{/* Sticky header */}
|
||||
<header className="sticky top-0 z-10 flex items-center gap-3 border-b bg-background/95 px-6 py-3 backdrop-blur supports-[backdrop-filter]:bg-background/60">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => router.push(`/workflow/${workflowId}`)}
|
||||
>
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
</Button>
|
||||
<div>
|
||||
<p className="text-xs text-muted-foreground">Workflow Settings</p>
|
||||
<h1 className="text-sm font-semibold">{workflowName || workflow.name}</h1>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{/* Main + right nav */}
|
||||
<div className="mx-auto flex max-w-5xl gap-8 px-6 py-8">
|
||||
{/* Sections */}
|
||||
<div className="min-w-0 flex-1 space-y-8">
|
||||
{dataReady && (
|
||||
<>
|
||||
{/* General */}
|
||||
<GeneralSection
|
||||
workflowConfigurations={workflowConfigurations}
|
||||
workflowName={workflowName || workflow.name}
|
||||
onSave={saveWorkflowConfigurations}
|
||||
/>
|
||||
|
||||
{/* Model Overrides */}
|
||||
<Card id="models">
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2 text-base">
|
||||
<Brain className="h-4 w-4" />
|
||||
Model Overrides
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
Override global model settings for this workflow. Toggle individual services to
|
||||
customize.{" "}
|
||||
<a href={SETTINGS_DOCUMENTATION_URLS.modelOverrides} target="_blank" rel="noopener noreferrer" className="inline-flex items-center gap-0.5 underline">Learn more <ExternalLink className="h-3 w-3" /></a>
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<ServiceConfigurationForm
|
||||
mode="override"
|
||||
currentOverrides={workflowConfigurations.model_overrides}
|
||||
submitLabel="Save Model Overrides"
|
||||
onSave={async (config) => {
|
||||
await saveWorkflowConfigurations(
|
||||
{
|
||||
...workflowConfigurations,
|
||||
model_overrides:
|
||||
config.model_overrides as WorkflowConfigurations["model_overrides"],
|
||||
} as WorkflowConfigurations,
|
||||
workflowName,
|
||||
);
|
||||
}}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Template Variables */}
|
||||
<TemplateVariablesSection
|
||||
templateContextVariables={templateContextVariables}
|
||||
onSave={saveTemplateContextVariables}
|
||||
/>
|
||||
|
||||
{/* Dictionary */}
|
||||
<DictionarySection dictionary={dictionary} onSave={saveDictionary} />
|
||||
|
||||
{/* Voicemail Detection */}
|
||||
<VoicemailSection
|
||||
workflowConfigurations={workflowConfigurations}
|
||||
workflowName={workflowName}
|
||||
onSave={saveWorkflowConfigurations}
|
||||
/>
|
||||
|
||||
{/* Recordings (dialog trigger) */}
|
||||
<Card id="recordings">
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2 text-base">
|
||||
<Mic className="h-4 w-4" />
|
||||
Recordings
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
Upload or record audio for hybrid prompts. Use{" "}
|
||||
<code className="rounded bg-muted px-1 text-xs">@</code> in prompt fields to
|
||||
insert them.{" "}
|
||||
<a href={SETTINGS_DOCUMENTATION_URLS.recordings} target="_blank" rel="noopener noreferrer" className="inline-flex items-center gap-0.5 underline">Learn more <ExternalLink className="h-3 w-3" /></a>
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardFooter className="border-t pt-6">
|
||||
<Button variant="outline" onClick={() => setIsRecordingsDialogOpen(true)}>
|
||||
Manage Recordings
|
||||
</Button>
|
||||
</CardFooter>
|
||||
</Card>
|
||||
|
||||
{/* Deployment (dialog trigger) */}
|
||||
<Card id="deployment">
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2 text-base">
|
||||
<Rocket className="h-4 w-4" />
|
||||
Deployment
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
Generate and manage the embed configuration to deploy this workflow on external
|
||||
websites.{" "}
|
||||
<a href={SETTINGS_DOCUMENTATION_URLS.deployment} target="_blank" rel="noopener noreferrer" className="inline-flex items-center gap-0.5 underline">Learn more <ExternalLink className="h-3 w-3" /></a>
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardFooter className="border-t pt-6">
|
||||
<Button variant="outline" onClick={() => setIsEmbedDialogOpen(true)}>
|
||||
Configure Embed
|
||||
</Button>
|
||||
</CardFooter>
|
||||
</Card>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* ---- Right-side sticky nav ---- */}
|
||||
<nav className="hidden w-44 shrink-0 lg:block">
|
||||
<div className="sticky top-20 space-y-1">
|
||||
<p className="mb-2 text-xs font-medium uppercase tracking-wider text-muted-foreground">
|
||||
On this page
|
||||
</p>
|
||||
{NAV_ITEMS.map((item) => (
|
||||
<a
|
||||
key={item.id}
|
||||
href={`#${item.id}`}
|
||||
className={`block rounded-md px-2 py-1 text-sm transition-colors hover:text-foreground ${
|
||||
activeSection === item.id
|
||||
? "font-medium text-foreground"
|
||||
: "text-muted-foreground"
|
||||
}`}
|
||||
>
|
||||
{item.label}
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
</nav>
|
||||
</div>
|
||||
|
||||
{/* Dialogs for complex sections */}
|
||||
<RecordingsDialog
|
||||
open={isRecordingsDialogOpen}
|
||||
onOpenChange={setIsRecordingsDialogOpen}
|
||||
workflowId={workflowId}
|
||||
/>
|
||||
<EmbedDialog
|
||||
open={isEmbedDialogOpen}
|
||||
onOpenChange={setIsEmbedDialogOpen}
|
||||
workflowId={workflowId}
|
||||
workflowName={workflowName || workflow.name}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,6 +1,7 @@
|
|||
import { Suspense } from 'react';
|
||||
|
||||
import { getWorkflowsApiV1WorkflowFetchGet } from '@/client/sdk.gen';
|
||||
import type { WorkflowListResponse } from '@/client/types.gen';
|
||||
import { CreateWorkflowButton } from "@/components/workflow/CreateWorkflowButton";
|
||||
import { UploadWorkflowButton } from '@/components/workflow/UploadWorkflowButton';
|
||||
import { WorkflowTable } from "@/components/workflow/WorkflowTable";
|
||||
|
|
@ -46,12 +47,12 @@ async function WorkflowList() {
|
|||
|
||||
// Separate active and archived workflows
|
||||
const activeWorkflows = allWorkflowData
|
||||
.filter(w => w.status === 'active')
|
||||
.sort((a, b) => new Date(b.created_at).getTime() - new Date(a.created_at).getTime());
|
||||
.filter((w: WorkflowListResponse) => w.status === 'active')
|
||||
.sort((a: WorkflowListResponse, b: WorkflowListResponse) => new Date(b.created_at).getTime() - new Date(a.created_at).getTime());
|
||||
|
||||
const archivedWorkflows = allWorkflowData
|
||||
.filter(w => w.status === 'archived')
|
||||
.sort((a, b) => new Date(b.created_at).getTime() - new Date(a.created_at).getTime());
|
||||
.filter((w: WorkflowListResponse) => w.status === 'archived')
|
||||
.sort((a: WorkflowListResponse, b: WorkflowListResponse) => new Date(b.created_at).getTime() - new Date(a.created_at).getTime());
|
||||
|
||||
return (
|
||||
<>
|
||||
|
|
|
|||
|
|
@ -1,9 +1,9 @@
|
|||
// This file is auto-generated by @hey-api/openapi-ts
|
||||
|
||||
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';
|
||||
|
||||
import { type ClientOptions, type Config, createClient, createConfig } from './client';
|
||||
import type { ClientOptions as ClientOptions2 } from './types.gen';
|
||||
|
||||
/**
|
||||
* The `createClientConfig()` function will be called on client initialization
|
||||
|
|
@ -13,8 +13,6 @@ import type { ClientOptions } from './types.gen';
|
|||
* `setConfig()`. This is useful for example if you're using Next.js
|
||||
* to ensure your client always has the correct values.
|
||||
*/
|
||||
export type CreateClientConfig<T extends DefaultClientOptions = ClientOptions> = (override?: Config<DefaultClientOptions & T>) => Config<Required<DefaultClientOptions> & T>;
|
||||
export type CreateClientConfig<T extends ClientOptions = ClientOptions2> = (override?: Config<ClientOptions & T>) => Config<Required<ClientOptions> & T>;
|
||||
|
||||
export const client = createClient(createClientConfig(createConfig<ClientOptions>({
|
||||
baseUrl: 'https://app.dograh.com'
|
||||
})));
|
||||
export const client = createClient(createClientConfig(createConfig<ClientOptions2>({ baseUrl: 'https://app.dograh.com' })));
|
||||
|
|
|
|||
298
ui/src/client/client/client.gen.ts
Normal file
298
ui/src/client/client/client.gen.ts
Normal file
|
|
@ -0,0 +1,298 @@
|
|||
// This file is auto-generated by @hey-api/openapi-ts
|
||||
|
||||
import { createSseClient } from '../core/serverSentEvents.gen';
|
||||
import type { HttpMethod } from '../core/types.gen';
|
||||
import { getValidRequestBody } from '../core/utils.gen';
|
||||
import type { Client, Config, RequestOptions, ResolvedRequestOptions } from './types.gen';
|
||||
import {
|
||||
buildUrl,
|
||||
createConfig,
|
||||
createInterceptors,
|
||||
getParseAs,
|
||||
mergeConfigs,
|
||||
mergeHeaders,
|
||||
setAuthParams,
|
||||
} from './utils.gen';
|
||||
|
||||
type ReqInit = Omit<RequestInit, 'body' | 'headers'> & {
|
||||
body?: any;
|
||||
headers: ReturnType<typeof mergeHeaders>;
|
||||
};
|
||||
|
||||
export const createClient = (config: Config = {}): Client => {
|
||||
let _config = mergeConfigs(createConfig(), config);
|
||||
|
||||
const getConfig = (): Config => ({ ..._config });
|
||||
|
||||
const setConfig = (config: Config): Config => {
|
||||
_config = mergeConfigs(_config, config);
|
||||
return getConfig();
|
||||
};
|
||||
|
||||
const interceptors = createInterceptors<Request, Response, unknown, ResolvedRequestOptions>();
|
||||
|
||||
const beforeRequest = async <
|
||||
TData = unknown,
|
||||
TResponseStyle extends 'data' | 'fields' = 'fields',
|
||||
ThrowOnError extends boolean = boolean,
|
||||
Url extends string = string,
|
||||
>(
|
||||
options: RequestOptions<TData, TResponseStyle, ThrowOnError, Url>,
|
||||
) => {
|
||||
const opts = {
|
||||
..._config,
|
||||
...options,
|
||||
fetch: options.fetch ?? _config.fetch ?? globalThis.fetch,
|
||||
headers: mergeHeaders(_config.headers, options.headers),
|
||||
serializedBody: undefined as string | undefined,
|
||||
};
|
||||
|
||||
if (opts.security) {
|
||||
await setAuthParams({
|
||||
...opts,
|
||||
security: opts.security,
|
||||
});
|
||||
}
|
||||
|
||||
if (opts.requestValidator) {
|
||||
await opts.requestValidator(opts);
|
||||
}
|
||||
|
||||
if (opts.body !== undefined && opts.bodySerializer) {
|
||||
opts.serializedBody = opts.bodySerializer(opts.body) as string | undefined;
|
||||
}
|
||||
|
||||
// remove Content-Type header if body is empty to avoid sending invalid requests
|
||||
if (opts.body === undefined || opts.serializedBody === '') {
|
||||
opts.headers.delete('Content-Type');
|
||||
}
|
||||
|
||||
const resolvedOpts = opts as typeof opts &
|
||||
ResolvedRequestOptions<TResponseStyle, ThrowOnError, Url>;
|
||||
const url = buildUrl(resolvedOpts);
|
||||
|
||||
return { opts: resolvedOpts, url };
|
||||
};
|
||||
|
||||
const request: Client['request'] = async (options) => {
|
||||
const { opts, url } = await beforeRequest(options);
|
||||
const requestInit: ReqInit = {
|
||||
redirect: 'follow',
|
||||
...opts,
|
||||
body: getValidRequestBody(opts),
|
||||
};
|
||||
|
||||
let request = new Request(url, requestInit);
|
||||
|
||||
for (const fn of interceptors.request.fns) {
|
||||
if (fn) {
|
||||
request = await fn(request, opts);
|
||||
}
|
||||
}
|
||||
|
||||
// fetch must be assigned here, otherwise it would throw the error:
|
||||
// TypeError: Failed to execute 'fetch' on 'Window': Illegal invocation
|
||||
const _fetch = opts.fetch!;
|
||||
let response: Response;
|
||||
|
||||
try {
|
||||
response = await _fetch(request);
|
||||
} catch (error) {
|
||||
// Handle fetch exceptions (AbortError, network errors, etc.)
|
||||
let finalError = error;
|
||||
|
||||
for (const fn of interceptors.error.fns) {
|
||||
if (fn) {
|
||||
finalError = (await fn(error, undefined as any, request, opts)) as unknown;
|
||||
}
|
||||
}
|
||||
|
||||
finalError = finalError || ({} as unknown);
|
||||
|
||||
if (opts.throwOnError) {
|
||||
throw finalError;
|
||||
}
|
||||
|
||||
// Return error response
|
||||
return opts.responseStyle === 'data'
|
||||
? undefined
|
||||
: {
|
||||
error: finalError,
|
||||
request,
|
||||
response: undefined as any,
|
||||
};
|
||||
}
|
||||
|
||||
for (const fn of interceptors.response.fns) {
|
||||
if (fn) {
|
||||
response = await fn(response, request, opts);
|
||||
}
|
||||
}
|
||||
|
||||
const result = {
|
||||
request,
|
||||
response,
|
||||
};
|
||||
|
||||
if (response.ok) {
|
||||
const parseAs =
|
||||
(opts.parseAs === 'auto'
|
||||
? getParseAs(response.headers.get('Content-Type'))
|
||||
: opts.parseAs) ?? 'json';
|
||||
|
||||
if (response.status === 204 || response.headers.get('Content-Length') === '0') {
|
||||
let emptyData: any;
|
||||
switch (parseAs) {
|
||||
case 'arrayBuffer':
|
||||
case 'blob':
|
||||
case 'text':
|
||||
emptyData = await response[parseAs]();
|
||||
break;
|
||||
case 'formData':
|
||||
emptyData = new FormData();
|
||||
break;
|
||||
case 'stream':
|
||||
emptyData = response.body;
|
||||
break;
|
||||
case 'json':
|
||||
default:
|
||||
emptyData = {};
|
||||
break;
|
||||
}
|
||||
return opts.responseStyle === 'data'
|
||||
? emptyData
|
||||
: {
|
||||
data: emptyData,
|
||||
...result,
|
||||
};
|
||||
}
|
||||
|
||||
let data: any;
|
||||
switch (parseAs) {
|
||||
case 'arrayBuffer':
|
||||
case 'blob':
|
||||
case 'formData':
|
||||
case 'text':
|
||||
data = await response[parseAs]();
|
||||
break;
|
||||
case 'json': {
|
||||
// Some servers return 200 with no Content-Length and empty body.
|
||||
// response.json() would throw; read as text and parse if non-empty.
|
||||
const text = await response.text();
|
||||
data = text ? JSON.parse(text) : {};
|
||||
break;
|
||||
}
|
||||
case 'stream':
|
||||
return opts.responseStyle === 'data'
|
||||
? response.body
|
||||
: {
|
||||
data: response.body,
|
||||
...result,
|
||||
};
|
||||
}
|
||||
|
||||
if (parseAs === 'json') {
|
||||
if (opts.responseValidator) {
|
||||
await opts.responseValidator(data);
|
||||
}
|
||||
|
||||
if (opts.responseTransformer) {
|
||||
data = await opts.responseTransformer(data);
|
||||
}
|
||||
}
|
||||
|
||||
return opts.responseStyle === 'data'
|
||||
? data
|
||||
: {
|
||||
data,
|
||||
...result,
|
||||
};
|
||||
}
|
||||
|
||||
const textError = await response.text();
|
||||
let jsonError: unknown;
|
||||
|
||||
try {
|
||||
jsonError = JSON.parse(textError);
|
||||
} catch {
|
||||
// noop
|
||||
}
|
||||
|
||||
const error = jsonError ?? textError;
|
||||
let finalError = error;
|
||||
|
||||
for (const fn of interceptors.error.fns) {
|
||||
if (fn) {
|
||||
finalError = (await fn(error, response, request, opts)) as string;
|
||||
}
|
||||
}
|
||||
|
||||
finalError = finalError || ({} as string);
|
||||
|
||||
if (opts.throwOnError) {
|
||||
throw finalError;
|
||||
}
|
||||
|
||||
// TODO: we probably want to return error and improve types
|
||||
return opts.responseStyle === 'data'
|
||||
? undefined
|
||||
: {
|
||||
error: finalError,
|
||||
...result,
|
||||
};
|
||||
};
|
||||
|
||||
const makeMethodFn = (method: Uppercase<HttpMethod>) => (options: RequestOptions) =>
|
||||
request({ ...options, method });
|
||||
|
||||
const makeSseFn = (method: Uppercase<HttpMethod>) => async (options: RequestOptions) => {
|
||||
const { opts, url } = await beforeRequest(options);
|
||||
return createSseClient({
|
||||
...opts,
|
||||
body: opts.body as BodyInit | null | undefined,
|
||||
headers: opts.headers as unknown as Record<string, string>,
|
||||
method,
|
||||
onRequest: async (url, init) => {
|
||||
let request = new Request(url, init);
|
||||
for (const fn of interceptors.request.fns) {
|
||||
if (fn) {
|
||||
request = await fn(request, opts);
|
||||
}
|
||||
}
|
||||
return request;
|
||||
},
|
||||
serializedBody: getValidRequestBody(opts) as BodyInit | null | undefined,
|
||||
url,
|
||||
});
|
||||
};
|
||||
|
||||
const _buildUrl: Client['buildUrl'] = (options) => buildUrl({ ..._config, ...options });
|
||||
|
||||
return {
|
||||
buildUrl: _buildUrl,
|
||||
connect: makeMethodFn('CONNECT'),
|
||||
delete: makeMethodFn('DELETE'),
|
||||
get: makeMethodFn('GET'),
|
||||
getConfig,
|
||||
head: makeMethodFn('HEAD'),
|
||||
interceptors,
|
||||
options: makeMethodFn('OPTIONS'),
|
||||
patch: makeMethodFn('PATCH'),
|
||||
post: makeMethodFn('POST'),
|
||||
put: makeMethodFn('PUT'),
|
||||
request,
|
||||
setConfig,
|
||||
sse: {
|
||||
connect: makeSseFn('CONNECT'),
|
||||
delete: makeSseFn('DELETE'),
|
||||
get: makeSseFn('GET'),
|
||||
head: makeSseFn('HEAD'),
|
||||
options: makeSseFn('OPTIONS'),
|
||||
patch: makeSseFn('PATCH'),
|
||||
post: makeSseFn('POST'),
|
||||
put: makeSseFn('PUT'),
|
||||
trace: makeSseFn('TRACE'),
|
||||
},
|
||||
trace: makeMethodFn('TRACE'),
|
||||
} as Client;
|
||||
};
|
||||
25
ui/src/client/client/index.ts
Normal file
25
ui/src/client/client/index.ts
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
// This file is auto-generated by @hey-api/openapi-ts
|
||||
|
||||
export type { Auth } from '../core/auth.gen';
|
||||
export type { QuerySerializerOptions } from '../core/bodySerializer.gen';
|
||||
export {
|
||||
formDataBodySerializer,
|
||||
jsonBodySerializer,
|
||||
urlSearchParamsBodySerializer,
|
||||
} from '../core/bodySerializer.gen';
|
||||
export { buildClientParams } from '../core/params.gen';
|
||||
export { serializeQueryKeyValue } from '../core/queryKeySerializer.gen';
|
||||
export { createClient } from './client.gen';
|
||||
export type {
|
||||
Client,
|
||||
ClientOptions,
|
||||
Config,
|
||||
CreateClientConfig,
|
||||
Options,
|
||||
RequestOptions,
|
||||
RequestResult,
|
||||
ResolvedRequestOptions,
|
||||
ResponseStyle,
|
||||
TDataShape,
|
||||
} from './types.gen';
|
||||
export { createConfig, mergeHeaders } from './utils.gen';
|
||||
214
ui/src/client/client/types.gen.ts
Normal file
214
ui/src/client/client/types.gen.ts
Normal file
|
|
@ -0,0 +1,214 @@
|
|||
// This file is auto-generated by @hey-api/openapi-ts
|
||||
|
||||
import type { Auth } from '../core/auth.gen';
|
||||
import type {
|
||||
ServerSentEventsOptions,
|
||||
ServerSentEventsResult,
|
||||
} from '../core/serverSentEvents.gen';
|
||||
import type { Client as CoreClient, Config as CoreConfig } from '../core/types.gen';
|
||||
import type { Middleware } from './utils.gen';
|
||||
|
||||
export type ResponseStyle = 'data' | 'fields';
|
||||
|
||||
export interface Config<T extends ClientOptions = ClientOptions>
|
||||
extends Omit<RequestInit, 'body' | 'headers' | 'method'>, CoreConfig {
|
||||
/**
|
||||
* Base URL for all requests made by this client.
|
||||
*/
|
||||
baseUrl?: T['baseUrl'];
|
||||
/**
|
||||
* Fetch API implementation. You can use this option to provide a custom
|
||||
* fetch instance.
|
||||
*
|
||||
* @default globalThis.fetch
|
||||
*/
|
||||
fetch?: typeof fetch;
|
||||
/**
|
||||
* Please don't use the Fetch client for Next.js applications. The `next`
|
||||
* options won't have any effect.
|
||||
*
|
||||
* Install {@link https://www.npmjs.com/package/@hey-api/client-next `@hey-api/client-next`} instead.
|
||||
*/
|
||||
next?: never;
|
||||
/**
|
||||
* Return the response data parsed in a specified format. By default, `auto`
|
||||
* will infer the appropriate method from the `Content-Type` response header.
|
||||
* You can override this behavior with any of the {@link Body} methods.
|
||||
* Select `stream` if you don't want to parse response data at all.
|
||||
*
|
||||
* @default 'auto'
|
||||
*/
|
||||
parseAs?: 'arrayBuffer' | 'auto' | 'blob' | 'formData' | 'json' | 'stream' | 'text';
|
||||
/**
|
||||
* Should we return only data or multiple fields (data, error, response, etc.)?
|
||||
*
|
||||
* @default 'fields'
|
||||
*/
|
||||
responseStyle?: ResponseStyle;
|
||||
/**
|
||||
* Throw an error instead of returning it in the response?
|
||||
*
|
||||
* @default false
|
||||
*/
|
||||
throwOnError?: T['throwOnError'];
|
||||
}
|
||||
|
||||
export interface RequestOptions<
|
||||
TData = unknown,
|
||||
TResponseStyle extends ResponseStyle = 'fields',
|
||||
ThrowOnError extends boolean = boolean,
|
||||
Url extends string = string,
|
||||
>
|
||||
extends
|
||||
Config<{
|
||||
responseStyle: TResponseStyle;
|
||||
throwOnError: ThrowOnError;
|
||||
}>,
|
||||
Pick<
|
||||
ServerSentEventsOptions<TData>,
|
||||
| 'onRequest'
|
||||
| 'onSseError'
|
||||
| 'onSseEvent'
|
||||
| 'sseDefaultRetryDelay'
|
||||
| 'sseMaxRetryAttempts'
|
||||
| 'sseMaxRetryDelay'
|
||||
> {
|
||||
/**
|
||||
* Any body that you want to add to your request.
|
||||
*
|
||||
* {@link https://developer.mozilla.org/docs/Web/API/fetch#body}
|
||||
*/
|
||||
body?: unknown;
|
||||
path?: Record<string, unknown>;
|
||||
query?: Record<string, unknown>;
|
||||
/**
|
||||
* Security mechanism(s) to use for the request.
|
||||
*/
|
||||
security?: ReadonlyArray<Auth>;
|
||||
url: Url;
|
||||
}
|
||||
|
||||
export interface ResolvedRequestOptions<
|
||||
TResponseStyle extends ResponseStyle = 'fields',
|
||||
ThrowOnError extends boolean = boolean,
|
||||
Url extends string = string,
|
||||
> extends RequestOptions<unknown, TResponseStyle, ThrowOnError, Url> {
|
||||
serializedBody?: string;
|
||||
}
|
||||
|
||||
export type RequestResult<
|
||||
TData = unknown,
|
||||
TError = unknown,
|
||||
ThrowOnError extends boolean = boolean,
|
||||
TResponseStyle extends ResponseStyle = 'fields',
|
||||
> = ThrowOnError extends true
|
||||
? Promise<
|
||||
TResponseStyle extends 'data'
|
||||
? TData extends Record<string, unknown>
|
||||
? TData[keyof TData]
|
||||
: TData
|
||||
: {
|
||||
data: TData extends Record<string, unknown> ? TData[keyof TData] : TData;
|
||||
request: Request;
|
||||
response: Response;
|
||||
}
|
||||
>
|
||||
: Promise<
|
||||
TResponseStyle extends 'data'
|
||||
? (TData extends Record<string, unknown> ? TData[keyof TData] : TData) | undefined
|
||||
: (
|
||||
| {
|
||||
data: TData extends Record<string, unknown> ? TData[keyof TData] : TData;
|
||||
error: undefined;
|
||||
}
|
||||
| {
|
||||
data: undefined;
|
||||
error: TError extends Record<string, unknown> ? TError[keyof TError] : TError;
|
||||
}
|
||||
) & {
|
||||
request: Request;
|
||||
response: Response;
|
||||
}
|
||||
>;
|
||||
|
||||
export interface ClientOptions {
|
||||
baseUrl?: string;
|
||||
responseStyle?: ResponseStyle;
|
||||
throwOnError?: boolean;
|
||||
}
|
||||
|
||||
type MethodFn = <
|
||||
TData = unknown,
|
||||
TError = unknown,
|
||||
ThrowOnError extends boolean = false,
|
||||
TResponseStyle extends ResponseStyle = 'fields',
|
||||
>(
|
||||
options: Omit<RequestOptions<TData, TResponseStyle, ThrowOnError>, 'method'>,
|
||||
) => RequestResult<TData, TError, ThrowOnError, TResponseStyle>;
|
||||
|
||||
type SseFn = <
|
||||
TData = unknown,
|
||||
TError = unknown,
|
||||
ThrowOnError extends boolean = false,
|
||||
TResponseStyle extends ResponseStyle = 'fields',
|
||||
>(
|
||||
options: Omit<RequestOptions<never, TResponseStyle, ThrowOnError>, 'method'>,
|
||||
) => Promise<ServerSentEventsResult<TData, TError>>;
|
||||
|
||||
type RequestFn = <
|
||||
TData = unknown,
|
||||
TError = unknown,
|
||||
ThrowOnError extends boolean = false,
|
||||
TResponseStyle extends ResponseStyle = 'fields',
|
||||
>(
|
||||
options: Omit<RequestOptions<TData, TResponseStyle, ThrowOnError>, 'method'> &
|
||||
Pick<Required<RequestOptions<TData, TResponseStyle, ThrowOnError>>, 'method'>,
|
||||
) => RequestResult<TData, TError, ThrowOnError, TResponseStyle>;
|
||||
|
||||
type BuildUrlFn = <
|
||||
TData extends {
|
||||
body?: unknown;
|
||||
path?: Record<string, unknown>;
|
||||
query?: Record<string, unknown>;
|
||||
url: string;
|
||||
},
|
||||
>(
|
||||
options: TData & Options<TData>,
|
||||
) => string;
|
||||
|
||||
export type Client = CoreClient<RequestFn, Config, MethodFn, BuildUrlFn, SseFn> & {
|
||||
interceptors: Middleware<Request, Response, unknown, ResolvedRequestOptions>;
|
||||
};
|
||||
|
||||
/**
|
||||
* The `createClientConfig()` function will be called on client initialization
|
||||
* and the returned object will become the client's initial configuration.
|
||||
*
|
||||
* You may want to initialize your client this way instead of calling
|
||||
* `setConfig()`. This is useful for example if you're using Next.js
|
||||
* to ensure your client always has the correct values.
|
||||
*/
|
||||
export type CreateClientConfig<T extends ClientOptions = ClientOptions> = (
|
||||
override?: Config<ClientOptions & T>,
|
||||
) => Config<Required<ClientOptions> & T>;
|
||||
|
||||
export interface TDataShape {
|
||||
body?: unknown;
|
||||
headers?: unknown;
|
||||
path?: unknown;
|
||||
query?: unknown;
|
||||
url: string;
|
||||
}
|
||||
|
||||
type OmitKeys<T, K> = Pick<T, Exclude<keyof T, K>>;
|
||||
|
||||
export type Options<
|
||||
TData extends TDataShape = TDataShape,
|
||||
ThrowOnError extends boolean = boolean,
|
||||
TResponse = unknown,
|
||||
TResponseStyle extends ResponseStyle = 'fields',
|
||||
> = OmitKeys<
|
||||
RequestOptions<TResponse, TResponseStyle, ThrowOnError>,
|
||||
'body' | 'path' | 'query' | 'url'
|
||||
> &
|
||||
([TData] extends [never] ? unknown : Omit<TData, 'url'>);
|
||||
316
ui/src/client/client/utils.gen.ts
Normal file
316
ui/src/client/client/utils.gen.ts
Normal file
|
|
@ -0,0 +1,316 @@
|
|||
// This file is auto-generated by @hey-api/openapi-ts
|
||||
|
||||
import { getAuthToken } from '../core/auth.gen';
|
||||
import type { QuerySerializerOptions } from '../core/bodySerializer.gen';
|
||||
import { jsonBodySerializer } from '../core/bodySerializer.gen';
|
||||
import {
|
||||
serializeArrayParam,
|
||||
serializeObjectParam,
|
||||
serializePrimitiveParam,
|
||||
} from '../core/pathSerializer.gen';
|
||||
import { getUrl } from '../core/utils.gen';
|
||||
import type { Client, ClientOptions, Config, RequestOptions } from './types.gen';
|
||||
|
||||
export const createQuerySerializer = <T = unknown>({
|
||||
parameters = {},
|
||||
...args
|
||||
}: QuerySerializerOptions = {}) => {
|
||||
const querySerializer = (queryParams: T) => {
|
||||
const search: string[] = [];
|
||||
if (queryParams && typeof queryParams === 'object') {
|
||||
for (const name in queryParams) {
|
||||
const value = queryParams[name];
|
||||
|
||||
if (value === undefined || value === null) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const options = parameters[name] || args;
|
||||
|
||||
if (Array.isArray(value)) {
|
||||
const serializedArray = serializeArrayParam({
|
||||
allowReserved: options.allowReserved,
|
||||
explode: true,
|
||||
name,
|
||||
style: 'form',
|
||||
value,
|
||||
...options.array,
|
||||
});
|
||||
if (serializedArray) search.push(serializedArray);
|
||||
} else if (typeof value === 'object') {
|
||||
const serializedObject = serializeObjectParam({
|
||||
allowReserved: options.allowReserved,
|
||||
explode: true,
|
||||
name,
|
||||
style: 'deepObject',
|
||||
value: value as Record<string, unknown>,
|
||||
...options.object,
|
||||
});
|
||||
if (serializedObject) search.push(serializedObject);
|
||||
} else {
|
||||
const serializedPrimitive = serializePrimitiveParam({
|
||||
allowReserved: options.allowReserved,
|
||||
name,
|
||||
value: value as string,
|
||||
});
|
||||
if (serializedPrimitive) search.push(serializedPrimitive);
|
||||
}
|
||||
}
|
||||
}
|
||||
return search.join('&');
|
||||
};
|
||||
return querySerializer;
|
||||
};
|
||||
|
||||
/**
|
||||
* Infers parseAs value from provided Content-Type header.
|
||||
*/
|
||||
export const getParseAs = (contentType: string | null): Exclude<Config['parseAs'], 'auto'> => {
|
||||
if (!contentType) {
|
||||
// If no Content-Type header is provided, the best we can do is return the raw response body,
|
||||
// which is effectively the same as the 'stream' option.
|
||||
return 'stream';
|
||||
}
|
||||
|
||||
const cleanContent = contentType.split(';')[0]?.trim();
|
||||
|
||||
if (!cleanContent) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (cleanContent.startsWith('application/json') || cleanContent.endsWith('+json')) {
|
||||
return 'json';
|
||||
}
|
||||
|
||||
if (cleanContent === 'multipart/form-data') {
|
||||
return 'formData';
|
||||
}
|
||||
|
||||
if (
|
||||
['application/', 'audio/', 'image/', 'video/'].some((type) => cleanContent.startsWith(type))
|
||||
) {
|
||||
return 'blob';
|
||||
}
|
||||
|
||||
if (cleanContent.startsWith('text/')) {
|
||||
return 'text';
|
||||
}
|
||||
|
||||
return;
|
||||
};
|
||||
|
||||
const checkForExistence = (
|
||||
options: Pick<RequestOptions, 'auth' | 'query'> & {
|
||||
headers: Headers;
|
||||
},
|
||||
name?: string,
|
||||
): boolean => {
|
||||
if (!name) {
|
||||
return false;
|
||||
}
|
||||
if (
|
||||
options.headers.has(name) ||
|
||||
options.query?.[name] ||
|
||||
options.headers.get('Cookie')?.includes(`${name}=`)
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
export const setAuthParams = async ({
|
||||
security,
|
||||
...options
|
||||
}: Pick<Required<RequestOptions>, 'security'> &
|
||||
Pick<RequestOptions, 'auth' | 'query'> & {
|
||||
headers: Headers;
|
||||
}) => {
|
||||
for (const auth of security) {
|
||||
if (checkForExistence(options, auth.name)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const token = await getAuthToken(auth, options.auth);
|
||||
|
||||
if (!token) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const name = auth.name ?? 'Authorization';
|
||||
|
||||
switch (auth.in) {
|
||||
case 'query':
|
||||
if (!options.query) {
|
||||
options.query = {};
|
||||
}
|
||||
options.query[name] = token;
|
||||
break;
|
||||
case 'cookie':
|
||||
options.headers.append('Cookie', `${name}=${token}`);
|
||||
break;
|
||||
case 'header':
|
||||
default:
|
||||
options.headers.set(name, token);
|
||||
break;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
export const buildUrl: Client['buildUrl'] = (options) =>
|
||||
getUrl({
|
||||
baseUrl: options.baseUrl as string,
|
||||
path: options.path,
|
||||
query: options.query,
|
||||
querySerializer:
|
||||
typeof options.querySerializer === 'function'
|
||||
? options.querySerializer
|
||||
: createQuerySerializer(options.querySerializer),
|
||||
url: options.url,
|
||||
});
|
||||
|
||||
export const mergeConfigs = (a: Config, b: Config): Config => {
|
||||
const config = { ...a, ...b };
|
||||
if (config.baseUrl?.endsWith('/')) {
|
||||
config.baseUrl = config.baseUrl.substring(0, config.baseUrl.length - 1);
|
||||
}
|
||||
config.headers = mergeHeaders(a.headers, b.headers);
|
||||
return config;
|
||||
};
|
||||
|
||||
const headersEntries = (headers: Headers): Array<[string, string]> => {
|
||||
const entries: Array<[string, string]> = [];
|
||||
headers.forEach((value, key) => {
|
||||
entries.push([key, value]);
|
||||
});
|
||||
return entries;
|
||||
};
|
||||
|
||||
export const mergeHeaders = (
|
||||
...headers: Array<Required<Config>['headers'] | undefined>
|
||||
): Headers => {
|
||||
const mergedHeaders = new Headers();
|
||||
for (const header of headers) {
|
||||
if (!header) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const iterator = header instanceof Headers ? headersEntries(header) : Object.entries(header);
|
||||
|
||||
for (const [key, value] of iterator) {
|
||||
if (value === null) {
|
||||
mergedHeaders.delete(key);
|
||||
} else if (Array.isArray(value)) {
|
||||
for (const v of value) {
|
||||
mergedHeaders.append(key, v as string);
|
||||
}
|
||||
} else if (value !== undefined) {
|
||||
// assume object headers are meant to be JSON stringified, i.e., their
|
||||
// content value in OpenAPI specification is 'application/json'
|
||||
mergedHeaders.set(
|
||||
key,
|
||||
typeof value === 'object' ? JSON.stringify(value) : (value as string),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
return mergedHeaders;
|
||||
};
|
||||
|
||||
type ErrInterceptor<Err, Res, Req, Options> = (
|
||||
error: Err,
|
||||
response: Res,
|
||||
request: Req,
|
||||
options: Options,
|
||||
) => Err | Promise<Err>;
|
||||
|
||||
type ReqInterceptor<Req, Options> = (request: Req, options: Options) => Req | Promise<Req>;
|
||||
|
||||
type ResInterceptor<Res, Req, Options> = (
|
||||
response: Res,
|
||||
request: Req,
|
||||
options: Options,
|
||||
) => Res | Promise<Res>;
|
||||
|
||||
class Interceptors<Interceptor> {
|
||||
fns: Array<Interceptor | null> = [];
|
||||
|
||||
clear(): void {
|
||||
this.fns = [];
|
||||
}
|
||||
|
||||
eject(id: number | Interceptor): void {
|
||||
const index = this.getInterceptorIndex(id);
|
||||
if (this.fns[index]) {
|
||||
this.fns[index] = null;
|
||||
}
|
||||
}
|
||||
|
||||
exists(id: number | Interceptor): boolean {
|
||||
const index = this.getInterceptorIndex(id);
|
||||
return Boolean(this.fns[index]);
|
||||
}
|
||||
|
||||
getInterceptorIndex(id: number | Interceptor): number {
|
||||
if (typeof id === 'number') {
|
||||
return this.fns[id] ? id : -1;
|
||||
}
|
||||
return this.fns.indexOf(id);
|
||||
}
|
||||
|
||||
update(id: number | Interceptor, fn: Interceptor): number | Interceptor | false {
|
||||
const index = this.getInterceptorIndex(id);
|
||||
if (this.fns[index]) {
|
||||
this.fns[index] = fn;
|
||||
return id;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
use(fn: Interceptor): number {
|
||||
this.fns.push(fn);
|
||||
return this.fns.length - 1;
|
||||
}
|
||||
}
|
||||
|
||||
export interface Middleware<Req, Res, Err, Options> {
|
||||
error: Interceptors<ErrInterceptor<Err, Res, Req, Options>>;
|
||||
request: Interceptors<ReqInterceptor<Req, Options>>;
|
||||
response: Interceptors<ResInterceptor<Res, Req, Options>>;
|
||||
}
|
||||
|
||||
export const createInterceptors = <Req, Res, Err, Options>(): Middleware<
|
||||
Req,
|
||||
Res,
|
||||
Err,
|
||||
Options
|
||||
> => ({
|
||||
error: new Interceptors<ErrInterceptor<Err, Res, Req, Options>>(),
|
||||
request: new Interceptors<ReqInterceptor<Req, Options>>(),
|
||||
response: new Interceptors<ResInterceptor<Res, Req, Options>>(),
|
||||
});
|
||||
|
||||
const defaultQuerySerializer = createQuerySerializer({
|
||||
allowReserved: false,
|
||||
array: {
|
||||
explode: true,
|
||||
style: 'form',
|
||||
},
|
||||
object: {
|
||||
explode: true,
|
||||
style: 'deepObject',
|
||||
},
|
||||
});
|
||||
|
||||
const defaultHeaders = {
|
||||
'Content-Type': 'application/json',
|
||||
};
|
||||
|
||||
export const createConfig = <T extends ClientOptions = ClientOptions>(
|
||||
override: Config<Omit<ClientOptions, keyof T> & T> = {},
|
||||
): Config<Omit<ClientOptions, keyof T> & T> => ({
|
||||
...jsonBodySerializer,
|
||||
headers: defaultHeaders,
|
||||
parseAs: 'auto',
|
||||
querySerializer: defaultQuerySerializer,
|
||||
...override,
|
||||
});
|
||||
41
ui/src/client/core/auth.gen.ts
Normal file
41
ui/src/client/core/auth.gen.ts
Normal file
|
|
@ -0,0 +1,41 @@
|
|||
// This file is auto-generated by @hey-api/openapi-ts
|
||||
|
||||
export type AuthToken = string | undefined;
|
||||
|
||||
export interface Auth {
|
||||
/**
|
||||
* Which part of the request do we use to send the auth?
|
||||
*
|
||||
* @default 'header'
|
||||
*/
|
||||
in?: 'header' | 'query' | 'cookie';
|
||||
/**
|
||||
* Header or query parameter name.
|
||||
*
|
||||
* @default 'Authorization'
|
||||
*/
|
||||
name?: string;
|
||||
scheme?: 'basic' | 'bearer';
|
||||
type: 'apiKey' | 'http';
|
||||
}
|
||||
|
||||
export const getAuthToken = async (
|
||||
auth: Auth,
|
||||
callback: ((auth: Auth) => Promise<AuthToken> | AuthToken) | AuthToken,
|
||||
): Promise<string | undefined> => {
|
||||
const token = typeof callback === 'function' ? await callback(auth) : callback;
|
||||
|
||||
if (!token) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (auth.scheme === 'bearer') {
|
||||
return `Bearer ${token}`;
|
||||
}
|
||||
|
||||
if (auth.scheme === 'basic') {
|
||||
return `Basic ${btoa(token)}`;
|
||||
}
|
||||
|
||||
return token;
|
||||
};
|
||||
82
ui/src/client/core/bodySerializer.gen.ts
Normal file
82
ui/src/client/core/bodySerializer.gen.ts
Normal file
|
|
@ -0,0 +1,82 @@
|
|||
// This file is auto-generated by @hey-api/openapi-ts
|
||||
|
||||
import type { ArrayStyle, ObjectStyle, SerializerOptions } from './pathSerializer.gen';
|
||||
|
||||
export type QuerySerializer = (query: Record<string, unknown>) => string;
|
||||
|
||||
export type BodySerializer = (body: unknown) => unknown;
|
||||
|
||||
type QuerySerializerOptionsObject = {
|
||||
allowReserved?: boolean;
|
||||
array?: Partial<SerializerOptions<ArrayStyle>>;
|
||||
object?: Partial<SerializerOptions<ObjectStyle>>;
|
||||
};
|
||||
|
||||
export type QuerySerializerOptions = QuerySerializerOptionsObject & {
|
||||
/**
|
||||
* Per-parameter serialization overrides. When provided, these settings
|
||||
* override the global array/object settings for specific parameter names.
|
||||
*/
|
||||
parameters?: Record<string, QuerySerializerOptionsObject>;
|
||||
};
|
||||
|
||||
const serializeFormDataPair = (data: FormData, key: string, value: unknown): void => {
|
||||
if (typeof value === 'string' || value instanceof Blob) {
|
||||
data.append(key, value);
|
||||
} else if (value instanceof Date) {
|
||||
data.append(key, value.toISOString());
|
||||
} else {
|
||||
data.append(key, JSON.stringify(value));
|
||||
}
|
||||
};
|
||||
|
||||
const serializeUrlSearchParamsPair = (data: URLSearchParams, key: string, value: unknown): void => {
|
||||
if (typeof value === 'string') {
|
||||
data.append(key, value);
|
||||
} else {
|
||||
data.append(key, JSON.stringify(value));
|
||||
}
|
||||
};
|
||||
|
||||
export const formDataBodySerializer = {
|
||||
bodySerializer: (body: unknown): FormData => {
|
||||
const data = new FormData();
|
||||
|
||||
Object.entries(body as Record<string, unknown>).forEach(([key, value]) => {
|
||||
if (value === undefined || value === null) {
|
||||
return;
|
||||
}
|
||||
if (Array.isArray(value)) {
|
||||
value.forEach((v) => serializeFormDataPair(data, key, v));
|
||||
} else {
|
||||
serializeFormDataPair(data, key, value);
|
||||
}
|
||||
});
|
||||
|
||||
return data;
|
||||
},
|
||||
};
|
||||
|
||||
export const jsonBodySerializer = {
|
||||
bodySerializer: (body: unknown): string =>
|
||||
JSON.stringify(body, (_key, value) => (typeof value === 'bigint' ? value.toString() : value)),
|
||||
};
|
||||
|
||||
export const urlSearchParamsBodySerializer = {
|
||||
bodySerializer: (body: unknown): string => {
|
||||
const data = new URLSearchParams();
|
||||
|
||||
Object.entries(body as Record<string, unknown>).forEach(([key, value]) => {
|
||||
if (value === undefined || value === null) {
|
||||
return;
|
||||
}
|
||||
if (Array.isArray(value)) {
|
||||
value.forEach((v) => serializeUrlSearchParamsPair(data, key, v));
|
||||
} else {
|
||||
serializeUrlSearchParamsPair(data, key, value);
|
||||
}
|
||||
});
|
||||
|
||||
return data.toString();
|
||||
},
|
||||
};
|
||||
169
ui/src/client/core/params.gen.ts
Normal file
169
ui/src/client/core/params.gen.ts
Normal file
|
|
@ -0,0 +1,169 @@
|
|||
// This file is auto-generated by @hey-api/openapi-ts
|
||||
|
||||
type Slot = 'body' | 'headers' | 'path' | 'query';
|
||||
|
||||
export type Field =
|
||||
| {
|
||||
in: Exclude<Slot, 'body'>;
|
||||
/**
|
||||
* Field name. This is the name we want the user to see and use.
|
||||
*/
|
||||
key: string;
|
||||
/**
|
||||
* Field mapped name. This is the name we want to use in the request.
|
||||
* If omitted, we use the same value as `key`.
|
||||
*/
|
||||
map?: string;
|
||||
}
|
||||
| {
|
||||
in: Extract<Slot, 'body'>;
|
||||
/**
|
||||
* Key isn't required for bodies.
|
||||
*/
|
||||
key?: string;
|
||||
map?: string;
|
||||
}
|
||||
| {
|
||||
/**
|
||||
* Field name. This is the name we want the user to see and use.
|
||||
*/
|
||||
key: string;
|
||||
/**
|
||||
* Field mapped name. This is the name we want to use in the request.
|
||||
* If `in` is omitted, `map` aliases `key` to the transport layer.
|
||||
*/
|
||||
map: Slot;
|
||||
};
|
||||
|
||||
export interface Fields {
|
||||
allowExtra?: Partial<Record<Slot, boolean>>;
|
||||
args?: ReadonlyArray<Field>;
|
||||
}
|
||||
|
||||
export type FieldsConfig = ReadonlyArray<Field | Fields>;
|
||||
|
||||
const extraPrefixesMap: Record<string, Slot> = {
|
||||
$body_: 'body',
|
||||
$headers_: 'headers',
|
||||
$path_: 'path',
|
||||
$query_: 'query',
|
||||
};
|
||||
const extraPrefixes = Object.entries(extraPrefixesMap);
|
||||
|
||||
type KeyMap = Map<
|
||||
string,
|
||||
| {
|
||||
in: Slot;
|
||||
map?: string;
|
||||
}
|
||||
| {
|
||||
in?: never;
|
||||
map: Slot;
|
||||
}
|
||||
>;
|
||||
|
||||
const buildKeyMap = (fields: FieldsConfig, map?: KeyMap): KeyMap => {
|
||||
if (!map) {
|
||||
map = new Map();
|
||||
}
|
||||
|
||||
for (const config of fields) {
|
||||
if ('in' in config) {
|
||||
if (config.key) {
|
||||
map.set(config.key, {
|
||||
in: config.in,
|
||||
map: config.map,
|
||||
});
|
||||
}
|
||||
} else if ('key' in config) {
|
||||
map.set(config.key, {
|
||||
map: config.map,
|
||||
});
|
||||
} else if (config.args) {
|
||||
buildKeyMap(config.args, map);
|
||||
}
|
||||
}
|
||||
|
||||
return map;
|
||||
};
|
||||
|
||||
interface Params {
|
||||
body: unknown;
|
||||
headers: Record<string, unknown>;
|
||||
path: Record<string, unknown>;
|
||||
query: Record<string, unknown>;
|
||||
}
|
||||
|
||||
const stripEmptySlots = (params: Params) => {
|
||||
for (const [slot, value] of Object.entries(params)) {
|
||||
if (value && typeof value === 'object' && !Array.isArray(value) && !Object.keys(value).length) {
|
||||
delete params[slot as Slot];
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
export const buildClientParams = (args: ReadonlyArray<unknown>, fields: FieldsConfig) => {
|
||||
const params: Params = {
|
||||
body: {},
|
||||
headers: {},
|
||||
path: {},
|
||||
query: {},
|
||||
};
|
||||
|
||||
const map = buildKeyMap(fields);
|
||||
|
||||
let config: FieldsConfig[number] | undefined;
|
||||
|
||||
for (const [index, arg] of args.entries()) {
|
||||
if (fields[index]) {
|
||||
config = fields[index];
|
||||
}
|
||||
|
||||
if (!config) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if ('in' in config) {
|
||||
if (config.key) {
|
||||
const field = map.get(config.key)!;
|
||||
const name = field.map || config.key;
|
||||
if (field.in) {
|
||||
(params[field.in] as Record<string, unknown>)[name] = arg;
|
||||
}
|
||||
} else {
|
||||
params.body = arg;
|
||||
}
|
||||
} else {
|
||||
for (const [key, value] of Object.entries(arg ?? {})) {
|
||||
const field = map.get(key);
|
||||
|
||||
if (field) {
|
||||
if (field.in) {
|
||||
const name = field.map || key;
|
||||
(params[field.in] as Record<string, unknown>)[name] = value;
|
||||
} else {
|
||||
params[field.map] = value;
|
||||
}
|
||||
} else {
|
||||
const extra = extraPrefixes.find(([prefix]) => key.startsWith(prefix));
|
||||
|
||||
if (extra) {
|
||||
const [prefix, slot] = extra;
|
||||
(params[slot] as Record<string, unknown>)[key.slice(prefix.length)] = value;
|
||||
} else if ('allowExtra' in config && config.allowExtra) {
|
||||
for (const [slot, allowed] of Object.entries(config.allowExtra)) {
|
||||
if (allowed) {
|
||||
(params[slot as Slot] as Record<string, unknown>)[key] = value;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
stripEmptySlots(params);
|
||||
|
||||
return params;
|
||||
};
|
||||
171
ui/src/client/core/pathSerializer.gen.ts
Normal file
171
ui/src/client/core/pathSerializer.gen.ts
Normal file
|
|
@ -0,0 +1,171 @@
|
|||
// This file is auto-generated by @hey-api/openapi-ts
|
||||
|
||||
interface SerializeOptions<T> extends SerializePrimitiveOptions, SerializerOptions<T> {}
|
||||
|
||||
interface SerializePrimitiveOptions {
|
||||
allowReserved?: boolean;
|
||||
name: string;
|
||||
}
|
||||
|
||||
export interface SerializerOptions<T> {
|
||||
/**
|
||||
* @default true
|
||||
*/
|
||||
explode: boolean;
|
||||
style: T;
|
||||
}
|
||||
|
||||
export type ArrayStyle = 'form' | 'spaceDelimited' | 'pipeDelimited';
|
||||
export type ArraySeparatorStyle = ArrayStyle | MatrixStyle;
|
||||
type MatrixStyle = 'label' | 'matrix' | 'simple';
|
||||
export type ObjectStyle = 'form' | 'deepObject';
|
||||
type ObjectSeparatorStyle = ObjectStyle | MatrixStyle;
|
||||
|
||||
interface SerializePrimitiveParam extends SerializePrimitiveOptions {
|
||||
value: string;
|
||||
}
|
||||
|
||||
export const separatorArrayExplode = (style: ArraySeparatorStyle) => {
|
||||
switch (style) {
|
||||
case 'label':
|
||||
return '.';
|
||||
case 'matrix':
|
||||
return ';';
|
||||
case 'simple':
|
||||
return ',';
|
||||
default:
|
||||
return '&';
|
||||
}
|
||||
};
|
||||
|
||||
export const separatorArrayNoExplode = (style: ArraySeparatorStyle) => {
|
||||
switch (style) {
|
||||
case 'form':
|
||||
return ',';
|
||||
case 'pipeDelimited':
|
||||
return '|';
|
||||
case 'spaceDelimited':
|
||||
return '%20';
|
||||
default:
|
||||
return ',';
|
||||
}
|
||||
};
|
||||
|
||||
export const separatorObjectExplode = (style: ObjectSeparatorStyle) => {
|
||||
switch (style) {
|
||||
case 'label':
|
||||
return '.';
|
||||
case 'matrix':
|
||||
return ';';
|
||||
case 'simple':
|
||||
return ',';
|
||||
default:
|
||||
return '&';
|
||||
}
|
||||
};
|
||||
|
||||
export const serializeArrayParam = ({
|
||||
allowReserved,
|
||||
explode,
|
||||
name,
|
||||
style,
|
||||
value,
|
||||
}: SerializeOptions<ArraySeparatorStyle> & {
|
||||
value: unknown[];
|
||||
}) => {
|
||||
if (!explode) {
|
||||
const joinedValues = (
|
||||
allowReserved ? value : value.map((v) => encodeURIComponent(v as string))
|
||||
).join(separatorArrayNoExplode(style));
|
||||
switch (style) {
|
||||
case 'label':
|
||||
return `.${joinedValues}`;
|
||||
case 'matrix':
|
||||
return `;${name}=${joinedValues}`;
|
||||
case 'simple':
|
||||
return joinedValues;
|
||||
default:
|
||||
return `${name}=${joinedValues}`;
|
||||
}
|
||||
}
|
||||
|
||||
const separator = separatorArrayExplode(style);
|
||||
const joinedValues = value
|
||||
.map((v) => {
|
||||
if (style === 'label' || style === 'simple') {
|
||||
return allowReserved ? v : encodeURIComponent(v as string);
|
||||
}
|
||||
|
||||
return serializePrimitiveParam({
|
||||
allowReserved,
|
||||
name,
|
||||
value: v as string,
|
||||
});
|
||||
})
|
||||
.join(separator);
|
||||
return style === 'label' || style === 'matrix' ? separator + joinedValues : joinedValues;
|
||||
};
|
||||
|
||||
export const serializePrimitiveParam = ({
|
||||
allowReserved,
|
||||
name,
|
||||
value,
|
||||
}: SerializePrimitiveParam) => {
|
||||
if (value === undefined || value === null) {
|
||||
return '';
|
||||
}
|
||||
|
||||
if (typeof value === 'object') {
|
||||
throw new Error(
|
||||
'Deeply-nested arrays/objects aren’t supported. Provide your own `querySerializer()` to handle these.',
|
||||
);
|
||||
}
|
||||
|
||||
return `${name}=${allowReserved ? value : encodeURIComponent(value)}`;
|
||||
};
|
||||
|
||||
export const serializeObjectParam = ({
|
||||
allowReserved,
|
||||
explode,
|
||||
name,
|
||||
style,
|
||||
value,
|
||||
valueOnly,
|
||||
}: SerializeOptions<ObjectSeparatorStyle> & {
|
||||
value: Record<string, unknown> | Date;
|
||||
valueOnly?: boolean;
|
||||
}) => {
|
||||
if (value instanceof Date) {
|
||||
return valueOnly ? value.toISOString() : `${name}=${value.toISOString()}`;
|
||||
}
|
||||
|
||||
if (style !== 'deepObject' && !explode) {
|
||||
let values: string[] = [];
|
||||
Object.entries(value).forEach(([key, v]) => {
|
||||
values = [...values, key, allowReserved ? (v as string) : encodeURIComponent(v as string)];
|
||||
});
|
||||
const joinedValues = values.join(',');
|
||||
switch (style) {
|
||||
case 'form':
|
||||
return `${name}=${joinedValues}`;
|
||||
case 'label':
|
||||
return `.${joinedValues}`;
|
||||
case 'matrix':
|
||||
return `;${name}=${joinedValues}`;
|
||||
default:
|
||||
return joinedValues;
|
||||
}
|
||||
}
|
||||
|
||||
const separator = separatorObjectExplode(style);
|
||||
const joinedValues = Object.entries(value)
|
||||
.map(([key, v]) =>
|
||||
serializePrimitiveParam({
|
||||
allowReserved,
|
||||
name: style === 'deepObject' ? `${name}[${key}]` : key,
|
||||
value: v as string,
|
||||
}),
|
||||
)
|
||||
.join(separator);
|
||||
return style === 'label' || style === 'matrix' ? separator + joinedValues : joinedValues;
|
||||
};
|
||||
117
ui/src/client/core/queryKeySerializer.gen.ts
Normal file
117
ui/src/client/core/queryKeySerializer.gen.ts
Normal file
|
|
@ -0,0 +1,117 @@
|
|||
// This file is auto-generated by @hey-api/openapi-ts
|
||||
|
||||
/**
|
||||
* JSON-friendly union that mirrors what Pinia Colada can hash.
|
||||
*/
|
||||
export type JsonValue =
|
||||
| null
|
||||
| string
|
||||
| number
|
||||
| boolean
|
||||
| JsonValue[]
|
||||
| { [key: string]: JsonValue };
|
||||
|
||||
/**
|
||||
* Replacer that converts non-JSON values (bigint, Date, etc.) to safe substitutes.
|
||||
*/
|
||||
export const queryKeyJsonReplacer = (_key: string, value: unknown) => {
|
||||
if (value === undefined || typeof value === 'function' || typeof value === 'symbol') {
|
||||
return undefined;
|
||||
}
|
||||
if (typeof value === 'bigint') {
|
||||
return value.toString();
|
||||
}
|
||||
if (value instanceof Date) {
|
||||
return value.toISOString();
|
||||
}
|
||||
return value;
|
||||
};
|
||||
|
||||
/**
|
||||
* Safely stringifies a value and parses it back into a JsonValue.
|
||||
*/
|
||||
export const stringifyToJsonValue = (input: unknown): JsonValue | undefined => {
|
||||
try {
|
||||
const json = JSON.stringify(input, queryKeyJsonReplacer);
|
||||
if (json === undefined) {
|
||||
return undefined;
|
||||
}
|
||||
return JSON.parse(json) as JsonValue;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Detects plain objects (including objects with a null prototype).
|
||||
*/
|
||||
const isPlainObject = (value: unknown): value is Record<string, unknown> => {
|
||||
if (value === null || typeof value !== 'object') {
|
||||
return false;
|
||||
}
|
||||
const prototype = Object.getPrototypeOf(value as object);
|
||||
return prototype === Object.prototype || prototype === null;
|
||||
};
|
||||
|
||||
/**
|
||||
* Turns URLSearchParams into a sorted JSON object for deterministic keys.
|
||||
*/
|
||||
const serializeSearchParams = (params: URLSearchParams): JsonValue => {
|
||||
const entries = Array.from(params.entries()).sort(([a], [b]) => a.localeCompare(b));
|
||||
const result: Record<string, JsonValue> = {};
|
||||
|
||||
for (const [key, value] of entries) {
|
||||
const existing = result[key];
|
||||
if (existing === undefined) {
|
||||
result[key] = value;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (Array.isArray(existing)) {
|
||||
(existing as string[]).push(value);
|
||||
} else {
|
||||
result[key] = [existing, value];
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
};
|
||||
|
||||
/**
|
||||
* Normalizes any accepted value into a JSON-friendly shape for query keys.
|
||||
*/
|
||||
export const serializeQueryKeyValue = (value: unknown): JsonValue | undefined => {
|
||||
if (value === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (typeof value === 'string' || typeof value === 'number' || typeof value === 'boolean') {
|
||||
return value;
|
||||
}
|
||||
|
||||
if (value === undefined || typeof value === 'function' || typeof value === 'symbol') {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
if (typeof value === 'bigint') {
|
||||
return value.toString();
|
||||
}
|
||||
|
||||
if (value instanceof Date) {
|
||||
return value.toISOString();
|
||||
}
|
||||
|
||||
if (Array.isArray(value)) {
|
||||
return stringifyToJsonValue(value);
|
||||
}
|
||||
|
||||
if (typeof URLSearchParams !== 'undefined' && value instanceof URLSearchParams) {
|
||||
return serializeSearchParams(value);
|
||||
}
|
||||
|
||||
if (isPlainObject(value)) {
|
||||
return stringifyToJsonValue(value);
|
||||
}
|
||||
|
||||
return undefined;
|
||||
};
|
||||
243
ui/src/client/core/serverSentEvents.gen.ts
Normal file
243
ui/src/client/core/serverSentEvents.gen.ts
Normal file
|
|
@ -0,0 +1,243 @@
|
|||
// This file is auto-generated by @hey-api/openapi-ts
|
||||
|
||||
import type { Config } from './types.gen';
|
||||
|
||||
export type ServerSentEventsOptions<TData = unknown> = Omit<RequestInit, 'method'> &
|
||||
Pick<Config, 'method' | 'responseTransformer' | 'responseValidator'> & {
|
||||
/**
|
||||
* Fetch API implementation. You can use this option to provide a custom
|
||||
* fetch instance.
|
||||
*
|
||||
* @default globalThis.fetch
|
||||
*/
|
||||
fetch?: typeof fetch;
|
||||
/**
|
||||
* Implementing clients can call request interceptors inside this hook.
|
||||
*/
|
||||
onRequest?: (url: string, init: RequestInit) => Promise<Request>;
|
||||
/**
|
||||
* Callback invoked when a network or parsing error occurs during streaming.
|
||||
*
|
||||
* This option applies only if the endpoint returns a stream of events.
|
||||
*
|
||||
* @param error The error that occurred.
|
||||
*/
|
||||
onSseError?: (error: unknown) => void;
|
||||
/**
|
||||
* Callback invoked when an event is streamed from the server.
|
||||
*
|
||||
* This option applies only if the endpoint returns a stream of events.
|
||||
*
|
||||
* @param event Event streamed from the server.
|
||||
* @returns Nothing (void).
|
||||
*/
|
||||
onSseEvent?: (event: StreamEvent<TData>) => void;
|
||||
serializedBody?: RequestInit['body'];
|
||||
/**
|
||||
* Default retry delay in milliseconds.
|
||||
*
|
||||
* This option applies only if the endpoint returns a stream of events.
|
||||
*
|
||||
* @default 3000
|
||||
*/
|
||||
sseDefaultRetryDelay?: number;
|
||||
/**
|
||||
* Maximum number of retry attempts before giving up.
|
||||
*/
|
||||
sseMaxRetryAttempts?: number;
|
||||
/**
|
||||
* Maximum retry delay in milliseconds.
|
||||
*
|
||||
* Applies only when exponential backoff is used.
|
||||
*
|
||||
* This option applies only if the endpoint returns a stream of events.
|
||||
*
|
||||
* @default 30000
|
||||
*/
|
||||
sseMaxRetryDelay?: number;
|
||||
/**
|
||||
* Optional sleep function for retry backoff.
|
||||
*
|
||||
* Defaults to using `setTimeout`.
|
||||
*/
|
||||
sseSleepFn?: (ms: number) => Promise<void>;
|
||||
url: string;
|
||||
};
|
||||
|
||||
export interface StreamEvent<TData = unknown> {
|
||||
data: TData;
|
||||
event?: string;
|
||||
id?: string;
|
||||
retry?: number;
|
||||
}
|
||||
|
||||
export type ServerSentEventsResult<TData = unknown, TReturn = void, TNext = unknown> = {
|
||||
stream: AsyncGenerator<
|
||||
TData extends Record<string, unknown> ? TData[keyof TData] : TData,
|
||||
TReturn,
|
||||
TNext
|
||||
>;
|
||||
};
|
||||
|
||||
export const createSseClient = <TData = unknown>({
|
||||
onRequest,
|
||||
onSseError,
|
||||
onSseEvent,
|
||||
responseTransformer,
|
||||
responseValidator,
|
||||
sseDefaultRetryDelay,
|
||||
sseMaxRetryAttempts,
|
||||
sseMaxRetryDelay,
|
||||
sseSleepFn,
|
||||
url,
|
||||
...options
|
||||
}: ServerSentEventsOptions): ServerSentEventsResult<TData> => {
|
||||
let lastEventId: string | undefined;
|
||||
|
||||
const sleep = sseSleepFn ?? ((ms: number) => new Promise((resolve) => setTimeout(resolve, ms)));
|
||||
|
||||
const createStream = async function* () {
|
||||
let retryDelay: number = sseDefaultRetryDelay ?? 3000;
|
||||
let attempt = 0;
|
||||
const signal = options.signal ?? new AbortController().signal;
|
||||
|
||||
while (true) {
|
||||
if (signal.aborted) break;
|
||||
|
||||
attempt++;
|
||||
|
||||
const headers =
|
||||
options.headers instanceof Headers
|
||||
? options.headers
|
||||
: new Headers(options.headers as Record<string, string> | undefined);
|
||||
|
||||
if (lastEventId !== undefined) {
|
||||
headers.set('Last-Event-ID', lastEventId);
|
||||
}
|
||||
|
||||
try {
|
||||
const requestInit: RequestInit = {
|
||||
redirect: 'follow',
|
||||
...options,
|
||||
body: options.serializedBody,
|
||||
headers,
|
||||
signal,
|
||||
};
|
||||
let request = new Request(url, requestInit);
|
||||
if (onRequest) {
|
||||
request = await onRequest(url, requestInit);
|
||||
}
|
||||
// fetch must be assigned here, otherwise it would throw the error:
|
||||
// TypeError: Failed to execute 'fetch' on 'Window': Illegal invocation
|
||||
const _fetch = options.fetch ?? globalThis.fetch;
|
||||
const response = await _fetch(request);
|
||||
|
||||
if (!response.ok) throw new Error(`SSE failed: ${response.status} ${response.statusText}`);
|
||||
|
||||
if (!response.body) throw new Error('No body in SSE response');
|
||||
|
||||
const reader = response.body.pipeThrough(new TextDecoderStream()).getReader();
|
||||
|
||||
let buffer = '';
|
||||
|
||||
const abortHandler = () => {
|
||||
try {
|
||||
reader.cancel();
|
||||
} catch {
|
||||
// noop
|
||||
}
|
||||
};
|
||||
|
||||
signal.addEventListener('abort', abortHandler);
|
||||
|
||||
try {
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
buffer += value;
|
||||
// Normalize line endings: CRLF -> LF, then CR -> LF
|
||||
buffer = buffer.replace(/\r\n/g, '\n').replace(/\r/g, '\n');
|
||||
|
||||
const chunks = buffer.split('\n\n');
|
||||
buffer = chunks.pop() ?? '';
|
||||
|
||||
for (const chunk of chunks) {
|
||||
const lines = chunk.split('\n');
|
||||
const dataLines: Array<string> = [];
|
||||
let eventName: string | undefined;
|
||||
|
||||
for (const line of lines) {
|
||||
if (line.startsWith('data:')) {
|
||||
dataLines.push(line.replace(/^data:\s*/, ''));
|
||||
} else if (line.startsWith('event:')) {
|
||||
eventName = line.replace(/^event:\s*/, '');
|
||||
} else if (line.startsWith('id:')) {
|
||||
lastEventId = line.replace(/^id:\s*/, '');
|
||||
} else if (line.startsWith('retry:')) {
|
||||
const parsed = Number.parseInt(line.replace(/^retry:\s*/, ''), 10);
|
||||
if (!Number.isNaN(parsed)) {
|
||||
retryDelay = parsed;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let data: unknown;
|
||||
let parsedJson = false;
|
||||
|
||||
if (dataLines.length) {
|
||||
const rawData = dataLines.join('\n');
|
||||
try {
|
||||
data = JSON.parse(rawData);
|
||||
parsedJson = true;
|
||||
} catch {
|
||||
data = rawData;
|
||||
}
|
||||
}
|
||||
|
||||
if (parsedJson) {
|
||||
if (responseValidator) {
|
||||
await responseValidator(data);
|
||||
}
|
||||
|
||||
if (responseTransformer) {
|
||||
data = await responseTransformer(data);
|
||||
}
|
||||
}
|
||||
|
||||
onSseEvent?.({
|
||||
data,
|
||||
event: eventName,
|
||||
id: lastEventId,
|
||||
retry: retryDelay,
|
||||
});
|
||||
|
||||
if (dataLines.length) {
|
||||
yield data as any;
|
||||
}
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
signal.removeEventListener('abort', abortHandler);
|
||||
reader.releaseLock();
|
||||
}
|
||||
|
||||
break; // exit loop on normal completion
|
||||
} catch (error) {
|
||||
// connection failed or aborted; retry after delay
|
||||
onSseError?.(error);
|
||||
|
||||
if (sseMaxRetryAttempts !== undefined && attempt >= sseMaxRetryAttempts) {
|
||||
break; // stop after firing error
|
||||
}
|
||||
|
||||
// exponential backoff: double retry each attempt, cap at 30s
|
||||
const backoff = Math.min(retryDelay * 2 ** (attempt - 1), sseMaxRetryDelay ?? 30000);
|
||||
await sleep(backoff);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const stream = createStream();
|
||||
|
||||
return { stream };
|
||||
};
|
||||
104
ui/src/client/core/types.gen.ts
Normal file
104
ui/src/client/core/types.gen.ts
Normal file
|
|
@ -0,0 +1,104 @@
|
|||
// This file is auto-generated by @hey-api/openapi-ts
|
||||
|
||||
import type { Auth, AuthToken } from './auth.gen';
|
||||
import type { BodySerializer, QuerySerializer, QuerySerializerOptions } from './bodySerializer.gen';
|
||||
|
||||
export type HttpMethod =
|
||||
| 'connect'
|
||||
| 'delete'
|
||||
| 'get'
|
||||
| 'head'
|
||||
| 'options'
|
||||
| 'patch'
|
||||
| 'post'
|
||||
| 'put'
|
||||
| 'trace';
|
||||
|
||||
export type Client<
|
||||
RequestFn = never,
|
||||
Config = unknown,
|
||||
MethodFn = never,
|
||||
BuildUrlFn = never,
|
||||
SseFn = never,
|
||||
> = {
|
||||
/**
|
||||
* Returns the final request URL.
|
||||
*/
|
||||
buildUrl: BuildUrlFn;
|
||||
getConfig: () => Config;
|
||||
request: RequestFn;
|
||||
setConfig: (config: Config) => Config;
|
||||
} & {
|
||||
[K in HttpMethod]: MethodFn;
|
||||
} & ([SseFn] extends [never] ? { sse?: never } : { sse: { [K in HttpMethod]: SseFn } });
|
||||
|
||||
export interface Config {
|
||||
/**
|
||||
* Auth token or a function returning auth token. The resolved value will be
|
||||
* added to the request payload as defined by its `security` array.
|
||||
*/
|
||||
auth?: ((auth: Auth) => Promise<AuthToken> | AuthToken) | AuthToken;
|
||||
/**
|
||||
* A function for serializing request body parameter. By default,
|
||||
* {@link JSON.stringify()} will be used.
|
||||
*/
|
||||
bodySerializer?: BodySerializer | null;
|
||||
/**
|
||||
* An object containing any HTTP headers that you want to pre-populate your
|
||||
* `Headers` object with.
|
||||
*
|
||||
* {@link https://developer.mozilla.org/docs/Web/API/Headers/Headers#init See more}
|
||||
*/
|
||||
headers?:
|
||||
| RequestInit['headers']
|
||||
| Record<
|
||||
string,
|
||||
string | number | boolean | (string | number | boolean)[] | null | undefined | unknown
|
||||
>;
|
||||
/**
|
||||
* The request method.
|
||||
*
|
||||
* {@link https://developer.mozilla.org/docs/Web/API/fetch#method See more}
|
||||
*/
|
||||
method?: Uppercase<HttpMethod>;
|
||||
/**
|
||||
* A function for serializing request query parameters. By default, arrays
|
||||
* will be exploded in form style, objects will be exploded in deepObject
|
||||
* style, and reserved characters are percent-encoded.
|
||||
*
|
||||
* This method will have no effect if the native `paramsSerializer()` Axios
|
||||
* API function is used.
|
||||
*
|
||||
* {@link https://swagger.io/docs/specification/serialization/#query View examples}
|
||||
*/
|
||||
querySerializer?: QuerySerializer | QuerySerializerOptions;
|
||||
/**
|
||||
* A function validating request data. This is useful if you want to ensure
|
||||
* the request conforms to the desired shape, so it can be safely sent to
|
||||
* the server.
|
||||
*/
|
||||
requestValidator?: (data: unknown) => Promise<unknown>;
|
||||
/**
|
||||
* A function transforming response data before it's returned. This is useful
|
||||
* for post-processing data, e.g., converting ISO strings into Date objects.
|
||||
*/
|
||||
responseTransformer?: (data: unknown) => Promise<unknown>;
|
||||
/**
|
||||
* A function validating response data. This is useful if you want to ensure
|
||||
* the response conforms to the desired shape, so it can be safely passed to
|
||||
* the transformers and returned to the user.
|
||||
*/
|
||||
responseValidator?: (data: unknown) => Promise<unknown>;
|
||||
}
|
||||
|
||||
type IsExactlyNeverOrNeverUndefined<T> = [T] extends [never]
|
||||
? true
|
||||
: [T] extends [never | undefined]
|
||||
? [undefined] extends [T]
|
||||
? false
|
||||
: true
|
||||
: false;
|
||||
|
||||
export type OmitNever<T extends Record<string, unknown>> = {
|
||||
[K in keyof T as IsExactlyNeverOrNeverUndefined<T[K]> extends true ? never : K]: T[K];
|
||||
};
|
||||
140
ui/src/client/core/utils.gen.ts
Normal file
140
ui/src/client/core/utils.gen.ts
Normal file
|
|
@ -0,0 +1,140 @@
|
|||
// This file is auto-generated by @hey-api/openapi-ts
|
||||
|
||||
import type { BodySerializer, QuerySerializer } from './bodySerializer.gen';
|
||||
import {
|
||||
type ArraySeparatorStyle,
|
||||
serializeArrayParam,
|
||||
serializeObjectParam,
|
||||
serializePrimitiveParam,
|
||||
} from './pathSerializer.gen';
|
||||
|
||||
export interface PathSerializer {
|
||||
path: Record<string, unknown>;
|
||||
url: string;
|
||||
}
|
||||
|
||||
export const PATH_PARAM_RE = /\{[^{}]+\}/g;
|
||||
|
||||
export const defaultPathSerializer = ({ path, url: _url }: PathSerializer) => {
|
||||
let url = _url;
|
||||
const matches = _url.match(PATH_PARAM_RE);
|
||||
if (matches) {
|
||||
for (const match of matches) {
|
||||
let explode = false;
|
||||
let name = match.substring(1, match.length - 1);
|
||||
let style: ArraySeparatorStyle = 'simple';
|
||||
|
||||
if (name.endsWith('*')) {
|
||||
explode = true;
|
||||
name = name.substring(0, name.length - 1);
|
||||
}
|
||||
|
||||
if (name.startsWith('.')) {
|
||||
name = name.substring(1);
|
||||
style = 'label';
|
||||
} else if (name.startsWith(';')) {
|
||||
name = name.substring(1);
|
||||
style = 'matrix';
|
||||
}
|
||||
|
||||
const value = path[name];
|
||||
|
||||
if (value === undefined || value === null) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (Array.isArray(value)) {
|
||||
url = url.replace(match, serializeArrayParam({ explode, name, style, value }));
|
||||
continue;
|
||||
}
|
||||
|
||||
if (typeof value === 'object') {
|
||||
url = url.replace(
|
||||
match,
|
||||
serializeObjectParam({
|
||||
explode,
|
||||
name,
|
||||
style,
|
||||
value: value as Record<string, unknown>,
|
||||
valueOnly: true,
|
||||
}),
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (style === 'matrix') {
|
||||
url = url.replace(
|
||||
match,
|
||||
`;${serializePrimitiveParam({
|
||||
name,
|
||||
value: value as string,
|
||||
})}`,
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
const replaceValue = encodeURIComponent(
|
||||
style === 'label' ? `.${value as string}` : (value as string),
|
||||
);
|
||||
url = url.replace(match, replaceValue);
|
||||
}
|
||||
}
|
||||
return url;
|
||||
};
|
||||
|
||||
export const getUrl = ({
|
||||
baseUrl,
|
||||
path,
|
||||
query,
|
||||
querySerializer,
|
||||
url: _url,
|
||||
}: {
|
||||
baseUrl?: string;
|
||||
path?: Record<string, unknown>;
|
||||
query?: Record<string, unknown>;
|
||||
querySerializer: QuerySerializer;
|
||||
url: string;
|
||||
}) => {
|
||||
const pathUrl = _url.startsWith('/') ? _url : `/${_url}`;
|
||||
let url = (baseUrl ?? '') + pathUrl;
|
||||
if (path) {
|
||||
url = defaultPathSerializer({ path, url });
|
||||
}
|
||||
let search = query ? querySerializer(query) : '';
|
||||
if (search.startsWith('?')) {
|
||||
search = search.substring(1);
|
||||
}
|
||||
if (search) {
|
||||
url += `?${search}`;
|
||||
}
|
||||
return url;
|
||||
};
|
||||
|
||||
export function getValidRequestBody(options: {
|
||||
body?: unknown;
|
||||
bodySerializer?: BodySerializer | null;
|
||||
serializedBody?: unknown;
|
||||
}) {
|
||||
const hasBody = options.body !== undefined;
|
||||
const isSerializedBody = hasBody && options.bodySerializer;
|
||||
|
||||
if (isSerializedBody) {
|
||||
if ('serializedBody' in options) {
|
||||
const hasSerializedBody =
|
||||
options.serializedBody !== undefined && options.serializedBody !== '';
|
||||
|
||||
return hasSerializedBody ? options.serializedBody : null;
|
||||
}
|
||||
|
||||
// not all clients implement a serializedBody property (i.e., client-axios)
|
||||
return options.body !== '' ? options.body : null;
|
||||
}
|
||||
|
||||
// plain/text body
|
||||
if (hasBody) {
|
||||
return options.body;
|
||||
}
|
||||
|
||||
// no body was provided
|
||||
return undefined;
|
||||
}
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because it is too large
Load diff
|
|
@ -1,648 +1,10 @@
|
|||
"use client";
|
||||
|
||||
import { Plus, X } from "lucide-react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
|
||||
import { getDefaultConfigurationsApiV1UserConfigurationsDefaultsGet } from '@/client/sdk.gen';
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { VoiceSelector } from "@/components/VoiceSelector";
|
||||
import { LANGUAGE_DISPLAY_NAMES } from "@/constants/languages";
|
||||
import { ServiceConfigurationForm } from "@/components/ServiceConfigurationForm";
|
||||
import { useUserConfig } from "@/context/UserConfigContext";
|
||||
|
||||
type ServiceSegment = "llm" | "tts" | "stt" | "embeddings" | "realtime";
|
||||
|
||||
interface SchemaProperty {
|
||||
type?: string;
|
||||
default?: string | number | boolean;
|
||||
enum?: string[];
|
||||
examples?: string[];
|
||||
model_options?: Record<string, string[]>;
|
||||
allow_custom_input?: boolean;
|
||||
$ref?: string;
|
||||
description?: string;
|
||||
format?: string;
|
||||
}
|
||||
|
||||
interface ProviderSchema {
|
||||
properties: Record<string, SchemaProperty>;
|
||||
required?: string[];
|
||||
$defs?: Record<string, SchemaProperty>;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
interface FormValues {
|
||||
[key: string]: string | number | boolean;
|
||||
}
|
||||
|
||||
const STANDARD_TABS: { key: ServiceSegment; label: string }[] = [
|
||||
{ key: "llm", label: "LLM" },
|
||||
{ key: "tts", label: "Voice" },
|
||||
{ key: "stt", label: "Transcriber" },
|
||||
{ key: "embeddings", label: "Embedding" },
|
||||
];
|
||||
|
||||
const REALTIME_TABS: { key: ServiceSegment; label: string }[] = [
|
||||
{ key: "realtime", label: "Realtime Model" },
|
||||
{ key: "embeddings", label: "Embedding" },
|
||||
];
|
||||
|
||||
// Display names for Sarvam voices
|
||||
const VOICE_DISPLAY_NAMES: Record<string, string> = {
|
||||
"anushka": "Anushka (Female)",
|
||||
"manisha": "Manisha (Female)",
|
||||
"vidya": "Vidya (Female)",
|
||||
"arya": "Arya (Female)",
|
||||
"abhilash": "Abhilash (Male)",
|
||||
"karun": "Karun (Male)",
|
||||
"hitesh": "Hitesh (Male)",
|
||||
};
|
||||
|
||||
export default function ServiceConfiguration() {
|
||||
const [apiError, setApiError] = useState<string | null>(null);
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
const [isRealtime, setIsRealtime] = useState(false);
|
||||
const { userConfig, saveUserConfig } = useUserConfig();
|
||||
const [schemas, setSchemas] = useState<Record<ServiceSegment, Record<string, ProviderSchema>>>({
|
||||
llm: {},
|
||||
tts: {},
|
||||
stt: {},
|
||||
embeddings: {},
|
||||
realtime: {},
|
||||
});
|
||||
const [serviceProviders, setServiceProviders] = useState<Record<ServiceSegment, string>>({
|
||||
llm: "",
|
||||
tts: "",
|
||||
stt: "",
|
||||
embeddings: "",
|
||||
realtime: "",
|
||||
});
|
||||
const [apiKeys, setApiKeys] = useState<Record<ServiceSegment, string[]>>({
|
||||
llm: [""],
|
||||
tts: [""],
|
||||
stt: [""],
|
||||
embeddings: [""],
|
||||
realtime: [""],
|
||||
});
|
||||
const [isCustomInput, setIsCustomInput] = useState<Record<string, boolean>>({});
|
||||
|
||||
const {
|
||||
register,
|
||||
handleSubmit,
|
||||
formState: { },
|
||||
reset,
|
||||
getValues,
|
||||
setValue,
|
||||
watch
|
||||
} = useForm();
|
||||
|
||||
useEffect(() => {
|
||||
const fetchConfigurations = async () => {
|
||||
const response = await getDefaultConfigurationsApiV1UserConfigurationsDefaultsGet();
|
||||
if (response.data) {
|
||||
const data = response.data as Record<string, unknown>;
|
||||
setSchemas({
|
||||
llm: response.data.llm as Record<string, ProviderSchema>,
|
||||
tts: response.data.tts as Record<string, ProviderSchema>,
|
||||
stt: response.data.stt as Record<string, ProviderSchema>,
|
||||
embeddings: response.data.embeddings as Record<string, ProviderSchema>,
|
||||
realtime: (data.realtime || {}) as Record<string, ProviderSchema>,
|
||||
});
|
||||
|
||||
// Restore realtime toggle from saved config
|
||||
const configData = userConfig as Record<string, unknown> | null;
|
||||
if (configData?.is_realtime) {
|
||||
setIsRealtime(true);
|
||||
}
|
||||
} else {
|
||||
console.error("Failed to fetch configurations");
|
||||
return;
|
||||
}
|
||||
|
||||
const defaultValues: Record<string, string | number | boolean> = {};
|
||||
const selectedProviders: Record<ServiceSegment, string> = {
|
||||
llm: response.data.default_providers.llm,
|
||||
tts: response.data.default_providers.tts,
|
||||
stt: response.data.default_providers.stt,
|
||||
embeddings: response.data.default_providers.embeddings,
|
||||
realtime: "",
|
||||
};
|
||||
|
||||
// Set default realtime provider from schema keys
|
||||
const data = response.data as Record<string, unknown>;
|
||||
const realtimeSchemas = (data.realtime || {}) as Record<string, ProviderSchema>;
|
||||
const realtimeProviderKeys = Object.keys(realtimeSchemas);
|
||||
if (realtimeProviderKeys.length > 0) {
|
||||
selectedProviders.realtime = realtimeProviderKeys[0];
|
||||
}
|
||||
|
||||
const loadedApiKeys: Record<ServiceSegment, string[]> = {
|
||||
llm: [""],
|
||||
tts: [""],
|
||||
stt: [""],
|
||||
embeddings: [""],
|
||||
realtime: [""],
|
||||
};
|
||||
|
||||
const setServicePropertyValues = (service: ServiceSegment) => {
|
||||
// For realtime, read from userConfig.realtime; for others, read from userConfig[service]
|
||||
const configSource = service === "realtime"
|
||||
? (userConfig as Record<string, unknown> | null)?.realtime as Record<string, unknown> | undefined
|
||||
: userConfig?.[service as "llm" | "tts" | "stt" | "embeddings"];
|
||||
|
||||
const schemaSource = service === "realtime"
|
||||
? realtimeSchemas
|
||||
: response.data[service as "llm" | "tts" | "stt" | "embeddings"] as Record<string, ProviderSchema> | undefined;
|
||||
|
||||
if (configSource?.provider) {
|
||||
Object.entries(configSource).forEach(([field, value]) => {
|
||||
if (field === "api_key") {
|
||||
if (Array.isArray(value)) {
|
||||
loadedApiKeys[service] = (value as string[]).length > 0 ? value as string[] : [""];
|
||||
} else {
|
||||
loadedApiKeys[service] = value ? [value as string] : [""];
|
||||
}
|
||||
} else if (field !== "provider") {
|
||||
defaultValues[`${service}_${field}`] = value as string | number | boolean;
|
||||
}
|
||||
});
|
||||
selectedProviders[service] = configSource.provider as string;
|
||||
// Fill in schema defaults for fields not present in config
|
||||
const properties = schemaSource?.[selectedProviders[service]]?.properties as Record<string, SchemaProperty>;
|
||||
if (properties) {
|
||||
Object.entries(properties).forEach(([field, schema]) => {
|
||||
const key = `${service}_${field}`;
|
||||
if (field !== "provider" && field !== "api_key" && schema.default !== undefined && !(key in defaultValues)) {
|
||||
defaultValues[key] = schema.default;
|
||||
}
|
||||
});
|
||||
}
|
||||
} else {
|
||||
const properties = schemaSource?.[selectedProviders[service]]?.properties as Record<string, SchemaProperty>;
|
||||
if (properties) {
|
||||
Object.entries(properties).forEach(([field, schema]) => {
|
||||
if (field !== "provider" && schema.default !== undefined) {
|
||||
defaultValues[`${service}_${field}`] = schema.default;
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
setServicePropertyValues("llm");
|
||||
setServicePropertyValues("tts");
|
||||
setServicePropertyValues("stt");
|
||||
setServicePropertyValues("embeddings");
|
||||
setServicePropertyValues("realtime");
|
||||
|
||||
// Detect saved values that are not in suggested options (custom value)
|
||||
const detectedCustomInput: Record<string, boolean> = {};
|
||||
const allSchemas = { ...response.data, realtime: realtimeSchemas } as unknown as Record<string, Record<string, ProviderSchema>>;
|
||||
(["llm", "tts", "stt", "embeddings", "realtime"] as ServiceSegment[]).forEach(service => {
|
||||
const provider = selectedProviders[service];
|
||||
const providerSchema = allSchemas[service]?.[provider];
|
||||
if (!providerSchema) return;
|
||||
|
||||
const configSource = service === "realtime"
|
||||
? (userConfig as Record<string, unknown> | null)?.realtime as Record<string, unknown> | undefined
|
||||
: userConfig?.[service as "llm" | "tts" | "stt" | "embeddings"];
|
||||
|
||||
Object.entries(providerSchema.properties).forEach(([field, schema]) => {
|
||||
const actualSchema = (schema as SchemaProperty).$ref && providerSchema.$defs
|
||||
? providerSchema.$defs[(schema as SchemaProperty).$ref!.split('/').pop() || '']
|
||||
: schema as SchemaProperty;
|
||||
|
||||
if (!actualSchema?.allow_custom_input || !actualSchema?.examples) return;
|
||||
|
||||
const savedValue = configSource?.[field] as string | undefined;
|
||||
if (savedValue && !actualSchema.examples.includes(savedValue)) {
|
||||
detectedCustomInput[`${service}_${field}`] = true;
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// IMPORTANT: Reset form values BEFORE changing providers
|
||||
// Otherwise, Radix Select sees old values that don't match new provider's enum
|
||||
// and calls onValueChange('') to clear "invalid" values
|
||||
reset(defaultValues);
|
||||
setApiKeys(loadedApiKeys);
|
||||
setServiceProviders(selectedProviders);
|
||||
setIsCustomInput(detectedCustomInput);
|
||||
};
|
||||
fetchConfigurations();
|
||||
}, [reset, userConfig]);
|
||||
|
||||
// Reset voice when TTS model changes if the provider has model-dependent voice options
|
||||
const ttsModel = watch("tts_model");
|
||||
useEffect(() => {
|
||||
const voiceSchema = schemas?.tts?.[serviceProviders.tts]?.properties?.voice;
|
||||
const modelOptions = voiceSchema?.model_options;
|
||||
if (!modelOptions || !ttsModel) return;
|
||||
|
||||
const validVoices = modelOptions[ttsModel as string];
|
||||
const currentVoice = getValues("tts_voice") as string;
|
||||
if (validVoices && currentVoice && !validVoices.includes(currentVoice)) {
|
||||
setValue("tts_voice", validVoices[0], { shouldDirty: true });
|
||||
}
|
||||
}, [ttsModel, serviceProviders.tts, setValue, getValues, schemas]);
|
||||
|
||||
// Reset language when STT model changes if the provider has model-dependent language options
|
||||
const sttModel = watch("stt_model");
|
||||
useEffect(() => {
|
||||
const languageSchema = schemas?.stt?.[serviceProviders.stt]?.properties?.language;
|
||||
const modelOptions = languageSchema?.model_options;
|
||||
if (!modelOptions || !sttModel) return;
|
||||
|
||||
const validLanguages = modelOptions[sttModel as string];
|
||||
const currentLanguage = getValues("stt_language") as string;
|
||||
if (validLanguages && currentLanguage && !validLanguages.includes(currentLanguage)) {
|
||||
setValue("stt_language", validLanguages[0], { shouldDirty: true });
|
||||
}
|
||||
}, [sttModel, serviceProviders.stt, setValue, getValues, schemas]);
|
||||
|
||||
const handleProviderChange = (service: ServiceSegment, providerName: string) => {
|
||||
if (!providerName) {
|
||||
return;
|
||||
}
|
||||
|
||||
const currentValues = getValues();
|
||||
const preservedValues: Record<string, string | number | boolean> = {};
|
||||
|
||||
// Preserve values from other services
|
||||
Object.keys(currentValues).forEach(key => {
|
||||
if (!key.startsWith(`${service}_`)) {
|
||||
preservedValues[key] = currentValues[key];
|
||||
}
|
||||
});
|
||||
|
||||
// Set default values from schema
|
||||
if (schemas?.[service]?.[providerName]) {
|
||||
const providerSchema = schemas[service][providerName];
|
||||
Object.entries(providerSchema.properties).forEach(([field, schema]: [string, SchemaProperty]) => {
|
||||
if (field !== "provider" && schema.default !== undefined) {
|
||||
preservedValues[`${service}_${field}`] = schema.default;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
preservedValues[`${service}_provider`] = providerName;
|
||||
reset(preservedValues);
|
||||
setServiceProviders(prev => ({ ...prev, [service]: providerName }));
|
||||
setApiKeys(prev => ({ ...prev, [service]: [""] }));
|
||||
|
||||
// Reset custom input toggles when provider changes
|
||||
setIsCustomInput(prev => {
|
||||
const next = { ...prev };
|
||||
Object.keys(next).forEach(key => {
|
||||
if (key.startsWith(`${service}_`)) delete next[key];
|
||||
});
|
||||
return next;
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
const onSubmit = async (data: FormValues) => {
|
||||
setApiError(null);
|
||||
setIsSaving(true);
|
||||
|
||||
// Collect non-empty API keys per service
|
||||
const getServiceApiKeys = (service: ServiceSegment): string[] =>
|
||||
apiKeys[service].map(k => k.trim()).filter(k => k.length > 0);
|
||||
|
||||
// Build service configs from form data
|
||||
const buildServiceConfig = (service: ServiceSegment) => {
|
||||
const config: Record<string, string | number | string[]> = {
|
||||
provider: serviceProviders[service],
|
||||
};
|
||||
const keys = getServiceApiKeys(service);
|
||||
if (keys.length > 0) {
|
||||
config.api_key = keys;
|
||||
}
|
||||
// Add all form fields for this service
|
||||
Object.entries(data).forEach(([property, value]) => {
|
||||
if (!property.startsWith(`${service}_`)) return;
|
||||
const field = property.slice(service.length + 1);
|
||||
if (field === "api_key" || field === "provider") return;
|
||||
config[field] = value as string | number;
|
||||
});
|
||||
return config;
|
||||
};
|
||||
|
||||
// Always save all configs so switching modes preserves everything
|
||||
const saveConfig: Record<string, unknown> = {
|
||||
llm: buildServiceConfig("llm"),
|
||||
tts: buildServiceConfig("tts"),
|
||||
stt: buildServiceConfig("stt"),
|
||||
is_realtime: isRealtime,
|
||||
};
|
||||
|
||||
// Save realtime config if provider is set
|
||||
if (serviceProviders.realtime) {
|
||||
saveConfig.realtime = buildServiceConfig("realtime");
|
||||
}
|
||||
|
||||
// Only include embeddings if user has configured it (has api_key)
|
||||
const embeddingsKeys = getServiceApiKeys("embeddings");
|
||||
if (embeddingsKeys.length > 0) {
|
||||
saveConfig.embeddings = buildServiceConfig("embeddings");
|
||||
}
|
||||
|
||||
try {
|
||||
await saveUserConfig(saveConfig);
|
||||
setApiError(null);
|
||||
} catch (error: unknown) {
|
||||
if (error instanceof Error) {
|
||||
setApiError(error.message);
|
||||
} else {
|
||||
setApiError('An unknown error occurred');
|
||||
}
|
||||
} finally {
|
||||
setIsSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const getConfigFields = (service: ServiceSegment): string[] => {
|
||||
const currentProvider = serviceProviders[service];
|
||||
const providerSchema = schemas?.[service]?.[currentProvider];
|
||||
if (!providerSchema) return [];
|
||||
|
||||
// Find all config fields (not provider, not api_key)
|
||||
const fields = Object.keys(providerSchema.properties).filter(
|
||||
field => field !== "provider" && field !== "api_key"
|
||||
);
|
||||
|
||||
return fields;
|
||||
};
|
||||
|
||||
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(s) */}
|
||||
{currentProvider && providerSchema && providerSchema.properties.api_key && (
|
||||
<div className="space-y-2">
|
||||
<Label>API Key(s)</Label>
|
||||
{apiKeys[service].map((key, index) => (
|
||||
<div key={index} className="flex gap-2">
|
||||
<Input
|
||||
type="text"
|
||||
placeholder="Enter API key"
|
||||
value={key}
|
||||
onChange={(e) => {
|
||||
const newKeys = [...apiKeys[service]];
|
||||
newKeys[index] = e.target.value;
|
||||
setApiKeys(prev => ({ ...prev, [service]: newKeys }));
|
||||
}}
|
||||
/>
|
||||
{apiKeys[service].length > 1 && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="shrink-0"
|
||||
onClick={() => {
|
||||
setApiKeys(prev => ({
|
||||
...prev,
|
||||
[service]: prev[service].filter((_, i) => i !== index),
|
||||
}));
|
||||
}}
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
setApiKeys(prev => ({
|
||||
...prev,
|
||||
[service]: [...prev[service], ""],
|
||||
}));
|
||||
}}
|
||||
>
|
||||
<Plus className="h-4 w-4 mr-1" /> Add API Key
|
||||
</Button>
|
||||
</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;
|
||||
|
||||
// VoiceSelector for TTS voice fields without predefined options or manual input flag
|
||||
if (service === "tts" && field === "voice" && !actualSchema?.allow_custom_input) {
|
||||
const hasVoiceOptions = actualSchema?.enum || actualSchema?.examples;
|
||||
if (!hasVoiceOptions) {
|
||||
return (
|
||||
<VoiceSelector
|
||||
provider={serviceProviders.tts}
|
||||
value={watch(`${service}_${field}`) as string || ""}
|
||||
onChange={(voiceId) => {
|
||||
setValue(`${service}_${field}`, voiceId, { shouldDirty: true });
|
||||
}}
|
||||
model={watch("tts_model") as string || undefined}
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Generic allow_custom_input handler for any field (model, voice with options, etc.)
|
||||
if (actualSchema?.allow_custom_input && actualSchema?.examples) {
|
||||
const fieldKey = `${service}_${field}`;
|
||||
const currentValue = watch(fieldKey) as string || "";
|
||||
const options = actualSchema.examples;
|
||||
|
||||
if (isCustomInput[fieldKey]) {
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<Input
|
||||
type="text"
|
||||
placeholder={`Enter ${field}`}
|
||||
value={currentValue}
|
||||
onChange={(e) => {
|
||||
setValue(fieldKey, e.target.value, { shouldDirty: true });
|
||||
}}
|
||||
/>
|
||||
<div className="flex items-center space-x-2">
|
||||
<Checkbox
|
||||
id={`custom-input-${fieldKey}`}
|
||||
checked={true}
|
||||
onCheckedChange={(checked) => {
|
||||
setIsCustomInput(prev => ({ ...prev, [fieldKey]: checked as boolean }));
|
||||
if (!checked && options.length > 0) {
|
||||
setValue(fieldKey, options[0], { shouldDirty: true });
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<Label htmlFor={`custom-input-${fieldKey}`} className="text-sm font-normal cursor-pointer">
|
||||
Enter Custom Value
|
||||
</Label>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<Select
|
||||
value={currentValue}
|
||||
onValueChange={(value) => {
|
||||
if (!value) return;
|
||||
setValue(fieldKey, value, { shouldDirty: true });
|
||||
}}
|
||||
>
|
||||
<SelectTrigger className="w-full">
|
||||
<SelectValue placeholder={`Select ${field}`} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{options.map((value: string) => (
|
||||
<SelectItem key={value} value={value}>
|
||||
{value}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<div className="flex items-center space-x-2">
|
||||
<Checkbox
|
||||
id={`custom-input-${fieldKey}-dropdown`}
|
||||
checked={false}
|
||||
onCheckedChange={(checked) => {
|
||||
setIsCustomInput(prev => ({ ...prev, [fieldKey]: checked as boolean }));
|
||||
}}
|
||||
/>
|
||||
<Label htmlFor={`custom-input-${fieldKey}-dropdown`} className="text-sm font-normal cursor-pointer">
|
||||
Enter Custom Value
|
||||
</Label>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Handle fields with enum or examples (dropdown options)
|
||||
let dropdownOptions = actualSchema?.enum || actualSchema?.examples;
|
||||
|
||||
// Use model-dependent options when available (e.g., Sarvam voices per model)
|
||||
if (actualSchema?.model_options) {
|
||||
const modelValue = watch(`${service}_model`) as string;
|
||||
if (modelValue && actualSchema.model_options[modelValue]) {
|
||||
dropdownOptions = actualSchema.model_options[modelValue];
|
||||
}
|
||||
}
|
||||
|
||||
if (dropdownOptions && dropdownOptions.length > 0) {
|
||||
// Use friendly display names for language and voice fields
|
||||
const getDisplayName = (value: string) => {
|
||||
if (field === "language") {
|
||||
return LANGUAGE_DISPLAY_NAMES[value] || value;
|
||||
}
|
||||
if (field === "voice") {
|
||||
return VOICE_DISPLAY_NAMES[value] || value.charAt(0).toUpperCase() + value.slice(1);
|
||||
}
|
||||
return value;
|
||||
};
|
||||
|
||||
return (
|
||||
<Select
|
||||
value={watch(`${service}_${field}`) as string || ""}
|
||||
onValueChange={(value) => {
|
||||
// Ignore empty string - Radix Select sometimes calls onValueChange('')
|
||||
// when options change, even if current value is valid
|
||||
if (!value) return;
|
||||
setValue(`${service}_${field}`, value, { shouldDirty: true });
|
||||
}}
|
||||
>
|
||||
<SelectTrigger className="w-full">
|
||||
<SelectValue placeholder={`Select ${field}`} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{dropdownOptions.map((value: string) => (
|
||||
<SelectItem key={value} value={value}>
|
||||
{getDisplayName(value)}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Input
|
||||
type={actualSchema?.type === "number" ? "number" : "text"}
|
||||
{...(actualSchema?.type === "number" && { step: "any" })}
|
||||
placeholder={`Enter ${field}`}
|
||||
{...register(`${service}_${field}`, {
|
||||
// Embeddings is optional, so don't require its fields
|
||||
required: service !== "embeddings" && providerSchema.required?.includes(field),
|
||||
valueAsNumber: actualSchema?.type === "number"
|
||||
})}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
const visibleTabs = isRealtime ? REALTIME_TABS : STANDARD_TABS;
|
||||
const defaultTab = isRealtime ? "realtime" : "llm";
|
||||
const { saveUserConfig } = useUserConfig();
|
||||
|
||||
return (
|
||||
<div className="w-full max-w-2xl mx-auto">
|
||||
|
|
@ -653,50 +15,12 @@ export default function ServiceConfiguration() {
|
|||
</p>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSubmit(onSubmit)}>
|
||||
{/* Realtime toggle */}
|
||||
<div className="flex items-center justify-between mb-4 p-4 border rounded-lg">
|
||||
<div>
|
||||
<Label htmlFor="realtime-toggle" className="text-sm font-medium">
|
||||
Realtime Mode
|
||||
</Label>
|
||||
<p className="text-xs text-muted-foreground mt-0.5">
|
||||
Uses a single speech-to-speech model (no separate STT/TTS)
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
id="realtime-toggle"
|
||||
checked={isRealtime}
|
||||
onCheckedChange={setIsRealtime}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
<CardContent className="pt-6">
|
||||
<Tabs key={defaultTab} defaultValue={defaultTab} className="w-full">
|
||||
<TabsList className="grid w-full mb-6" style={{ gridTemplateColumns: `repeat(${visibleTabs.length}, 1fr)` }}>
|
||||
{visibleTabs.map(({ key, label }) => (
|
||||
<TabsTrigger key={key} value={key}>
|
||||
{label}
|
||||
</TabsTrigger>
|
||||
))}
|
||||
</TabsList>
|
||||
|
||||
{visibleTabs.map(({ key }) => (
|
||||
<TabsContent key={key} value={key} className="mt-0">
|
||||
{renderServiceFields(key)}
|
||||
</TabsContent>
|
||||
))}
|
||||
</Tabs>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{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>
|
||||
<ServiceConfigurationForm
|
||||
mode="global"
|
||||
onSave={async (config) => {
|
||||
await saveUserConfig(config);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
799
ui/src/components/ServiceConfigurationForm.tsx
Normal file
799
ui/src/components/ServiceConfigurationForm.tsx
Normal file
|
|
@ -0,0 +1,799 @@
|
|||
"use client";
|
||||
|
||||
import { Plus, X } from "lucide-react";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
|
||||
import { getDefaultConfigurationsApiV1UserConfigurationsDefaultsGet } from '@/client/sdk.gen';
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { VoiceSelector } from "@/components/VoiceSelector";
|
||||
import { LANGUAGE_DISPLAY_NAMES } from "@/constants/languages";
|
||||
import { useUserConfig } from "@/context/UserConfigContext";
|
||||
import type { ModelOverrides } from "@/types/workflow-configurations";
|
||||
|
||||
type ServiceSegment = "llm" | "tts" | "stt" | "embeddings" | "realtime";
|
||||
|
||||
interface SchemaProperty {
|
||||
type?: string;
|
||||
default?: string | number | boolean;
|
||||
enum?: string[];
|
||||
examples?: string[];
|
||||
model_options?: Record<string, string[]>;
|
||||
allow_custom_input?: boolean;
|
||||
$ref?: string;
|
||||
description?: string;
|
||||
format?: string;
|
||||
}
|
||||
|
||||
interface ProviderSchema {
|
||||
properties: Record<string, SchemaProperty>;
|
||||
required?: string[];
|
||||
$defs?: Record<string, SchemaProperty>;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
interface FormValues {
|
||||
[key: string]: string | number | boolean;
|
||||
}
|
||||
|
||||
const STANDARD_TABS: { key: ServiceSegment; label: string }[] = [
|
||||
{ key: "llm", label: "LLM" },
|
||||
{ key: "tts", label: "Voice" },
|
||||
{ key: "stt", label: "Transcriber" },
|
||||
{ key: "embeddings", label: "Embedding" },
|
||||
];
|
||||
|
||||
const REALTIME_TABS: { key: ServiceSegment; label: string }[] = [
|
||||
{ key: "realtime", label: "Realtime Model" },
|
||||
{ key: "embeddings", label: "Embedding" },
|
||||
];
|
||||
|
||||
const OVERRIDE_STANDARD_TABS: { key: ServiceSegment; label: string }[] = [
|
||||
{ key: "llm", label: "LLM" },
|
||||
{ key: "tts", label: "Voice" },
|
||||
{ key: "stt", label: "Transcriber" },
|
||||
];
|
||||
|
||||
const OVERRIDE_REALTIME_TABS: { key: ServiceSegment; label: string }[] = [
|
||||
{ key: "realtime", label: "Realtime Model" },
|
||||
];
|
||||
|
||||
// Display names for Sarvam voices
|
||||
const VOICE_DISPLAY_NAMES: Record<string, string> = {
|
||||
"anushka": "Anushka (Female)",
|
||||
"manisha": "Manisha (Female)",
|
||||
"vidya": "Vidya (Female)",
|
||||
"arya": "Arya (Female)",
|
||||
"abhilash": "Abhilash (Male)",
|
||||
"karun": "Karun (Male)",
|
||||
"hitesh": "Hitesh (Male)",
|
||||
};
|
||||
|
||||
export interface ServiceConfigurationFormProps {
|
||||
mode: 'global' | 'override';
|
||||
currentOverrides?: ModelOverrides;
|
||||
onSave: (config: Record<string, unknown>) => Promise<void>;
|
||||
/** Text for the submit button. Defaults to "Save Configuration". */
|
||||
submitLabel?: string;
|
||||
}
|
||||
|
||||
function getGlobalSummary(config: Record<string, unknown> | null | undefined): string {
|
||||
if (!config) return "Not configured";
|
||||
const provider = config.provider as string | undefined;
|
||||
const model = config.model as string | undefined;
|
||||
if (!provider) return "Not configured";
|
||||
return model ? `${provider} / ${model}` : provider;
|
||||
}
|
||||
|
||||
export function ServiceConfigurationForm({
|
||||
mode,
|
||||
currentOverrides,
|
||||
onSave,
|
||||
submitLabel,
|
||||
}: ServiceConfigurationFormProps) {
|
||||
const [apiError, setApiError] = useState<string | null>(null);
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
const [isRealtime, setIsRealtime] = useState(false);
|
||||
const { userConfig } = useUserConfig();
|
||||
const [schemas, setSchemas] = useState<Record<ServiceSegment, Record<string, ProviderSchema>>>({
|
||||
llm: {},
|
||||
tts: {},
|
||||
stt: {},
|
||||
embeddings: {},
|
||||
realtime: {},
|
||||
});
|
||||
const [serviceProviders, setServiceProviders] = useState<Record<ServiceSegment, string>>({
|
||||
llm: "",
|
||||
tts: "",
|
||||
stt: "",
|
||||
embeddings: "",
|
||||
realtime: "",
|
||||
});
|
||||
const [apiKeys, setApiKeys] = useState<Record<ServiceSegment, string[]>>({
|
||||
llm: [""],
|
||||
tts: [""],
|
||||
stt: [""],
|
||||
embeddings: [""],
|
||||
realtime: [""],
|
||||
});
|
||||
const [isCustomInput, setIsCustomInput] = useState<Record<string, boolean>>({});
|
||||
|
||||
// Override-specific state: which services have the override toggle enabled
|
||||
const [enabledOverrides, setEnabledOverrides] = useState<Record<string, boolean>>({
|
||||
llm: false,
|
||||
tts: false,
|
||||
stt: false,
|
||||
realtime: false,
|
||||
});
|
||||
|
||||
const {
|
||||
register,
|
||||
handleSubmit,
|
||||
formState: { },
|
||||
reset,
|
||||
getValues,
|
||||
setValue,
|
||||
watch
|
||||
} = useForm();
|
||||
|
||||
// Build effective config source: overlay overrides onto global config
|
||||
const configSource = useMemo(() => {
|
||||
if (mode === 'global' || !currentOverrides) return userConfig;
|
||||
// Merge overrides onto global config for form initialization
|
||||
const merged = { ...userConfig } as Record<string, unknown>;
|
||||
const overrideServices: (keyof ModelOverrides)[] = ["llm", "tts", "stt", "realtime"];
|
||||
for (const svc of overrideServices) {
|
||||
if (svc === "is_realtime") continue;
|
||||
const overrideVal = currentOverrides[svc];
|
||||
if (overrideVal && typeof overrideVal === "object") {
|
||||
const globalVal = (userConfig as Record<string, unknown> | null)?.[svc] as Record<string, unknown> | undefined;
|
||||
merged[svc] = { ...globalVal, ...overrideVal };
|
||||
}
|
||||
}
|
||||
if (currentOverrides.is_realtime !== undefined) {
|
||||
merged.is_realtime = currentOverrides.is_realtime;
|
||||
}
|
||||
return merged as typeof userConfig;
|
||||
}, [mode, userConfig, currentOverrides]);
|
||||
|
||||
useEffect(() => {
|
||||
const fetchConfigurations = async () => {
|
||||
const response = await getDefaultConfigurationsApiV1UserConfigurationsDefaultsGet();
|
||||
if (!response.data) {
|
||||
console.error("Failed to fetch configurations");
|
||||
return;
|
||||
}
|
||||
|
||||
const data = response.data as Record<string, unknown>;
|
||||
const realtimeSchemas = (data.realtime || {}) as Record<string, ProviderSchema>;
|
||||
|
||||
setSchemas({
|
||||
llm: response.data.llm as Record<string, ProviderSchema>,
|
||||
tts: response.data.tts as Record<string, ProviderSchema>,
|
||||
stt: response.data.stt as Record<string, ProviderSchema>,
|
||||
embeddings: response.data.embeddings as Record<string, ProviderSchema>,
|
||||
realtime: realtimeSchemas,
|
||||
});
|
||||
|
||||
// Restore realtime toggle
|
||||
const configData = configSource as Record<string, unknown> | null;
|
||||
if (configData?.is_realtime) {
|
||||
setIsRealtime(true);
|
||||
}
|
||||
|
||||
const defaultValues: Record<string, string | number | boolean> = {};
|
||||
const selectedProviders: Record<ServiceSegment, string> = {
|
||||
llm: response.data.default_providers.llm,
|
||||
tts: response.data.default_providers.tts,
|
||||
stt: response.data.default_providers.stt,
|
||||
embeddings: response.data.default_providers.embeddings,
|
||||
realtime: "",
|
||||
};
|
||||
|
||||
const realtimeProviderKeys = Object.keys(realtimeSchemas);
|
||||
if (realtimeProviderKeys.length > 0) {
|
||||
selectedProviders.realtime = realtimeProviderKeys[0];
|
||||
}
|
||||
|
||||
const loadedApiKeys: Record<ServiceSegment, string[]> = {
|
||||
llm: [""],
|
||||
tts: [""],
|
||||
stt: [""],
|
||||
embeddings: [""],
|
||||
realtime: [""],
|
||||
};
|
||||
|
||||
const setServicePropertyValues = (service: ServiceSegment) => {
|
||||
const src = service === "realtime"
|
||||
? (configSource as Record<string, unknown> | null)?.realtime as Record<string, unknown> | undefined
|
||||
: (configSource as Record<string, unknown> | null)?.[service] as Record<string, unknown> | undefined;
|
||||
|
||||
const schemaSource = service === "realtime"
|
||||
? realtimeSchemas
|
||||
: response.data![service as "llm" | "tts" | "stt" | "embeddings"] as Record<string, ProviderSchema> | undefined;
|
||||
|
||||
if (src?.provider) {
|
||||
Object.entries(src).forEach(([field, value]) => {
|
||||
if (field === "api_key") {
|
||||
if (mode === 'override') {
|
||||
// In override mode, only load API keys from the override itself
|
||||
const overrideVal = currentOverrides?.[service as keyof ModelOverrides];
|
||||
const overrideApiKey = overrideVal && typeof overrideVal === "object"
|
||||
? (overrideVal as Record<string, unknown>).api_key
|
||||
: undefined;
|
||||
if (overrideApiKey) {
|
||||
loadedApiKeys[service] = Array.isArray(overrideApiKey)
|
||||
? overrideApiKey as string[]
|
||||
: [overrideApiKey as string];
|
||||
} else {
|
||||
loadedApiKeys[service] = [""];
|
||||
}
|
||||
} else {
|
||||
if (Array.isArray(value)) {
|
||||
loadedApiKeys[service] = (value as string[]).length > 0 ? value as string[] : [""];
|
||||
} else {
|
||||
loadedApiKeys[service] = value ? [value as string] : [""];
|
||||
}
|
||||
}
|
||||
} else if (field !== "provider") {
|
||||
defaultValues[`${service}_${field}`] = value as string | number | boolean;
|
||||
}
|
||||
});
|
||||
selectedProviders[service] = src.provider as string;
|
||||
const properties = schemaSource?.[selectedProviders[service]]?.properties as Record<string, SchemaProperty>;
|
||||
if (properties) {
|
||||
Object.entries(properties).forEach(([field, schema]) => {
|
||||
const key = `${service}_${field}`;
|
||||
if (field !== "provider" && field !== "api_key" && schema.default !== undefined && !(key in defaultValues)) {
|
||||
defaultValues[key] = schema.default;
|
||||
}
|
||||
});
|
||||
}
|
||||
} else {
|
||||
const properties = schemaSource?.[selectedProviders[service]]?.properties as Record<string, SchemaProperty>;
|
||||
if (properties) {
|
||||
Object.entries(properties).forEach(([field, schema]) => {
|
||||
if (field !== "provider" && schema.default !== undefined) {
|
||||
defaultValues[`${service}_${field}`] = schema.default;
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
setServicePropertyValues("llm");
|
||||
setServicePropertyValues("tts");
|
||||
setServicePropertyValues("stt");
|
||||
setServicePropertyValues("embeddings");
|
||||
setServicePropertyValues("realtime");
|
||||
|
||||
// Detect custom inputs
|
||||
const detectedCustomInput: Record<string, boolean> = {};
|
||||
const allSchemas = { ...response.data, realtime: realtimeSchemas } as unknown as Record<string, Record<string, ProviderSchema>>;
|
||||
(["llm", "tts", "stt", "embeddings", "realtime"] as ServiceSegment[]).forEach(service => {
|
||||
const provider = selectedProviders[service];
|
||||
const providerSchema = allSchemas[service]?.[provider];
|
||||
if (!providerSchema) return;
|
||||
|
||||
const src = service === "realtime"
|
||||
? (configSource as Record<string, unknown> | null)?.realtime as Record<string, unknown> | undefined
|
||||
: (configSource as Record<string, unknown> | null)?.[service] as Record<string, unknown> | undefined;
|
||||
|
||||
Object.entries(providerSchema.properties).forEach(([field, schema]) => {
|
||||
const actualSchema = (schema as SchemaProperty).$ref && providerSchema.$defs
|
||||
? providerSchema.$defs[(schema as SchemaProperty).$ref!.split('/').pop() || '']
|
||||
: schema as SchemaProperty;
|
||||
|
||||
if (!actualSchema?.allow_custom_input || !actualSchema?.examples) return;
|
||||
|
||||
const savedValue = src?.[field] as string | undefined;
|
||||
if (savedValue && !actualSchema.examples.includes(savedValue)) {
|
||||
detectedCustomInput[`${service}_${field}`] = true;
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// Initialize override toggles
|
||||
if (mode === 'override') {
|
||||
setEnabledOverrides({
|
||||
llm: !!currentOverrides?.llm,
|
||||
tts: !!currentOverrides?.tts,
|
||||
stt: !!currentOverrides?.stt,
|
||||
realtime: !!currentOverrides?.realtime,
|
||||
});
|
||||
}
|
||||
|
||||
reset(defaultValues);
|
||||
setApiKeys(loadedApiKeys);
|
||||
setServiceProviders(selectedProviders);
|
||||
setIsCustomInput(detectedCustomInput);
|
||||
};
|
||||
fetchConfigurations();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [reset, configSource]);
|
||||
|
||||
// Reset voice when TTS model changes if the provider has model-dependent voice options
|
||||
const ttsModel = watch("tts_model");
|
||||
useEffect(() => {
|
||||
const voiceSchema = schemas?.tts?.[serviceProviders.tts]?.properties?.voice;
|
||||
const modelOptions = voiceSchema?.model_options;
|
||||
if (!modelOptions || !ttsModel) return;
|
||||
|
||||
const validVoices = modelOptions[ttsModel as string];
|
||||
const currentVoice = getValues("tts_voice") as string;
|
||||
if (validVoices && currentVoice && !validVoices.includes(currentVoice)) {
|
||||
setValue("tts_voice", validVoices[0], { shouldDirty: true });
|
||||
}
|
||||
}, [ttsModel, serviceProviders.tts, setValue, getValues, schemas]);
|
||||
|
||||
// Reset language when STT model changes if the provider has model-dependent language options
|
||||
const sttModel = watch("stt_model");
|
||||
useEffect(() => {
|
||||
const languageSchema = schemas?.stt?.[serviceProviders.stt]?.properties?.language;
|
||||
const modelOptions = languageSchema?.model_options;
|
||||
if (!modelOptions || !sttModel) return;
|
||||
|
||||
const validLanguages = modelOptions[sttModel as string];
|
||||
const currentLanguage = getValues("stt_language") as string;
|
||||
if (validLanguages && currentLanguage && !validLanguages.includes(currentLanguage)) {
|
||||
setValue("stt_language", validLanguages[0], { shouldDirty: true });
|
||||
}
|
||||
}, [sttModel, serviceProviders.stt, setValue, getValues, schemas]);
|
||||
|
||||
const handleProviderChange = (service: ServiceSegment, providerName: string) => {
|
||||
if (!providerName) return;
|
||||
|
||||
const currentValues = getValues();
|
||||
const preservedValues: Record<string, string | number | boolean> = {};
|
||||
|
||||
Object.keys(currentValues).forEach(key => {
|
||||
if (!key.startsWith(`${service}_`)) {
|
||||
preservedValues[key] = currentValues[key];
|
||||
}
|
||||
});
|
||||
|
||||
if (schemas?.[service]?.[providerName]) {
|
||||
const providerSchema = schemas[service][providerName];
|
||||
Object.entries(providerSchema.properties).forEach(([field, schema]: [string, SchemaProperty]) => {
|
||||
if (field !== "provider" && schema.default !== undefined) {
|
||||
preservedValues[`${service}_${field}`] = schema.default;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
preservedValues[`${service}_provider`] = providerName;
|
||||
reset(preservedValues);
|
||||
setServiceProviders(prev => ({ ...prev, [service]: providerName }));
|
||||
setApiKeys(prev => ({ ...prev, [service]: [""] }));
|
||||
|
||||
setIsCustomInput(prev => {
|
||||
const next = { ...prev };
|
||||
Object.keys(next).forEach(key => {
|
||||
if (key.startsWith(`${service}_`)) delete next[key];
|
||||
});
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
const buildServiceConfig = (service: ServiceSegment, data: FormValues) => {
|
||||
const config: Record<string, string | number | string[]> = {
|
||||
provider: serviceProviders[service],
|
||||
};
|
||||
const keys = apiKeys[service].map(k => k.trim()).filter(k => k.length > 0);
|
||||
if (keys.length > 0) {
|
||||
config.api_key = mode === 'override' ? keys[0] : keys;
|
||||
}
|
||||
Object.entries(data).forEach(([property, value]) => {
|
||||
if (!property.startsWith(`${service}_`)) return;
|
||||
const field = property.slice(service.length + 1);
|
||||
if (field === "api_key" || field === "provider") return;
|
||||
config[field] = value as string | number;
|
||||
});
|
||||
return config;
|
||||
};
|
||||
|
||||
const onSubmit = async (data: FormValues) => {
|
||||
setApiError(null);
|
||||
setIsSaving(true);
|
||||
|
||||
try {
|
||||
if (mode === 'override') {
|
||||
// Build model_overrides for enabled services only
|
||||
const modelOverrides: Record<string, unknown> = {};
|
||||
const services = isRealtime ? ["realtime"] : ["llm", "tts", "stt"];
|
||||
for (const svc of services) {
|
||||
if (enabledOverrides[svc]) {
|
||||
modelOverrides[svc] = buildServiceConfig(svc as ServiceSegment, data);
|
||||
}
|
||||
}
|
||||
// Include is_realtime if it differs from global
|
||||
const globalIsRealtime = !!(userConfig as Record<string, unknown> | null)?.is_realtime;
|
||||
if (isRealtime !== globalIsRealtime) {
|
||||
modelOverrides.is_realtime = isRealtime;
|
||||
}
|
||||
await onSave({
|
||||
model_overrides: Object.keys(modelOverrides).length > 0 ? modelOverrides : undefined,
|
||||
});
|
||||
} else {
|
||||
// Global mode: save all services
|
||||
const saveConfig: Record<string, unknown> = {
|
||||
llm: buildServiceConfig("llm", data),
|
||||
tts: buildServiceConfig("tts", data),
|
||||
stt: buildServiceConfig("stt", data),
|
||||
is_realtime: isRealtime,
|
||||
};
|
||||
if (serviceProviders.realtime) {
|
||||
saveConfig.realtime = buildServiceConfig("realtime", data);
|
||||
}
|
||||
const embeddingsKeys = apiKeys.embeddings.map(k => k.trim()).filter(k => k.length > 0);
|
||||
if (embeddingsKeys.length > 0) {
|
||||
saveConfig.embeddings = buildServiceConfig("embeddings", data);
|
||||
}
|
||||
await onSave(saveConfig);
|
||||
}
|
||||
setApiError(null);
|
||||
} catch (error: unknown) {
|
||||
if (error instanceof Error) {
|
||||
setApiError(error.message);
|
||||
} else {
|
||||
setApiError('An unknown error occurred');
|
||||
}
|
||||
} finally {
|
||||
setIsSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const getConfigFields = (service: ServiceSegment): string[] => {
|
||||
const currentProvider = serviceProviders[service];
|
||||
const providerSchema = schemas?.[service]?.[currentProvider];
|
||||
if (!providerSchema) return [];
|
||||
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">
|
||||
<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>
|
||||
|
||||
{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>
|
||||
)}
|
||||
|
||||
{currentProvider && providerSchema && providerSchema.properties.api_key && (
|
||||
<div className="space-y-2">
|
||||
<Label>{mode === 'override' ? 'API Key (leave empty to use global)' : 'API Key(s)'}</Label>
|
||||
{apiKeys[service].map((key, index) => (
|
||||
<div key={index} className="flex gap-2">
|
||||
<Input
|
||||
type="text"
|
||||
placeholder="Enter API key"
|
||||
value={key}
|
||||
onChange={(e) => {
|
||||
const newKeys = [...apiKeys[service]];
|
||||
newKeys[index] = e.target.value;
|
||||
setApiKeys(prev => ({ ...prev, [service]: newKeys }));
|
||||
}}
|
||||
/>
|
||||
{apiKeys[service].length > 1 && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="shrink-0"
|
||||
onClick={() => {
|
||||
setApiKeys(prev => ({
|
||||
...prev,
|
||||
[service]: prev[service].filter((_, i) => i !== index),
|
||||
}));
|
||||
}}
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
{mode !== 'override' && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
setApiKeys(prev => ({
|
||||
...prev,
|
||||
[service]: [...prev[service], ""],
|
||||
}));
|
||||
}}
|
||||
>
|
||||
<Plus className="h-4 w-4 mr-1" /> Add API Key
|
||||
</Button>
|
||||
)}
|
||||
</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 (service === "tts" && field === "voice" && !actualSchema?.allow_custom_input) {
|
||||
const hasVoiceOptions = actualSchema?.enum || actualSchema?.examples;
|
||||
if (!hasVoiceOptions) {
|
||||
return (
|
||||
<VoiceSelector
|
||||
provider={serviceProviders.tts}
|
||||
value={watch(`${service}_${field}`) as string || ""}
|
||||
onChange={(voiceId) => {
|
||||
setValue(`${service}_${field}`, voiceId, { shouldDirty: true });
|
||||
}}
|
||||
model={watch("tts_model") as string || undefined}
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (actualSchema?.allow_custom_input && actualSchema?.examples) {
|
||||
const fieldKey = `${service}_${field}`;
|
||||
const currentValue = watch(fieldKey) as string || "";
|
||||
const options = actualSchema.examples;
|
||||
|
||||
if (isCustomInput[fieldKey]) {
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<Input
|
||||
type="text"
|
||||
placeholder={`Enter ${field}`}
|
||||
value={currentValue}
|
||||
onChange={(e) => {
|
||||
setValue(fieldKey, e.target.value, { shouldDirty: true });
|
||||
}}
|
||||
/>
|
||||
<div className="flex items-center space-x-2">
|
||||
<Checkbox
|
||||
id={`custom-input-${fieldKey}`}
|
||||
checked={true}
|
||||
onCheckedChange={(checked) => {
|
||||
setIsCustomInput(prev => ({ ...prev, [fieldKey]: checked as boolean }));
|
||||
if (!checked && options.length > 0) {
|
||||
setValue(fieldKey, options[0], { shouldDirty: true });
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<Label htmlFor={`custom-input-${fieldKey}`} className="text-sm font-normal cursor-pointer">
|
||||
Enter Custom Value
|
||||
</Label>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<Select
|
||||
value={currentValue}
|
||||
onValueChange={(value) => {
|
||||
if (!value) return;
|
||||
setValue(fieldKey, value, { shouldDirty: true });
|
||||
}}
|
||||
>
|
||||
<SelectTrigger className="w-full">
|
||||
<SelectValue placeholder={`Select ${field}`} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{options.map((value: string) => (
|
||||
<SelectItem key={value} value={value}>
|
||||
{value}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<div className="flex items-center space-x-2">
|
||||
<Checkbox
|
||||
id={`custom-input-${fieldKey}-dropdown`}
|
||||
checked={false}
|
||||
onCheckedChange={(checked) => {
|
||||
setIsCustomInput(prev => ({ ...prev, [fieldKey]: checked as boolean }));
|
||||
}}
|
||||
/>
|
||||
<Label htmlFor={`custom-input-${fieldKey}-dropdown`} className="text-sm font-normal cursor-pointer">
|
||||
Enter Custom Value
|
||||
</Label>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
let dropdownOptions = actualSchema?.enum || actualSchema?.examples;
|
||||
|
||||
if (actualSchema?.model_options) {
|
||||
const modelValue = watch(`${service}_model`) as string;
|
||||
if (modelValue && actualSchema.model_options[modelValue]) {
|
||||
dropdownOptions = actualSchema.model_options[modelValue];
|
||||
}
|
||||
}
|
||||
|
||||
if (dropdownOptions && dropdownOptions.length > 0) {
|
||||
const getDisplayName = (value: string) => {
|
||||
if (field === "language") {
|
||||
return LANGUAGE_DISPLAY_NAMES[value] || value;
|
||||
}
|
||||
if (field === "voice") {
|
||||
return VOICE_DISPLAY_NAMES[value] || value.charAt(0).toUpperCase() + value.slice(1);
|
||||
}
|
||||
return value;
|
||||
};
|
||||
|
||||
return (
|
||||
<Select
|
||||
value={watch(`${service}_${field}`) as string || ""}
|
||||
onValueChange={(value) => {
|
||||
if (!value) return;
|
||||
setValue(`${service}_${field}`, value, { shouldDirty: true });
|
||||
}}
|
||||
>
|
||||
<SelectTrigger className="w-full">
|
||||
<SelectValue placeholder={`Select ${field}`} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{dropdownOptions.map((value: string) => (
|
||||
<SelectItem key={value} value={value}>
|
||||
{getDisplayName(value)}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Input
|
||||
type={actualSchema?.type === "number" ? "number" : "text"}
|
||||
{...(actualSchema?.type === "number" && { step: "any" })}
|
||||
placeholder={`Enter ${field}`}
|
||||
{...register(`${service}_${field}`, {
|
||||
required: service !== "embeddings" && providerSchema.required?.includes(field),
|
||||
valueAsNumber: actualSchema?.type === "number"
|
||||
})}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
const handleOverrideToggle = (service: string, enabled: boolean) => {
|
||||
setEnabledOverrides(prev => ({ ...prev, [service]: enabled }));
|
||||
};
|
||||
|
||||
const renderOverrideToggle = (service: ServiceSegment, label: string) => {
|
||||
const globalVal = (userConfig as Record<string, unknown> | null)?.[service] as Record<string, unknown> | null | undefined;
|
||||
const isEnabled = enabledOverrides[service];
|
||||
|
||||
return (
|
||||
<div className="flex items-center justify-between p-3 border rounded-md bg-muted/20 mb-4">
|
||||
<div className="space-y-0.5">
|
||||
<Label htmlFor={`override-${service}`} className="text-sm cursor-pointer font-medium">
|
||||
Override {label}
|
||||
</Label>
|
||||
{!isEnabled && (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Using global: {getGlobalSummary(globalVal)}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<Switch
|
||||
id={`override-${service}`}
|
||||
checked={isEnabled}
|
||||
onCheckedChange={(checked) => handleOverrideToggle(service, checked)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const getVisibleTabs = () => {
|
||||
if (mode === 'override') {
|
||||
return isRealtime ? OVERRIDE_REALTIME_TABS : OVERRIDE_STANDARD_TABS;
|
||||
}
|
||||
return isRealtime ? REALTIME_TABS : STANDARD_TABS;
|
||||
};
|
||||
|
||||
const visibleTabs = getVisibleTabs();
|
||||
const defaultTab = isRealtime ? "realtime" : "llm";
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit(onSubmit)}>
|
||||
{/* Realtime toggle */}
|
||||
<div className="flex items-center justify-between mb-4 p-4 border rounded-lg">
|
||||
<div>
|
||||
<Label htmlFor="realtime-toggle" className="text-sm font-medium">
|
||||
Realtime Mode
|
||||
</Label>
|
||||
<p className="text-xs text-muted-foreground mt-0.5">
|
||||
Uses a single speech-to-speech model (no separate STT/TTS)
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
id="realtime-toggle"
|
||||
checked={isRealtime}
|
||||
onCheckedChange={setIsRealtime}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
<CardContent className="pt-6">
|
||||
<Tabs key={defaultTab} defaultValue={defaultTab} className="w-full">
|
||||
<TabsList className="grid w-full mb-6" style={{ gridTemplateColumns: `repeat(${visibleTabs.length}, 1fr)` }}>
|
||||
{visibleTabs.map(({ key, label }) => (
|
||||
<TabsTrigger key={key} value={key}>
|
||||
{label}
|
||||
</TabsTrigger>
|
||||
))}
|
||||
</TabsList>
|
||||
|
||||
{visibleTabs.map(({ key, label }) => (
|
||||
<TabsContent key={key} value={key} className="mt-0">
|
||||
{mode === 'override' && renderOverrideToggle(key, label)}
|
||||
{(mode === 'global' || enabledOverrides[key]) && renderServiceFields(key)}
|
||||
</TabsContent>
|
||||
))}
|
||||
</Tabs>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{apiError && <p className="text-red-500 mt-4">{apiError}</p>}
|
||||
|
||||
<Button type="submit" className="w-full mt-6" disabled={isSaving}>
|
||||
{isSaving ? "Saving..." : (submitLabel || "Save Configuration")}
|
||||
</Button>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
|
|
@ -2,7 +2,7 @@ import { BaseEdge, type Edge, EdgeLabelRenderer, type EdgeProps, getSmoothStepPa
|
|||
import { AlertCircle, Pencil } from 'lucide-react';
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
|
||||
import { useWorkflow } from "@/app/workflow/[workflowId]/contexts/WorkflowContext";
|
||||
import { useWorkflow, useWorkflowOptional } from "@/app/workflow/[workflowId]/contexts/WorkflowContext";
|
||||
import { useWorkflowStore } from "@/app/workflow/[workflowId]/stores/workflowStore";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog";
|
||||
|
|
@ -23,6 +23,7 @@ interface EdgeDetailsDialogProps {
|
|||
}
|
||||
|
||||
const EdgeDetailsDialog = ({ open, onOpenChange, data, onSave }: EdgeDetailsDialogProps) => {
|
||||
const readOnly = useWorkflowOptional()?.readOnly ?? false;
|
||||
const [condition, setCondition] = useState(data?.condition ?? '');
|
||||
const [label, setLabel] = useState(data?.label ?? '');
|
||||
const [transitionSpeech, setTransitionSpeech] = useState(data?.transition_speech ?? '');
|
||||
|
|
@ -43,7 +44,7 @@ const EdgeDetailsDialog = ({ open, onOpenChange, data, onSave }: EdgeDetailsDial
|
|||
|
||||
// Handle Cmd+S / Ctrl+S keyboard shortcut to save
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
if (!open || readOnly) return;
|
||||
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if ((e.metaKey || e.ctrlKey) && e.key === 's') {
|
||||
|
|
@ -55,7 +56,7 @@ const EdgeDetailsDialog = ({ open, onOpenChange, data, onSave }: EdgeDetailsDial
|
|||
|
||||
window.addEventListener('keydown', handleKeyDown, true);
|
||||
return () => window.removeEventListener('keydown', handleKeyDown, true);
|
||||
}, [open, handleSave]);
|
||||
}, [open, readOnly, handleSave]);
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
|
|
@ -115,7 +116,9 @@ const EdgeDetailsDialog = ({ open, onOpenChange, data, onSave }: EdgeDetailsDial
|
|||
<DialogFooter>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button variant="outline" onClick={() => onOpenChange(false)}>Cancel</Button>
|
||||
<Button onClick={handleSave}>Save</Button>
|
||||
<Button onClick={handleSave} disabled={readOnly}>
|
||||
{readOnly ? "Read Only" : "Save"}
|
||||
</Button>
|
||||
</div>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import { AlertCircle, ExternalLink } from "lucide-react";
|
||||
import { ReactNode, useCallback, useEffect, useState } from "react";
|
||||
|
||||
import { useWorkflowOptional } from "@/app/workflow/[workflowId]/contexts/WorkflowContext";
|
||||
import { FlowNodeData } from "@/components/flow/types";
|
||||
import {
|
||||
AlertDialog,
|
||||
|
|
@ -38,6 +39,7 @@ export const NodeEditDialog = ({
|
|||
isDirty = false,
|
||||
documentationUrl,
|
||||
}: NodeEditDialogProps) => {
|
||||
const readOnly = useWorkflowOptional()?.readOnly ?? false;
|
||||
const [showDiscardAlert, setShowDiscardAlert] = useState(false);
|
||||
|
||||
const handleClose = () => onOpenChange(false);
|
||||
|
|
@ -66,7 +68,7 @@ export const NodeEditDialog = ({
|
|||
|
||||
// Handle Cmd+S / Ctrl+S keyboard shortcut to save
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
if (!open || readOnly) return;
|
||||
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if ((e.metaKey || e.ctrlKey) && e.key === 's') {
|
||||
|
|
@ -78,7 +80,7 @@ export const NodeEditDialog = ({
|
|||
|
||||
window.addEventListener('keydown', handleKeyDown, true);
|
||||
return () => window.removeEventListener('keydown', handleKeyDown, true);
|
||||
}, [open, handleSave]);
|
||||
}, [open, readOnly, handleSave]);
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={handleOpenChange}>
|
||||
|
|
@ -128,7 +130,9 @@ export const NodeEditDialog = ({
|
|||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button onClick={handleSave}>Save</Button>
|
||||
<Button onClick={handleSave} disabled={readOnly}>
|
||||
{readOnly ? "Read Only" : "Save"}
|
||||
</Button>
|
||||
</div>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@
|
|||
import { useEffect, useState } from 'react';
|
||||
|
||||
import { listTestSessionsApiV1LooptalkTestSessionsGet } from '@/client/sdk.gen';
|
||||
import type { TestSessionResponse } from '@/client/types.gen';
|
||||
import { useAuth } from '@/lib/auth';
|
||||
import logger from '@/lib/logger';
|
||||
|
||||
|
|
@ -32,7 +33,7 @@ export function LoopTalkTestSessionsList({ status }: LoopTalkTestSessionsListPro
|
|||
});
|
||||
|
||||
// Transform API response to match UI types
|
||||
const transformedSessions = (response.data || []).map(session => ({
|
||||
const transformedSessions = (response.data || []).map((session: TestSessionResponse) => ({
|
||||
id: session.id,
|
||||
name: session.name,
|
||||
description: '', // API doesn't return description
|
||||
|
|
|
|||
|
|
@ -18,6 +18,15 @@ export const KNOWLEDGE_BASE_DOC_URL = `${DOCS_BASE}/voice-agent/knowledge-base`;
|
|||
|
||||
export const PRE_CALL_DATA_FETCH_DOC_URL = `${DOCS_BASE}/voice-agent/pre-call-data-fetch`;
|
||||
|
||||
export const SETTINGS_DOCUMENTATION_URLS: Record<string, string> = {
|
||||
general: `${DOCS_BASE}/voice-agent/editing-a-workflow`,
|
||||
modelOverrides: `${DOCS_BASE}/configurations/inference-providers`,
|
||||
templateVariables: `${DOCS_BASE}/voice-agent/template-variables`,
|
||||
|
||||
recordings: `${DOCS_BASE}/voice-agent/pre-recorded-audio`,
|
||||
deployment: `${DOCS_BASE}/deployment/web-widget`,
|
||||
};
|
||||
|
||||
export const TOOL_DOCUMENTATION_URLS: Record<string, string> = {
|
||||
http_api: `${DOCS_BASE}/voice-agent/tools/http-api`,
|
||||
end_call: `${DOCS_BASE}/voice-agent/tools/end-call`,
|
||||
|
|
|
|||
|
|
@ -1,5 +1,4 @@
|
|||
import type { Client } from '@hey-api/client-fetch';
|
||||
|
||||
import type { Client } from '@/client/client';
|
||||
import type { CreateClientConfig } from '@/client/client.gen';
|
||||
|
||||
export const createClientConfig: CreateClientConfig = (config) => {
|
||||
|
|
|
|||
|
|
@ -28,6 +28,36 @@ export const DEFAULT_VOICEMAIL_DETECTION_CONFIGURATION: VoicemailDetectionConfig
|
|||
long_speech_timeout: 8.0,
|
||||
};
|
||||
|
||||
export interface ModelOverrides {
|
||||
llm?: {
|
||||
provider?: string;
|
||||
model?: string;
|
||||
api_key?: string;
|
||||
[key: string]: unknown;
|
||||
};
|
||||
tts?: {
|
||||
provider?: string;
|
||||
model?: string;
|
||||
voice?: string;
|
||||
api_key?: string;
|
||||
[key: string]: unknown;
|
||||
};
|
||||
stt?: {
|
||||
provider?: string;
|
||||
model?: string;
|
||||
api_key?: string;
|
||||
[key: string]: unknown;
|
||||
};
|
||||
realtime?: {
|
||||
provider?: string;
|
||||
model?: string;
|
||||
voice?: string;
|
||||
api_key?: string;
|
||||
[key: string]: unknown;
|
||||
};
|
||||
is_realtime?: boolean;
|
||||
}
|
||||
|
||||
export interface WorkflowConfigurations {
|
||||
vad_configuration?: VADConfiguration;
|
||||
ambient_noise_configuration: AmbientNoiseConfiguration;
|
||||
|
|
@ -38,6 +68,7 @@ export interface WorkflowConfigurations {
|
|||
dictionary?: string; // Comma-separated words for voice agent to listen for
|
||||
voicemail_detection?: VoicemailDetectionConfiguration;
|
||||
context_compaction_enabled?: boolean; // Summarize context on node transitions to remove stale tool calls
|
||||
model_overrides?: ModelOverrides; // Per-workflow model configuration overrides
|
||||
[key: string]: unknown; // Allow additional properties for future configurations
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue