feat: add chat based testing for voice agent (#308)

* feat: add backend foundations

* feat: add text chat UI

* chore: simplify the reload behaviour

* fix: fix upgrade banner to be triggered after package upload

* feat: simplify TesterPanel design

* chore: fix formatting and generate client

* chore: fix tracing for text chat mode

* fix: fix revert and edit CTA

* refactor: refactor TesterPanel into smaller components

* feat: enable runtime transition of nodes

* fix: fix review comments
This commit is contained in:
Abhishek 2026-05-21 15:20:02 +05:30 committed by GitHub
parent 67479e98fd
commit d97d1d72cd
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
96 changed files with 7630 additions and 1684 deletions

View file

@ -41,6 +41,10 @@ api/
- Telephony is a full subsystem under `services/telephony/`, with provider-specific packages under `services/telephony/providers/`
- Integrations extend through `services/integrations/`; package-specific rules should live in that subtree's own `AGENTS.md`
## Routes vs Service Layer
**Keep route handlers thin** — parse/validate the request, resolve auth and `organization_id`, delegate, shape the response. Domain logic (orchestration, business rules, external calls, computation) belongs in `services/`. Before adding logic to a handler, find its home: extend an existing `services/<domain>/` module that owns the concern (see *Where to Find Things*) before adding a focused new module; never a catch-all. Keep DB access in `db/` clients — routes call services, services call DB clients. Litmus test: if `tasks/`, `mcp_server/`, or another route could reuse it, it must live in `services/` to be importable.
## Database Migrations
```bash

View file

@ -2,6 +2,7 @@
Revision ID: 19d2a4b6c8ef
Revises: 0a1b2c3d4e5f
Create Date: 2026-05-19 00:00:00.000000
"""

View file

@ -0,0 +1,64 @@
"""add workflow_run_text_sessions
Revision ID: 2f638891cbb6
Revises: 19d2a4b6c8ef
Create Date: 2026-05-18 12:58:58.573381
"""
from typing import Sequence, Union
import sqlalchemy as sa
from alembic import op
# revision identifiers, used by Alembic.
revision: str = "2f638891cbb6"
down_revision: Union[str, None] = "19d2a4b6c8ef"
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.create_table(
"workflow_run_text_sessions",
sa.Column("workflow_run_id", sa.Integer(), nullable=False),
sa.Column(
"revision", sa.Integer(), server_default=sa.text("0"), nullable=False
),
sa.Column(
"session_data",
sa.JSON(),
server_default=sa.text("'{}'::json"),
nullable=False,
),
sa.Column(
"checkpoint",
sa.JSON(),
server_default=sa.text("'{}'::json"),
nullable=False,
),
sa.Column("created_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=True),
sa.ForeignKeyConstraint(
["workflow_run_id"], ["workflow_runs.id"], ondelete="CASCADE"
),
sa.PrimaryKeyConstraint("workflow_run_id"),
)
op.create_index(
"ix_workflow_run_text_sessions_updated_at",
"workflow_run_text_sessions",
["updated_at"],
unique=False,
)
# ### end Alembic commands ###
def downgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.drop_index(
"ix_workflow_run_text_sessions_updated_at",
table_name="workflow_run_text_sessions",
)
op.drop_table("workflow_run_text_sessions")
# ### end Alembic commands ###

View file

@ -16,12 +16,14 @@ from api.db.webhook_credential_client import WebhookCredentialClient
from api.db.workflow_client import WorkflowClient
from api.db.workflow_recording_client import WorkflowRecordingClient
from api.db.workflow_run_client import WorkflowRunClient
from api.db.workflow_run_text_session_client import WorkflowRunTextSessionClient
from api.db.workflow_template_client import WorkflowTemplateClient
class DBClient(
WorkflowClient,
WorkflowRunClient,
WorkflowRunTextSessionClient,
UserClient,
OrganizationClient,
OrganizationConfigurationClient,

View file

@ -484,6 +484,12 @@ class WorkflowRunModel(Base):
queued_run_id = Column(Integer, ForeignKey("queued_runs.id"), nullable=True)
queued_run = relationship("QueuedRunModel", foreign_keys=[queued_run_id])
public_access_token = Column(String(36), nullable=True)
text_session = relationship(
"WorkflowRunTextSessionModel",
back_populates="workflow_run",
uselist=False,
cascade="all, delete-orphan",
)
# Indexes
__table_args__ = (
@ -503,6 +509,43 @@ class WorkflowRunModel(Base):
)
class WorkflowRunTextSessionModel(Base):
__tablename__ = "workflow_run_text_sessions"
workflow_run_id = Column(
Integer,
ForeignKey("workflow_runs.id", ondelete="CASCADE"),
primary_key=True,
)
workflow_run = relationship("WorkflowRunModel", back_populates="text_session")
revision = Column(
Integer,
nullable=False,
default=0,
server_default=text("0"),
)
session_data = Column(
JSON,
nullable=False,
default=dict,
server_default=text("'{}'::json"),
)
checkpoint = Column(
JSON,
nullable=False,
default=dict,
server_default=text("'{}'::json"),
)
created_at = Column(DateTime(timezone=True), default=lambda: datetime.now(UTC))
updated_at = Column(
DateTime(timezone=True),
default=lambda: datetime.now(UTC),
onupdate=lambda: datetime.now(UTC),
)
__table_args__ = (Index("ix_workflow_run_text_sessions_updated_at", "updated_at"),)
class OrganizationUsageCycleModel(Base):
"""
This model is used to track the usage of Dograh tokens for an organization for a given usage

View file

@ -151,9 +151,9 @@ class OrganizationUsageClient(BaseDBClient):
async def update_usage_after_run(
self,
organization_id: int,
actual_tokens: int,
duration_seconds: int = 0,
charge_usd: float = None,
actual_tokens: float,
duration_seconds: float = 0,
charge_usd: float | None = None,
) -> None:
"""Update usage after a workflow run completes with actual token count and duration.
@ -354,6 +354,7 @@ class OrganizationUsageClient(BaseDBClient):
"caller_number": caller_number,
"called_number": called_number,
"call_type": run.call_type,
"mode": run.mode,
"disposition": disposition,
"initial_context": run.initial_context,
"gathered_context": run.gathered_context,

View file

@ -32,16 +32,22 @@ class WorkflowRunClient(BaseDBClient):
campaign_id: int = None,
queued_run_id: int = None,
use_draft: bool = False,
organization_id: int | None = None,
) -> WorkflowRunModel:
async with self.async_session() as session:
# Get workflow and user to check organization
workflow = await session.execute(
workflow_query = (
select(WorkflowModel)
.options(joinedload(WorkflowModel.user))
.where(
WorkflowModel.id == workflow_id, WorkflowModel.user_id == user_id
)
)
if organization_id is not None:
workflow_query = workflow_query.where(
WorkflowModel.organization_id == organization_id
)
workflow = await session.execute(workflow_query)
workflow = workflow.scalars().first()
if not workflow:
raise ValueError(f"Workflow with ID {workflow_id} not found")

View file

@ -0,0 +1,124 @@
from sqlalchemy.future import select
from sqlalchemy.orm import joinedload
from api.db.base_client import BaseDBClient
from api.db.models import (
WorkflowModel,
WorkflowRunModel,
WorkflowRunTextSessionModel,
)
class WorkflowRunTextSessionRevisionConflictError(Exception):
def __init__(self, expected_revision: int, actual_revision: int):
self.expected_revision = expected_revision
self.actual_revision = actual_revision
super().__init__(
"Workflow run text session revision conflict: "
f"expected {expected_revision}, found {actual_revision}"
)
class WorkflowRunTextSessionClient(BaseDBClient):
async def ensure_workflow_run_text_session(
self,
workflow_run_id: int,
session_data: dict | None = None,
checkpoint: dict | None = None,
) -> WorkflowRunTextSessionModel:
async with self.async_session() as session:
result = await session.execute(
select(WorkflowRunTextSessionModel)
.where(WorkflowRunTextSessionModel.workflow_run_id == workflow_run_id)
.with_for_update()
)
text_session = result.scalars().first()
if text_session:
return text_session
run_result = await session.execute(
select(WorkflowRunModel).where(WorkflowRunModel.id == workflow_run_id)
)
workflow_run = run_result.scalars().first()
if not workflow_run:
raise ValueError(f"Workflow run with ID {workflow_run_id} not found")
text_session = WorkflowRunTextSessionModel(
workflow_run_id=workflow_run_id,
session_data=session_data or {},
checkpoint=checkpoint or {},
)
session.add(text_session)
try:
await session.commit()
except Exception as e:
await session.rollback()
raise e
await session.refresh(text_session)
return text_session
async def get_workflow_run_text_session(
self,
workflow_run_id: int,
*,
organization_id: int,
) -> WorkflowRunTextSessionModel | None:
async with self.async_session() as session:
query = (
select(WorkflowRunTextSessionModel)
.options(
joinedload(WorkflowRunTextSessionModel.workflow_run).joinedload(
WorkflowRunModel.workflow
)
)
.join(WorkflowRunTextSessionModel.workflow_run)
.join(WorkflowRunModel.workflow)
.where(WorkflowRunTextSessionModel.workflow_run_id == workflow_run_id)
.where(WorkflowModel.organization_id == organization_id)
)
result = await session.execute(query)
return result.scalars().first()
async def update_workflow_run_text_session(
self,
workflow_run_id: int,
*,
session_data: dict | None = None,
checkpoint: dict | None = None,
expected_revision: int | None = None,
) -> WorkflowRunTextSessionModel:
async with self.async_session() as session:
result = await session.execute(
select(WorkflowRunTextSessionModel)
.where(WorkflowRunTextSessionModel.workflow_run_id == workflow_run_id)
.with_for_update()
)
text_session = result.scalars().first()
if not text_session:
raise ValueError(
f"Workflow run text session with run ID {workflow_run_id} not found"
)
if (
expected_revision is not None
and text_session.revision != expected_revision
):
raise WorkflowRunTextSessionRevisionConflictError(
expected_revision=expected_revision,
actual_revision=text_session.revision,
)
if session_data is not None:
text_session.session_data = session_data
if checkpoint is not None:
text_session.checkpoint = checkpoint
text_session.revision += 1
try:
await session.commit()
except Exception as e:
await session.rollback()
raise e
await session.refresh(text_session)
return text_session

View file

@ -27,6 +27,7 @@ class WorkflowRunMode(Enum):
TELNYX = "telnyx"
WEBRTC = "webrtc"
SMALLWEBRTC = "smallwebrtc"
TEXTCHAT = "textchat"
# Historical, not used anymore. Don't
# use and don't remove

View file

@ -25,6 +25,7 @@ from api.routes.webrtc_signaling import router as webrtc_signaling_router
from api.routes.workflow import router as workflow_router
from api.routes.workflow_embed import router as workflow_embed_router
from api.routes.workflow_recording import router as workflow_recording_router
from api.routes.workflow_text_chat import router as workflow_text_chat_router
from api.services.integrations import all_routers
router = APIRouter(
@ -35,6 +36,7 @@ router = APIRouter(
router.include_router(telephony_router)
router.include_router(superuser_router)
router.include_router(workflow_router)
router.include_router(workflow_text_chat_router)
router.include_router(user_router)
router.include_router(campaign_router)
router.include_router(credentials_router)

View file

@ -57,6 +57,7 @@ class WorkflowRunUsageResponse(BaseModel):
caller_number: Optional[str] = None
called_number: Optional[str] = None
call_type: Optional[str] = None
mode: Optional[str] = None
disposition: Optional[str] = None
initial_context: Optional[Dict[str, Any]] = None
gathered_context: Optional[Dict[str, Any]] = None

View file

@ -153,6 +153,7 @@ async def initiate_call(
"telephony_configuration_id": telephony_configuration_id,
},
use_draft=True,
organization_id=user.selected_organization_id,
)
workflow_run_id = workflow_run.id
else:

View file

@ -53,9 +53,9 @@ router = APIRouter(prefix="/ws")
class NonRelayFilterPolicy(Enum):
"""What to filter from non-relay ICE candidates. Relay candidates always pass."""
NONE = "none" # filter nothing — pass all candidates
NONE = "none" # filter nothing — pass all candidates
PRIVATE = "private" # filter non-relay candidates with private/CGNAT IPs
ALL = "all" # filter all non-relay candidates (relay-only mode)
ALL = "all" # filter all non-relay candidates (relay-only mode)
def is_local_or_cgnat_ip(ip_str: str) -> bool:

View file

@ -1081,7 +1081,12 @@ 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, use_draft=True
request.name,
workflow_id,
request.mode,
user.id,
use_draft=True,
organization_id=user.selected_organization_id,
)
return {
"id": run.id,

View file

@ -0,0 +1,282 @@
from datetime import datetime
from typing import Any, Dict
from uuid import uuid4
from fastapi import APIRouter, Depends, HTTPException
from pipecat.utils.run_context import set_current_run_id
from pydantic import BaseModel, Field
from api.db import db_client
from api.db.models import UserModel, WorkflowRunTextSessionModel
from api.enums import WorkflowRunMode
from api.services.auth.depends import get_user
from api.services.quota_service import check_dograh_quota
from api.services.workflow.text_chat_session_service import (
TextChatPendingTurnLostError,
TextChatSessionExecutionError,
TextChatSessionRevisionConflictError,
TextChatTurnNotFoundError,
append_text_chat_user_message,
default_text_chat_checkpoint,
default_text_chat_session_data,
execute_pending_text_chat_turn,
initialize_text_chat_session,
normalize_text_chat_checkpoint,
normalize_text_chat_session_data,
rewind_text_chat_session_state,
)
router = APIRouter(prefix="/workflow", tags=["workflow-text-chat"])
class CreateTextChatSessionRequest(BaseModel):
name: str | None = None
initial_context: Dict[str, Any] | None = None
annotations: Dict[str, Any] | None = None
class AppendTextChatMessageRequest(BaseModel):
text: str = Field(min_length=1)
expected_revision: int | None = None
class RewindTextChatSessionRequest(BaseModel):
cursor_turn_id: str | None = None
expected_revision: int | None = None
class WorkflowRunTextSessionResponse(BaseModel):
workflow_run_id: int
workflow_id: int
name: str
mode: str
state: str
is_completed: bool
revision: int
initial_context: Dict[str, Any] | None = None
gathered_context: Dict[str, Any] | None = None
annotations: Dict[str, Any] | None = None
session_data: Dict[str, Any]
checkpoint: Dict[str, Any]
created_at: datetime
updated_at: datetime | None = None
def _get_state_value(state: Any) -> str:
return state.value if hasattr(state, "value") else str(state)
def _build_response(
text_session: WorkflowRunTextSessionModel,
) -> WorkflowRunTextSessionResponse:
workflow_run = text_session.workflow_run
return WorkflowRunTextSessionResponse(
workflow_run_id=workflow_run.id,
workflow_id=workflow_run.workflow_id,
name=workflow_run.name,
mode=workflow_run.mode,
state=_get_state_value(workflow_run.state),
is_completed=workflow_run.is_completed,
revision=text_session.revision,
initial_context=workflow_run.initial_context,
gathered_context=workflow_run.gathered_context,
annotations=workflow_run.annotations,
session_data=normalize_text_chat_session_data(text_session.session_data),
checkpoint=normalize_text_chat_checkpoint(text_session.checkpoint),
created_at=text_session.created_at,
updated_at=text_session.updated_at,
)
def _revision_conflict_detail(e: Any) -> dict[str, Any]:
return {
"message": "Text chat session revision conflict",
"expected_revision": e.expected_revision,
"actual_revision": e.actual_revision,
}
def _require_selected_organization_id(user: UserModel) -> int:
if user.selected_organization_id is None:
raise HTTPException(status_code=403, detail="Organization context is required")
return user.selected_organization_id
async def _ensure_text_chat_quota(user: UserModel, workflow_id: int) -> None:
quota_result = await check_dograh_quota(user, workflow_id=workflow_id)
if not quota_result.has_quota:
raise HTTPException(status_code=402, detail=quota_result.error_message)
async def _load_text_session_or_404(
workflow_id: int,
run_id: int,
user: UserModel,
) -> WorkflowRunTextSessionModel:
set_current_run_id(run_id)
organization_id = _require_selected_organization_id(user)
text_session = await db_client.get_workflow_run_text_session(
run_id, organization_id=organization_id
)
if not text_session or not text_session.workflow_run:
raise HTTPException(status_code=404, detail="Text chat session not found")
if text_session.workflow_run.workflow_id != workflow_id:
raise HTTPException(status_code=404, detail="Text chat session not found")
if text_session.workflow_run.mode != WorkflowRunMode.TEXTCHAT.value:
raise HTTPException(
status_code=400, detail="Workflow run is not a text chat session"
)
return text_session
async def _execute_pending_turn_response(
*,
workflow_id: int,
run_id: int,
text_session: WorkflowRunTextSessionModel,
) -> WorkflowRunTextSessionResponse:
try:
updated_text_session = await execute_pending_text_chat_turn(
workflow_id=workflow_id,
run_id=run_id,
text_session=text_session,
)
except TextChatSessionRevisionConflictError as e:
raise HTTPException(status_code=409, detail=_revision_conflict_detail(e))
except TextChatPendingTurnLostError as e:
raise HTTPException(status_code=500, detail=str(e))
except TextChatSessionExecutionError as e:
raise HTTPException(status_code=500, detail=str(e))
return _build_response(updated_text_session)
@router.post(
"/{workflow_id}/text-chat/sessions",
response_model=WorkflowRunTextSessionResponse,
)
async def create_text_chat_session(
workflow_id: int,
request: CreateTextChatSessionRequest,
user: UserModel = Depends(get_user),
) -> WorkflowRunTextSessionResponse:
organization_id = _require_selected_organization_id(user)
await _ensure_text_chat_quota(user, workflow_id)
session_name = request.name or f"WR-TEXT-{uuid4().hex[:6].upper()}"
try:
workflow_run = await db_client.create_workflow_run(
name=session_name,
workflow_id=workflow_id,
mode=WorkflowRunMode.TEXTCHAT.value,
user_id=user.id,
initial_context=request.initial_context,
use_draft=True,
organization_id=organization_id,
)
except ValueError as e:
raise HTTPException(status_code=404, detail=str(e))
set_current_run_id(workflow_run.id)
annotations = {
"tester": {
"source": "workflow_editor",
"modality": "text",
}
}
if request.annotations:
annotations = {**annotations, **request.annotations}
workflow_run = await db_client.update_workflow_run(
workflow_run.id,
annotations=annotations,
)
text_session = await db_client.ensure_workflow_run_text_session(
workflow_run.id,
session_data=default_text_chat_session_data(),
checkpoint=default_text_chat_checkpoint(),
)
try:
text_session = await initialize_text_chat_session(
run_id=workflow_run.id,
text_session=text_session,
)
except TextChatSessionRevisionConflictError as e:
raise HTTPException(status_code=409, detail=_revision_conflict_detail(e))
return await _execute_pending_turn_response(
workflow_id=workflow_id,
run_id=workflow_run.id,
text_session=text_session,
)
@router.get(
"/{workflow_id}/text-chat/sessions/{run_id}",
response_model=WorkflowRunTextSessionResponse,
)
async def get_text_chat_session(
workflow_id: int,
run_id: int,
user: UserModel = Depends(get_user),
) -> WorkflowRunTextSessionResponse:
text_session = await _load_text_session_or_404(workflow_id, run_id, user)
return _build_response(text_session)
@router.post(
"/{workflow_id}/text-chat/sessions/{run_id}/messages",
response_model=WorkflowRunTextSessionResponse,
)
async def append_text_chat_message(
workflow_id: int,
run_id: int,
request: AppendTextChatMessageRequest,
user: UserModel = Depends(get_user),
) -> WorkflowRunTextSessionResponse:
text_session = await _load_text_session_or_404(workflow_id, run_id, user)
await _ensure_text_chat_quota(user, workflow_id)
try:
text_session = await append_text_chat_user_message(
run_id=run_id,
text_session=text_session,
user_text=request.text,
expected_revision=request.expected_revision,
)
except TextChatSessionRevisionConflictError as e:
raise HTTPException(status_code=409, detail=_revision_conflict_detail(e))
return await _execute_pending_turn_response(
workflow_id=workflow_id,
run_id=run_id,
text_session=text_session,
)
@router.post(
"/{workflow_id}/text-chat/sessions/{run_id}/rewind",
response_model=WorkflowRunTextSessionResponse,
)
async def rewind_text_chat_session(
workflow_id: int,
run_id: int,
request: RewindTextChatSessionRequest,
user: UserModel = Depends(get_user),
) -> WorkflowRunTextSessionResponse:
text_session = await _load_text_session_or_404(workflow_id, run_id, user)
try:
text_session = await rewind_text_chat_session_state(
run_id=run_id,
text_session=text_session,
cursor_turn_id=request.cursor_turn_id,
expected_revision=request.expected_revision,
)
except TextChatTurnNotFoundError as e:
raise HTTPException(status_code=404, detail=str(e))
except TextChatSessionRevisionConflictError as e:
raise HTTPException(status_code=409, detail=_revision_conflict_detail(e))
return _build_response(text_session)

View file

@ -7,7 +7,7 @@ from api.enums import PostHogEvent, WorkflowRunState
from api.services.campaign.circuit_breaker import circuit_breaker
from api.services.integrations import IntegrationRuntimeSession
from api.services.pipecat.audio_config import AudioConfig
from api.services.pipecat.audio_playback import play_audio, play_audio_loop
from api.services.pipecat.audio_playback import play_audio_loop
from api.services.pipecat.in_memory_buffers import (
InMemoryAudioBuffer,
InMemoryLogsBuffer,
@ -20,8 +20,6 @@ from api.tasks.arq import enqueue_job
from api.tasks.function_names import FunctionNames
from pipecat.frames.frames import (
Frame,
LLMContextFrame,
TTSSpeakFrame,
)
from pipecat.pipeline.task import PipelineTask
from pipecat.processors.audio.audio_buffer_processor import AudioBufferProcessor
@ -69,7 +67,6 @@ def register_event_handlers(
pipeline_metrics_aggregator: PipelineMetricsAggregator,
audio_config=AudioConfig,
pre_call_fetch_task: asyncio.Task | None = None,
fetch_recording_audio=None,
user_provider_id: str | None = None,
integration_runtime_sessions: list[IntegrationRuntimeSession] | None = None,
):
@ -99,20 +96,11 @@ def register_event_handlers(
"initial_response_triggered": False,
}
async def queue_initial_llm_context():
# Queue LLMContextFrame after the VoicemailDetector since the detector
# gates LLMContextFrames until voicemail detection completes. We also
# don't want to trigger the Voicemail LLM with this initial frame.
await engine.llm.queue_frame(LLMContextFrame(engine.context))
async def maybe_trigger_initial_response():
"""Start the conversation after both pipeline_started and client_connected events.
If a pre-call fetch is in progress, plays a ringer while waiting for the
response, then merges the result into the call context before proceeding.
If the start node has a greeting configured, play it directly via TTS.
Otherwise, trigger an LLM generation for the opening message.
"""
if (
ready_state["pipeline_started"]
@ -167,46 +155,11 @@ def register_event_handlers(
# Set the start node now (after pre-call fetch data is merged)
# so that render_template() has the complete _call_context_vars.
await engine.set_node(engine.workflow.start_node_id)
greeting_info = engine.get_start_greeting()
if greeting_info:
greeting_type, greeting_value = greeting_info
if (
greeting_type == "audio"
and greeting_value
and fetch_recording_audio
):
logger.debug(f"Playing audio greeting recording: {greeting_value}")
result = await fetch_recording_audio(
recording_pk=int(greeting_value)
)
if result:
await play_audio(
result.audio,
sample_rate=audio_config.pipeline_sample_rate or 16000,
queue_frame=transport.output().queue_frame,
transcript=result.transcript,
append_to_context=True,
)
else:
logger.warning(
f"Failed to fetch audio greeting {greeting_value}, "
"falling back to LLM generation"
)
await queue_initial_llm_context()
else:
logger.debug("Playing text greeting via TTS")
# append_to_context=True so the assistant aggregator commits
# the greeting to the LLM context once TTS finishes; without
# it the LLM would re-greet on its first generation.
await task.queue_frame(
TTSSpeakFrame(greeting_value, append_to_context=True)
)
else:
logger.debug(
"Both pipeline_started and client_connected received - triggering initial LLM generation"
)
await queue_initial_llm_context()
await engine.queue_node_opening(
node_id=engine.workflow.start_node_id,
previous_node_id=None,
generate_if_no_greeting=True,
)
@transport.event_handler("on_client_connected")
async def on_client_connected(_transport, _participant):

View file

@ -6,6 +6,10 @@ from typing import List, Optional
from loguru import logger
from api.services.pipecat.realtime_feedback_events import (
realtime_feedback_event_sort_key,
stamp_realtime_feedback_event,
)
from api.utils.transcript import generate_transcript_text as _generate_transcript_text
from pipecat.utils.enums import RealtimeFeedbackType
@ -98,16 +102,13 @@ class InMemoryLogsBuffer:
async def append(self, event: dict):
"""Append a feedback event to the buffer with timestamp and current node."""
# Add timestamp, turn tracking, and current node
timestamped_event = {
**event,
"timestamp": datetime.now(UTC).isoformat(),
"turn": self._turn_counter,
}
if self._current_node_id:
timestamped_event["node_id"] = self._current_node_id
if self._current_node_name:
timestamped_event["node_name"] = self._current_node_name
timestamped_event = stamp_realtime_feedback_event(
event,
timestamp=datetime.now(UTC).isoformat(),
turn=self._turn_counter,
node_id=self._current_node_id,
node_name=self._current_node_name,
)
self._events.append(timestamped_event)
logger.trace(
f"Appended event {event.get('type')} to logs buffer for workflow {self._workflow_run_id}"
@ -120,17 +121,12 @@ class InMemoryLogsBuffer:
f"Incremented turn counter to {self._turn_counter} for workflow {self._workflow_run_id}"
)
@staticmethod
def _event_sort_key(event: dict) -> str:
payload_ts = event.get("payload", {}).get("timestamp")
return payload_ts or event.get("timestamp", "")
def _sorted_events(self) -> List[dict]:
# Stable sort by the realtime (payload) timestamp when available, falling
# back to the buffer-append timestamp. Python's sort is stable, so events
# sharing a key retain their original insertion order — this keeps
# consecutive bot-text chunks of a single turn contiguous.
return sorted(self._events, key=self._event_sort_key)
return sorted(self._events, key=realtime_feedback_event_sort_key)
def get_events(self) -> List[dict]:
"""Get all events for final storage, ordered by realtime timestamp."""

View file

@ -152,8 +152,30 @@ def build_realtime_pipeline(
return Pipeline(processors)
def create_pipeline_task(pipeline, workflow_run_id, audio_config: AudioConfig = None):
"""Create a pipeline task with appropriate parameters"""
def create_pipeline_task(
pipeline,
workflow_run_id,
audio_config: AudioConfig = None,
*,
conversation_parent_context=None,
conversation_type: str = "voice",
additional_span_attributes: dict | None = None,
):
"""Create a pipeline task with appropriate parameters.
Args:
pipeline: The pipeline to run.
workflow_run_id: Run id, used as the conversation id.
audio_config: Optional audio configuration.
conversation_parent_context: Optional OTEL context carrying a fixed
trace id. When provided, the conversation span attaches to that
trace instead of starting a new root trace (used by text chat to
stitch every per-turn pipeline into one trace).
conversation_type: ``conversation.type`` span attribute value.
additional_span_attributes: Extra attributes set on the conversation
span (e.g. ``langfuse.trace.name`` to name a stitched trace that
has no real root span).
"""
# Set up pipeline params with audio configuration if provided
pipeline_params = PipelineParams(
enable_metrics=True,
@ -178,6 +200,9 @@ def create_pipeline_task(pipeline, workflow_run_id, audio_config: AudioConfig =
enable_tracing=True,
enable_rtvi=False,
conversation_id=f"{workflow_run_id}",
conversation_parent_context=conversation_parent_context,
conversation_type=conversation_type,
additional_span_attributes=additional_span_attributes,
)
# Check if turn logging is enabled

View file

@ -0,0 +1,163 @@
"""Shared helpers for building and ordering realtime feedback events."""
from typing import Any
from pipecat.utils.enums import RealtimeFeedbackType
def build_node_transition_event(
*,
node_id: str | None,
node_name: str | None,
previous_node_id: str | None,
previous_node_name: str | None,
allow_interrupt: bool = False,
) -> dict[str, Any]:
return {
"type": RealtimeFeedbackType.NODE_TRANSITION.value,
"payload": {
"node_id": node_id,
"node_name": node_name,
"previous_node_id": previous_node_id,
"previous_node_name": previous_node_name,
"allow_interrupt": allow_interrupt,
},
}
def build_user_transcription_event(
*,
text: str,
final: bool,
timestamp: str | None = None,
user_id: str | None = None,
) -> dict[str, Any]:
payload: dict[str, Any] = {
"text": text,
"final": final,
}
if timestamp is not None:
payload["timestamp"] = timestamp
if user_id is not None:
payload["user_id"] = user_id
return {
"type": RealtimeFeedbackType.USER_TRANSCRIPTION.value,
"payload": payload,
}
def build_bot_text_event(
*,
text: str,
timestamp: str | None = None,
) -> dict[str, Any]:
payload: dict[str, Any] = {"text": text}
if timestamp is not None:
payload["timestamp"] = timestamp
return {
"type": RealtimeFeedbackType.BOT_TEXT.value,
"payload": payload,
}
def build_function_call_start_event(
*,
function_name: str | None,
tool_call_id: str | None,
arguments: dict[str, Any] | None = None,
) -> dict[str, Any]:
payload: dict[str, Any] = {
"function_name": function_name,
"tool_call_id": tool_call_id,
}
if arguments is not None:
payload["arguments"] = arguments
return {
"type": RealtimeFeedbackType.FUNCTION_CALL_START.value,
"payload": payload,
}
def serialize_realtime_feedback_tool_result(result: Any) -> str | None:
"""Normalize function-call results to the string shape stored in logs."""
if result is None:
return None
return str(result)
def build_function_call_end_event(
*,
function_name: str | None,
tool_call_id: str | None,
result: Any,
) -> dict[str, Any]:
return {
"type": RealtimeFeedbackType.FUNCTION_CALL_END.value,
"payload": {
"function_name": function_name,
"tool_call_id": tool_call_id,
"result": serialize_realtime_feedback_tool_result(result),
},
}
def build_ttfb_metric_event(
*,
ttfb_seconds: float,
processor: str | None,
model: str | None,
) -> dict[str, Any]:
return {
"type": RealtimeFeedbackType.TTFB_METRIC.value,
"payload": {
"ttfb_seconds": ttfb_seconds,
"processor": processor,
"model": model,
},
}
def build_pipeline_error_event(
*,
error: str,
fatal: bool,
processor: str | None = None,
extra_payload: dict[str, Any] | None = None,
) -> dict[str, Any]:
payload: dict[str, Any] = {
"error": error,
"fatal": fatal,
}
if processor is not None:
payload["processor"] = processor
if extra_payload:
payload.update(extra_payload)
return {
"type": RealtimeFeedbackType.PIPELINE_ERROR.value,
"payload": payload,
}
def stamp_realtime_feedback_event(
event: dict[str, Any],
*,
timestamp: str | None = None,
turn: int | None = None,
node_id: str | None = None,
node_name: str | None = None,
) -> dict[str, Any]:
stamped = dict(event)
if timestamp is not None:
stamped["timestamp"] = timestamp
if turn is not None:
stamped["turn"] = turn
if node_id is not None:
stamped["node_id"] = node_id
if node_name is not None:
stamped["node_name"] = node_name
return stamped
def realtime_feedback_event_sort_key(event: dict[str, Any]) -> str:
payload_timestamp = (event.get("payload") or {}).get("timestamp")
return payload_timestamp or event.get("timestamp") or ""

View file

@ -27,6 +27,15 @@ from typing import TYPE_CHECKING, Awaitable, Callable, Optional, Set
from loguru import logger
from api.services.pipecat.realtime_feedback_events import (
build_bot_text_event,
build_function_call_end_event,
build_function_call_start_event,
build_pipeline_error_event,
build_ttfb_metric_event,
build_user_transcription_event,
)
if TYPE_CHECKING:
from api.services.pipecat.in_memory_buffers import InMemoryLogsBuffer
@ -211,29 +220,23 @@ class RealtimeFeedbackObserver(BaseObserver):
# Handle user transcriptions (interim) - WebSocket only
elif isinstance(frame, InterimTranscriptionFrame):
await self._send_ws(
{
"type": RealtimeFeedbackType.USER_TRANSCRIPTION.value,
"payload": {
"text": frame.text,
"final": False,
"user_id": frame.user_id,
"timestamp": frame.timestamp,
},
}
build_user_transcription_event(
text=frame.text,
final=False,
user_id=frame.user_id,
timestamp=frame.timestamp,
)
)
# Handle user transcriptions (final) - WebSocket only
# Complete turn text is persisted via register_turn_handlers
elif isinstance(frame, TranscriptionFrame):
await self._send_ws(
{
"type": RealtimeFeedbackType.USER_TRANSCRIPTION.value,
"payload": {
"text": frame.text,
"final": True,
"user_id": frame.user_id,
"timestamp": frame.timestamp,
},
}
build_user_transcription_event(
text=frame.text,
final=True,
user_id=frame.user_id,
timestamp=frame.timestamp,
)
)
# Handle engine-queued speech (transition/tool messages) marked for
# log persistence. The downstream TTSTextFrame(s) from the TTS service
@ -241,23 +244,13 @@ class RealtimeFeedbackObserver(BaseObserver):
# to avoid word-level log entries from word-timestamp providers.
elif isinstance(frame, TTSSpeakFrame):
if getattr(frame, "persist_to_logs", False):
await self._append_to_buffer(
{
"type": RealtimeFeedbackType.BOT_TEXT.value,
"payload": {"text": frame.text},
}
)
await self._append_to_buffer(build_bot_text_event(text=frame.text))
# Handle bot TTS text - respect pts timing, WebSocket only
# Complete turn text is persisted via register_turn_handlers,
# except for frames explicitly flagged persist_to_logs (e.g. recording
# transcripts from play_audio) which bypass the aggregator path.
elif isinstance(frame, TTSTextFrame):
message = {
"type": RealtimeFeedbackType.BOT_TEXT.value,
"payload": {
"text": frame.text,
},
}
message = build_bot_text_event(text=frame.text)
# If frame has pts, queue it for timed delivery
if frame.pts:
@ -280,13 +273,11 @@ class RealtimeFeedbackObserver(BaseObserver):
and frame_direction == FrameDirection.DOWNSTREAM
):
await self._send_message(
{
"type": RealtimeFeedbackType.FUNCTION_CALL_START.value,
"payload": {
"function_name": frame.function_name,
"tool_call_id": frame.tool_call_id,
},
}
build_function_call_start_event(
function_name=frame.function_name,
tool_call_id=frame.tool_call_id,
arguments=dict(frame.arguments or {}),
)
)
# Handle function call result
elif (
@ -294,14 +285,11 @@ class RealtimeFeedbackObserver(BaseObserver):
and frame_direction == FrameDirection.DOWNSTREAM
):
await self._send_message(
{
"type": RealtimeFeedbackType.FUNCTION_CALL_END.value,
"payload": {
"function_name": frame.function_name,
"tool_call_id": frame.tool_call_id,
"result": str(frame.result) if frame.result else None,
},
}
build_function_call_end_event(
function_name=frame.function_name,
tool_call_id=frame.tool_call_id,
result=frame.result,
)
)
# Handle TTFB metrics - capture LLM generation time only
elif isinstance(frame, MetricsFrame):
@ -311,47 +299,42 @@ class RealtimeFeedbackObserver(BaseObserver):
# Only send TTFB if it's from an LLM processor
if metric_data.processor and "LLM" in metric_data.processor:
await self._send_message(
{
"type": RealtimeFeedbackType.TTFB_METRIC.value,
"payload": {
"ttfb_seconds": metric_data.value,
"processor": metric_data.processor,
"model": metric_data.model,
},
}
build_ttfb_metric_event(
ttfb_seconds=metric_data.value,
processor=metric_data.processor,
model=metric_data.model,
)
)
# Handle pipeline errors
elif isinstance(frame, ErrorFrame):
processor_name = str(frame.processor) if frame.processor else None
payload = {
"error": frame.error,
"fatal": frame.fatal,
"processor": processor_name,
}
extra_payload: dict[str, object] = {}
# Surface structured fields when the underlying exception carries
# them (e.g. google.genai APIError: code=1008, status=None,
# message="Your project has been denied access...").
exc = frame.exception
if exc is not None:
exc_type = type(exc).__name__
payload["exception_type"] = exc_type
payload["exception_message"] = str(exc)
extra_payload["exception_type"] = exc_type
extra_payload["exception_message"] = str(exc)
for attr in ("code", "status", "message", "details"):
value = getattr(exc, attr, None)
if value is None or attr in payload:
if value is None or attr in extra_payload:
continue
try:
# Ensure the value is JSON-serializable; fall back
# to str() for opaque objects (e.g. raw response).
json.dumps(value)
payload[attr] = value
extra_payload[attr] = value
except (TypeError, ValueError):
payload[attr] = str(value)
extra_payload[attr] = str(value)
await self._send_message(
{
"type": RealtimeFeedbackType.PIPELINE_ERROR.value,
"payload": payload,
}
build_pipeline_error_event(
error=frame.error,
fatal=frame.fatal,
processor=processor_name,
extra_payload=extra_payload or None,
)
)
async def _send_ws(self, message: dict):
@ -401,14 +384,11 @@ def register_turn_log_handlers(
logs_buffer.increment_turn()
try:
await logs_buffer.append(
{
"type": RealtimeFeedbackType.USER_TRANSCRIPTION.value,
"payload": {
"text": message.content,
"final": True,
"timestamp": message.timestamp,
},
}
build_user_transcription_event(
text=message.content,
final=True,
timestamp=message.timestamp,
)
)
except Exception as e:
logger.error(f"Failed to append user turn to logs buffer: {e}")
@ -418,13 +398,10 @@ def register_turn_log_handlers(
if message.content:
try:
await logs_buffer.append(
{
"type": RealtimeFeedbackType.BOT_TEXT.value,
"payload": {
"text": message.content,
"timestamp": message.timestamp,
},
}
build_bot_text_event(
text=message.content,
timestamp=message.timestamp,
)
)
except Exception as e:
logger.error(f"Failed to append assistant turn to logs buffer: {e}")

View file

@ -28,6 +28,9 @@ from api.services.pipecat.pipeline_engine_callbacks_processor import (
)
from api.services.pipecat.pipeline_metrics_aggregator import PipelineMetricsAggregator
from api.services.pipecat.pre_call_fetch import execute_pre_call_fetch
from api.services.pipecat.realtime_feedback_events import (
build_node_transition_event,
)
from api.services.pipecat.realtime_feedback_observer import (
RealtimeFeedbackObserver,
register_turn_log_handlers,
@ -465,16 +468,13 @@ async def _run_pipeline(
# Update current node on the buffer so subsequent events are tagged
in_memory_logs_buffer.set_current_node(node_id, node_name)
message = {
"type": RealtimeFeedbackType.NODE_TRANSITION.value,
"payload": {
"node_id": node_id,
"node_name": node_name,
"previous_node_id": previous_node_id,
"previous_node_name": previous_node_name,
"allow_interrupt": allow_interrupt,
},
}
message = build_node_transition_event(
node_id=node_id,
node_name=node_name,
previous_node_id=previous_node_id,
previous_node_name=previous_node_name,
allow_interrupt=allow_interrupt,
)
# Send via WebSocket if available
if ws_sender:
try:
@ -803,7 +803,6 @@ async def _run_pipeline(
pipeline_metrics_aggregator=pipeline_metrics_aggregator,
audio_config=audio_config,
pre_call_fetch_task=pre_call_fetch_task,
fetch_recording_audio=fetch_audio,
user_provider_id=user_provider_id,
integration_runtime_sessions=integration_runtime_sessions,
)

View file

@ -254,6 +254,44 @@ async def handle_langfuse_sync(event):
unregister_org_langfuse_credentials(org_id)
def build_remote_parent_context(trace_id: str | None):
"""Build an OTEL context whose active span carries ``trace_id``.
Spans started under the returned context join the Langfuse trace identified
by ``trace_id`` (Langfuse groups observations by trace id). The parent span
id is a non-existent placeholder, so spans created under it attach at the
trace root rather than nesting under a real parent span.
This is the shared primitive behind both post-call QA tracing and text-chat
trace stitching. Returns the context, or ``None`` when tracing is
unavailable or ``trace_id`` is missing/invalid.
"""
if not trace_id:
return None
if not ensure_tracing():
return None
try:
from opentelemetry.trace import (
NonRecordingSpan,
SpanContext,
TraceFlags,
set_span_in_context,
)
parent_span_context = SpanContext(
trace_id=int(trace_id, 16),
span_id=0x1,
is_remote=True,
trace_flags=TraceFlags(0x01),
)
return set_span_in_context(NonRecordingSpan(parent_span_context))
except Exception as e:
logger.warning(
f"Failed to build remote parent context for trace {trace_id}: {e}"
)
return None
def get_trace_url(trace_id: str, org_id=None) -> str | None:
"""Build a Langfuse trace URL, using org-specific host when available."""
if org_id is None:

View file

@ -1,3 +1,5 @@
from decimal import Decimal
from loguru import logger
from api.db import db_client
@ -63,24 +65,31 @@ async def _update_organization_usage(
)
async def calculate_workflow_run_cost(workflow_run_id: int):
logger.debug("Calculating cost for workflow run")
async def _get_pricing_organization(workflow_run):
workflow = getattr(workflow_run, "workflow", None)
organization_id = getattr(workflow, "organization_id", None)
if organization_id is None and workflow and workflow.user:
organization_id = workflow.user.selected_organization_id
if organization_id is None:
return None
return await db_client.get_organization_by_id(organization_id)
workflow_run = await db_client.get_workflow_run_by_id(workflow_run_id)
if not workflow_run:
logger.warning("Workflow run not found")
return
workflow_usage_info = workflow_run.usage_info
if not workflow_usage_info:
async def _build_usage_cost_snapshot(
usage_info: dict | None,
*,
workflow_run=None,
include_telephony_cost: bool = False,
organization=None,
calculated_at: str | None = None,
) -> dict | None:
if not usage_info:
logger.warning("No usage info available for workflow run")
return
return None
try:
# Calculate cost breakdown
cost_breakdown = cost_calculator.calculate_total_cost(workflow_usage_info)
cost_breakdown = cost_calculator.calculate_total_cost(usage_info)
# Fetch telephony call cost
if include_telephony_cost and workflow_run is not None:
try:
telephony_cost = await _fetch_telephony_cost(workflow_run)
if telephony_cost:
@ -95,61 +104,127 @@ async def calculate_workflow_run_cost(workflow_run_id: int):
logger.error(f"Failed to fetch telephony call cost: {e}")
# Don't fail the whole cost calculation if telephony API fails
# Store cost information back to the workflow run
# Convert USD to Dograh Tokens (1 cent = 1 token)
dograh_tokens = round(float(cost_breakdown["total"]) * 100, 2)
total_cost_usd = Decimal(str(cost_breakdown["total"]))
dograh_tokens = float(total_cost_usd * Decimal("100"))
# Get organization to check if it has USD pricing
org = None
charge_usd = None
if (
workflow_run.workflow
and workflow_run.workflow.user
and workflow_run.workflow.user.selected_organization_id
):
org = await db_client.get_organization_by_id(
workflow_run.workflow.user.selected_organization_id
)
if organization is None and workflow_run is not None:
organization = await _get_pricing_organization(workflow_run)
# Calculate USD cost if organization has pricing configured
if org and org.price_per_second_usd:
duration_seconds = workflow_usage_info.get("call_duration_seconds", 0)
charge_usd = duration_seconds * org.price_per_second_usd
charge_usd = None
if organization and organization.price_per_second_usd:
duration_seconds = usage_info.get("call_duration_seconds", 0)
charge_usd = float(
Decimal(str(duration_seconds))
* Decimal(str(organization.price_per_second_usd))
)
cost_info = {
**workflow_run.cost_info,
"cost_breakdown": cost_breakdown,
"total_cost_usd": float(cost_breakdown["total"]),
"dograh_token_usage": dograh_tokens,
"calculated_at": workflow_run.created_at.isoformat(),
"call_duration_seconds": workflow_usage_info["call_duration_seconds"],
}
cost_info = {
"cost_breakdown": cost_breakdown,
"total_cost_usd": float(total_cost_usd),
"dograh_token_usage": dograh_tokens,
"calculated_at": calculated_at
or (workflow_run.created_at.isoformat() if workflow_run is not None else None),
"call_duration_seconds": usage_info.get("call_duration_seconds", 0),
}
# Add USD cost if available
if charge_usd is not None:
cost_info["charge_usd"] = charge_usd
cost_info["price_per_second_usd"] = org.price_per_second_usd
if charge_usd is not None:
cost_info["charge_usd"] = charge_usd
cost_info["price_per_second_usd"] = organization.price_per_second_usd
# Update workflow run with cost information
await db_client.update_workflow_run(run_id=workflow_run_id, cost_info=cost_info)
return cost_info
# Update organization usage if applicable
if org:
try:
duration_seconds = workflow_usage_info.get("call_duration_seconds", 0)
await _update_organization_usage(
org, dograh_tokens, duration_seconds, charge_usd
)
except Exception as e:
async def build_workflow_run_cost_info(workflow_run) -> dict | None:
cost_info = await _build_usage_cost_snapshot(
workflow_run.usage_info,
workflow_run=workflow_run,
include_telephony_cost=True,
calculated_at=workflow_run.created_at.isoformat(),
)
if cost_info is None:
return None
return {
**(workflow_run.cost_info or {}),
**cost_info,
}
async def save_workflow_run_cost_info(
workflow_run_id: int, cost_info: dict | None
) -> None:
if cost_info is None:
return
await db_client.update_workflow_run(run_id=workflow_run_id, cost_info=cost_info)
async def apply_workflow_run_usage_to_organization(
workflow_run, cost_info: dict | None
) -> None:
if cost_info is None:
return
org = await _get_pricing_organization(workflow_run)
if not org:
return
await _update_organization_usage(
org,
float(cost_info.get("dograh_token_usage") or 0),
float(cost_info.get("call_duration_seconds") or 0),
cost_info.get("charge_usd"),
)
async def apply_usage_delta_to_organization(
workflow_run, usage_info: dict | None
) -> dict | None:
org = await _get_pricing_organization(workflow_run)
if not org:
return None
cost_info = await _build_usage_cost_snapshot(usage_info, organization=org)
if cost_info is None:
return None
await _update_organization_usage(
org,
float(cost_info.get("dograh_token_usage") or 0),
float(cost_info.get("call_duration_seconds") or 0),
cost_info.get("charge_usd"),
)
return cost_info
async def calculate_workflow_run_cost(workflow_run_id: int):
logger.debug("Calculating cost for workflow run")
workflow_run = await db_client.get_workflow_run_by_id(workflow_run_id)
if not workflow_run:
logger.warning("Workflow run not found")
return
try:
cost_info = await build_workflow_run_cost_info(workflow_run)
if cost_info is None:
return
await save_workflow_run_cost_info(workflow_run_id, cost_info)
try:
await apply_workflow_run_usage_to_organization(workflow_run, cost_info)
except Exception as e:
org = await _get_pricing_organization(workflow_run)
if org:
logger.error(
f"Failed to update organization usage for org {org.id}: {e}"
)
# Don't fail the whole task if usage update fails
else:
logger.error(f"Failed to update organization usage: {e}")
# Don't fail the whole cost calculation if usage update fails
logger.info(
f"Calculated cost for workflow run: ${cost_breakdown['total']:.6f} USD ({dograh_tokens} Dograh Tokens)"
f"Calculated cost for workflow run: ${cost_info['total_cost_usd']:.6f} USD ({cost_info['dograh_token_usage']} Dograh Tokens)"
)
except Exception as e:
logger.error(f"Error calculating cost for workflow run: {e}")
raise

View file

@ -1,4 +1,4 @@
from typing import TYPE_CHECKING, Awaitable, Callable, Dict, Optional, Union
from typing import TYPE_CHECKING, Awaitable, Callable, Dict, Literal, Optional, Union
from pipecat.adapters.schemas.tools_schema import ToolsSchema
from pipecat.frames.frames import (
@ -7,6 +7,7 @@ from pipecat.frames.frames import (
CancelFrame,
EndFrame,
FunctionCallResultProperties,
LLMContextFrame,
TTSSpeakFrame,
)
from pipecat.pipeline.task import PipelineTask
@ -533,7 +534,7 @@ class PipecatEngine:
)
await self._update_llm_context(system_prompt, functions)
async def set_node(self, node_id: str):
async def set_node(self, node_id: str, emit_transition_event: bool = True):
"""
Simplified set_node implementation according to v2 PRD.
"""
@ -556,7 +557,7 @@ class PipecatEngine:
nodes_visited.append(node.name)
# Send node transition event if callback is provided
if self._node_transition_callback:
if emit_transition_event and self._node_transition_callback:
try:
await self._node_transition_callback(
node_id,
@ -598,8 +599,8 @@ class PipecatEngine:
# Setup LLM context with prompts and functions.
await self._setup_llm_context(node)
def get_start_greeting(self) -> Optional[tuple[str, Optional[str]]]:
"""Return the greeting info for the start node, or None if not configured.
def get_node_greeting(self, node_id: str) -> Optional[tuple[str, Optional[str]]]:
"""Return the greeting info for a node, or None if not configured.
Returns:
A tuple of (greeting_type, value) where:
@ -607,20 +608,93 @@ class PipecatEngine:
- ("audio", recording_id) for pre-recorded audio greetings
Or None if no greeting is configured.
"""
start_node = self.workflow.nodes.get(self.workflow.start_node_id)
if not start_node:
node = self.workflow.nodes.get(node_id)
if not node:
return None
greeting_type = start_node.greeting_type or "text"
greeting_type = node.greeting_type or "text"
if greeting_type == "audio" and start_node.greeting_recording_id:
return ("audio", start_node.greeting_recording_id)
if greeting_type == "audio" and node.greeting_recording_id:
return ("audio", node.greeting_recording_id)
if start_node.greeting:
return ("text", self._format_prompt(start_node.greeting))
if node.greeting:
return ("text", self._format_prompt(node.greeting))
return None
def get_start_greeting(self) -> Optional[tuple[str, Optional[str]]]:
"""Return the greeting info for the start node, or None if not configured."""
return self.get_node_greeting(self.workflow.start_node_id)
async def queue_node_opening(
self,
*,
node_id: str,
previous_node_id: Optional[str] = None,
generate_if_no_greeting: bool = False,
) -> Literal["none", "greeting", "llm"]:
"""Queue the opening behavior for a node.
This is the shared source of truth for how a node begins once the
engine is ready and the node has already been set on the context.
Returns:
"greeting" when a text/audio greeting was queued,
"llm" when an initial LLM generation was queued,
"none" when nothing was queued.
"""
if previous_node_id != node_id:
greeting_info = self.get_node_greeting(node_id)
if greeting_info:
greeting_type, greeting_value = greeting_info
if (
greeting_type == "audio"
and greeting_value
and self._fetch_recording_audio
and self._transport_output is not None
):
logger.debug(f"Playing audio greeting recording: {greeting_value}")
result = await self._fetch_recording_audio(
recording_pk=int(greeting_value)
)
if result:
await play_audio(
result.audio,
sample_rate=self._audio_config.pipeline_sample_rate
if self._audio_config
else 16000,
queue_frame=self._transport_output.queue_frame,
transcript=result.transcript,
append_to_context=True,
)
return "greeting"
logger.warning(
f"Failed to fetch audio greeting {greeting_value}, "
"falling back to LLM generation"
)
elif greeting_value and self.task is not None:
logger.debug("Playing text greeting via TTS")
# append_to_context=True so the assistant aggregator commits
# the greeting to the LLM context once TTS finishes; without
# it the LLM would re-greet on its first generation.
await self.task.queue_frame(
TTSSpeakFrame(greeting_value, append_to_context=True)
)
return "greeting"
if (
generate_if_no_greeting
and self.llm is not None
and self.context is not None
):
logger.debug("Queueing initial LLM generation for node opening")
# Queue after the voicemail detector in the live pipeline so the
# detector can gate initial generations when needed.
await self.llm.queue_frame(LLMContextFrame(self.context))
return "llm"
return "none"
async def _handle_end_node(self, node: Node) -> None:
"""Handle end node execution."""
# Setup LLM context with prompts and functions.

View file

@ -511,6 +511,17 @@ class CustomToolManager:
workflow_run = await db_client.get_workflow_run_by_id(
self._engine._workflow_run_id
)
if workflow_run.mode == WorkflowRunMode.TEXTCHAT.value:
textchat_error_result = {
"status": "failed",
"message": "I'm sorry, but call transfers are not available in text chat tests.",
"action": "transfer_failed",
"reason": "textchat_not_supported",
}
await self._handle_transfer_result(
textchat_error_result, function_call_params, properties
)
return
if workflow_run.mode in [
WorkflowRunMode.WEBRTC.value,
WorkflowRunMode.SMALLWEBRTC.value,

View file

@ -6,7 +6,10 @@ import re
from loguru import logger
from api.db.models import WorkflowRunModel
from api.services.pipecat.tracing_config import get_trace_url
from api.services.pipecat.tracing_config import (
build_remote_parent_context,
get_trace_url,
)
def extract_trace_id(gathered_context: dict) -> str | None:
@ -33,36 +36,12 @@ def setup_langfuse_parent_context(workflow_run: WorkflowRunModel):
Returns the parent context object, or None if tracing is unavailable.
"""
try:
from opentelemetry.trace import (
NonRecordingSpan,
SpanContext,
TraceFlags,
set_span_in_context,
)
from api.services.pipecat.tracing_config import ensure_tracing
if not ensure_tracing():
return None
gathered_context = workflow_run.gathered_context or {}
trace_id = extract_trace_id(gathered_context)
if not trace_id:
logger.debug("No trace_id found, skipping Langfuse tracing")
return None
parent_span_ctx = SpanContext(
trace_id=int(trace_id, 16),
span_id=0x1,
is_remote=True,
trace_flags=TraceFlags(0x01),
)
return set_span_in_context(NonRecordingSpan(parent_span_ctx))
except Exception as e:
logger.warning(f"Failed to set up Langfuse parent context: {e}")
gathered_context = workflow_run.gathered_context or {}
trace_id = extract_trace_id(gathered_context)
if not trace_id:
logger.debug("No trace_id found, skipping Langfuse tracing")
return None
return build_remote_parent_context(trace_id)
def add_qa_span_to_trace(

View file

@ -0,0 +1,144 @@
"""Helpers for projecting text-chat session state into run-log snapshots."""
from typing import Any
from api.services.pipecat.realtime_feedback_events import (
build_bot_text_event,
build_function_call_end_event,
build_function_call_start_event,
build_node_transition_event,
build_pipeline_error_event,
build_user_transcription_event,
realtime_feedback_event_sort_key,
stamp_realtime_feedback_event,
)
def visible_text_chat_turns(session_data: dict[str, Any]) -> list[dict[str, Any]]:
"""Return the active branch of turns for the current text-chat session.
After a rewind, `session_data["turns"]` may still contain future turns until
the next message is sent. Those turns are no longer part of the visible
branch, so callers that synthesize transcript/log views should trim at
`cursor_turn_id`.
"""
turns = list(session_data.get("turns") or [])
cursor_turn_id = session_data.get("cursor_turn_id")
if cursor_turn_id is None:
return turns
for index, turn in enumerate(turns):
if turn.get("id") == cursor_turn_id:
return turns[: index + 1]
return turns
def build_text_chat_realtime_feedback_events(
session_data: dict[str, Any],
) -> list[dict[str, Any]]:
"""Project text-chat session state into `workflow_runs.logs` event format.
`workflow_run_text_sessions` holds the authoritative rewindable conversation
state. Historical run pages and QA helpers read the normalized
`workflow_runs.logs.realtime_feedback_events` schema instead, so this helper
rebuilds that snapshot from the currently visible branch.
"""
events: list[dict[str, Any]] = []
last_emitted_node_id: str | None = None
for turn_index, turn in enumerate(visible_text_chat_turns(session_data)):
turn_events = list(turn.get("events") or [])
for event in turn_events:
payload = dict(event.get("payload") or {})
event_type = event.get("type")
timestamp = event.get("created_at") or turn.get("created_at")
if event_type == "node_transition":
node_id = payload.get("node_id")
if node_id is not None and node_id == last_emitted_node_id:
continue
snapshot_event = stamp_realtime_feedback_event(
build_node_transition_event(
node_id=node_id,
node_name=payload.get("node_name"),
previous_node_id=payload.get("previous_node_id"),
previous_node_name=payload.get("previous_node_name"),
allow_interrupt=bool(payload.get("allow_interrupt", False)),
),
timestamp=timestamp,
turn=turn_index,
node_id=node_id,
node_name=payload.get("node_name"),
)
if node_id is not None:
last_emitted_node_id = node_id
events.append(snapshot_event)
elif event_type == "tool_call_started":
events.append(
stamp_realtime_feedback_event(
build_function_call_start_event(
function_name=payload.get("function_name"),
tool_call_id=payload.get("tool_call_id"),
arguments=payload.get("arguments"),
),
timestamp=timestamp,
turn=turn_index,
)
)
elif event_type == "tool_call_result":
events.append(
stamp_realtime_feedback_event(
build_function_call_end_event(
function_name=payload.get("function_name"),
tool_call_id=payload.get("tool_call_id"),
result=payload.get("result"),
),
timestamp=timestamp,
turn=turn_index,
)
)
elif event_type == "execution_error":
events.append(
stamp_realtime_feedback_event(
build_pipeline_error_event(
error=payload.get("message", "Execution error"),
fatal=True,
),
timestamp=timestamp,
turn=turn_index,
)
)
user_message = turn.get("user_message") or {}
if user_message.get("text"):
message_timestamp = user_message.get("created_at") or turn.get("created_at")
events.append(
stamp_realtime_feedback_event(
build_user_transcription_event(
text=user_message["text"],
final=True,
timestamp=message_timestamp,
),
timestamp=message_timestamp,
turn=turn_index,
)
)
assistant_message = turn.get("assistant_message") or {}
if assistant_message.get("text"):
message_timestamp = assistant_message.get("created_at") or turn.get(
"created_at"
)
events.append(
stamp_realtime_feedback_event(
build_bot_text_event(
text=assistant_message["text"],
timestamp=message_timestamp,
),
timestamp=message_timestamp,
turn=turn_index,
)
)
return sorted(events, key=realtime_feedback_event_sort_key)

View file

@ -0,0 +1,649 @@
import asyncio
import hashlib
import time
from dataclasses import dataclass, field
from datetime import UTC, datetime
from typing import Any
from fastapi.encoders import jsonable_encoder
from loguru import logger
from pipecat.frames.frames import (
BotStoppedSpeakingFrame,
CancelFrame,
EndFrame,
FunctionCallInProgressFrame,
FunctionCallResultFrame,
LLMAssistantPushAggregationFrame,
LLMContextFrame,
LLMFullResponseEndFrame,
LLMFullResponseStartFrame,
TextFrame,
TTSSpeakFrame,
TTSStoppedFrame,
)
from pipecat.pipeline.pipeline import Pipeline
from pipecat.pipeline.runner import PipelineRunner
from pipecat.processors.aggregators.llm_context import LLMContext
from pipecat.processors.aggregators.llm_response_universal import (
LLMAssistantAggregatorParams,
LLMContextAggregatorPair,
)
from pipecat.processors.frame_processor import FrameDirection, FrameProcessor
from pipecat.utils.run_context import set_current_org_id
from api.db import db_client
from api.enums import WorkflowRunMode, WorkflowRunState
from api.services.configuration.resolve import resolve_effective_config
from api.services.pipecat.audio_config import create_audio_config
from api.services.pipecat.pipeline_builder import create_pipeline_task
from api.services.pipecat.pipeline_metrics_aggregator import (
PipelineMetricsAggregator,
)
from api.services.pipecat.recording_audio_cache import create_recording_audio_fetcher
from api.services.pipecat.service_factory import create_llm_service
from api.services.pipecat.tracing_config import (
build_remote_parent_context,
get_trace_url,
)
from api.services.workflow.dto import ReactFlowDTO
from api.services.workflow.pipecat_engine import PipecatEngine
from api.services.workflow.workflow_graph import WorkflowGraph
TEXT_CHAT_CHECKPOINT_VERSION = 1
TEXT_CHAT_TURN_TIMEOUT_SECONDS = 60.0
TEXT_CHAT_IDLE_SETTLE_SECONDS = 0.2
TEXT_CHAT_INTERNAL_CANCEL_REASON = "text_chat_turn_complete"
def text_chat_trace_id(workflow_run_id: int) -> str:
"""Deterministic Langfuse trace id for a text-chat session.
Each turn runs in its own short-lived pipeline, so there is no single
long-running task to own the trace the way a voice call does. Deriving the
id from the run id means every turn re-creates the *same* trace id and all
per-turn spans land in one shared trace without persisting extra state
across the otherwise stateless turn requests.
"""
digest = hashlib.sha256(f"dograh-text-chat:{workflow_run_id}".encode()).hexdigest()
return digest[:32]
def default_text_chat_checkpoint() -> dict[str, Any]:
return {
"version": TEXT_CHAT_CHECKPOINT_VERSION,
"anchor_turn_id": None,
"current_node_id": None,
"messages": [],
"gathered_context": {},
"tool_state": {},
}
def normalize_text_chat_checkpoint(
checkpoint: dict[str, Any] | None,
) -> dict[str, Any]:
normalized = {
**default_text_chat_checkpoint(),
**(checkpoint or {}),
}
normalized["messages"] = list(normalized.get("messages") or [])
normalized["gathered_context"] = dict(normalized.get("gathered_context") or {})
normalized["tool_state"] = dict(normalized.get("tool_state") or {})
return normalized
@dataclass
class TextChatTurnExecutionResult:
assistant_text: str | None
assistant_created_at: str
events: list[dict[str, Any]]
usage: dict[str, Any]
checkpoint: dict[str, Any]
gathered_context: dict[str, Any]
initial_context: dict[str, Any]
state: str
is_completed: bool
@dataclass
class _ResponseWindowState:
active_assistant_segments: int = 0
active_llm_completions: int = 0
pending_context_requests: int = 0
blocking_tool_call_ids: set[str] = field(default_factory=set)
outputs: list[str] = field(default_factory=list)
def note_direct_context_request(self) -> None:
self.pending_context_requests += 1
def note_upstream_context_request(self) -> None:
self.pending_context_requests += 1
def note_llm_start(self) -> None:
if self.pending_context_requests > 0:
self.pending_context_requests -= 1
self.active_llm_completions += 1
def note_llm_end(self) -> None:
if self.active_llm_completions > 0:
self.active_llm_completions -= 1
def note_assistant_turn_started(self) -> None:
self.active_assistant_segments += 1
def note_assistant_turn_stopped(self, content: str) -> None:
if self.active_assistant_segments > 0:
self.active_assistant_segments -= 1
normalized_content = content.strip()
if normalized_content:
self.outputs.append(normalized_content)
def note_function_call_in_progress(self, tool_call_id: str, blocking: bool) -> None:
if blocking:
self.blocking_tool_call_ids.add(tool_call_id)
def note_function_call_result(self, tool_call_id: str) -> None:
self.blocking_tool_call_ids.discard(tool_call_id)
@property
def has_blocking_tool_calls(self) -> bool:
return bool(self.blocking_tool_call_ids)
@property
def frontier_is_idle(self) -> bool:
return (
self.pending_context_requests == 0
and self.active_llm_completions == 0
and self.active_assistant_segments == 0
and not self.has_blocking_tool_calls
)
class _TaskQueueProxy:
def __init__(self, queue_frame):
self.queue_frame = queue_frame
class _TextChatCaptureProcessor(FrameProcessor):
def __init__(self, response_window: _ResponseWindowState) -> None:
super().__init__()
self.last_activity_at = time.monotonic()
self.activity_count = 0
self.events: list[dict[str, Any]] = []
self._response_window = response_window
def _touch(self) -> None:
self.last_activity_at = time.monotonic()
self.activity_count += 1
def _append_event(self, event_type: str, payload: dict[str, Any]) -> None:
self.events.append(
{
"type": event_type,
"created_at": datetime.now(UTC).isoformat(),
"payload": jsonable_encoder(payload),
}
)
async def process_frame(self, frame, direction: FrameDirection):
await super().process_frame(frame, direction)
self._touch()
if isinstance(frame, TTSSpeakFrame):
text_frame = TextFrame(frame.text)
text_frame.append_to_context = (
frame.append_to_context if frame.append_to_context is not None else True
)
await self.push_frame(text_frame, direction)
await self.push_frame(LLMAssistantPushAggregationFrame(), direction)
return
if isinstance(frame, LLMContextFrame) and direction == FrameDirection.UPSTREAM:
self._response_window.note_upstream_context_request()
if isinstance(frame, TTSStoppedFrame):
await self.push_frame(frame, direction)
await self.push_frame(LLMAssistantPushAggregationFrame(), direction)
return
if (
isinstance(frame, LLMFullResponseStartFrame)
and direction == FrameDirection.DOWNSTREAM
):
self._response_window.note_llm_start()
if (
isinstance(frame, LLMFullResponseEndFrame)
and direction is FrameDirection.DOWNSTREAM
):
self._response_window.note_llm_end()
await self.push_frame(frame, direction)
# Text chat has no TTS/output transport, so mixed text+tool responses
# would otherwise leave function calls waiting forever on a
# BotStoppedSpeakingFrame that never arrives.
await self.push_frame(BotStoppedSpeakingFrame(), FrameDirection.UPSTREAM)
return
if isinstance(frame, FunctionCallInProgressFrame):
self._response_window.note_function_call_in_progress(
tool_call_id=frame.tool_call_id,
blocking=frame.cancel_on_interruption,
)
self._append_event(
"tool_call_started",
{
"function_name": frame.function_name,
"tool_call_id": frame.tool_call_id,
"arguments": dict(frame.arguments or {}),
},
)
elif isinstance(frame, FunctionCallResultFrame):
self._response_window.note_function_call_result(frame.tool_call_id)
self._append_event(
"tool_call_result",
{
"function_name": frame.function_name,
"tool_call_id": frame.tool_call_id,
"result": frame.result,
},
)
elif isinstance(frame, EndFrame):
self._append_event("session_end", {"reason": frame.reason})
elif isinstance(frame, CancelFrame):
if frame.reason != TEXT_CHAT_INTERNAL_CANCEL_REASON:
self._append_event("session_cancelled", {"reason": frame.reason})
await self.push_frame(frame, direction)
def _merge_usage_info(
existing: dict[str, Any] | None,
delta: dict[str, Any] | None,
) -> dict[str, Any]:
merged = dict(existing or {})
delta = dict(delta or {})
merged_llm = dict(merged.get("llm") or {})
for key, value in (delta.get("llm") or {}).items():
current = dict(merged_llm.get(key) or {})
merged_llm[key] = {
"prompt_tokens": int(current.get("prompt_tokens") or 0)
+ int(value.get("prompt_tokens") or 0),
"completion_tokens": int(current.get("completion_tokens") or 0)
+ int(value.get("completion_tokens") or 0),
"total_tokens": int(current.get("total_tokens") or 0)
+ int(value.get("total_tokens") or 0),
"cache_read_input_tokens": int(current.get("cache_read_input_tokens") or 0)
+ int(value.get("cache_read_input_tokens") or 0),
"cache_creation_input_tokens": int(
current.get("cache_creation_input_tokens") or 0
)
+ int(value.get("cache_creation_input_tokens") or 0),
}
merged["llm"] = merged_llm
for section in ("tts", "stt"):
merged_section = dict(merged.get(section) or {})
for key, value in (delta.get(section) or {}).items():
merged_section[key] = float(merged_section.get(key) or 0) + float(value)
merged[section] = merged_section
merged["call_duration_seconds"] = int(
merged.get("call_duration_seconds") or 0
) + int(delta.get("call_duration_seconds") or 0)
return merged
def merge_text_chat_usage_info(
existing: dict[str, Any] | None,
delta: dict[str, Any] | None,
) -> dict[str, Any]:
return _merge_usage_info(existing, delta)
def _resolve_checkpoint_for_pending_turn(
session_data: dict[str, Any],
checkpoint: dict[str, Any] | None,
) -> dict[str, Any]:
turns = list(session_data.get("turns") or [])
if not turns:
return normalize_text_chat_checkpoint(checkpoint)
pending_turn = turns[-1]
if pending_turn.get("status") != "pending":
return normalize_text_chat_checkpoint(checkpoint)
for turn in reversed(turns[:-1]):
if turn.get("status") != "completed":
continue
stored_checkpoint = turn.get("checkpoint_after_turn")
if stored_checkpoint:
return normalize_text_chat_checkpoint(stored_checkpoint)
break
return normalize_text_chat_checkpoint(checkpoint)
async def _wait_for_quiescence(
*,
capture_processor: _TextChatCaptureProcessor,
response_window: _ResponseWindowState,
runner_task: asyncio.Task,
activity_marker: int,
timeout_seconds: float = TEXT_CHAT_TURN_TIMEOUT_SECONDS,
) -> None:
loop = asyncio.get_running_loop()
deadline = loop.time() + timeout_seconds
while loop.time() < deadline:
if runner_task.done():
await runner_task
return
if (
capture_processor.activity_count <= activity_marker
and response_window.frontier_is_idle
):
await asyncio.sleep(0.05)
continue
if (
response_window.frontier_is_idle
and (time.monotonic() - capture_processor.last_activity_at)
>= TEXT_CHAT_IDLE_SETTLE_SECONDS
):
return
await asyncio.sleep(0.05)
raise TimeoutError(
"Timed out waiting for text chat response window to settle "
f"(pending_context_requests={response_window.pending_context_requests}, "
f"active_llm_completions={response_window.active_llm_completions}, "
f"active_assistant_segments={response_window.active_assistant_segments}, "
f"blocking_tool_calls={sorted(response_window.blocking_tool_call_ids)})"
)
async def execute_text_chat_pending_turn(
*,
workflow_run_id: int,
workflow_id: int,
session_data: dict[str, Any],
checkpoint: dict[str, Any] | None,
) -> TextChatTurnExecutionResult:
turns = list(session_data.get("turns") or [])
if not turns or turns[-1].get("status") != "pending":
raise ValueError("Text chat session has no pending turn to execute")
pending_turn = turns[-1]
pending_user_message = (
((pending_turn.get("user_message") or {}).get("text") or "").strip()
if pending_turn.get("user_message") is not None
else None
)
workflow_run, _ = await db_client.get_workflow_run_with_context(workflow_run_id)
if not workflow_run or workflow_run.workflow_id != workflow_id:
raise ValueError("Workflow run not found for text chat execution")
if workflow_run.definition is None:
raise ValueError("Workflow run is missing a pinned definition")
if workflow_run.workflow is None or workflow_run.workflow.user is None:
raise ValueError("Workflow run is missing workflow context")
workflow = await db_client.get_workflow(
workflow_id, organization_id=workflow_run.workflow.organization_id
)
if workflow is None:
raise ValueError("Workflow not found for text chat execution")
# Stamp the async context so OTEL spans are tagged with this org and routed
# to its Langfuse project (the voice paths do this in run_pipeline /
# webrtc_signaling; the text path previously skipped it, so its spans never
# reached org-specific exporters).
set_current_org_id(workflow.organization_id)
run_definition = workflow_run.definition
run_configs = run_definition.workflow_configurations or {}
user_config = await db_client.get_user_configurations(workflow_run.workflow.user.id)
user_config = resolve_effective_config(
user_config, run_configs.get("model_overrides")
)
if user_config.llm is None:
raise ValueError("Text chat requires an LLM configuration")
llm = create_llm_service(user_config)
inference_llm = llm
runtime_configuration = {
"llm_provider": user_config.llm.provider,
"llm_model": user_config.llm.model,
}
initial_context = {
**(workflow_run.initial_context or {}),
"runtime_configuration": runtime_configuration,
}
workflow_graph = WorkflowGraph(
ReactFlowDTO.model_validate(run_definition.workflow_json)
)
base_checkpoint = _resolve_checkpoint_for_pending_turn(session_data, checkpoint)
response_window = _ResponseWindowState()
capture_processor = _TextChatCaptureProcessor(response_window)
context = LLMContext()
context.set_messages(base_checkpoint["messages"])
node_transition_events = capture_processor.events
async def send_node_transition(
node_id: str,
node_name: str,
previous_node_id: str | None,
previous_node_name: str | None,
allow_interrupt: bool = False,
) -> None:
node_transition_events.append(
{
"type": "node_transition",
"created_at": datetime.now(UTC).isoformat(),
"payload": {
"node_id": node_id,
"node_name": node_name,
"previous_node_id": previous_node_id,
"previous_node_name": previous_node_name,
"allow_interrupt": allow_interrupt,
},
}
)
embeddings_api_key = None
embeddings_model = None
embeddings_base_url = None
if user_config.embeddings:
embeddings_api_key = user_config.embeddings.api_key
embeddings_model = user_config.embeddings.model
embeddings_base_url = getattr(user_config.embeddings, "base_url", None)
has_recordings = await db_client.has_active_recordings(workflow.organization_id)
context_compaction_enabled = (workflow.workflow_configurations or {}).get(
"context_compaction_enabled", False
)
engine = PipecatEngine(
llm=llm,
inference_llm=inference_llm,
context=context,
workflow=workflow_graph,
call_context_vars=initial_context,
workflow_run_id=workflow_run_id,
node_transition_callback=send_node_transition,
embeddings_api_key=embeddings_api_key,
embeddings_model=embeddings_model,
embeddings_base_url=embeddings_base_url,
has_recordings=has_recordings,
context_compaction_enabled=context_compaction_enabled,
)
engine._gathered_context = dict(base_checkpoint["gathered_context"])
assistant_params = LLMAssistantAggregatorParams()
context_aggregator = LLMContextAggregatorPair(
context, assistant_params=assistant_params
)
assistant_context_aggregator = context_aggregator.assistant()
@assistant_context_aggregator.event_handler("on_assistant_turn_started")
async def on_assistant_turn_started(_aggregator):
response_window.note_assistant_turn_started()
@assistant_context_aggregator.event_handler("on_assistant_turn_stopped")
async def on_assistant_turn_stopped(_aggregator, message):
response_window.note_assistant_turn_stopped(message.content or "")
# Text chat has no wire transport; reuse the neutral 16 kHz config shape
# from the browser pipeline so TTS/recording helpers still have sane defaults.
audio_config = create_audio_config(WorkflowRunMode.SMALLWEBRTC.value)
pipeline_metrics_aggregator = PipelineMetricsAggregator()
# Stitch every per-turn pipeline of this session into one Langfuse trace by
# handing each task the same remote parent context (derived from the run id).
trace_id = text_chat_trace_id(workflow_run_id)
conversation_parent_context = build_remote_parent_context(trace_id)
# The stitched trace has no real root span (each per-turn conversation span
# hangs off a synthetic remote parent), so Langfuse can't infer a name and
# shows "Unnamed trace". Name it explicitly via the conversation span.
trace_span_attributes = {
"langfuse.trace.name": workflow_run.name or f"text-chat-{workflow_run_id}"
}
pipeline = Pipeline(
[
llm,
capture_processor,
assistant_context_aggregator,
pipeline_metrics_aggregator,
]
)
task = create_pipeline_task(
pipeline,
workflow_run_id,
audio_config,
conversation_parent_context=conversation_parent_context,
conversation_type="text",
additional_span_attributes=trace_span_attributes,
)
runner = PipelineRunner(handle_sigint=False, handle_sigterm=False)
runner_task = asyncio.create_task(runner.run(task))
engine.set_task(task)
engine.set_audio_config(audio_config)
engine.set_transport_output(_TaskQueueProxy(task.queue_frame))
engine.set_fetch_recording_audio(
create_recording_audio_fetcher(
organization_id=workflow.organization_id,
pipeline_sample_rate=audio_config.pipeline_sample_rate,
)
)
try:
await asyncio.wait_for(task._pipeline_start_event.wait(), timeout=5.0)
await engine.initialize()
current_node_id = base_checkpoint.get("current_node_id")
target_node_id = current_node_id or workflow_graph.start_node_id
await engine.set_node(
target_node_id,
emit_transition_event=current_node_id is None,
)
opening_marker = capture_processor.activity_count
opening_expects_llm = pending_user_message is None and (
current_node_id == target_node_id
or engine.get_node_greeting(target_node_id) is None
)
if opening_expects_llm:
response_window.note_direct_context_request()
opening_action = await engine.queue_node_opening(
node_id=target_node_id,
previous_node_id=current_node_id,
generate_if_no_greeting=pending_user_message is None,
)
if opening_action != "llm" and opening_expects_llm:
response_window.pending_context_requests = max(
0, response_window.pending_context_requests - 1
)
if opening_action != "none":
await _wait_for_quiescence(
capture_processor=capture_processor,
response_window=response_window,
runner_task=runner_task,
activity_marker=opening_marker,
)
if pending_user_message is not None:
context.add_message({"role": "user", "content": pending_user_message})
generation_marker = capture_processor.activity_count
response_window.note_direct_context_request()
await llm.queue_frame(LLMContextFrame(context))
await _wait_for_quiescence(
capture_processor=capture_processor,
response_window=response_window,
runner_task=runner_task,
activity_marker=generation_marker,
)
finally:
if not task.has_finished():
await task.cancel(reason=TEXT_CHAT_INTERNAL_CANCEL_REASON)
try:
await runner_task
except Exception:
logger.exception(
"Transportless text chat pipeline failed while closing run {}",
workflow_run_id,
)
await engine.cleanup()
raise
await engine.cleanup()
gathered_context = await engine.get_gathered_context()
assistant_text = (
"\n\n".join(part for part in response_window.outputs if part).strip()
if response_window.outputs
else None
)
assistant_created_at = datetime.now(UTC).isoformat()
usage = pipeline_metrics_aggregator.get_all_usage_metrics_serialized()
current_node = getattr(engine, "_current_node", None)
updated_checkpoint = {
"version": TEXT_CHAT_CHECKPOINT_VERSION,
"anchor_turn_id": pending_turn.get("id"),
"current_node_id": current_node.id if current_node else None,
"messages": jsonable_encoder(context.get_messages()),
"gathered_context": jsonable_encoder(gathered_context),
"tool_state": jsonable_encoder(base_checkpoint.get("tool_state") or {}),
}
encoded_gathered_context = jsonable_encoder(gathered_context)
trace_url = get_trace_url(trace_id, org_id=workflow.organization_id)
if trace_url:
encoded_gathered_context = {**encoded_gathered_context, "trace_url": trace_url}
return TextChatTurnExecutionResult(
assistant_text=assistant_text,
assistant_created_at=assistant_created_at,
events=jsonable_encoder(capture_processor.events),
usage=jsonable_encoder(usage),
checkpoint=updated_checkpoint,
gathered_context=encoded_gathered_context,
initial_context=jsonable_encoder(initial_context),
state=(
WorkflowRunState.COMPLETED.value
if engine.is_call_disposed()
else WorkflowRunState.RUNNING.value
),
is_completed=engine.is_call_disposed(),
)

View file

@ -0,0 +1,411 @@
"""Service helpers for text-chat session lifecycle orchestration."""
from datetime import UTC, datetime
from typing import Any
from uuid import uuid4
from loguru import logger
from api.db import db_client
from api.db.models import WorkflowRunTextSessionModel
from api.db.workflow_run_text_session_client import (
WorkflowRunTextSessionRevisionConflictError,
)
from api.services.pricing.workflow_run_cost import (
apply_usage_delta_to_organization,
build_workflow_run_cost_info,
)
from api.services.workflow.text_chat_logs import (
build_text_chat_realtime_feedback_events,
)
from api.services.workflow.text_chat_runner import (
default_text_chat_checkpoint,
execute_text_chat_pending_turn,
merge_text_chat_usage_info,
normalize_text_chat_checkpoint,
)
TEXT_CHAT_SESSION_VERSION = 1
class TextChatSessionRevisionConflictError(Exception):
def __init__(self, expected_revision: int, actual_revision: int):
self.expected_revision = expected_revision
self.actual_revision = actual_revision
super().__init__(
"Text chat session revision conflict: "
f"expected {expected_revision}, found {actual_revision}"
)
class TextChatSessionExecutionError(Exception):
"""Raised when the assistant turn fails to execute."""
class TextChatPendingTurnLostError(Exception):
"""Raised when the pending turn disappears before persistence completes."""
class TextChatTurnNotFoundError(Exception):
"""Raised when a requested rewind cursor does not exist in the session."""
def default_text_chat_session_data() -> dict[str, Any]:
return {
"version": TEXT_CHAT_SESSION_VERSION,
"status": "idle",
"cursor_turn_id": None,
"turns": [],
"discarded_future": [],
"simulator": {
"enabled": False,
"config": {},
},
}
def normalize_text_chat_session_data(
session_data: dict[str, Any] | None,
) -> dict[str, Any]:
normalized = {
**default_text_chat_session_data(),
**(session_data or {}),
}
normalized["turns"] = list(normalized.get("turns") or [])
normalized["discarded_future"] = list(normalized.get("discarded_future") or [])
simulator = normalized.get("simulator") or {}
normalized["simulator"] = {
"enabled": bool(simulator.get("enabled", False)),
"config": dict(simulator.get("config") or {}),
}
return normalized
async def initialize_text_chat_session(
*,
run_id: int,
text_session: WorkflowRunTextSessionModel,
) -> WorkflowRunTextSessionModel:
session_data = normalize_text_chat_session_data(text_session.session_data)
checkpoint = normalize_text_chat_checkpoint(text_session.checkpoint)
session_data["turns"] = [build_pending_text_chat_turn(user_text=None)]
session_data["status"] = "pending_assistant_turn"
checkpoint["anchor_turn_id"] = latest_completed_text_chat_turn_id(
session_data["turns"]
)
try:
await db_client.update_workflow_run_text_session(
run_id,
session_data=session_data,
checkpoint=checkpoint,
expected_revision=text_session.revision,
)
except WorkflowRunTextSessionRevisionConflictError as e:
raise TextChatSessionRevisionConflictError(
expected_revision=e.expected_revision,
actual_revision=e.actual_revision,
) from e
return await _reload_text_chat_session(run_id)
async def append_text_chat_user_message(
*,
run_id: int,
text_session: WorkflowRunTextSessionModel,
user_text: str,
expected_revision: int | None,
) -> WorkflowRunTextSessionModel:
session_data = normalize_text_chat_session_data(text_session.session_data)
checkpoint = normalize_text_chat_checkpoint(text_session.checkpoint)
active_turns, discarded_future = truncate_text_chat_future_turns(session_data)
active_turns.append(build_pending_text_chat_turn(user_text=user_text))
session_data["turns"] = active_turns
session_data["discarded_future"] = discarded_future
session_data["cursor_turn_id"] = None
session_data["status"] = "pending_assistant_turn"
checkpoint["anchor_turn_id"] = latest_completed_text_chat_turn_id(active_turns)
try:
await db_client.update_workflow_run_text_session(
run_id,
session_data=session_data,
checkpoint=checkpoint,
expected_revision=expected_revision,
)
except WorkflowRunTextSessionRevisionConflictError as e:
raise TextChatSessionRevisionConflictError(
expected_revision=e.expected_revision,
actual_revision=e.actual_revision,
) from e
return await _reload_text_chat_session(run_id)
async def rewind_text_chat_session_state(
*,
run_id: int,
text_session: WorkflowRunTextSessionModel,
cursor_turn_id: str | None,
expected_revision: int | None,
) -> WorkflowRunTextSessionModel:
session_data = normalize_text_chat_session_data(text_session.session_data)
validate_text_chat_turn_cursor(session_data, cursor_turn_id)
session_data["cursor_turn_id"] = cursor_turn_id
session_data["status"] = "rewound" if cursor_turn_id else "idle"
try:
await db_client.update_workflow_run_text_session(
run_id,
session_data=session_data,
expected_revision=expected_revision,
)
except WorkflowRunTextSessionRevisionConflictError as e:
raise TextChatSessionRevisionConflictError(
expected_revision=e.expected_revision,
actual_revision=e.actual_revision,
) from e
await db_client.update_workflow_run(
run_id,
logs={
"realtime_feedback_events": build_text_chat_realtime_feedback_events(
session_data
)
},
)
return await _reload_text_chat_session(run_id)
async def execute_pending_text_chat_turn(
*,
workflow_id: int,
run_id: int,
text_session: WorkflowRunTextSessionModel,
) -> WorkflowRunTextSessionModel:
"""Execute the current pending assistant turn and persist its side effects."""
session_data = normalize_text_chat_session_data(text_session.session_data)
checkpoint = normalize_text_chat_checkpoint(text_session.checkpoint)
try:
execution = await execute_text_chat_pending_turn(
workflow_run_id=run_id,
workflow_id=workflow_id,
session_data=session_data,
checkpoint=checkpoint,
)
except Exception as e:
await _mark_pending_turn_failed(
run_id=run_id,
text_session=text_session,
error_message=str(e),
)
raise TextChatSessionExecutionError(
"Failed to execute text chat assistant turn"
) from e
completed_session_data = normalize_text_chat_session_data(text_session.session_data)
completed_turns = list(completed_session_data.get("turns") or [])
if not completed_turns or completed_turns[-1].get("status") != "pending":
raise TextChatPendingTurnLostError(
"Text chat session lost its pending turn before completion"
)
completed_turns[-1]["status"] = "completed"
completed_turns[-1]["assistant_message"] = (
{
"text": execution.assistant_text,
"created_at": execution.assistant_created_at,
}
if execution.assistant_text
else None
)
completed_turns[-1]["events"] = execution.events
completed_turns[-1]["usage"] = execution.usage
completed_turns[-1]["checkpoint_after_turn"] = execution.checkpoint
completed_session_data["turns"] = completed_turns
completed_session_data["status"] = "idle"
try:
await db_client.update_workflow_run_text_session(
run_id,
session_data=completed_session_data,
checkpoint=execution.checkpoint,
expected_revision=text_session.revision,
)
except WorkflowRunTextSessionRevisionConflictError as e:
raise TextChatSessionRevisionConflictError(
expected_revision=e.expected_revision,
actual_revision=e.actual_revision,
) from e
existing_usage_info = text_session.workflow_run.usage_info or {}
merged_usage_info = merge_text_chat_usage_info(existing_usage_info, execution.usage)
text_chat_logs = {
"realtime_feedback_events": build_text_chat_realtime_feedback_events(
completed_session_data
)
}
await db_client.update_workflow_run(
run_id,
initial_context=execution.initial_context,
usage_info=merged_usage_info,
gathered_context=execution.gathered_context,
logs=text_chat_logs,
state=execution.state,
is_completed=execution.is_completed,
)
workflow_run = await db_client.get_workflow_run_by_id(run_id)
if workflow_run:
try:
# Apply the per-turn delta so org usage tracks cumulative run cost
# without replaying the full session totals on every turn.
await apply_usage_delta_to_organization(workflow_run, execution.usage)
except Exception as e:
logger.error(
f"Failed to update organization usage for text chat run {run_id}: {e}"
)
cost_info = await build_workflow_run_cost_info(workflow_run)
if cost_info is not None:
await db_client.update_workflow_run(run_id, cost_info=cost_info)
return await _reload_text_chat_session(run_id)
def validate_text_chat_turn_cursor(
session_data: dict[str, Any],
cursor_turn_id: str | None,
) -> None:
if cursor_turn_id is None:
return
if not any(turn.get("id") == cursor_turn_id for turn in session_data["turns"]):
raise TextChatTurnNotFoundError("Turn not found in text chat session")
def truncate_text_chat_future_turns(
session_data: dict[str, Any],
) -> tuple[list[dict[str, Any]], list[dict[str, Any]]]:
cursor_turn_id = session_data.get("cursor_turn_id")
turns = list(session_data.get("turns") or [])
discarded_future = list(session_data.get("discarded_future") or [])
if cursor_turn_id is None:
return turns, discarded_future
for index, turn in enumerate(turns):
if turn.get("id") == cursor_turn_id:
active_turns = turns[: index + 1]
future_turns = turns[index + 1 :]
if future_turns:
discarded_future.append(
{
"rewound_from_turn_id": cursor_turn_id,
"discarded_at": datetime.now(UTC).isoformat(),
"turns": future_turns,
}
)
return active_turns, discarded_future
raise TextChatTurnNotFoundError("Turn not found in text chat session")
def latest_completed_text_chat_turn_id(turns: list[dict[str, Any]]) -> str | None:
for turn in reversed(turns):
if turn.get("status") == "completed":
return turn.get("id")
return None
def build_pending_text_chat_turn(*, user_text: str | None) -> dict[str, Any]:
now = datetime.now(UTC).isoformat()
return {
"id": f"turn_{uuid4().hex[:12]}",
"status": "pending",
"created_at": now,
"user_message": (
{
"text": user_text,
"created_at": now,
}
if user_text is not None
else None
),
"assistant_message": None,
"events": [],
"usage": {},
}
async def _mark_pending_turn_failed(
*,
run_id: int,
text_session: WorkflowRunTextSessionModel,
error_message: str,
) -> None:
failed_session_data = normalize_text_chat_session_data(text_session.session_data)
failed_turns = list(failed_session_data.get("turns") or [])
if not failed_turns or failed_turns[-1].get("status") != "pending":
return
failed_turns[-1]["status"] = "failed"
failed_turns[-1]["events"] = [
*(failed_turns[-1].get("events") or []),
{
"type": "execution_error",
"created_at": datetime.now(UTC).isoformat(),
"payload": {"message": error_message},
},
]
failed_session_data["turns"] = failed_turns
failed_session_data["status"] = "error"
try:
await db_client.update_workflow_run_text_session(
run_id,
session_data=failed_session_data,
expected_revision=text_session.revision,
)
except WorkflowRunTextSessionRevisionConflictError:
return
async def _reload_text_chat_session(run_id: int) -> WorkflowRunTextSessionModel:
organization_id = await db_client.get_organization_id_by_workflow_run_id(run_id)
if organization_id is None:
raise TextChatSessionExecutionError(
"Workflow run organization not found after update"
)
updated_text_session = await db_client.get_workflow_run_text_session(
run_id,
organization_id=organization_id,
)
if updated_text_session is None:
raise TextChatSessionExecutionError("Text chat session not found after update")
return updated_text_session
__all__ = [
"TEXT_CHAT_SESSION_VERSION",
"TextChatTurnNotFoundError",
"append_text_chat_user_message",
"build_pending_text_chat_turn",
"TextChatPendingTurnLostError",
"TextChatSessionExecutionError",
"TextChatSessionRevisionConflictError",
"default_text_chat_checkpoint",
"default_text_chat_session_data",
"execute_pending_text_chat_turn",
"initialize_text_chat_session",
"latest_completed_text_chat_turn_id",
"normalize_text_chat_checkpoint",
"normalize_text_chat_session_data",
"rewind_text_chat_session_state",
"truncate_text_chat_future_turns",
"validate_text_chat_turn_cursor",
]

View file

@ -167,9 +167,7 @@ class TestIsLocalOrCgnatIp:
class TestKeepCandidate:
def test_private_relay_candidate_survives_private_policy(self):
candidate = (
"candidate:111 1 udp 41885439 192.168.1.50 50000 typ relay raddr 0.0.0.0 rport 0"
)
candidate = "candidate:111 1 udp 41885439 192.168.1.50 50000 typ relay raddr 0.0.0.0 rport 0"
assert _keep_candidate(candidate, NonRelayFilterPolicy.PRIVATE) is True
def test_private_host_candidate_drops_under_private_policy(self):

View file

@ -0,0 +1,53 @@
from api.services.pipecat.realtime_feedback_events import (
build_bot_text_event,
build_function_call_end_event,
build_node_transition_event,
realtime_feedback_event_sort_key,
stamp_realtime_feedback_event,
)
def test_build_function_call_end_event_serializes_results():
event = build_function_call_end_event(
function_name="lookup_contact",
tool_call_id="tool-1",
result={"contact_id": 42},
)
assert event == {
"type": "rtf-function-call-end",
"payload": {
"function_name": "lookup_contact",
"tool_call_id": "tool-1",
"result": "{'contact_id': 42}",
},
}
def test_stamp_and_sort_realtime_feedback_events():
node_transition = stamp_realtime_feedback_event(
build_node_transition_event(
node_id="node-1",
node_name="Greeting",
previous_node_id=None,
previous_node_name=None,
),
timestamp="2026-01-01T00:00:03+00:00",
turn=0,
node_id="node-1",
node_name="Greeting",
)
bot_text = stamp_realtime_feedback_event(
build_bot_text_event(
text="Hello there",
timestamp="2026-01-01T00:00:01+00:00",
),
timestamp="2026-01-01T00:00:02+00:00",
turn=0,
)
events = sorted([node_transition, bot_text], key=realtime_feedback_event_sort_key)
assert events == [bot_text, node_transition]
assert node_transition["node_id"] == "node-1"
assert node_transition["node_name"] == "Greeting"

View file

@ -382,6 +382,105 @@ class TestStartGreeting:
result = engine.get_start_greeting()
assert result == ("text", "Hello Alice!")
@pytest.mark.asyncio
async def test_queue_node_opening_queues_text_greeting(
self, text_workflow: WorkflowGraph
):
"""Fresh node entry with a greeting should queue TTS and skip LLM bootstrap."""
llm = Mock()
llm.queue_frame = AsyncMock()
task = Mock()
task.queue_frame = AsyncMock()
engine = PipecatEngine(
llm=llm,
context=LLMContext(),
workflow=text_workflow,
call_context_vars={},
workflow_run_id=1,
)
engine.set_task(task)
result = await engine.queue_node_opening(
node_id=text_workflow.start_node_id,
previous_node_id=None,
generate_if_no_greeting=True,
)
assert result == "greeting"
llm.queue_frame.assert_not_awaited()
queued_frame = task.queue_frame.await_args.args[0]
assert isinstance(queued_frame, TTSSpeakFrame)
assert queued_frame.text == TEXT_GREETING
assert queued_frame.append_to_context is True
@pytest.mark.asyncio
async def test_queue_node_opening_falls_back_to_llm_without_greeting(self):
"""When a node has no greeting, the engine should queue initial LLM generation."""
dto = ReactFlowDTO(
nodes=[
RFNodeDTO(
id="start",
type="startCall",
position=Position(x=0, y=0),
data=StartCallNodeData(
name="Start",
prompt="Prompt",
is_start=True,
add_global_prompt=False,
extraction_enabled=False,
),
),
RFNodeDTO(
id="end",
type="endCall",
position=Position(x=0, y=200),
data=EndCallNodeData(
name="End",
prompt="End",
is_end=True,
add_global_prompt=False,
extraction_enabled=False,
),
),
],
edges=[
RFEdgeDTO(
id="e",
source="start",
target="end",
data=EdgeDataDTO(label="End", condition="End"),
),
],
)
workflow = WorkflowGraph(dto)
context = LLMContext()
llm = Mock()
llm.queue_frame = AsyncMock()
task = Mock()
task.queue_frame = AsyncMock()
engine = PipecatEngine(
llm=llm,
context=context,
workflow=workflow,
call_context_vars={},
workflow_run_id=1,
)
engine.set_task(task)
result = await engine.queue_node_opening(
node_id=workflow.start_node_id,
previous_node_id=None,
generate_if_no_greeting=True,
)
assert result == "llm"
task.queue_frame.assert_not_awaited()
queued_frame = llm.queue_frame.await_args.args[0]
assert isinstance(queued_frame, LLMContextFrame)
assert queued_frame.context is context
# ─── Tests: Transition Speech (Pipeline) ────────────────────────

View file

@ -0,0 +1,126 @@
from api.services.workflow.text_chat_logs import (
build_text_chat_realtime_feedback_events,
visible_text_chat_turns,
)
def test_visible_text_chat_turns_trims_to_cursor_branch():
session_data = {
"cursor_turn_id": "turn-2",
"turns": [
{"id": "turn-1"},
{"id": "turn-2"},
{"id": "turn-3"},
],
}
assert visible_text_chat_turns(session_data) == [
{"id": "turn-1"},
{"id": "turn-2"},
]
def test_build_text_chat_realtime_feedback_events_uses_visible_branch_and_dedupes_node_transitions():
session_data = {
"cursor_turn_id": "turn-2",
"turns": [
{
"id": "turn-1",
"created_at": "2026-01-01T00:00:00+00:00",
"events": [
{
"type": "node_transition",
"created_at": "2026-01-01T00:00:00+00:00",
"payload": {
"node_id": "node-start",
"node_name": "Start",
"previous_node_id": None,
"previous_node_name": None,
"allow_interrupt": False,
},
}
],
"user_message": None,
"assistant_message": {
"text": "Hello",
"created_at": "2026-01-01T00:00:01+00:00",
},
},
{
"id": "turn-2",
"created_at": "2026-01-01T00:00:02+00:00",
"events": [
{
"type": "node_transition",
"created_at": "2026-01-01T00:00:02+00:00",
"payload": {
"node_id": "node-start",
"node_name": "Start",
"previous_node_id": None,
"previous_node_name": None,
"allow_interrupt": False,
},
},
{
"type": "tool_call_started",
"created_at": "2026-01-01T00:00:03+00:00",
"payload": {
"function_name": "lookup_contact",
"tool_call_id": "tool-1",
},
},
{
"type": "tool_call_result",
"created_at": "2026-01-01T00:00:04+00:00",
"payload": {
"function_name": "lookup_contact",
"tool_call_id": "tool-1",
"result": {"contact_id": 42},
},
},
],
"user_message": {
"text": "Find Abhishek",
"created_at": "2026-01-01T00:00:02+00:00",
},
"assistant_message": {
"text": "I found one match.",
"created_at": "2026-01-01T00:00:05+00:00",
},
},
{
"id": "turn-3",
"created_at": "2026-01-01T00:00:06+00:00",
"events": [
{
"type": "execution_error",
"created_at": "2026-01-01T00:00:06+00:00",
"payload": {"message": "Should be hidden after rewind"},
}
],
"user_message": {
"text": "This turn is rewound away",
"created_at": "2026-01-01T00:00:06+00:00",
},
"assistant_message": None,
},
],
}
events = build_text_chat_realtime_feedback_events(session_data)
assert [event["type"] for event in events] == [
"rtf-node-transition",
"rtf-bot-text",
"rtf-user-transcription",
"rtf-function-call-start",
"rtf-function-call-end",
"rtf-bot-text",
]
assert events[0]["payload"]["node_name"] == "Start"
assert events[2]["payload"]["text"] == "Find Abhishek"
assert events[4]["payload"]["result"] == "{'contact_id': 42}"
assert all(
event.get("payload", {}).get("error") != "Should be hidden after rewind"
for event in events
)

View file

@ -0,0 +1,91 @@
from unittest.mock import AsyncMock
import pytest
import api.services.workflow.text_chat_session_service as text_chat_session_service
from api.db.models import WorkflowRunTextSessionModel
from api.services.workflow.text_chat_session_service import (
TextChatSessionExecutionError,
TextChatTurnNotFoundError,
_reload_text_chat_session,
build_pending_text_chat_turn,
truncate_text_chat_future_turns,
validate_text_chat_turn_cursor,
)
def test_build_pending_text_chat_turn_sets_pending_shape():
turn = build_pending_text_chat_turn(user_text="Hello")
assert turn["id"].startswith("turn_")
assert turn["status"] == "pending"
assert turn["user_message"]["text"] == "Hello"
assert turn["assistant_message"] is None
assert turn["events"] == []
assert turn["usage"] == {}
def test_truncate_text_chat_future_turns_moves_rewound_branch_to_discarded_future():
session_data = {
"cursor_turn_id": "turn-2",
"turns": [
{"id": "turn-1"},
{"id": "turn-2"},
{"id": "turn-3"},
],
"discarded_future": [],
}
active_turns, discarded_future = truncate_text_chat_future_turns(session_data)
assert active_turns == [{"id": "turn-1"}, {"id": "turn-2"}]
assert discarded_future[0]["rewound_from_turn_id"] == "turn-2"
assert discarded_future[0]["turns"] == [{"id": "turn-3"}]
def test_validate_text_chat_turn_cursor_raises_for_missing_turn():
with pytest.raises(TextChatTurnNotFoundError):
validate_text_chat_turn_cursor(
{"turns": [{"id": "turn-1"}]},
"turn-404",
)
@pytest.mark.asyncio
async def test_reload_text_chat_session_uses_run_id_to_resolve_organization(
monkeypatch,
):
reloaded_session = WorkflowRunTextSessionModel(workflow_run_id=123)
get_org_id = AsyncMock(return_value=77)
get_text_session = AsyncMock(return_value=reloaded_session)
monkeypatch.setattr(
text_chat_session_service.db_client,
"get_organization_id_by_workflow_run_id",
get_org_id,
)
monkeypatch.setattr(
text_chat_session_service.db_client,
"get_workflow_run_text_session",
get_text_session,
)
result = await _reload_text_chat_session(123)
assert result is reloaded_session
get_org_id.assert_awaited_once_with(123)
get_text_session.assert_awaited_once_with(123, organization_id=77)
@pytest.mark.asyncio
async def test_reload_text_chat_session_raises_when_run_organization_is_missing(
monkeypatch,
):
monkeypatch.setattr(
text_chat_session_service.db_client,
"get_organization_id_by_workflow_run_id",
AsyncMock(return_value=None),
)
with pytest.raises(TextChatSessionExecutionError, match="organization not found"):
await _reload_text_chat_session(123)

View file

@ -0,0 +1,181 @@
from datetime import UTC, datetime
from types import SimpleNamespace
from unittest.mock import AsyncMock
import pytest
from api.services.pricing import workflow_run_cost as workflow_run_cost_mod
from api.services.pricing.workflow_run_cost import (
apply_usage_delta_to_organization,
build_workflow_run_cost_info,
calculate_workflow_run_cost,
)
def _make_workflow_run():
return SimpleNamespace(
id=123,
workflow_id=456,
mode="textchat",
created_at=datetime.now(UTC),
usage_info={
"llm": {},
"tts": {},
"stt": {},
"call_duration_seconds": 7,
},
cost_info={},
workflow=SimpleNamespace(
organization_id=42,
user=SimpleNamespace(selected_organization_id=42),
),
)
@pytest.mark.asyncio
async def test_build_workflow_run_cost_info_does_not_update_org_usage(monkeypatch):
workflow_run = _make_workflow_run()
get_org = AsyncMock(return_value=SimpleNamespace(id=42, price_per_second_usd=1.5))
update_usage = AsyncMock()
monkeypatch.setattr(
workflow_run_cost_mod.db_client, "get_organization_by_id", get_org
)
monkeypatch.setattr(
workflow_run_cost_mod.db_client, "update_usage_after_run", update_usage
)
cost_info = await build_workflow_run_cost_info(workflow_run)
assert cost_info is not None
assert cost_info["call_duration_seconds"] == 7
assert "cost_breakdown" in cost_info
assert "dograh_token_usage" in cost_info
assert cost_info["charge_usd"] == 10.5
update_usage.assert_not_called()
@pytest.mark.asyncio
async def test_calculate_workflow_run_cost_keeps_org_usage_side_effect_in_wrapper(
monkeypatch,
):
workflow_run = _make_workflow_run()
get_org = AsyncMock(return_value=SimpleNamespace(id=42, price_per_second_usd=None))
update_run = AsyncMock()
update_usage = AsyncMock()
monkeypatch.setattr(
workflow_run_cost_mod.db_client,
"get_workflow_run_by_id",
AsyncMock(return_value=workflow_run),
)
monkeypatch.setattr(
workflow_run_cost_mod.db_client, "get_organization_by_id", get_org
)
monkeypatch.setattr(
workflow_run_cost_mod.db_client, "update_workflow_run", update_run
)
monkeypatch.setattr(
workflow_run_cost_mod.db_client, "update_usage_after_run", update_usage
)
await calculate_workflow_run_cost(workflow_run.id)
update_run.assert_awaited_once()
saved_kwargs = update_run.await_args.kwargs
assert saved_kwargs["run_id"] == workflow_run.id
assert "cost_breakdown" in saved_kwargs["cost_info"]
update_usage.assert_awaited_once()
@pytest.mark.asyncio
async def test_apply_usage_delta_to_organization_uses_incremental_costs(
monkeypatch,
):
workflow_run = _make_workflow_run()
workflow_run.cost_info = {"call_id": "preserve-me"}
usage_delta_one = {
"llm": {
"OpenAILLMService#0|||gpt-4.1-mini": {
"prompt_tokens": 1_000,
"completion_tokens": 100,
"total_tokens": 1_100,
"cache_read_input_tokens": 0,
"cache_creation_input_tokens": 0,
}
},
"tts": {},
"stt": {},
"call_duration_seconds": 3,
}
usage_delta_two = {
"llm": {
"OpenAILLMService#0|||gpt-4.1-mini": {
"prompt_tokens": 2_000,
"completion_tokens": 50,
"total_tokens": 2_050,
"cache_read_input_tokens": 0,
"cache_creation_input_tokens": 0,
}
},
"tts": {},
"stt": {},
"call_duration_seconds": 4,
}
merged_usage = {
"llm": {
"OpenAILLMService#0|||gpt-4.1-mini": {
"prompt_tokens": 3_000,
"completion_tokens": 150,
"total_tokens": 3_150,
"cache_read_input_tokens": 0,
"cache_creation_input_tokens": 0,
}
},
"tts": {},
"stt": {},
"call_duration_seconds": 7,
}
get_org = AsyncMock(return_value=SimpleNamespace(id=42, price_per_second_usd=1.5))
update_usage = AsyncMock()
monkeypatch.setattr(
workflow_run_cost_mod.db_client, "get_organization_by_id", get_org
)
monkeypatch.setattr(
workflow_run_cost_mod.db_client, "update_usage_after_run", update_usage
)
first_delta = await apply_usage_delta_to_organization(workflow_run, usage_delta_one)
second_delta = await apply_usage_delta_to_organization(
workflow_run, usage_delta_two
)
total_workflow_run = SimpleNamespace(**workflow_run.__dict__)
total_workflow_run.usage_info = merged_usage
total_cost = await build_workflow_run_cost_info(total_workflow_run)
assert first_delta is not None
assert second_delta is not None
assert total_cost is not None
assert update_usage.await_count == 2
assert update_usage.await_args_list[0].args == (
42,
first_delta["dograh_token_usage"],
3.0,
first_delta["charge_usd"],
)
assert update_usage.await_args_list[1].args == (
42,
second_delta["dograh_token_usage"],
4.0,
second_delta["charge_usd"],
)
assert (
first_delta["dograh_token_usage"] + second_delta["dograh_token_usage"]
) == pytest.approx(total_cost["dograh_token_usage"])
assert (
first_delta["charge_usd"] + second_delta["charge_usd"]
== total_cost["charge_usd"]
)

File diff suppressed because it is too large Load diff

File diff suppressed because one or more lines are too long

@ -1 +1 @@
Subproject commit 6b4474c1b870eae5e42ef7c5eec1b7f37fcecc61
Subproject commit d1e23ca521f5412a9dc09430ada730500e15a7ab

View file

@ -1,6 +1,6 @@
# generated by datamodel-codegen:
# filename: dograh-openapi-XXXXXX.json.bGP2QR1Vrx
# timestamp: 2026-05-20T08:41:57+00:00
# filename: dograh-openapi-XXXXXX.json.4p15PiTCyh
# timestamp: 2026-05-21T07:00:16+00:00
from __future__ import annotations

122
ui/package-lock.json generated
View file

@ -26,7 +26,7 @@
"@radix-ui/react-tooltip": "^1.2.8",
"@sentry/nextjs": "^9.28.1",
"@stackframe/stack": "^2.8.80",
"@xyflow/react": "^12.9.2",
"@xyflow/react": "^12.10.2",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"date-fns": "^4.1.0",
@ -11227,12 +11227,12 @@
"peer": true
},
"node_modules/@xyflow/react": {
"version": "12.9.2",
"resolved": "https://registry.npmjs.org/@xyflow/react/-/react-12.9.2.tgz",
"integrity": "sha512-Xr+LFcysHCCoc5KRHaw+FwbqbWYxp9tWtk1mshNcqy25OAPuaKzXSdqIMNOA82TIXF/gFKo0Wgpa6PU7wUUVqw==",
"version": "12.10.2",
"resolved": "https://registry.npmjs.org/@xyflow/react/-/react-12.10.2.tgz",
"integrity": "sha512-CgIi6HwlcHXwlkTpr0fxLv/0sRVNZ8IdwKLzzeCscaYBwpvfcH1QFOCeaTCuEn1FQEs/B8CjnTSjhs8udgmBgQ==",
"license": "MIT",
"dependencies": {
"@xyflow/system": "0.0.72",
"@xyflow/system": "0.0.76",
"classcat": "^5.0.3",
"zustand": "^4.4.0"
},
@ -11270,9 +11270,9 @@
}
},
"node_modules/@xyflow/system": {
"version": "0.0.72",
"resolved": "https://registry.npmjs.org/@xyflow/system/-/system-0.0.72.tgz",
"integrity": "sha512-WBI5Aau0fXTXwxHPzceLNS6QdXggSWnGjDtj/gG669crApN8+SCmEtkBth1m7r6pStNo/5fI9McEi7Dk0ymCLA==",
"version": "0.0.76",
"resolved": "https://registry.npmjs.org/@xyflow/system/-/system-0.0.76.tgz",
"integrity": "sha512-hvwvnRS1B3REwVDlWexsq7YQaPZeG3/mKo1jv38UmnpWmxihp14bW6VtEOuHEwJX2FvzFw8k77LyKSk/wiZVNA==",
"license": "MIT",
"dependencies": {
"@types/d3-drag": "^3.0.7",
@ -11661,12 +11661,6 @@
"tslib": "^2.4.0"
}
},
"node_modules/asynckit": {
"version": "0.4.0",
"resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz",
"integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==",
"license": "MIT"
},
"node_modules/atomic-sleep": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/atomic-sleep/-/atomic-sleep-1.0.0.tgz",
@ -11702,17 +11696,6 @@
"node": ">=4"
}
},
"node_modules/axios": {
"version": "1.13.5",
"resolved": "https://registry.npmjs.org/axios/-/axios-1.13.5.tgz",
"integrity": "sha512-cz4ur7Vb0xS4/KUN0tPWe44eqxrIu31me+fbang3ijiNscE129POzipJJA6zniq2C/Z6sJCjMimjS8Lc/GAs8Q==",
"license": "MIT",
"dependencies": {
"follow-redirects": "^1.15.11",
"form-data": "^4.0.5",
"proxy-from-env": "^1.1.0"
}
},
"node_modules/axobject-query": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/axobject-query/-/axobject-query-4.1.0.tgz",
@ -11957,6 +11940,7 @@
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz",
"integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"es-errors": "^1.3.0",
@ -12243,18 +12227,6 @@
"integrity": "sha512-IfEDxwoWIjkeXL1eXcDiow4UbKjhLdq6/EuSVR9GMN7KVH3r9gQ83e73hsz1Nd1T3ijd5xv1wcWRYO+D6kCI2w==",
"license": "MIT"
},
"node_modules/combined-stream": {
"version": "1.0.8",
"resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz",
"integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==",
"license": "MIT",
"dependencies": {
"delayed-stream": "~1.0.0"
},
"engines": {
"node": ">= 0.8"
}
},
"node_modules/commander": {
"version": "14.0.3",
"resolved": "https://registry.npmjs.org/commander/-/commander-14.0.3.tgz",
@ -12797,15 +12769,6 @@
"dev": true,
"license": "MIT"
},
"node_modules/delayed-stream": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz",
"integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==",
"license": "MIT",
"engines": {
"node": ">=0.4.0"
}
},
"node_modules/destr": {
"version": "2.0.5",
"resolved": "https://registry.npmjs.org/destr/-/destr-2.0.5.tgz",
@ -12875,6 +12838,7 @@
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
"integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==",
"dev": true,
"license": "MIT",
"dependencies": {
"call-bind-apply-helpers": "^1.0.1",
@ -13022,6 +12986,7 @@
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz",
"integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">= 0.4"
@ -13031,6 +12996,7 @@
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz",
"integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">= 0.4"
@ -13075,6 +13041,7 @@
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz",
"integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==",
"dev": true,
"license": "MIT",
"dependencies": {
"es-errors": "^1.3.0"
@ -13087,6 +13054,7 @@
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz",
"integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==",
"dev": true,
"license": "MIT",
"dependencies": {
"es-errors": "^1.3.0",
@ -13908,26 +13876,6 @@
"dev": true,
"license": "ISC"
},
"node_modules/follow-redirects": {
"version": "1.15.11",
"resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.11.tgz",
"integrity": "sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ==",
"funding": [
{
"type": "individual",
"url": "https://github.com/sponsors/RubenVerborgh"
}
],
"license": "MIT",
"engines": {
"node": ">=4.0"
},
"peerDependenciesMeta": {
"debug": {
"optional": true
}
}
},
"node_modules/for-each": {
"version": "0.3.5",
"resolved": "https://registry.npmjs.org/for-each/-/for-each-0.3.5.tgz",
@ -13944,22 +13892,6 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/form-data": {
"version": "4.0.5",
"resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz",
"integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==",
"license": "MIT",
"dependencies": {
"asynckit": "^0.4.0",
"combined-stream": "^1.0.8",
"es-set-tostringtag": "^2.1.0",
"hasown": "^2.0.2",
"mime-types": "^2.1.12"
},
"engines": {
"node": ">= 6"
}
},
"node_modules/forwarded-parse": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/forwarded-parse/-/forwarded-parse-2.1.2.tgz",
@ -14048,6 +13980,7 @@
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz",
"integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"call-bind-apply-helpers": "^1.0.2",
@ -14081,6 +14014,7 @@
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz",
"integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==",
"dev": true,
"license": "MIT",
"dependencies": {
"dunder-proto": "^1.0.1",
@ -14192,6 +14126,7 @@
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz",
"integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">= 0.4"
@ -14268,6 +14203,7 @@
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz",
"integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">= 0.4"
@ -14280,6 +14216,7 @@
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz",
"integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==",
"dev": true,
"license": "MIT",
"dependencies": {
"has-symbols": "^1.0.3"
@ -15084,12 +15021,6 @@
"license": "MIT",
"peer": true
},
"node_modules/json-schema": {
"version": "0.4.0",
"resolved": "https://registry.npmjs.org/json-schema/-/json-schema-0.4.0.tgz",
"integrity": "sha512-es94M3nTIfsEPisRafak+HDLfHXnKBhV3vU5eqPcS3flIWqcxJWgXHXiey3YrpaNsanY5ei1VoYEbOzijuq9BA==",
"license": "(AFL-2.1 OR BSD-3-Clause)"
},
"node_modules/json-schema-traverse": {
"version": "0.4.1",
"resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz",
@ -15499,6 +15430,7 @@
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
"integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">= 0.4"
@ -15547,6 +15479,7 @@
"resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz",
"integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">= 0.6"
}
@ -15556,6 +15489,7 @@
"resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz",
"integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==",
"license": "MIT",
"peer": true,
"dependencies": {
"mime-db": "1.52.0"
},
@ -18131,18 +18065,6 @@
"node": ">= 0.8.0"
}
},
"node_modules/type-fest": {
"version": "4.41.0",
"resolved": "https://registry.npmjs.org/type-fest/-/type-fest-4.41.0.tgz",
"integrity": "sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA==",
"license": "(MIT OR CC0-1.0)",
"engines": {
"node": ">=16"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/typed-array-buffer": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/typed-array-buffer/-/typed-array-buffer-1.0.3.tgz",

View file

@ -30,7 +30,7 @@
"@radix-ui/react-tooltip": "^1.2.8",
"@sentry/nextjs": "^9.28.1",
"@stackframe/stack": "^2.8.80",
"@xyflow/react": "^12.9.2",
"@xyflow/react": "^12.10.2",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"date-fns": "^4.1.0",

View file

@ -0,0 +1,77 @@
import { NextResponse } from "next/server";
const GHCR_IMAGES = ["dograh-hq/dograh-ui", "dograh-hq/dograh-api"] as const;
const SEMVER_RE = /^(\d+)\.(\d+)\.(\d+)$/;
const REVALIDATE_SECONDS = 60 * 60;
type Semver = [number, number, number];
function parseSemver(tag: string): Semver | null {
const m = tag.match(SEMVER_RE);
if (!m) return null;
return [Number(m[1]), Number(m[2]), Number(m[3])];
}
function compareSemver(a: Semver, b: Semver): number {
for (let i = 0; i < 3; i++) {
if (a[i] !== b[i]) return a[i] - b[i];
}
return 0;
}
async function fetchLatestTag(image: string): Promise<string | null> {
const tokenRes = await fetch(
`https://ghcr.io/token?scope=repository:${image}:pull&service=ghcr.io`,
{ next: { revalidate: REVALIDATE_SECONDS } },
);
if (!tokenRes.ok) return null;
const { token } = (await tokenRes.json()) as { token?: string };
if (!token) return null;
const tagsRes = await fetch(`https://ghcr.io/v2/${image}/tags/list`, {
headers: { Authorization: `Bearer ${token}` },
next: { revalidate: REVALIDATE_SECONDS },
});
if (!tagsRes.ok) return null;
const { tags } = (await tagsRes.json()) as { tags?: string[] };
let latest: { tag: string; parsed: Semver } | null = null;
for (const tag of tags ?? []) {
const parsed = parseSemver(tag);
if (!parsed) continue;
if (!latest || compareSemver(parsed, latest.parsed) > 0) {
latest = { tag, parsed };
}
}
return latest?.tag ?? null;
}
export async function GET() {
try {
const results = await Promise.all(GHCR_IMAGES.map(fetchLatestTag));
// Only advertise an update once every image has published a tag at that
// version — otherwise we'd nudge users to upgrade before the matching
// container actually exists.
let minLatest: { tag: string; parsed: Semver } | null = null;
for (const tag of results) {
if (!tag) return NextResponse.json({ latest: null }, { status: 200 });
const parsed = parseSemver(tag);
if (!parsed) return NextResponse.json({ latest: null }, { status: 200 });
if (!minLatest || compareSemver(parsed, minLatest.parsed) < 0) {
minLatest = { tag, parsed };
}
}
return NextResponse.json(
{ latest: minLatest?.tag ?? null },
{
headers: {
"Cache-Control": `public, max-age=${REVALIDATE_SECONDS}, s-maxage=${REVALIDATE_SECONDS}`,
},
},
);
} catch {
return NextResponse.json({ latest: null }, { status: 200 });
}
}

View file

@ -135,22 +135,3 @@
.animate-spin-slow {
animation: spin-slow 3s linear infinite;
}
/* Smaller Chatwoot bubble on workflow run pages */
body.chatwoot-compact .woot--bubble-holder {
transform: scale(0.7) !important;
transform-origin: bottom right !important;
right: 4px !important;
bottom: 4px !important;
}
body.chatwoot-compact .woot--bubble-holder .woot-widget-bubble {
width: 48px !important;
height: 48px !important;
}
body.chatwoot-compact #chatwoot_live_chat_widget {
bottom: 60px !important;
right: 4px !important;
}

View file

@ -1,6 +1,6 @@
"use client";
import { ChevronLeft, ChevronRight, Download, Globe } from 'lucide-react';
import { ArrowDownLeft, ArrowUpRight, ChevronLeft, ChevronRight, Download, Globe, MessageSquare, Phone } from 'lucide-react';
import { useRouter, useSearchParams } from 'next/navigation';
import { useCallback, useEffect, useId, useState } from 'react';
import TimezoneSelect, { type ITimezoneOption } from 'react-timezone-select';
@ -23,6 +23,7 @@ import {
TableHeader,
TableRow,
} from '@/components/ui/table';
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
import { useUserConfig } from '@/context/UserConfigContext';
import { useAuth } from '@/lib/auth';
import { usageFilterAttributes } from '@/lib/filterAttributes';
@ -32,6 +33,53 @@ import { ActiveFilter, DateRangeValue } from '@/types/filters';
// Get local timezone
const getLocalTimezone = () => Intl.DateTimeFormat().resolvedOptions().timeZone;
// Collapse a run's `mode` (from WorkflowRunMode in api/enums.py) into a coarse
// channel. Telephony providers (twilio, plivo, telnyx, vonage, vobiz, cloudonix,
// ari, ...) are phone calls; webrtc/smallwebrtc are browser web calls; textchat
// is a text conversation. Anything unknown falls back to "phone".
const WEB_CALL_MODES = new Set(['webrtc', 'smallwebrtc']);
const TEXT_CHAT_MODES = new Set(['textchat']);
const getCallChannel = (mode?: string | null): 'phone' | 'web' | 'chat' => {
if (mode && TEXT_CHAT_MODES.has(mode)) return 'chat';
if (mode && WEB_CALL_MODES.has(mode)) return 'web';
return 'phone';
};
// Render the call's channel (mode) and direction (call_type) as two compact
// icons in a single cell, with a tooltip spelling out the full label. The
// channel icon shows medium/how (phone / web / chat); the colored arrow shows
// direction (inbound = incoming/emerald, outbound = outgoing/blue).
const CallTypeCell = ({ mode, callType }: { mode?: string | null; callType?: string | null }) => {
if (!mode && !callType) {
return <span className="text-sm text-muted-foreground">-</span>;
}
const channel = getCallChannel(mode);
const ChannelIcon = channel === 'chat' ? MessageSquare : channel === 'web' ? Globe : Phone;
const channelLabel = channel === 'chat' ? 'Text chat' : channel === 'web' ? 'Web call' : 'Phone call';
const isInbound = callType === 'inbound';
const DirectionIcon = isInbound ? ArrowDownLeft : ArrowUpRight;
const directionLabel = isInbound ? 'Inbound' : 'Outbound';
return (
<Tooltip>
<TooltipTrigger asChild>
<span className="inline-flex items-center gap-1">
<ChannelIcon className="h-4 w-4 text-muted-foreground" />
<DirectionIcon
className={`h-3.5 w-3.5 ${isInbound ? 'text-emerald-600' : 'text-blue-600'}`}
/>
</span>
</TooltipTrigger>
<TooltipContent sideOffset={4}>
{directionLabel} · {channelLabel}
</TooltipContent>
</Tooltip>
);
};
export default function UsagePage() {
const router = useRouter();
const searchParams = useSearchParams();
@ -534,13 +582,7 @@ export default function UsagePage() {
</TableCell>
<TableCell>{run.workflow_name || 'Unknown'}</TableCell>
<TableCell>
{run.call_type ? (
<Badge variant={run.call_type === 'inbound' ? "secondary" : "default"}>
{run.call_type === 'inbound' ? 'Inbound' : 'Outbound'}
</Badge>
) : (
<span className="text-sm text-muted-foreground">-</span>
)}
<CallTypeCell mode={run.mode} callType={run.call_type} />
</TableCell>
<TableCell className="text-sm">
{(run.call_type === 'inbound'

View file

@ -15,7 +15,9 @@ import type { DocumentResponseSchema, RecordingResponseSchema, ToolResponse } fr
import { useNodeSpecs } from "@/components/flow/renderer";
import { FlowEdge, FlowNode, NodeType } from "@/components/flow/types";
import { Button } from '@/components/ui/button';
import { Sheet, SheetContent } from '@/components/ui/sheet';
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
import { useOnboarding } from '@/context/OnboardingContext';
import { WorkflowConfigurations } from '@/types/workflow-configurations';
import AddNodePanel from "../../../components/flow/AddNodePanel";
@ -23,7 +25,9 @@ import CustomEdge from "../../../components/flow/edges/CustomEdge";
import { GenericNode } from "../../../components/flow/nodes/GenericNode";
import { PhoneCallDialog } from './components/PhoneCallDialog';
import { VersionHistoryPanel, WorkflowVersion } from './components/VersionHistoryPanel';
import type { WorkflowRuntimeFocusMode, WorkflowRuntimeNodeTransition } from './components/workflow-tester/types';
import { WorkflowEditorHeader } from "./components/WorkflowEditorHeader";
import { WorkflowTesterPanel } from './components/WorkflowTesterPanel';
import { WorkflowProvider } from "./contexts/WorkflowContext";
import { useWorkflowState } from "./hooks/useWorkflowState";
import { layoutNodes } from './utils/layoutNodes';
@ -38,6 +42,8 @@ interface RenderWorkflowProps {
initialWorkflowName: string;
workflowId: number;
workflowUuid?: string;
initialTotalRuns?: number | null;
openTesterOnLoad?: boolean;
initialFlow?: {
nodes: FlowNode[];
edges: FlowEdge[];
@ -54,16 +60,33 @@ interface RenderWorkflowProps {
user: { id: string; email?: string };
}
function RenderWorkflow({ initialWorkflowName, workflowId, workflowUuid, initialFlow, initialTemplateContextVariables, initialWorkflowConfigurations, initialVersionNumber, initialVersionStatus, user }: RenderWorkflowProps) {
function RenderWorkflow({
initialWorkflowName,
workflowId,
workflowUuid,
initialTotalRuns,
openTesterOnLoad = false,
initialFlow,
initialTemplateContextVariables,
initialWorkflowConfigurations,
initialVersionNumber,
initialVersionStatus,
user,
}: RenderWorkflowProps) {
const router = useRouter();
const { specs } = useNodeSpecs();
const { hasCompletedAction } = useOnboarding();
const [isPhoneCallDialogOpen, setIsPhoneCallDialogOpen] = useState(false);
const [isVersionPanelOpen, setIsVersionPanelOpen] = useState(false);
const [isTesterRailOpen, setIsTesterRailOpen] = useState(true);
const [isTesterSheetOpen, setIsTesterSheetOpen] = useState(false);
const [isDesktopViewport, setIsDesktopViewport] = useState(false);
const [versions, setVersions] = useState<WorkflowVersion[]>([]);
const [versionsLoading, setVersionsLoading] = useState(false);
const [versionsLoadingMore, setVersionsLoadingMore] = useState(false);
const [versionsHasMore, setVersionsHasMore] = useState(false);
const [activeVersionId, setActiveVersionId] = useState<number | null>(null);
const hasAutoOpenedTester = useRef(false);
// Version info that updates immediately from the GET/save/publish responses.
const [currentVersionNumber, setCurrentVersionNumber] = useState<number | null>(initialVersionNumber ?? null);
const [currentVersionStatus, setCurrentVersionStatus] = useState<string | null>(initialVersionStatus ?? null);
@ -71,6 +94,9 @@ function RenderWorkflow({ initialWorkflowName, workflowId, workflowUuid, initial
const [documents, setDocuments] = useState<DocumentResponseSchema[] | undefined>(undefined);
const [tools, setTools] = useState<ToolResponse[] | undefined>(undefined);
const [recordings, setRecordings] = useState<RecordingResponseSchema[]>([]);
const [runtimeFocusMode, setRuntimeFocusMode] = useState<WorkflowRuntimeFocusMode>("follow");
const [activeRuntimeNodeId, setActiveRuntimeNodeId] = useState<string | null>(null);
const [runtimePulseNonce, setRuntimePulseNonce] = useState(0);
const {
rfInstance,
@ -80,6 +106,7 @@ function RenderWorkflow({ initialWorkflowName, workflowId, workflowUuid, initial
workflowName,
isDirty,
workflowValidationErrors,
templateContextVariables,
setNodes,
setEdges,
setIsDirty,
@ -91,7 +118,6 @@ function RenderWorkflow({ initialWorkflowName, workflowId, workflowUuid, initial
onConnect,
onEdgesChange,
onNodesChange,
onRun,
} = useWorkflowState({
initialWorkflowName,
workflowId,
@ -204,6 +230,13 @@ function RenderWorkflow({ initialWorkflowName, workflowId, workflowUuid, initial
return true;
}, [activeVersionId, versions, hasDraft]);
useEffect(() => {
if (!isViewingHistoricalVersion) {
return;
}
setActiveRuntimeNodeId(null);
}, [isViewingHistoricalVersion]);
// Return to the draft version, creating one from published if needed
const handleBackToDraft = useCallback(async () => {
const existingDraft = versions.find((v) => v.status === "draft");
@ -258,6 +291,50 @@ function RenderWorkflow({ initialWorkflowName, workflowId, workflowUuid, initial
return undefined;
}, [activeVersionId, versions, currentVersionNumber, currentVersionStatus]);
const testerDisabledReason = useMemo(() => {
if (isViewingHistoricalVersion) {
return "Return to the draft before starting a new test session.";
}
if (isDirty) {
return "Save the latest draft before testing so the session uses the workflow you are looking at.";
}
if (workflowValidationErrors.length > 0) {
return "Resolve the current validation errors before starting another test.";
}
return null;
}, [isDirty, isViewingHistoricalVersion, workflowValidationErrors.length]);
const handleOpenTester = useCallback(() => {
if (window.innerWidth >= 1280) {
setIsTesterRailOpen(true);
return;
}
setIsTesterSheetOpen(true);
}, []);
const shouldShowWebCallOnboarding = useMemo(() => {
return (initialTotalRuns ?? 0) === 0 && !hasCompletedAction('web_call_started');
}, [hasCompletedAction, initialTotalRuns]);
useEffect(() => {
const syncViewport = () => {
setIsDesktopViewport(window.innerWidth >= 1280);
};
syncViewport();
window.addEventListener('resize', syncViewport);
return () => window.removeEventListener('resize', syncViewport);
}, []);
useEffect(() => {
if (hasAutoOpenedTester.current || !openTesterOnLoad || !shouldShowWebCallOnboarding || testerDisabledReason) {
return;
}
handleOpenTester();
hasAutoOpenedTester.current = true;
}, [handleOpenTester, openTesterOnLoad, shouldShowWebCallOnboarding, testerDisabledReason]);
// Fetch documents, tools, and recordings once for the entire workflow
useEffect(() => {
const fetchData = async () => {
@ -301,6 +378,48 @@ function RenderWorkflow({ initialWorkflowName, workflowId, workflowUuid, initial
type: "custom"
}), []);
const displayNodes = useMemo(
() =>
nodes.map((node) =>
node.id === activeRuntimeNodeId
? {
...node,
data: {
...node.data,
runtime_active: true,
runtime_pulse_nonce: runtimePulseNonce,
},
}
: node,
),
[activeRuntimeNodeId, nodes, runtimePulseNonce],
);
const handleRuntimeNodeTransition = useCallback(
(transition: WorkflowRuntimeNodeTransition) => {
const nodeId = transition.nodeId;
const instance = rfInstance.current;
if (!nodeId || !instance) {
return;
}
setActiveRuntimeNodeId(nodeId);
setRuntimePulseNonce((value) => value + 1);
if (runtimeFocusMode !== "follow" || !instance.viewportInitialized) {
return;
}
void instance.fitView({
nodes: [{ id: nodeId }],
duration: 350,
padding: 0.45,
maxZoom: 0.9,
});
},
[rfInstance, runtimeFocusMode],
);
// Guard saveWorkflow so it's a no-op when viewing a historical version.
// This is the single safety net that covers every save path: header button,
// Cmd+S, node edit dialogs, stale doc/tool cleanup, etc.
@ -372,12 +491,12 @@ function RenderWorkflow({ initialWorkflowName, workflowId, workflowUuid, initial
isDirty={isDirty}
workflowValidationErrors={workflowValidationErrors}
rfInstance={rfInstance}
onRun={onRun}
workflowId={workflowId}
workflowUuid={workflowUuid}
saveWorkflow={guardedSaveWorkflow}
user={user}
onPhoneCallClick={() => setIsPhoneCallDialogOpen(true)}
onTestAgentClick={handleOpenTester}
onHistoryClick={handleOpenVersionPanel}
activeVersionLabel={activeVersionLabel}
isViewingHistoricalVersion={isViewingHistoricalVersion}
@ -388,158 +507,191 @@ function RenderWorkflow({ initialWorkflowName, workflowId, workflowUuid, initial
/>
{/* Workflow Canvas */}
<div className="flex-1 relative">
<ReactFlow
key={activeVersionId ?? 'current'}
nodes={nodes}
edges={edges}
onNodesChange={onNodesChange}
onEdgesChange={onEdgesChange}
nodeTypes={nodeTypes}
edgeTypes={edgeTypes}
onConnect={isViewingHistoricalVersion ? undefined : onConnect}
minZoom={0.4}
onInit={(instance) => {
rfInstance.current = instance;
// Center the workflow on load
setTimeout(() => {
instance.fitView({ padding: 0.2, duration: 200, maxZoom: 0.75 });
}, 0);
}}
defaultEdgeOptions={defaultEdgeOptions}
defaultViewport={initialFlow?.viewport}
nodesDraggable={!isViewingHistoricalVersion}
nodesConnectable={!isViewingHistoricalVersion}
edgesReconnectable={!isViewingHistoricalVersion}
zoomOnDoubleClick={false}
deleteKeyCode={isViewingHistoricalVersion ? null : "Backspace"}
>
<Background
variant={BackgroundVariant.Dots}
gap={16}
size={1}
color="#94a3b8"
/>
<div className="flex-1 min-h-0">
<div className="flex h-full min-w-0">
<div className="relative min-w-0 flex-1">
<ReactFlow
key={activeVersionId ?? 'current'}
nodes={displayNodes}
edges={edges}
onNodesChange={onNodesChange}
onEdgesChange={onEdgesChange}
nodeTypes={nodeTypes}
edgeTypes={edgeTypes}
onConnect={isViewingHistoricalVersion ? undefined : onConnect}
minZoom={0.4}
onInit={(instance) => {
rfInstance.current = instance;
// Center the workflow on load
setTimeout(() => {
instance.fitView({ padding: 0.2, duration: 200, maxZoom: 0.75 });
}, 0);
}}
defaultEdgeOptions={defaultEdgeOptions}
defaultViewport={initialFlow?.viewport}
nodesDraggable={!isViewingHistoricalVersion}
nodesConnectable={!isViewingHistoricalVersion}
edgesReconnectable={!isViewingHistoricalVersion}
zoomOnDoubleClick={false}
deleteKeyCode={isViewingHistoricalVersion ? null : "Backspace"}
>
<Background
variant={BackgroundVariant.Dots}
gap={16}
size={1}
color="#94a3b8"
/>
{/* Top-right controls - vertical layout (hidden when viewing history) */}
{!isViewingHistoricalVersion && (
<Panel position="top-right">
{/* 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={() => 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 */}
<div className="absolute bottom-12 left-8 z-10 flex gap-2">
<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={() => rfInstance.current?.zoomIn()}
className="bg-white shadow-sm hover:shadow-md h-8 w-8"
>
<Plus className="h-4 w-4" />
</Button>
</TooltipTrigger>
<TooltipContent side="top">
<p>Zoom in</p>
</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="outline"
size="icon"
onClick={() => rfInstance.current?.zoomOut()}
className="bg-white shadow-sm hover:shadow-md h-8 w-8"
>
<Minus className="h-4 w-4" />
</Button>
</TooltipTrigger>
<TooltipContent side="top">
<p>Zoom out</p>
</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="outline"
size="icon"
onClick={() => rfInstance.current?.fitView()}
className="bg-white shadow-sm hover:shadow-md h-8 w-8"
>
<Maximize2 className="h-4 w-4" />
</Button>
</TooltipTrigger>
<TooltipContent side="top">
<p>Fit view</p>
</TooltipContent>
</Tooltip>
{!isViewingHistoricalVersion && (
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="outline"
size="icon"
onClick={() => router.push(`/workflow/${workflowId}/settings`)}
className="bg-white shadow-sm hover:shadow-md"
onClick={() => {
setNodes(layoutNodes(nodes, edges, 'TB', rfInstance));
setIsDirty(true);
}}
className="bg-white shadow-sm hover:shadow-md h-8 w-8"
>
<Settings className="h-4 w-4" />
<BrushCleaning className="h-4 w-4" />
</Button>
</TooltipTrigger>
<TooltipContent side="left">
<p>Workflow settings</p>
<TooltipContent side="top">
<p>Tidy Up</p>
</TooltipContent>
</Tooltip>
</div>
)}
</TooltipProvider>
</Panel>
</div>
</div>
{isTesterRailOpen && (
<aside className="hidden h-full w-[420px] shrink-0 border-l border-border xl:block">
<WorkflowTesterPanel
workflowId={workflowId}
initialContextVariables={templateContextVariables}
disabled={testerDisabledReason !== null}
disabledReason={testerDisabledReason}
showWebCallOnboarding={shouldShowWebCallOnboarding}
isVisible={isDesktopViewport}
onClose={() => setIsTesterRailOpen(false)}
runtimeFocusMode={runtimeFocusMode}
onRuntimeFocusModeChange={setRuntimeFocusMode}
onRuntimeNodeTransition={handleRuntimeNodeTransition}
/>
</aside>
)}
</ReactFlow>
{/* Bottom-left controls - horizontal layout with custom buttons */}
<div className="absolute bottom-12 left-8 z-10 flex gap-2">
<TooltipProvider>
{/* Zoom In */}
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="outline"
size="icon"
onClick={() => rfInstance.current?.zoomIn()}
className="bg-white shadow-sm hover:shadow-md h-8 w-8"
>
<Plus className="h-4 w-4" />
</Button>
</TooltipTrigger>
<TooltipContent side="top">
<p>Zoom in</p>
</TooltipContent>
</Tooltip>
{/* Zoom Out */}
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="outline"
size="icon"
onClick={() => rfInstance.current?.zoomOut()}
className="bg-white shadow-sm hover:shadow-md h-8 w-8"
>
<Minus className="h-4 w-4" />
</Button>
</TooltipTrigger>
<TooltipContent side="top">
<p>Zoom out</p>
</TooltipContent>
</Tooltip>
{/* Fit View */}
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="outline"
size="icon"
onClick={() => rfInstance.current?.fitView()}
className="bg-white shadow-sm hover:shadow-md h-8 w-8"
>
<Maximize2 className="h-4 w-4" />
</Button>
</TooltipTrigger>
<TooltipContent side="top">
<p>Fit view</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>
<Sheet open={isTesterSheetOpen} onOpenChange={setIsTesterSheetOpen}>
<SheetContent side="right" className="w-full max-w-none p-0 sm:max-w-xl xl:hidden">
<WorkflowTesterPanel
workflowId={workflowId}
initialContextVariables={templateContextVariables}
disabled={testerDisabledReason !== null}
disabledReason={testerDisabledReason}
showWebCallOnboarding={shouldShowWebCallOnboarding}
isVisible={isTesterSheetOpen}
runtimeFocusMode={runtimeFocusMode}
onRuntimeFocusModeChange={setRuntimeFocusMode}
onRuntimeNodeTransition={handleRuntimeNodeTransition}
/>
</SheetContent>
</Sheet>
</div>
<AddNodePanel

View file

@ -1,9 +1,8 @@
"use client";
import { ReactFlowInstance } from "@xyflow/react";
import { AlertCircle, ArrowLeft, ChevronDown, Clipboard, Copy, Download, Eye, History, LoaderCircle, Menu, MoreVertical, Pencil, Phone, Rocket } from "lucide-react";
import { AlertCircle, ArrowLeft, Bot, Clipboard, Copy, Download, Eye, History, LoaderCircle, Menu, MoreVertical, Pencil, Phone, Rocket } from "lucide-react";
import { useRouter } from "next/navigation";
import posthog from "posthog-js";
import { useRef, useState } from "react";
import { toast } from "sonner";
@ -28,20 +27,18 @@ import {
PopoverTrigger,
} from "@/components/ui/popover";
import { useSidebar } from "@/components/ui/sidebar";
import { PostHogEvent } from "@/constants/posthog-events";
import { WORKFLOW_RUN_MODES } from "@/constants/workflowRunModes";
interface WorkflowEditorHeaderProps {
workflowName: string;
isDirty: boolean;
workflowValidationErrors: WorkflowError[];
rfInstance: React.RefObject<ReactFlowInstance<FlowNode, FlowEdge> | null>;
onRun: (mode: string) => Promise<void>;
workflowId: number;
workflowUuid?: string;
saveWorkflow: (updateWorkflowDefinition?: boolean) => Promise<void>;
user: { id: string; email?: string };
onPhoneCallClick: () => void;
onTestAgentClick: () => void;
onHistoryClick: () => void;
activeVersionLabel?: string;
isViewingHistoricalVersion: boolean;
@ -57,8 +54,8 @@ export const WorkflowEditorHeader = ({
workflowValidationErrors,
rfInstance,
saveWorkflow,
onRun,
onPhoneCallClick,
onTestAgentClick,
onHistoryClick,
activeVersionLabel,
isViewingHistoricalVersion,
@ -301,7 +298,7 @@ export const WorkflowEditorHeader = ({
</div>
</div>
{/* Right section: Version + Unsaved indicator + Call button + Save button */}
{/* Right section: Version + status + tester/call actions + save */}
<div className="flex items-center gap-3">
{/* Read-only banner when viewing a historical version */}
{isViewingHistoricalVersion && (
@ -388,68 +385,6 @@ export const WorkflowEditorHeader = ({
</Popover>
)}
{/* 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={() => {
posthog.capture(PostHogEvent.WEB_CALL_INITIATED, {
workflow_id: workflowId,
workflow_name: workflowName,
});
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 (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
@ -472,6 +407,45 @@ export const WorkflowEditorHeader = ({
</Button>
)}
{!isViewingHistoricalVersion && (
<Button
variant="outline"
className="flex items-center gap-2 bg-transparent border-[#3a3a3a] hover:bg-[#2a2a2a] text-white"
disabled={isCallDisabled}
onClick={onPhoneCallClick}
>
<Phone className="w-4 h-4" />
Phone Call
</Button>
)}
<Button
variant="outline"
className="flex items-center gap-2 bg-transparent border-[#3a3a3a] hover:bg-[#2a2a2a] text-white"
onClick={onTestAgentClick}
>
<Bot className="w-4 h-4" />
Test Agent
</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>
)}
{/* More options dropdown */}
<DropdownMenu>
<DropdownMenuTrigger asChild>

View file

@ -0,0 +1,288 @@
"use client";
import { Loader2, MessageSquareText, Mic, Phone, RefreshCw, X } from "lucide-react";
import posthog from "posthog-js";
import { useCallback, useEffect, useRef, useState } from "react";
import { toast } from "sonner";
import { createWorkflowRunApiV1WorkflowWorkflowIdRunsPost } from "@/client/sdk.gen";
import { OnboardingTooltip } from "@/components/onboarding/OnboardingTooltip";
import { Button } from "@/components/ui/button";
import { Skeleton } from "@/components/ui/skeleton";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { PostHogEvent } from "@/constants/posthog-events";
import { WORKFLOW_RUN_MODES } from "@/constants/workflowRunModes";
import { useOnboarding } from "@/context/OnboardingContext";
import { useAuth } from "@/lib/auth";
import { cn, getRandomId } from "@/lib/utils";
import { AiSimulatorPlaceholder } from "./workflow-tester/AiSimulatorPlaceholder";
import { EmbeddedVoiceTester } from "./workflow-tester/EmbeddedVoiceTester";
import { ManualTextChatPanel } from "./workflow-tester/ManualTextChatPanel";
import { ChatModeToggle, DisabledNotice, EmptyState, RuntimeFocusToggle } from "./workflow-tester/shared";
import type { WorkflowRuntimeFocusMode, WorkflowRuntimeNodeTransition } from "./workflow-tester/types";
import { extractSdkErrorMessage, getErrorMessage } from "./workflow-tester/utils";
interface WorkflowTesterPanelProps {
workflowId: number;
initialContextVariables?: Record<string, string>;
disabled: boolean;
disabledReason: string | null;
showWebCallOnboarding?: boolean;
isVisible?: boolean;
className?: string;
onClose?: () => void;
runtimeFocusMode: WorkflowRuntimeFocusMode;
onRuntimeFocusModeChange: (mode: WorkflowRuntimeFocusMode) => void;
onRuntimeNodeTransition?: (transition: WorkflowRuntimeNodeTransition) => void;
}
export function WorkflowTesterPanel({
workflowId,
initialContextVariables,
disabled,
disabledReason,
showWebCallOnboarding = false,
isVisible = true,
className,
onClose,
runtimeFocusMode,
onRuntimeFocusModeChange,
onRuntimeNodeTransition,
}: WorkflowTesterPanelProps) {
const auth = useAuth();
const { hasSeenTooltip, markTooltipSeen, markActionCompleted } = useOnboarding();
const { isAuthenticated, loading: authLoading, getAccessToken } = auth;
const [accessToken, setAccessToken] = useState<string | null>(null);
const [activeMode, setActiveMode] = useState<"audio" | "text">("audio");
const [chatMode, setChatMode] = useState<"manual" | "simulated">("manual");
const [chatSessionKey, setChatSessionKey] = useState(0);
const [chatActive, setChatActive] = useState(false);
const [voiceRunId, setVoiceRunId] = useState<number | null>(null);
const [creatingVoiceRun, setCreatingVoiceRun] = useState(false);
const [tokenReady, setTokenReady] = useState(false);
const runTestButtonRef = useRef<HTMLButtonElement>(null);
useEffect(() => {
let ignore = false;
const hydrateAccessToken = async () => {
if (!isAuthenticated || authLoading) return;
try {
const token = await getAccessToken();
if (!ignore) {
setAccessToken(token);
}
} catch (error) {
if (!ignore) {
toast.error(getErrorMessage(error));
}
} finally {
if (!ignore) {
setTokenReady(true);
}
}
};
if (authLoading) {
return;
}
if (!isAuthenticated) {
setTokenReady(true);
return;
}
hydrateAccessToken();
return () => {
ignore = true;
};
}, [authLoading, getAccessToken, isAuthenticated]);
const createVoiceRun = useCallback(async () => {
if (!accessToken || disabled) return;
setCreatingVoiceRun(true);
try {
const response = await createWorkflowRunApiV1WorkflowWorkflowIdRunsPost({
path: { workflow_id: workflowId },
body: {
mode: WORKFLOW_RUN_MODES.SMALL_WEBRTC,
name: `WR-${getRandomId()}`,
},
});
if (response.error || !response.data?.id) {
throw new Error(extractSdkErrorMessage(response.error, "Failed to create browser test run"));
}
markActionCompleted("web_call_started");
markTooltipSeen("web_call");
posthog.capture(PostHogEvent.WEB_CALL_INITIATED, {
workflow_id: workflowId,
workflow_run_id: response.data.id,
source: "workflow_editor",
});
setVoiceRunId(response.data.id);
setActiveMode("audio");
} catch (error) {
toast.error(getErrorMessage(error));
} finally {
setCreatingVoiceRun(false);
}
}, [accessToken, disabled, markActionCompleted, markTooltipSeen, workflowId]);
const authUnavailableReason = tokenReady && !accessToken
? "Authentication is required before testing can start."
: null;
const effectiveDisabledReason = disabledReason ?? authUnavailableReason;
const testerBlocked = disabled || authUnavailableReason !== null;
const showRunTestTooltip =
showWebCallOnboarding &&
isVisible &&
activeMode === "audio" &&
!voiceRunId &&
tokenReady &&
!!accessToken &&
!testerBlocked &&
!hasSeenTooltip("web_call");
return (
<div className={cn("flex h-full min-h-0 flex-col bg-background", className)}>
<Tabs
value={activeMode}
onValueChange={(value) => setActiveMode(value as "audio" | "text")}
className="min-h-0 flex-1 gap-0"
>
<div className="border-b border-border/70 px-4 py-3">
<div className="flex items-center gap-3">
<TabsList className="grid h-9 flex-1 grid-cols-2 rounded-lg bg-muted/60 p-1">
<TabsTrigger value="audio" className="rounded-md text-sm">
<Mic className="h-4 w-4" />
Test Audio
</TabsTrigger>
<TabsTrigger value="text" className="rounded-md text-sm">
<MessageSquareText className="h-4 w-4" />
Test Chat
</TabsTrigger>
</TabsList>
{onClose ? (
<Button
variant="ghost"
size="icon"
onClick={onClose}
className="shrink-0 text-muted-foreground hover:text-foreground"
aria-label="Close tester panel"
>
<X className="h-4 w-4" />
</Button>
) : null}
</div>
<div className="mt-3 flex items-center justify-between gap-3">
<p className="text-xs text-muted-foreground">Canvas sync</p>
<RuntimeFocusToggle
value={runtimeFocusMode}
onChange={onRuntimeFocusModeChange}
/>
</div>
</div>
<TabsContent value="audio" className="min-h-0 flex-1 px-4 py-4">
<div className="flex h-full min-h-0 flex-col gap-3">
{!tokenReady ? (
<div className="space-y-4">
<Skeleton className="h-14 rounded-xl" />
<Skeleton className="h-80 rounded-xl" />
</div>
) : !accessToken ? (
<DisabledNotice
reason={authUnavailableReason ?? "Authentication is required before browser tests can start."}
/>
) : voiceRunId ? (
<EmbeddedVoiceTester
workflowId={workflowId}
workflowRunId={voiceRunId}
initialContextVariables={initialContextVariables}
accessToken={accessToken}
onReset={() => setVoiceRunId(null)}
onNodeTransition={onRuntimeNodeTransition}
/>
) : (
<>
{effectiveDisabledReason ? <DisabledNotice reason={effectiveDisabledReason} /> : null}
<EmptyState
icon={<Phone className="h-7 w-7" />}
title="Call this agent in the browser"
description="Test the agent over a voice call. Some telephony-only tools, like call transfer, are not yet supported here."
action={
<Button
ref={runTestButtonRef}
onClick={createVoiceRun}
disabled={creatingVoiceRun || testerBlocked}
>
{creatingVoiceRun ? (
<>
<Loader2 className="h-4 w-4 animate-spin" />
Starting test...
</>
) : (
<>
<Phone className="h-4 w-4" />
Run Test
</>
)}
</Button>
}
/>
</>
)}
</div>
</TabsContent>
<TabsContent value="text" className="min-h-0 flex-1 px-4 py-3">
<div className="flex h-full min-h-0 flex-col gap-3">
<div className="flex items-center justify-between gap-2">
<ChatModeToggle value={chatMode} onChange={setChatMode} />
{chatMode === "manual" && chatActive ? (
<Button
variant="ghost"
size="sm"
onClick={() => setChatSessionKey((value) => value + 1)}
disabled={testerBlocked}
className="h-7 px-2 text-xs text-muted-foreground hover:text-foreground"
>
<RefreshCw className="h-3.5 w-3.5" />
Reset
</Button>
) : null}
</div>
{chatMode === "manual" ? (
<ManualTextChatPanel
key={chatSessionKey}
workflowId={workflowId}
ready={tokenReady && !!accessToken}
initialContextVariables={initialContextVariables}
disabled={testerBlocked}
disabledReason={effectiveDisabledReason}
onActiveChange={setChatActive}
onNodeTransition={onRuntimeNodeTransition}
/>
) : (
<AiSimulatorPlaceholder disabledReason={effectiveDisabledReason} />
)}
</div>
</TabsContent>
</Tabs>
<OnboardingTooltip
targetRef={runTestButtonRef}
title="Try Your First Web Call"
message="Start a browser call here to hear the agent, inspect the transcript, and validate the workflow before you customize it further."
onDismiss={() => markTooltipSeen("web_call")}
showNext={false}
isVisible={showRunTestTooltip}
/>
</div>
);
}

View file

@ -0,0 +1,38 @@
"use client";
import { Sparkles } from "lucide-react";
import { useState } from "react";
import { Button } from "@/components/ui/button";
import { Textarea } from "@/components/ui/textarea";
import { DisabledNotice } from "./shared";
export function AiSimulatorPlaceholder({
disabledReason,
}: {
disabledReason: string | null;
}) {
const [simulatorPrompt, setSimulatorPrompt] = useState(
"Act like a skeptical prospect. Push on pricing, ask about integrations, and end the chat if the assistant becomes repetitive.",
);
return (
<div className="flex min-h-0 flex-1 flex-col gap-3">
{disabledReason ? <DisabledNotice reason={disabledReason} /> : null}
<p className="text-sm text-muted-foreground">
Drive multi-turn, agent-vs-agent tests with a persona prompt.
</p>
<Textarea
value={simulatorPrompt}
onChange={(event) => setSimulatorPrompt(event.target.value)}
placeholder="Describe the simulated user..."
className="min-h-32 resize-none text-sm leading-6"
/>
<Button size="sm" disabled className="self-start">
<Sparkles className="h-4 w-4" />
Coming soon
</Button>
</div>
);
}

View file

@ -0,0 +1,82 @@
"use client";
import { Loader2, X } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Textarea } from "@/components/ui/textarea";
interface ChatComposerProps {
composerId: string;
draft: string;
ready: boolean;
editing: boolean;
sendingMessage: boolean;
inputDisabled: boolean;
onDraftChange: (value: string) => void;
onCancelEditing: () => void;
onSubmit: () => Promise<void> | void;
}
export function ChatComposer({
composerId,
draft,
ready,
editing,
sendingMessage,
inputDisabled,
onDraftChange,
onCancelEditing,
onSubmit,
}: ChatComposerProps) {
return (
<div className="pt-3">
{editing ? (
<div className="mb-2 flex items-center justify-between gap-2 rounded-lg border border-border/70 bg-muted/35 px-3 py-2 text-xs text-muted-foreground">
<span>Edit the selected user message, then press Enter to rerun from that point.</span>
<button
type="button"
onClick={onCancelEditing}
className="inline-flex items-center gap-1 rounded text-foreground hover:text-foreground/80 focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
>
<X className="h-3.5 w-3.5" />
Cancel
</button>
</div>
) : null}
<div className="relative">
<Textarea
id={composerId}
value={draft}
onChange={(event) => onDraftChange(event.target.value)}
placeholder={ready ? (editing ? "Edit and rerun this message..." : "Send a message...") : "Preparing chat..."}
rows={1}
className="min-h-11! resize-none pr-20 text-sm leading-6"
disabled={inputDisabled}
onKeyDown={(event) => {
if (event.key === "Enter" && !event.shiftKey) {
event.preventDefault();
if (sendingMessage) return;
void onSubmit();
}
}}
/>
<Button
type="button"
size="sm"
onClick={() => void onSubmit()}
disabled={inputDisabled || sendingMessage || !draft.trim()}
className="absolute bottom-1.5 right-1.5 h-8 px-4"
>
{sendingMessage ? (
<>
<Loader2 className="h-3.5 w-3.5 animate-spin" />
{editing ? "Rerunning" : "Sending"}
</>
) : (
editing ? "Rerun" : "Send"
)}
</Button>
</div>
</div>
);
}

View file

@ -0,0 +1,162 @@
"use client";
import { Loader2, Phone, RefreshCw } from "lucide-react";
import { useRouter } from "next/navigation";
import { useEffect, useRef } from "react";
import { Button } from "@/components/ui/button";
import { RealtimeFeedback } from "@/components/workflow/conversation";
import { ApiKeyErrorDialog, ConnectionStatus, WorkflowConfigErrorDialog } from "../../run/[runId]/components";
import { useWebSocketRTC } from "../../run/[runId]/hooks";
import type { WorkflowRuntimeNodeTransition } from "./types";
interface EmbeddedVoiceTesterProps {
workflowId: number;
workflowRunId: number;
initialContextVariables?: Record<string, string>;
accessToken: string;
onReset: () => void;
onNodeTransition?: (transition: WorkflowRuntimeNodeTransition) => void;
}
export function EmbeddedVoiceTester({
workflowId,
workflowRunId,
initialContextVariables,
accessToken,
onReset,
onNodeTransition,
}: EmbeddedVoiceTesterProps) {
const router = useRouter();
const {
audioRef,
connectionActive,
permissionError,
isCompleted,
apiKeyModalOpen,
setApiKeyModalOpen,
apiKeyError,
apiKeyErrorCode,
workflowConfigError,
workflowConfigModalOpen,
setWorkflowConfigModalOpen,
connectionStatus,
start,
stop,
isStarting,
feedbackMessages,
} = useWebSocketRTC({
workflowId,
workflowRunId,
accessToken,
initialContextVariables,
onNodeTransition,
});
const autoStartedRef = useRef(false);
useEffect(() => {
if (autoStartedRef.current) {
return;
}
autoStartedRef.current = true;
void start();
}, [start]);
const endButtonLabel = connectionActive
? "End Call"
: isCompleted
? "Start Another Test"
: connectionStatus === "failed"
? "Retry Call"
: "Starting Test...";
const handleFooterAction = async () => {
if (connectionActive) {
stop();
return;
}
if (isCompleted) {
onReset();
return;
}
if (connectionStatus === "failed") {
await start();
}
};
return (
<>
<div className="flex min-h-0 flex-1 flex-col overflow-hidden rounded-xl border border-border/70 bg-background">
<div className="min-h-0 flex-1 overflow-hidden bg-muted/15">
<RealtimeFeedback
mode="live"
messages={feedbackMessages}
isCallActive={connectionActive}
isCallCompleted={isCompleted}
/>
</div>
<div className="border-t border-border/70 bg-background px-4 py-3">
<div className="flex flex-col gap-3">
<ConnectionStatus connectionStatus={connectionStatus} />
{permissionError ? (
<p className="text-center text-sm text-destructive">{permissionError}</p>
) : null}
<Button
onClick={handleFooterAction}
disabled={isStarting && connectionStatus !== "failed"}
variant={connectionActive ? "destructive" : "default"}
className="w-full"
>
{isStarting && connectionStatus !== "failed" ? (
<>
<Loader2 className="h-4 w-4 animate-spin" />
Starting Test...
</>
) : connectionActive ? (
<>
<Phone className="h-4 w-4" />
{endButtonLabel}
</>
) : connectionStatus === "failed" ? (
<>
<RefreshCw className="h-4 w-4" />
{endButtonLabel}
</>
) : isCompleted ? (
<>
<RefreshCw className="h-4 w-4" />
{endButtonLabel}
</>
) : (
<>
<Loader2 className="h-4 w-4 animate-spin" />
{endButtonLabel}
</>
)}
</Button>
</div>
</div>
<audio ref={audioRef} autoPlay playsInline className="hidden" />
</div>
<ApiKeyErrorDialog
open={apiKeyModalOpen}
onOpenChange={setApiKeyModalOpen}
error={apiKeyError}
errorCode={apiKeyErrorCode}
onNavigateToCredits={() => router.push("/api-keys")}
onNavigateToModelConfig={() => router.push("/model-configurations")}
/>
<WorkflowConfigErrorDialog
open={workflowConfigModalOpen}
onOpenChange={setWorkflowConfigModalOpen}
error={workflowConfigError}
onNavigateToWorkflow={() => router.push(`/workflow/${workflowId}`)}
/>
</>
);
}

View file

@ -0,0 +1,145 @@
"use client";
import { Skeleton } from "@/components/ui/skeleton";
import type { ConversationItem } from "@/components/workflow/conversation";
import { ConversationTimeline } from "@/components/workflow/conversation";
import { ChatComposer } from "./ChatComposer";
import { DisabledNotice, ManualChatEmptyState, TypingIndicator } from "./shared";
import { TurnMessageActions } from "./TurnMessageActions";
import type { WorkflowRuntimeNodeTransition } from "./types";
import { useTextChatSession } from "./useTextChatSession";
interface ManualTextChatPanelProps {
workflowId: number;
ready: boolean;
initialContextVariables?: Record<string, string>;
disabled: boolean;
disabledReason: string | null;
onActiveChange?: (active: boolean) => void;
onNodeTransition?: (transition: WorkflowRuntimeNodeTransition) => void;
}
export function ManualTextChatPanel({
workflowId,
ready,
initialContextVariables,
disabled,
disabledReason,
onActiveChange,
onNodeTransition,
}: ManualTextChatPanelProps) {
const {
session,
started,
draft,
turns,
editingTurn,
editingTurnId,
creatingSession,
sendingMessage,
activeTurnAction,
composerId,
inputDisabled,
conversationItems,
setDraft,
startSession,
rewindTurn,
startEditingTurn,
cancelEditingTurn,
submitComposer,
} = useTextChatSession({
workflowId,
ready,
initialContextVariables,
disabled,
onActiveChange,
onNodeTransition,
});
if (!started && !session) {
return (
<div className="flex h-full min-h-0 flex-col gap-3">
{disabledReason ? <DisabledNotice reason={disabledReason} /> : null}
<ManualChatEmptyState disabled={disabled} ready={ready} onStart={startSession} />
</div>
);
}
return (
<div className="flex min-h-0 flex-1 flex-col">
{disabledReason ? (
<div className="pb-3">
<DisabledNotice reason={disabledReason} />
</div>
) : null}
<div className="flex min-h-0 flex-1 flex-col">
{creatingSession && !session ? (
<div className="space-y-3 py-1">
<Skeleton className="ml-auto h-9 w-2/3 rounded-2xl" />
<Skeleton className="h-12 w-3/4 rounded-2xl" />
</div>
) : turns.length === 0 ? (
<div className="flex h-full items-center justify-center px-4 py-10 text-center">
<p className="text-sm text-muted-foreground">
{disabled
? (disabledReason ?? "Testing is paused.")
: "Send a message to start the conversation."}
</p>
</div>
) : (
<ConversationTimeline
items={conversationItems}
autoScroll={true}
scrollBehavior="smooth"
emptyState={{
title: "No conversation recorded",
subtitle: "Send a message to start the conversation.",
}}
pendingIndicator={sendingMessage ? <TypingIndicator /> : null}
className="py-1"
renderItemActions={(item: ConversationItem) => {
if (item.kind !== "message" || item.role !== "user" || !item.turnId) {
return null;
}
const turn = turns.find((candidate) => candidate.id === item.turnId);
if (!turn?.user_message) {
return null;
}
const rewindingThisTurn =
activeTurnAction?.turnId === turn.id && activeTurnAction.type === "rewind";
const rerunningEditedTurn =
activeTurnAction?.turnId === turn.id && activeTurnAction.type === "edit";
return (
<TurnMessageActions
disabled={disabled || sendingMessage}
editing={editingTurnId === turn.id}
rewinding={rewindingThisTurn}
rerunningEdit={rerunningEditedTurn}
onRewind={() => void rewindTurn(turn)}
onEdit={() => startEditingTurn(turn)}
/>
);
}}
/>
)}
</div>
<ChatComposer
composerId={composerId}
draft={draft}
ready={ready}
editing={!!editingTurn}
sendingMessage={sendingMessage}
inputDisabled={inputDisabled}
onDraftChange={setDraft}
onCancelEditing={cancelEditingTurn}
onSubmit={submitComposer}
/>
</div>
);
}

View file

@ -0,0 +1,59 @@
"use client";
import { Loader2, Pencil, RotateCcw } from "lucide-react";
import { cn } from "@/lib/utils";
interface TurnMessageActionsProps {
disabled: boolean;
editing: boolean;
rewinding: boolean;
rerunningEdit: boolean;
onRewind: () => void;
onEdit: () => void;
}
export function TurnMessageActions({
disabled,
editing,
rewinding,
rerunningEdit,
onRewind,
onEdit,
}: TurnMessageActionsProps) {
return (
<>
<button
type="button"
onClick={onRewind}
disabled={disabled}
aria-label="Rerun this turn"
title="Rerun this turn"
className="inline-flex h-6 w-6 items-center justify-center rounded text-muted-foreground hover:bg-muted hover:text-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:opacity-50"
>
{rewinding ? (
<Loader2 className="h-3.5 w-3.5 animate-spin" />
) : (
<RotateCcw className="h-3.5 w-3.5" />
)}
</button>
<button
type="button"
onClick={onEdit}
disabled={disabled}
aria-label="Edit and rerun this turn"
title="Edit and rerun this turn"
className={cn(
"inline-flex h-6 w-6 items-center justify-center rounded text-muted-foreground hover:bg-muted hover:text-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:opacity-50",
editing && "bg-muted text-foreground",
)}
>
{rerunningEdit ? (
<Loader2 className="h-3.5 w-3.5 animate-spin" />
) : (
<Pencil className="h-3.5 w-3.5" />
)}
</button>
</>
);
}

View file

@ -0,0 +1,156 @@
"use client";
import { AlertCircle, MessageSquareText } from "lucide-react";
import type { ReactNode } from "react";
import { Button } from "@/components/ui/button";
import { cn } from "@/lib/utils";
export function DisabledNotice({ reason }: { reason: string }) {
return (
<div className="rounded-lg border border-amber-200/80 bg-amber-50/80 px-3 py-2.5 text-sm text-amber-900 dark:border-amber-500/20 dark:bg-amber-500/10 dark:text-amber-200">
<div className="flex items-start gap-3">
<AlertCircle className="mt-0.5 h-4 w-4 shrink-0" />
<div className="space-y-0.5">
<p className="font-medium">Testing is paused</p>
<p className="text-amber-800/90 dark:text-amber-300">{reason}</p>
</div>
</div>
</div>
);
}
export function EmptyState({
icon,
title,
description,
action,
}: {
icon: ReactNode;
title: string;
description: string;
action?: ReactNode;
}) {
return (
<div className="flex flex-1 flex-col justify-center rounded-xl border border-border/70 bg-background px-5 py-6 text-left">
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-muted text-muted-foreground">
{icon}
</div>
<div className="mt-4 space-y-1.5">
<h3 className="text-sm font-semibold text-foreground">{title}</h3>
<p className="text-sm leading-6 text-muted-foreground">{description}</p>
</div>
{action ? <div className="mt-5">{action}</div> : null}
</div>
);
}
export function ChatModeToggle({
value,
onChange,
}: {
value: "manual" | "simulated";
onChange: (next: "manual" | "simulated") => void;
}) {
const options: Array<{ id: "manual" | "simulated"; label: string }> = [
{ id: "manual", label: "Manual" },
{ id: "simulated", label: "Simulated" },
];
return (
<div className="inline-flex items-center gap-0.5 rounded-md border border-border/70 bg-muted/40 p-0.5">
{options.map((option) => {
const active = option.id === value;
return (
<button
key={option.id}
type="button"
onClick={() => onChange(option.id)}
className={cn(
"rounded-[5px] px-2.5 py-1 text-xs font-medium transition",
active
? "bg-background text-foreground shadow-xs"
: "text-muted-foreground hover:text-foreground",
)}
>
{option.label}
</button>
);
})}
</div>
);
}
export function RuntimeFocusToggle({
value,
onChange,
}: {
value: "pulse" | "follow";
onChange: (next: "pulse" | "follow") => void;
}) {
const options: Array<{ id: "pulse" | "follow"; label: string }> = [
{ id: "pulse", label: "Pulse" },
{ id: "follow", label: "Follow" },
];
return (
<div className="inline-flex items-center gap-0.5 rounded-md border border-border/70 bg-muted/40 p-0.5">
{options.map((option) => {
const active = option.id === value;
return (
<button
key={option.id}
type="button"
onClick={() => onChange(option.id)}
className={cn(
"rounded-[5px] px-2.5 py-1 text-xs font-medium transition",
active
? "bg-background text-foreground shadow-xs"
: "text-muted-foreground hover:text-foreground",
)}
>
{option.label}
</button>
);
})}
</div>
);
}
export function TypingIndicator() {
return (
<div className="flex justify-start">
<div className="rounded-2xl rounded-bl-md bg-muted px-3.5 py-3">
<div className="flex items-center gap-1">
<span className="h-1.5 w-1.5 animate-bounce rounded-full bg-muted-foreground/60 [animation-delay:-0.3s]" />
<span className="h-1.5 w-1.5 animate-bounce rounded-full bg-muted-foreground/60 [animation-delay:-0.15s]" />
<span className="h-1.5 w-1.5 animate-bounce rounded-full bg-muted-foreground/60" />
</div>
</div>
</div>
);
}
export function ManualChatEmptyState({
disabled,
ready,
onStart,
}: {
disabled: boolean;
ready: boolean;
onStart: () => void;
}) {
return (
<EmptyState
icon={<MessageSquareText className="h-7 w-7" />}
title="Chat with this agent"
description="Test the agent over a text conversation. Send messages and see how it responds, with tool calls, transitions, and rewind support."
action={
<Button onClick={onStart} disabled={disabled || !ready}>
<MessageSquareText className="h-4 w-4" />
Start Test
</Button>
}
/>
);
}

View file

@ -0,0 +1,62 @@
import type { WorkflowRunTextSessionResponse } from "@/client/types.gen";
import type { ConversationNodeTransitionItem } from "@/components/workflow/conversation";
export interface TextChatMessage {
text: string;
created_at: string;
}
export interface TextChatTurn {
id: string;
status: string;
created_at: string;
user_message: TextChatMessage | null;
assistant_message: TextChatMessage | null;
events: Array<Record<string, unknown>>;
usage: Record<string, unknown>;
}
export interface TextChatSessionData {
version: number;
status: string;
cursor_turn_id: string | null;
turns: TextChatTurn[];
discarded_future: Array<Record<string, unknown>>;
simulator: {
enabled: boolean;
config: Record<string, unknown>;
};
}
export interface TextChatCheckpoint {
version: number;
anchor_turn_id: string | null;
current_node_id: string | null;
messages: Array<Record<string, unknown>>;
gathered_context: Record<string, unknown>;
tool_state: Record<string, unknown>;
}
export type TextChatSession = Omit<WorkflowRunTextSessionResponse, "session_data" | "checkpoint"> & {
session_data: TextChatSessionData;
checkpoint: TextChatCheckpoint;
};
export interface TurnActionState {
turnId: string;
type: "rewind" | "edit";
}
export type WorkflowRuntimeFocusMode = "pulse" | "follow";
export type WorkflowRuntimeNodeTransition = ConversationNodeTransitionItem;
export const EMPTY_TEXT_CHAT_TURNS: TextChatTurn[] = [];
export function toTextChatSession(response: WorkflowRunTextSessionResponse): TextChatSession {
return {
...response,
session_data: response.session_data as unknown as TextChatSessionData,
checkpoint: response.checkpoint as unknown as TextChatCheckpoint,
};
}

View file

@ -0,0 +1,234 @@
"use client";
import { useCallback, useEffect, useRef, useState } from "react";
import { toast } from "sonner";
import {
appendTextChatMessageApiV1WorkflowWorkflowIdTextChatSessionsRunIdMessagesPost,
createTextChatSessionApiV1WorkflowWorkflowIdTextChatSessionsPost,
rewindTextChatSessionApiV1WorkflowWorkflowIdTextChatSessionsRunIdRewindPost,
} from "@/client/sdk.gen";
import { conversationItemsFromTextChatTurns } from "@/components/workflow/conversation/adapters/fromTextChatTurns";
import {
EMPTY_TEXT_CHAT_TURNS,
type TextChatSession,
type TextChatTurn,
toTextChatSession,
type TurnActionState,
type WorkflowRuntimeNodeTransition,
} from "./types";
import { extractSdkErrorMessage, getErrorMessage, getReplayCursorTurnId } from "./utils";
interface UseTextChatSessionProps {
workflowId: number;
ready: boolean;
initialContextVariables?: Record<string, string>;
disabled: boolean;
onActiveChange?: (active: boolean) => void;
onNodeTransition?: (transition: WorkflowRuntimeNodeTransition) => void;
}
export function useTextChatSession({
workflowId,
ready,
initialContextVariables,
disabled,
onActiveChange,
onNodeTransition,
}: UseTextChatSessionProps) {
const [session, setSession] = useState<TextChatSession | null>(null);
const [started, setStarted] = useState(false);
const [draft, setDraft] = useState("");
const [creatingSession, setCreatingSession] = useState(false);
const [sendingMessage, setSendingMessage] = useState(false);
const [editingTurnId, setEditingTurnId] = useState<string | null>(null);
const [activeTurnAction, setActiveTurnAction] = useState<TurnActionState | null>(null);
const lastNotifiedNodeTransitionIdRef = useRef<string | null>(null);
const turns = session?.session_data.turns ?? EMPTY_TEXT_CHAT_TURNS;
const editingTurn = editingTurnId
? turns.find((turn) => turn.id === editingTurnId) ?? null
: null;
const composerId = `workflow-tester-compose-${workflowId}`;
const conversationItems = conversationItemsFromTextChatTurns(turns);
const createSession = useCallback(async () => {
if (disabled) return;
setCreatingSession(true);
try {
const response = await createTextChatSessionApiV1WorkflowWorkflowIdTextChatSessionsPost({
path: { workflow_id: workflowId },
body: {
initial_context: initialContextVariables ?? {},
annotations: {
tester: {
source: "workflow_editor",
modality: "text",
ui_mode: "manual_text",
},
},
},
});
if (response.error || !response.data) {
throw new Error(extractSdkErrorMessage(response.error, "Failed to create chat session"));
}
setSession(toTextChatSession(response.data));
setDraft("");
} catch (error) {
setSession(null);
setStarted(false);
toast.error(getErrorMessage(error));
} finally {
setCreatingSession(false);
}
}, [disabled, initialContextVariables, workflowId]);
useEffect(() => {
if (!started || creatingSession || session || !ready || disabled) {
return;
}
void createSession();
}, [createSession, creatingSession, disabled, ready, session, started]);
useEffect(() => {
onActiveChange?.(started);
}, [onActiveChange, started]);
useEffect(() => {
const latestNodeTransition = [...conversationItems]
.reverse()
.find(
(item): item is WorkflowRuntimeNodeTransition =>
item.kind === "node-transition" && !!item.nodeId,
);
if (!latestNodeTransition?.nodeId) {
return;
}
if (lastNotifiedNodeTransitionIdRef.current === latestNodeTransition.id) {
return;
}
lastNotifiedNodeTransitionIdRef.current = latestNodeTransition.id;
onNodeTransition?.(latestNodeTransition);
}, [conversationItems, onNodeTransition]);
useEffect(() => {
if (!editingTurnId) {
return;
}
if (!turns.some((turn) => turn.id === editingTurnId)) {
setEditingTurnId(null);
setDraft("");
}
}, [editingTurnId, turns]);
const submitMessage = useCallback(async (messageText: string, replayOptions?: TurnActionState) => {
const trimmedText = messageText.trim();
if (!session || !trimmedText || disabled) return;
setSendingMessage(true);
if (replayOptions) {
setActiveTurnAction(replayOptions);
}
try {
let activeSession = session;
if (replayOptions) {
const rewindResponse = await rewindTextChatSessionApiV1WorkflowWorkflowIdTextChatSessionsRunIdRewindPost({
path: { workflow_id: workflowId, run_id: activeSession.workflow_run_id },
body: {
cursor_turn_id: getReplayCursorTurnId(activeSession.session_data.turns, replayOptions.turnId),
expected_revision: activeSession.revision,
},
});
if (rewindResponse.error || !rewindResponse.data) {
throw new Error(extractSdkErrorMessage(rewindResponse.error, "Failed to rewind session"));
}
activeSession = toTextChatSession(rewindResponse.data);
setSession(activeSession);
}
const response = await appendTextChatMessageApiV1WorkflowWorkflowIdTextChatSessionsRunIdMessagesPost({
path: { workflow_id: workflowId, run_id: activeSession.workflow_run_id },
body: {
text: trimmedText,
expected_revision: activeSession.revision,
},
});
if (response.error || !response.data) {
throw new Error(extractSdkErrorMessage(response.error, "Failed to send message"));
}
setSession(toTextChatSession(response.data));
setDraft("");
setEditingTurnId(null);
} catch (error) {
toast.error(getErrorMessage(error));
} finally {
setSendingMessage(false);
setActiveTurnAction(null);
}
}, [disabled, session, workflowId]);
const rewindTurn = useCallback(async (turn: TextChatTurn) => {
if (!turn.user_message) return;
await submitMessage(turn.user_message.text, { turnId: turn.id, type: "rewind" });
}, [submitMessage]);
const startEditingTurn = useCallback((turn: TextChatTurn) => {
if (!turn.user_message) return;
const nextText = turn.user_message.text;
setEditingTurnId(turn.id);
setDraft(nextText);
requestAnimationFrame(() => {
const textarea = document.getElementById(composerId) as HTMLTextAreaElement | null;
textarea?.focus();
textarea?.setSelectionRange(nextText.length, nextText.length);
});
}, [composerId]);
const cancelEditingTurn = useCallback(() => {
setEditingTurnId(null);
setDraft("");
}, []);
const submitComposer = useCallback(async () => {
if (editingTurnId) {
await submitMessage(draft, { turnId: editingTurnId, type: "edit" });
return;
}
await submitMessage(draft);
}, [draft, editingTurnId, submitMessage]);
return {
session,
started,
draft,
turns,
editingTurn,
editingTurnId,
creatingSession,
sendingMessage,
activeTurnAction,
composerId,
inputDisabled: disabled || !session,
conversationItems,
setDraft,
startSession: () => setStarted(true),
rewindTurn,
startEditingTurn,
cancelEditingTurn,
submitComposer,
};
}

View file

@ -0,0 +1,29 @@
export function getErrorMessage(error: unknown) {
if (error instanceof Error) return error.message;
return "Something went wrong";
}
export function extractSdkErrorMessage(error: unknown, fallback: string) {
if (!error) return fallback;
if (typeof error === "string") return error;
if (typeof error === "object") {
const detail = (error as { detail?: unknown }).detail;
if (typeof detail === "string") return detail;
if (
detail &&
typeof detail === "object" &&
typeof (detail as { message?: unknown }).message === "string"
) {
return (detail as { message: string }).message;
}
}
return fallback;
}
export function getReplayCursorTurnId(turns: Array<{ id: string }>, turnId: string) {
const turnIndex = turns.findIndex((turn) => turn.id === turnId);
if (turnIndex < 0) {
throw new Error("Turn not found");
}
return turns[turnIndex - 1]?.id ?? null;
}

View file

@ -1,6 +1,6 @@
'use client';
import { useParams } from 'next/navigation';
import { useParams, useSearchParams } from 'next/navigation';
import posthog from 'posthog-js';
import { useEffect, useMemo, useState } from 'react';
@ -18,6 +18,7 @@ import WorkflowLayout from '../WorkflowLayout';
export default function WorkflowDetailPage() {
const params = useParams();
const searchParams = useSearchParams();
const [workflow, setWorkflow] = useState<WorkflowResponse | undefined>(undefined);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
@ -59,6 +60,7 @@ export default function WorkflowDetailPage() {
}, [params.workflowId, user]);
const stableUser = useMemo(() => user, [user]);
const openTesterOnLoad = searchParams.get('onboarding') === 'web_call';
if (loading) {
return (
@ -82,6 +84,8 @@ export default function WorkflowDetailPage() {
initialWorkflowName={workflow.name}
workflowId={workflow.id}
workflowUuid={workflow.workflow_uuid ?? undefined}
initialTotalRuns={workflow.total_runs ?? 0}
openTesterOnLoad={openTesterOnLoad}
initialFlow={{
nodes: workflow.workflow_definition.nodes as FlowNode[],
edges: workflow.workflow_definition.edges as FlowEdge[],

View file

@ -1,184 +0,0 @@
import { Loader2 } from "lucide-react";
import { useRouter } from "next/navigation";
import { useEffect, useState } from "react";
import { getWorkflowRunApiV1WorkflowWorkflowIdRunsRunIdGet } from "@/client/sdk.gen";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { useAuth } from "@/lib/auth";
import {
ApiKeyErrorDialog,
AudioControls,
ConnectionStatus,
RealtimeFeedback,
WorkflowConfigErrorDialog
} from "./components";
import { useWebSocketRTC } from "./hooks";
const BrowserCall = ({ workflowId, workflowRunId, initialContextVariables }: {
workflowId: number,
workflowRunId: number,
initialContextVariables?: Record<string, string> | null
}) => {
const router = useRouter();
const auth = useAuth();
const [accessToken, setAccessToken] = useState<string | null>(null);
const [checkingForRecording, setCheckingForRecording] = useState(false);
// Get access token for WebSocket connection (non-SDK usage)
useEffect(() => {
if (auth.isAuthenticated && !auth.loading) {
auth.getAccessToken().then(setAccessToken);
}
}, [auth]);
const {
audioRef,
audioInputs,
selectedAudioInput,
setSelectedAudioInput,
connectionActive,
permissionError,
isCompleted,
apiKeyModalOpen,
setApiKeyModalOpen,
apiKeyError,
apiKeyErrorCode,
workflowConfigError,
workflowConfigModalOpen,
setWorkflowConfigModalOpen,
connectionStatus,
start,
stop,
isStarting,
getAudioInputDevices,
feedbackMessages,
} = useWebSocketRTC({ workflowId, workflowRunId, accessToken, initialContextVariables });
// Poll for recording availability after call ends
useEffect(() => {
if (!isCompleted || !auth.isAuthenticated) return;
setCheckingForRecording(true);
const intervalId = setInterval(async () => {
try {
const response = await getWorkflowRunApiV1WorkflowWorkflowIdRunsRunIdGet({
path: {
workflow_id: workflowId,
run_id: workflowRunId,
},
});
if (response.data?.transcript_url || response.data?.recording_url) {
setCheckingForRecording(false);
clearInterval(intervalId);
// Refresh the page to show the recording
window.location.reload();
}
} catch (error) {
console.error('Error checking for recording:', error);
}
}, 5000); // Check every 5 seconds
// Clean up after 2 minutes
const timeoutId = setTimeout(() => {
clearInterval(intervalId);
setCheckingForRecording(false);
}, 120000);
return () => {
clearInterval(intervalId);
clearTimeout(timeoutId);
};
}, [isCompleted, auth.isAuthenticated, workflowId, workflowRunId]);
const navigateToCredits = () => {
router.push('/api-keys');
};
const navigateToModelConfig = () => {
router.push('/model-configurations');
};
const navigateToWorkflow = () => {
router.push(`/workflow/${workflowId}`)
}
return (
<>
<div className="flex h-screen w-full overflow-hidden">
{/* Main content - 2/3 width when panel visible, full width otherwise */}
<div className="w-2/3 h-full overflow-y-auto">
<div className="flex justify-center items-center h-full px-8">
<Card className="w-full max-w-xl">
<CardHeader>
<CardTitle>Call Voice Agent</CardTitle>
</CardHeader>
<CardContent>
{isCompleted && checkingForRecording ? (
<div className="flex flex-col items-center justify-center space-y-4 p-8">
<Loader2 className="h-8 w-8 animate-spin text-primary" />
<div className="text-center space-y-2">
<p className="text-foreground font-medium">Processing your call</p>
<p className="text-sm text-muted-foreground">Fetching transcript and recording...</p>
</div>
</div>
) : (
<>
<AudioControls
audioInputs={audioInputs}
selectedAudioInput={selectedAudioInput}
setSelectedAudioInput={setSelectedAudioInput}
isCompleted={isCompleted}
connectionActive={connectionActive}
permissionError={permissionError}
start={start}
stop={stop}
isStarting={isStarting}
getAudioInputDevices={getAudioInputDevices}
/>
<ConnectionStatus
connectionStatus={connectionStatus}
/>
</>
)}
</CardContent>
<audio ref={audioRef} autoPlay playsInline className="hidden" />
</Card>
</div>
</div>
{/* Show transcript panel */}
<div className="w-1/3 h-full shrink-0 overflow-hidden">
<RealtimeFeedback
mode="live"
messages={feedbackMessages}
isCallActive={connectionActive}
isCallCompleted={isCompleted}
/>
</div>
</div>
<ApiKeyErrorDialog
open={apiKeyModalOpen}
onOpenChange={setApiKeyModalOpen}
error={apiKeyError}
errorCode={apiKeyErrorCode}
onNavigateToCredits={navigateToCredits}
onNavigateToModelConfig={navigateToModelConfig}
/>
<WorkflowConfigErrorDialog
open={workflowConfigModalOpen}
onOpenChange={setWorkflowConfigModalOpen}
error={workflowConfigError}
onNavigateToWorkflow={navigateToWorkflow}
/>
</>
);
};
export default BrowserCall;

View file

@ -1,174 +0,0 @@
'use client';
import { FeedbackMessage } from '../hooks/useWebSocketRTC';
import { processLiveMessages, processTranscriptEvents, TranscriptEvent } from '../utils/processTranscriptEvents';
import { UnifiedTranscript } from './UnifiedTranscript';
// Historical log event format from the backend
interface RealtimeFeedbackEvent {
type: string;
payload: {
text?: string;
final?: boolean;
user_id?: string;
timestamp?: string;
function_name?: string;
tool_call_id?: string;
result?: string;
node_name?: string;
previous_node?: string;
allow_interrupt?: boolean;
ttfb_seconds?: number;
processor?: string;
model?: string;
error?: string;
fatal?: boolean;
};
timestamp: string;
turn: number;
}
export interface WorkflowRunLogs {
realtime_feedback_events?: RealtimeFeedbackEvent[];
}
// Props for live mode (WebSocket messages)
interface LiveModeProps {
mode: 'live';
messages: FeedbackMessage[];
isCallActive: boolean;
isCallCompleted: boolean;
}
// Props for historical mode (API logs)
interface HistoricalModeProps {
mode: 'historical';
logs: WorkflowRunLogs | null;
}
type RealtimeFeedbackProps = LiveModeProps | HistoricalModeProps;
/**
* Convert backend log events to unified TranscriptEvent format
*/
function convertLogEventsToTranscriptEvents(events: RealtimeFeedbackEvent[]): TranscriptEvent[] {
return events.map(event => {
let type: TranscriptEvent['type'];
let status: TranscriptEvent['status'];
switch (event.type) {
case 'rtf-user-transcription':
type = 'user-transcription';
break;
case 'rtf-bot-text':
type = 'bot-text';
break;
case 'rtf-function-call-start':
type = 'function-call';
status = 'running';
break;
case 'rtf-function-call-end':
type = 'function-call';
status = 'completed';
break;
case 'rtf-node-transition':
type = 'node-transition';
break;
case 'rtf-ttfb-metric':
type = 'ttfb-metric';
break;
case 'rtf-pipeline-error':
type = 'pipeline-error';
break;
case 'rtf-interrupt-warning':
type = 'interrupt-warning';
break;
default:
type = 'bot-text';
}
return {
type,
text: event.payload.text || event.payload.error || event.payload.result || event.payload.function_name || event.payload.node_name || '',
final: event.payload.final,
timestamp: event.timestamp,
turn: event.turn,
functionName: event.payload.function_name,
status,
nodeName: event.payload.node_name,
previousNode: event.payload.previous_node,
allowInterrupt: event.payload.allow_interrupt,
ttfbSeconds: event.payload.ttfb_seconds,
processor: event.payload.processor,
model: event.payload.model,
fatal: event.payload.fatal,
};
});
}
/**
* Convert live WebSocket messages to unified TranscriptEvent format
*/
function convertLiveMessagesToTranscriptEvents(messages: FeedbackMessage[]): TranscriptEvent[] {
return messages.map(msg => ({
type: msg.type,
text: msg.text,
final: msg.final,
timestamp: msg.timestamp,
functionName: msg.functionName,
status: msg.status,
nodeName: msg.nodeName,
previousNode: msg.previousNode,
allowInterrupt: msg.allowInterrupt,
ttfbSeconds: msg.ttfbSeconds,
processor: msg.processor,
model: msg.model,
fatal: msg.fatal,
}));
}
/**
* Single unified component that handles both live WebSocket messages
* and historical logs from the API.
*/
export const RealtimeFeedback = (props: RealtimeFeedbackProps) => {
if (props.mode === 'historical') {
// Historical mode - process logs from API
const rawEvents = props.logs?.realtime_feedback_events;
const messages = rawEvents
? processTranscriptEvents(convertLogEventsToTranscriptEvents(rawEvents))
: [];
return (
<UnifiedTranscript
messages={messages}
status="ended"
title="Call Transcript"
emptyState={{
title: "No conversation recorded",
subtitle: "Real-time feedback events were not captured for this call"
}}
/>
);
}
// Live mode - process WebSocket messages (optimized - messages already accumulated)
const { messages, isCallActive, isCallCompleted } = props;
const status = isCallActive ? 'live' : isCallCompleted ? 'ended' : 'ready';
const processedMessages = processLiveMessages(convertLiveMessagesToTranscriptEvents(messages));
return (
<UnifiedTranscript
messages={processedMessages}
status={status}
title="Live Transcript"
autoScroll={true}
emptyState={{
title: "No messages yet",
subtitle: isCallActive
? "Start speaking to see the transcript"
: "Start the call to begin the conversation"
}}
/>
);
};

View file

@ -1,98 +0,0 @@
"use client";
import { useEffect, useRef } from "react";
import { ProcessedMessage } from "../utils/processTranscriptEvents";
import { TranscriptContainer } from "./shared/TranscriptContainer";
import { TranscriptEmptyState } from "./shared/TranscriptEmptyState";
import { TranscriptMessage, TranscriptMessageData } from "./shared/TranscriptMessage";
interface UnifiedTranscriptProps {
messages: ProcessedMessage[];
status: 'ready' | 'live' | 'ended';
title?: string;
autoScroll?: boolean;
emptyState?: {
title: string;
subtitle: string;
};
}
export const UnifiedTranscript = ({
messages,
status,
title,
autoScroll = false,
emptyState
}: UnifiedTranscriptProps) => {
const scrollRef = useRef<HTMLDivElement>(null);
// Auto-scroll to bottom when new messages arrive (for live mode)
useEffect(() => {
if (autoScroll && scrollRef.current) {
scrollRef.current.scrollTop = scrollRef.current.scrollHeight;
}
}, [messages, autoScroll]);
// Calculate message count (exclude system messages like function calls, node transitions, TTFB)
const messageCount = messages.filter(
m => m.type === 'user-transcription' || m.type === 'bot-text'
).length;
// Convert ProcessedMessage to TranscriptMessageData
const transcriptMessages: TranscriptMessageData[] = messages.map(msg => ({
id: msg.id,
type: msg.type,
text: msg.text,
final: msg.final,
functionName: msg.functionName,
status: msg.status,
nodeName: msg.nodeName,
allowInterrupt: msg.allowInterrupt,
ttfbSeconds: msg.ttfbSeconds,
fatal: msg.fatal,
}));
// Default empty state
const defaultEmptyState = {
title: status === 'live' ? "No messages yet" : "No conversation recorded",
subtitle: status === 'live'
? "Start speaking to see the transcript"
: "Real-time feedback events were not captured"
};
const emptyStateToShow = emptyState || defaultEmptyState;
return (
<TranscriptContainer
title={title || (status === 'live' ? 'Live Transcript' : 'Call Transcript')}
status={status}
messageCount={messageCount > 0 ? messageCount : undefined}
>
<div ref={scrollRef} className="flex-1 overflow-y-auto">
{messages.length === 0 ? (
<TranscriptEmptyState
title={emptyStateToShow.title}
subtitle={emptyStateToShow.subtitle}
/>
) : (
<div className="space-y-3 p-4">
{transcriptMessages.map((msg, index) => {
// Skip standalone TTFB metrics (they're rendered inline with bot text)
if (msg.type === 'ttfb-metric') {
return null;
}
return (
<TranscriptMessage
key={`${msg.id}-${index}`}
message={msg}
nextMessage={transcriptMessages[index + 1]}
/>
);
})}
</div>
)}
</div>
</TranscriptContainer>
);
};

View file

@ -2,5 +2,4 @@ export * from './ApiKeyErrorDialog';
export * from './AudioControls';
export * from './ConnectionStatus';
export * from './ContextDisplay';
export * from './RealtimeFeedback';
export * from './WorkflowConfigErrorDialog';

View file

@ -1,72 +0,0 @@
'use client';
import { MessageSquare, Mic, MicOff } from 'lucide-react';
import { ReactNode } from 'react';
import { cn } from '@/lib/utils';
type CallStatus = 'ready' | 'live' | 'ended';
interface TranscriptContainerProps {
title: string;
status: CallStatus;
children: ReactNode;
messageCount?: number;
}
const STATUS_CONFIG = {
ready: {
icon: MicOff,
label: 'Ready',
className: 'bg-muted text-muted-foreground',
},
live: {
icon: Mic,
label: 'Live',
className: 'bg-green-500/10 text-green-600 dark:text-green-400',
},
ended: {
icon: MicOff,
label: 'Ended',
className: 'bg-muted text-muted-foreground',
},
};
export function TranscriptContainer({
title,
status,
children,
messageCount
}: TranscriptContainerProps) {
const statusConfig = STATUS_CONFIG[status];
const StatusIcon = statusConfig.icon;
return (
<div className="w-full h-full flex flex-col bg-background border-l border-border">
{/* Header */}
<div className="px-4 py-3 border-b border-border shrink-0">
<div className="flex items-center justify-center gap-2">
<MessageSquare className="h-4 w-4 text-muted-foreground shrink-0" />
<span className="font-medium text-sm whitespace-nowrap">{title}</span>
<div className={cn(
"flex items-center gap-1 text-xs px-2 py-0.5 rounded-full shrink-0",
statusConfig.className
)}>
<StatusIcon className="h-3 w-3" />
<span>{statusConfig.label}</span>
</div>
</div>
</div>
{/* Content */}
{children}
{/* Footer with message count */}
{messageCount !== undefined && messageCount > 0 && (
<div className="px-4 py-2 border-t border-border text-xs text-muted-foreground shrink-0">
{messageCount} messages
</div>
)}
</div>
);
}

View file

@ -1,20 +0,0 @@
'use client';
import { MessageSquare } from 'lucide-react';
interface TranscriptEmptyStateProps {
title: string;
subtitle: string;
}
export function TranscriptEmptyState({ title, subtitle }: TranscriptEmptyStateProps) {
return (
<div className="flex flex-col items-center justify-center h-full text-muted-foreground text-sm">
<MessageSquare className="h-10 w-10 mb-4 opacity-30" />
<p className="font-medium">{title}</p>
<p className="text-xs mt-1 text-center px-4">
{subtitle}
</p>
</div>
);
}

View file

@ -1,154 +0,0 @@
'use client';
import { AlertTriangle, Brain, ExternalLink, GitBranch, MicOff, Wrench } from 'lucide-react';
import { cn } from '@/lib/utils';
export interface TranscriptMessageData {
id: string;
type: 'user-transcription' | 'bot-text' | 'function-call' | 'node-transition' | 'ttfb-metric' | 'pipeline-error' | 'interrupt-warning';
text: string;
final?: boolean;
functionName?: string;
nodeName?: string;
allowInterrupt?: boolean;
ttfbSeconds?: number;
fatal?: boolean;
}
interface TranscriptMessageProps {
message: TranscriptMessageData;
nextMessage?: TranscriptMessageData;
}
export function TranscriptMessage({ message, nextMessage }: TranscriptMessageProps) {
// Node transition - show as section divider
if (message.type === 'node-transition') {
return (
<div className="flex items-center gap-2 py-2">
<div className="flex-1 h-px bg-border"></div>
<div className="px-2 py-1 rounded-md text-xs bg-blue-500/10 border border-blue-500/20 inline-flex items-center gap-1.5">
<GitBranch className="h-3 w-3 text-blue-500" />
<span className="font-medium text-blue-700 dark:text-blue-400">
{message.nodeName}
</span>
</div>
<div className="flex-1 h-px bg-border"></div>
</div>
);
}
// Interrupt warning - show as an amber alert (one-time)
if (message.type === 'interrupt-warning') {
return (
<div className="flex items-start gap-2 px-3 py-2 rounded-lg bg-amber-500/10 border border-amber-500/20">
<MicOff className="h-4 w-4 text-amber-500 mt-0.5 shrink-0" />
<div className="flex-1 min-w-0">
<div className="text-xs font-medium text-amber-700 dark:text-amber-400">
Interruption Disabled
</div>
<div className="text-sm text-amber-600 dark:text-amber-300 mt-0.5">
{message.text}
</div>
<a
href="https://docs.dograh.com/configurations/interruption"
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-1 text-xs text-amber-600 dark:text-amber-400 hover:underline mt-1"
>
Learn more <ExternalLink className="h-3 w-3" />
</a>
</div>
</div>
);
}
// Pipeline error - show as a red alert
if (message.type === 'pipeline-error') {
return (
<div className="flex items-start gap-2 px-3 py-2 rounded-lg bg-red-500/10 border border-red-500/20">
<AlertTriangle className="h-4 w-4 text-red-500 mt-0.5 shrink-0" />
<div className="flex-1 min-w-0">
<div className="text-xs font-medium text-red-700 dark:text-red-400">
{message.fatal ? 'Fatal Pipeline Error' : 'Pipeline Error'}
</div>
<div className="text-sm text-red-600 dark:text-red-300 mt-0.5 break-words">
{message.text}
</div>
</div>
</div>
);
}
// TTFB metric - don't render standalone, it'll be shown with bot messages and function calls
if (message.type === 'ttfb-metric') {
return null;
}
// Function call message - centered with TTFB if present
if (message.type === 'function-call') {
const ttfbMetric = nextMessage?.type === 'ttfb-metric' ? nextMessage : null;
return (
<div className="flex flex-col items-center gap-1">
{/* Show TTFB metric above function call */}
{ttfbMetric && ttfbMetric.ttfbSeconds !== undefined && (
<div className="flex items-center gap-1.5 text-xs text-muted-foreground">
<Brain className="h-3 w-3" />
<span className="font-medium">Reasoning Delay:</span>
<span>{(ttfbMetric.ttfbSeconds * 1000).toFixed(0)}ms</span>
</div>
)}
<div className="px-3 py-1.5 rounded-full text-xs bg-amber-500/10 border border-amber-500/20 inline-flex items-center gap-2">
<Wrench className="h-3 w-3 text-amber-500" />
<span className="font-mono text-amber-700 dark:text-amber-400">
{message.functionName}()
</span>
</div>
</div>
);
}
const isUser = message.type === 'user-transcription';
const isBot = message.type === 'bot-text';
// Check if next message is a TTFB metric (for bot messages)
const ttfbMetric = isBot && nextMessage?.type === 'ttfb-metric' ? nextMessage : null;
// User messages on right, bot messages on left
return (
<div className={cn(
"flex",
isUser ? "justify-end" : "justify-start"
)}>
<div className="flex flex-col gap-1 max-w-[85%]">
{/* Show TTFB metric above bot messages */}
{ttfbMetric && ttfbMetric.ttfbSeconds !== undefined && (
<div className="flex items-center gap-1.5 text-xs text-muted-foreground px-1">
<Brain className="h-3 w-3" />
<span className="font-medium">Reasoning Delay:</span>
<span>{(ttfbMetric.ttfbSeconds * 1000).toFixed(0)}ms</span>
</div>
)}
<div
className={cn(
"px-3 py-2 rounded-2xl text-sm",
isUser
? "bg-primary text-primary-foreground rounded-br-md"
: "bg-muted rounded-bl-md",
!message.final && "opacity-70"
)}
>
<div className="whitespace-pre-wrap leading-relaxed">{message.text}</div>
{!message.final && (
<div className={cn(
"text-[10px] mt-1 italic",
isUser ? "text-primary-foreground/70" : "text-muted-foreground"
)}>
speaking...
</div>
)}
</div>
</div>
</div>
);
}

View file

@ -4,6 +4,7 @@ import { client } from "@/client/client.gen";
import { getTurnCredentialsApiV1TurnCredentialsGet, validateUserConfigurationsApiV1UserConfigurationsUserValidateGet, validateWorkflowApiV1WorkflowWorkflowIdValidatePost } from "@/client/sdk.gen";
import { TurnCredentialsResponse } from "@/client/types.gen";
import { WorkflowValidationError } from "@/components/flow/types";
import type { ConversationNodeTransitionItem, RealtimeFeedbackMessage as FeedbackMessage } from "@/components/workflow/conversation";
import { useAppConfig } from "@/context/AppConfigContext";
import logger from '@/lib/logger';
@ -15,29 +16,10 @@ interface UseWebSocketRTCProps {
workflowRunId: number;
accessToken: string | null;
initialContextVariables?: Record<string, string> | null;
onNodeTransition?: (transition: ConversationNodeTransitionItem) => void;
}
export interface FeedbackMessage {
id: string;
type: 'user-transcription' | 'bot-text' | 'function-call' | 'node-transition' | 'ttfb-metric' | 'pipeline-error' | 'interrupt-warning';
text: string;
final?: boolean;
timestamp: string;
functionName?: string;
status?: 'running' | 'completed';
// Node transition fields
nodeName?: string;
previousNode?: string;
allowInterrupt?: boolean;
// TTFB metric fields
ttfbSeconds?: number;
processor?: string;
model?: string;
// Pipeline error fields
fatal?: boolean;
}
export const useWebSocketRTC = ({ workflowId, workflowRunId, accessToken, initialContextVariables }: UseWebSocketRTCProps) => {
export const useWebSocketRTC = ({ workflowId, workflowRunId, accessToken, initialContextVariables, onNodeTransition }: UseWebSocketRTCProps) => {
const [connectionStatus, setConnectionStatus] = useState<'idle' | 'connecting' | 'connected' | 'failed'>('idle');
const [connectionActive, setConnectionActive] = useState(false);
const [isCompleted, setIsCompleted] = useState(false);
@ -72,6 +54,11 @@ export const useWebSocketRTC = ({ workflowId, workflowRunId, accessToken, initia
const pcRef = useRef<RTCPeerConnection | null>(null);
const wsRef = useRef<WebSocket | null>(null);
const timeStartRef = useRef<number | null>(null);
const onNodeTransitionRef = useRef(onNodeTransition);
useEffect(() => {
onNodeTransitionRef.current = onNodeTransition;
}, [onNodeTransition]);
// Generate a cryptographically secure unique ID
const generateSecureId = () => {
@ -379,18 +366,22 @@ export const useWebSocketRTC = ({ workflowId, workflowRunId, accessToken, initia
}
case 'rtf-function-call-start': {
const { function_name, tool_call_id } = message.payload;
const { function_name, tool_call_id, arguments: toolArguments } = message.payload;
setFeedbackMessages(prev => {
// Check if we already have this function call
const existingId = `func-${tool_call_id}`;
const existingId = tool_call_id
? `func-${tool_call_id}`
: `func-${Date.now()}`;
if (prev.some(msg => msg.id === existingId)) {
return prev;
}
return [...prev, {
id: existingId,
type: 'function-call',
text: function_name,
functionName: function_name,
text: function_name ?? 'tool',
functionName: function_name ?? 'tool',
toolCallId: tool_call_id,
arguments: toolArguments,
status: 'running',
timestamp: new Date().toISOString(),
}];
@ -402,24 +393,44 @@ export const useWebSocketRTC = ({ workflowId, workflowRunId, accessToken, initia
const { tool_call_id, result } = message.payload;
setFeedbackMessages(prev => prev.map(msg =>
msg.id === `func-${tool_call_id}`
? { ...msg, status: 'completed' as const, text: result || msg.text }
? { ...msg, status: 'completed' as const, text: result || msg.text, result }
: msg
));
break;
}
case 'rtf-node-transition': {
const { node_name, previous_node_name, allow_interrupt } = message.payload;
const {
node_id,
node_name,
previous_node_id,
previous_node_name,
allow_interrupt,
} = message.payload;
currentAllowInterruptRef.current = allow_interrupt;
setFeedbackMessages(prev => [...prev, {
const transitionTimestamp = new Date().toISOString();
const transition: ConversationNodeTransitionItem = {
kind: 'node-transition',
id: `node-${Date.now()}`,
timestamp: transitionTimestamp,
nodeId: node_id,
nodeName: node_name ?? 'Node',
previousNodeId: previous_node_id,
previousNodeName: previous_node_name,
allowInterrupt: allow_interrupt,
};
setFeedbackMessages(prev => [...prev, {
id: transition.id,
type: 'node-transition',
text: node_name,
nodeName: node_name,
text: transition.nodeName,
nodeId: transition.nodeId,
nodeName: transition.nodeName,
previousNodeId: transition.previousNodeId,
previousNode: previous_node_name,
allowInterrupt: allow_interrupt,
timestamp: new Date().toISOString(),
timestamp: transitionTimestamp,
}]);
onNodeTransitionRef.current?.(transition);
break;
}

View file

@ -1,40 +1,107 @@
'use client';
import { Check, Copy, ExternalLink, FileText, LoaderCircle, Phone, Video } from 'lucide-react';
import { Check, Copy, ExternalLink, FileText, Video } from 'lucide-react';
import Link from 'next/link';
import { useParams, useRouter } from 'next/navigation';
import { useParams } from 'next/navigation';
import posthog from 'posthog-js';
import { useEffect, useRef, useState } from 'react';
import BrowserCall from '@/app/workflow/[workflowId]/run/[runId]/BrowserCall';
import { RealtimeFeedback, WorkflowRunLogs } from '@/app/workflow/[workflowId]/run/[runId]/components/RealtimeFeedback';
import WorkflowLayout from '@/app/workflow/WorkflowLayout';
import {
createWorkflowRunApiV1WorkflowWorkflowIdRunsPost,
getWorkflowRunApiV1WorkflowWorkflowIdRunsRunIdGet,
} from '@/client/sdk.gen';
import { getWorkflowRunApiV1WorkflowWorkflowIdRunsRunIdGet } from '@/client/sdk.gen';
import { MediaPreviewButton, MediaPreviewDialog } from '@/components/MediaPreviewDialog';
import { OnboardingTooltip } from '@/components/onboarding/OnboardingTooltip';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardFooter, CardHeader, CardTitle } from '@/components/ui/card';
import { Skeleton } from '@/components/ui/skeleton';
import { ConversationRailFrame, RealtimeFeedback, WorkflowRunLogs } from '@/components/workflow/conversation';
import { PostHogEvent } from '@/constants/posthog-events';
import { WORKFLOW_RUN_MODES } from '@/constants/workflowRunModes';
import { useOnboarding } from '@/context/OnboardingContext';
import { useAuth } from '@/lib/auth';
import { downloadFile } from '@/lib/files';
import { getRandomId } from '@/lib/utils';
interface WorkflowRunResponse {
mode: string;
is_completed: boolean;
transcript_url: string | null;
recording_url: string | null;
cost_info: {
dograh_token_usage?: number | null;
call_duration_seconds?: number | null;
} | null;
initial_context: Record<string, string | number | boolean | object> | null;
gathered_context: Record<string, string | number | boolean | object> | null;
logs: WorkflowRunLogs | null;
annotations: Record<string, unknown> | null;
}
const RUN_SHELL_HEIGHT_CLASS = "h-[calc(100svh-49px)] min-h-[calc(100svh-49px)] max-h-[calc(100svh-49px)]";
function formatDuration(seconds?: number | null) {
if (seconds == null || Number.isNaN(seconds)) return 'N/A';
const mins = Math.floor(seconds / 60);
const secs = seconds % 60;
if (mins === 0) return `${secs}s`;
return `${mins}m ${secs}s`;
}
function getTranscriptMetrics(logs: WorkflowRunLogs | null, gatheredContext: Record<string, string | number | boolean | object> | null) {
const events = logs?.realtime_feedback_events ?? [];
const userTurns = events.filter((event) => event.type === 'rtf-user-transcription' && event.payload.final).length;
const botTurns = events.filter((event) => event.type === 'rtf-bot-text').length;
const toolCalls = events.filter((event) => event.type === 'rtf-function-call-end').length;
const nodeNames = new Set(
events
.map((event) => event.payload.node_name)
.filter((nodeName): nodeName is string => Boolean(nodeName))
);
const visitedNodes = Array.isArray(gatheredContext?.nodes_visited)
? gatheredContext.nodes_visited.length
: nodeNames.size;
return { userTurns, botTurns, toolCalls, visitedNodes };
}
function MetricCard({ label, value }: { label: string; value: string }) {
return (
<div className="rounded-xl border border-border bg-muted/40 px-4 py-3">
<p className="text-xs font-medium uppercase tracking-[0.14em] text-muted-foreground">{label}</p>
<p className="mt-2 text-lg font-semibold text-foreground">{value}</p>
</div>
);
}
function RunMetricsSection({
costInfo,
logs,
gatheredContext,
}: {
costInfo: WorkflowRunResponse['cost_info'];
logs: WorkflowRunLogs | null;
gatheredContext: Record<string, string | number | boolean | object> | null;
}) {
const metrics = getTranscriptMetrics(logs, gatheredContext);
return (
<Card className="border-border">
<CardHeader className="pb-3">
<CardTitle className="text-lg">Run Metrics</CardTitle>
</CardHeader>
<CardContent className="grid gap-3 sm:grid-cols-2 xl:grid-cols-3">
<MetricCard label="Duration" value={formatDuration(costInfo?.call_duration_seconds)} />
<MetricCard
label="Token Usage"
value={costInfo?.dograh_token_usage != null ? costInfo.dograh_token_usage.toLocaleString() : 'N/A'}
/>
<MetricCard label="User Turns" value={String(metrics.userTurns)} />
<MetricCard label="Bot Turns" value={String(metrics.botTurns)} />
<MetricCard label="Tool Calls" value={String(metrics.toolCalls)} />
<MetricCard label="Nodes Visited" value={String(metrics.visitedNodes)} />
</CardContent>
</Card>
);
}
function ContextDisplay({ title, context }: { title: string; context: Record<string, string | number | boolean | object> | null }) {
const [copied, setCopied] = useState(false);
@ -79,9 +146,7 @@ function ContextDisplay({ title, context }: { title: string; context: Record<str
export default function WorkflowRunPage() {
const params = useParams();
const router = useRouter();
const [isLoading, setIsLoading] = useState(true);
const [startingCall, setStartingCall] = useState(false);
const auth = useAuth();
const [workflowRun, setWorkflowRun] = useState<WorkflowRunResponse | null>(null);
const { hasSeenTooltip, markTooltipSeen } = useOnboarding();
@ -94,12 +159,6 @@ export default function WorkflowRunPage() {
}
}, [auth]);
// Shrink and reposition Chatwoot bubble on this page
useEffect(() => {
document.body.classList.add('chatwoot-compact');
return () => document.body.classList.remove('chatwoot-compact');
}, []);
const { openPreview, dialog } = MediaPreviewDialog();
useEffect(() => {
@ -117,9 +176,11 @@ export default function WorkflowRunPage() {
});
setIsLoading(false);
const runData = {
mode: response.data?.mode ?? '',
is_completed: response.data?.is_completed ?? false,
transcript_url: response.data?.transcript_url ?? null,
recording_url: response.data?.recording_url ?? null,
cost_info: response.data?.cost_info ?? null,
initial_context: response.data?.initial_context as Record<string, string> | null ?? null,
gathered_context: response.data?.gathered_context as Record<string, string> | null ?? null,
logs: response.data?.logs as WorkflowRunLogs | null ?? null,
@ -137,25 +198,9 @@ export default function WorkflowRunPage() {
fetchWorkflowRun();
}, [params.workflowId, params.runId, auth]);
const handleTestAgain = async () => {
if (startingCall) return;
setStartingCall(true);
try {
const workflowId = Number(params.workflowId);
const workflowRunName = `WR-${getRandomId()}`;
const response = await createWorkflowRunApiV1WorkflowWorkflowIdRunsPost({
path: { workflow_id: workflowId },
body: { mode: WORKFLOW_RUN_MODES.SMALL_WEBRTC, name: workflowRunName },
});
if (response.data?.id) {
router.push(`/workflow/${workflowId}/run/${response.data.id}`);
}
} finally {
setStartingCall(false);
}
};
let returnValue = null;
const isTextChatRun = workflowRun?.mode === WORKFLOW_RUN_MODES.TEXTCHAT;
const showRunDetailsView = Boolean(workflowRun?.is_completed || isTextChatRun);
if (isLoading) {
returnValue = (
@ -179,36 +224,28 @@ export default function WorkflowRunPage() {
</div>
);
}
else if (workflowRun?.is_completed) {
else if (showRunDetailsView) {
returnValue = (
<div className="flex h-screen w-full overflow-hidden">
{/* Main content - 2/3 width */}
<div className="w-2/3 h-full overflow-y-auto">
<div className="w-full max-w-4xl space-y-6 p-6">
<div className={`flex ${RUN_SHELL_HEIGHT_CLASS} min-h-0 w-full overflow-hidden bg-background`}>
<div className="min-w-0 flex-1 overflow-y-auto">
<div className="mx-auto w-full max-w-4xl space-y-6 p-6">
<Card className="border-border">
<CardHeader className="flex flex-row items-center justify-between">
<div className="flex items-center gap-4">
<CardTitle className="text-2xl">Agent Run Completed</CardTitle>
<div className="h-8 w-8 bg-emerald-500/20 rounded-full flex items-center justify-center">
<svg className="h-5 w-5 text-emerald-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M5 13l4 4L19 7" />
</svg>
<CardTitle className="text-2xl">
{isTextChatRun ? 'Text Chat Session' : 'Agent Run Completed'}
</CardTitle>
<div className={`h-8 w-8 rounded-full flex items-center justify-center ${isTextChatRun ? 'bg-sky-500/15' : 'bg-emerald-500/20'}`}>
{isTextChatRun ? (
<FileText className="h-5 w-5 text-sky-500" />
) : (
<svg className="h-5 w-5 text-emerald-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M5 13l4 4L19 7" />
</svg>
)}
</div>
</div>
<div className="flex items-center gap-2">
<Button
onClick={handleTestAgain}
disabled={startingCall}
variant="outline"
className="gap-2"
>
{startingCall ? (
<LoaderCircle className="h-4 w-4 animate-spin" />
) : (
<Phone className="h-4 w-4" />
)}
{startingCall ? 'Starting...' : 'Test Again'}
</Button>
<Link href={`/workflow/${params.workflowId}`}>
<Button
ref={customizeButtonRef}
@ -228,41 +265,49 @@ export default function WorkflowRunPage() {
</div>
</CardHeader>
<CardContent>
<p className="text-muted-foreground mb-8">Your voice agent run has been completed successfully. You can preview or download the transcript and recording.</p>
<p className="text-muted-foreground mb-8">
{isTextChatRun
? 'Review the conversation history, metrics, and context captured for this text session.'
: 'Your voice agent run has been completed successfully. You can preview or download the transcript and recording.'}
</p>
<div className="flex flex-wrap gap-4">
<div className="flex items-center gap-2">
<span className="text-sm text-muted-foreground">Preview:</span>
<MediaPreviewButton
recordingUrl={workflowRun?.recording_url}
transcriptUrl={workflowRun?.transcript_url}
runId={Number(params.runId)}
onOpenPreview={openPreview}
/>
</div>
<div className="flex items-center gap-2 border-l border-border pl-4">
<span className="text-sm text-muted-foreground">Download:</span>
<Button
onClick={() => downloadFile(workflowRun?.transcript_url)}
disabled={!workflowRun?.transcript_url || !auth.isAuthenticated}
size="sm"
className="gap-2"
>
<FileText className="h-4 w-4" />
Transcript
</Button>
<Button
onClick={() => downloadFile(workflowRun?.recording_url)}
disabled={!workflowRun?.recording_url || !auth.isAuthenticated}
size="sm"
className="gap-2"
>
<Video className="h-4 w-4" />
Recording
</Button>
</div>
{!isTextChatRun && (
<>
<div className="flex items-center gap-2">
<span className="text-sm text-muted-foreground">Preview:</span>
<MediaPreviewButton
recordingUrl={workflowRun?.recording_url}
transcriptUrl={workflowRun?.transcript_url}
runId={Number(params.runId)}
onOpenPreview={openPreview}
/>
</div>
<div className="flex items-center gap-2 border-l border-border pl-4">
<span className="text-sm text-muted-foreground">Download:</span>
<Button
onClick={() => downloadFile(workflowRun?.transcript_url ?? null)}
disabled={!workflowRun?.transcript_url || !auth.isAuthenticated}
size="sm"
className="gap-2"
>
<FileText className="h-4 w-4" />
Transcript
</Button>
<Button
onClick={() => downloadFile(workflowRun?.recording_url ?? null)}
disabled={!workflowRun?.recording_url || !auth.isAuthenticated}
size="sm"
className="gap-2"
>
<Video className="h-4 w-4" />
Recording
</Button>
</div>
</>
)}
{workflowRun?.gathered_context?.trace_url && (
<div className="flex items-center gap-2 border-l border-border pl-4">
<div className={`flex items-center gap-2 ${isTextChatRun ? '' : 'border-l border-border pl-4'}`}>
<span className="text-sm text-muted-foreground">Trace:</span>
<Button
asChild
@ -285,14 +330,20 @@ export default function WorkflowRunPage() {
</CardContent>
</Card>
<RunMetricsSection
costInfo={workflowRun?.cost_info ?? null}
logs={workflowRun?.logs ?? null}
gatheredContext={workflowRun?.gathered_context ?? null}
/>
<div className="grid gap-6 md:grid-cols-2">
<ContextDisplay
title="Initial Context"
context={workflowRun?.initial_context}
context={workflowRun?.initial_context ?? null}
/>
<ContextDisplay
title="Gathered Context"
context={workflowRun?.gathered_context}
context={workflowRun?.gathered_context ?? null}
/>
</div>
@ -305,33 +356,34 @@ export default function WorkflowRunPage() {
</div>
</div>
{/* Transcript panel - 1/3 width */}
<div className="w-1/3 h-full shrink-0 overflow-hidden">
<RealtimeFeedback mode="historical" logs={workflowRun?.logs} />
<div className="h-full min-h-0 w-[420px] shrink-0 border-l border-border bg-background p-5">
<ConversationRailFrame className="h-full">
<RealtimeFeedback mode="historical" logs={workflowRun?.logs ?? null} />
</ConversationRailFrame>
</div>
</div>
);
}
else {
returnValue =
<div className="h-full flex items-center justify-center">
<BrowserCall
workflowId={Number(params.workflowId)}
workflowRunId={Number(params.runId)}
initialContextVariables={
workflowRun?.initial_context
? Object.fromEntries(
Object.entries(workflowRun.initial_context).map(([key, value]) => [
key,
typeof value === 'object' && value !== null
? JSON.stringify(value)
: String(value)
])
)
: null
}
/>
returnValue = (
<div className="flex h-full items-center justify-center p-6">
<Card className="w-full max-w-xl border-border">
<CardHeader className="space-y-2">
<CardTitle className="text-2xl">Run Details Unavailable</CardTitle>
<p className="text-sm text-muted-foreground">
This run does not have a details view yet. Go back to the workflow to continue testing or make changes.
</p>
</CardHeader>
<CardFooter>
<Button asChild className="gap-2">
<Link href={`/workflow/${params.workflowId}`}>
Customize Agent
</Link>
</Button>
</CardFooter>
</Card>
</div>
);
}
return (
@ -340,7 +392,7 @@ export default function WorkflowRunPage() {
{dialog}
{/* Onboarding Tooltip for Customize Workflow */}
{workflowRun?.is_completed && (
{showRunDetailsView && (
<OnboardingTooltip
title='Customize Your Workflow'
targetRef={customizeButtonRef}

View file

@ -1,153 +0,0 @@
/**
* Utility to process realtime feedback events into a unified transcript format.
* Used by both live WebSocket messages and post-call logs.
*/
export interface TranscriptEvent {
type: 'user-transcription' | 'bot-text' | 'function-call' | 'node-transition' | 'ttfb-metric' | 'pipeline-error' | 'interrupt-warning';
text: string;
final?: boolean;
timestamp: string;
turn?: number;
functionName?: string;
status?: 'running' | 'completed';
nodeName?: string;
previousNode?: string;
allowInterrupt?: boolean;
ttfbSeconds?: number;
processor?: string;
model?: string;
fatal?: boolean;
}
export interface ProcessedMessage {
id: string;
type: TranscriptEvent['type'];
text: string;
final?: boolean;
timestamp: string;
functionName?: string;
status?: 'running' | 'completed';
nodeName?: string;
allowInterrupt?: boolean;
ttfbSeconds?: number;
fatal?: boolean;
}
/**
* Process transcript events (both live and historical).
* Combines consecutive bot-text by turn and associates TTFB metrics.
*/
export function processTranscriptEvents(events: TranscriptEvent[]): ProcessedMessage[] {
// Filter out interim transcriptions and function-call-start events
const filteredEvents = events.filter(event => {
if (event.type === 'user-transcription' && !event.final) return false;
if (event.type === 'function-call' && event.status === 'running') return false;
return true;
});
const processed: ProcessedMessage[] = [];
let currentBotText: { event: TranscriptEvent; text: string } | null = null;
let pendingTtfb: TranscriptEvent | null = null;
const flushBotText = () => {
if (!currentBotText) return;
processed.push(convertToProcessedMessage(currentBotText.event, currentBotText.text));
// Add the pending TTFB metric if it exists
if (pendingTtfb) {
processed.push(convertToProcessedMessage(pendingTtfb));
pendingTtfb = null;
}
currentBotText = null;
};
for (const event of filteredEvents) {
if (event.type === 'ttfb-metric') {
// Store TTFB to associate with the next bot-text or function-call
pendingTtfb = event;
} else if (event.type === 'bot-text') {
// Combine consecutive bot-text from the same turn
if (currentBotText && currentBotText.event.turn === event.turn) {
currentBotText.text = currentBotText.text + ' ' + event.text;
} else {
flushBotText();
currentBotText = { event, text: event.text };
}
} else {
// Handle other events (user-transcription, function-call, node-transition)
flushBotText();
processed.push(convertToProcessedMessage(event));
// Add pending TTFB after function calls
if (event.type === 'function-call' && pendingTtfb) {
processed.push(convertToProcessedMessage(pendingTtfb));
pendingTtfb = null;
}
}
}
// Flush any remaining bot text
flushBotText();
return processed;
}
/**
* Process live messages - optimized version.
*
* Optimizations rely on useWebSocketRTC.tsx already handling:
* - Bot text accumulation (consecutive chunks combined with spaces)
* - Interim transcription filtering (only final transcriptions kept)
* - Function call status (start events filtered, only completed kept)
*
* This function only needs to:
* - Associate TTFB metrics with the preceding bot-text or function-call
* - Convert to ProcessedMessage format
*/
export function processLiveMessages(messages: TranscriptEvent[]): ProcessedMessage[] {
const processed: ProcessedMessage[] = [];
let pendingTtfb: TranscriptEvent | null = null;
for (const msg of messages) {
if (msg.type === 'ttfb-metric') {
// Store TTFB to associate with next message
pendingTtfb = msg;
} else {
// Add the message
processed.push(convertToProcessedMessage(msg));
// Add pending TTFB after final bot-text or completed function calls
if ((msg.type === 'bot-text' && msg.final) ||
(msg.type === 'function-call' && msg.status === 'completed')) {
if (pendingTtfb) {
processed.push(convertToProcessedMessage(pendingTtfb));
pendingTtfb = null;
}
}
}
}
return processed;
}
// Alias for backward compatibility
export const processHistoricalEvents = processTranscriptEvents;
function convertToProcessedMessage(event: TranscriptEvent, overrideText?: string): ProcessedMessage {
return {
id: `${event.type}-${event.timestamp}`,
type: event.type,
text: overrideText ?? event.text,
final: event.final ?? true,
timestamp: event.timestamp,
functionName: event.functionName,
status: event.status,
nodeName: event.nodeName,
allowInterrupt: event.allowInterrupt,
ttfbSeconds: event.ttfbSeconds,
fatal: event.fatal,
};
}

View file

@ -3,7 +3,7 @@
import { useRouter } from 'next/navigation';
import { useState } from 'react';
import { createWorkflowFromTemplateApiV1WorkflowCreateTemplatePost, createWorkflowRunApiV1WorkflowWorkflowIdRunsPost } from '@/client/sdk.gen';
import { createWorkflowFromTemplateApiV1WorkflowCreateTemplatePost } from '@/client/sdk.gen';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import {
@ -18,10 +18,8 @@ import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import { Textarea } from '@/components/ui/textarea';
import { WORKFLOW_RUN_MODES } from '@/constants/workflowRunModes';
import { useAuth } from '@/lib/auth';
import logger from '@/lib/logger';
import { getRandomId } from '@/lib/utils';
export default function CreateWorkflowPage() {
const router = useRouter();
@ -76,36 +74,9 @@ export default function CreateWorkflowPage() {
}
};
const handleModalContinue = async () => {
if (!workflowId || !user) return;
try {
const accessToken = await getAccessToken();
const workflowRunName = `WR-${getRandomId()}`;
// Create a workflow run
const response = await createWorkflowRunApiV1WorkflowWorkflowIdRunsPost({
path: {
workflow_id: Number(workflowId),
},
body: {
mode: WORKFLOW_RUN_MODES.SMALL_WEBRTC, // Same mode as "Web Call" button
name: workflowRunName
},
headers: {
'Authorization': `Bearer ${accessToken}`,
},
});
// Navigate to the workflow run page
if (response.data?.id) {
router.push(`/workflow/${workflowId}/run/${response.data.id}`);
}
} catch (err) {
logger.error(`Error creating workflow run: ${err}`);
// Fallback to workflow page if run creation fails
router.push(`/workflow/${workflowId}`);
}
const handleModalContinue = () => {
if (!workflowId) return;
router.push(`/workflow/${workflowId}?onboarding=web_call`);
};
return (
@ -233,7 +204,7 @@ export default function CreateWorkflowPage() {
The voice bot is pre-set to communicate in English with an American accent.
</p>
<p>
Next steps would be to test the voice bot using web call, and then modify it to suit your use case.
Next steps would be to test the voice bot in the editor, and then modify it to suit your use case.
</p>
</div>
</DialogDescription>
@ -243,7 +214,7 @@ export default function CreateWorkflowPage() {
onClick={handleModalContinue}
className="w-full"
>
Start Web Call
Open and Test Agent
</Button>
</DialogFooter>
</DialogContent>

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View file

@ -178,6 +178,20 @@ export type AmbientNoiseUploadResponse = {
storage_backend: string;
};
/**
* AppendTextChatMessageRequest
*/
export type AppendTextChatMessageRequest = {
/**
* Text
*/
text: string;
/**
* Expected Revision
*/
expected_revision?: number | null;
};
/**
* AuthResponse
*/
@ -890,6 +904,28 @@ export type CreateServiceKeyResponse = {
expires_at?: string | null;
};
/**
* CreateTextChatSessionRequest
*/
export type CreateTextChatSessionRequest = {
/**
* Name
*/
name?: string | null;
/**
* Initial Context
*/
initial_context?: {
[key: string]: unknown;
} | null;
/**
* Annotations
*/
annotations?: {
[key: string]: unknown;
} | null;
};
/**
* CreateToolRequest
*
@ -2932,6 +2968,20 @@ export type RetryConfigResponse = {
retry_on_voicemail: boolean;
};
/**
* RewindTextChatSessionRequest
*/
export type RewindTextChatSessionRequest = {
/**
* Cursor Turn Id
*/
cursor_turn_id?: string | null;
/**
* Expected Revision
*/
expected_revision?: number | null;
};
/**
* S3SignedUrlResponse
*/
@ -4493,6 +4543,78 @@ export type WorkflowRunResponseSchema = {
} | null;
};
/**
* WorkflowRunTextSessionResponse
*/
export type WorkflowRunTextSessionResponse = {
/**
* Workflow Run Id
*/
workflow_run_id: number;
/**
* Workflow Id
*/
workflow_id: number;
/**
* Name
*/
name: string;
/**
* Mode
*/
mode: string;
/**
* State
*/
state: string;
/**
* Is Completed
*/
is_completed: boolean;
/**
* Revision
*/
revision: number;
/**
* Initial Context
*/
initial_context?: {
[key: string]: unknown;
} | null;
/**
* Gathered Context
*/
gathered_context?: {
[key: string]: unknown;
} | null;
/**
* Annotations
*/
annotations?: {
[key: string]: unknown;
} | null;
/**
* Session Data
*/
session_data: {
[key: string]: unknown;
};
/**
* Checkpoint
*/
checkpoint: {
[key: string]: unknown;
};
/**
* Created At
*/
created_at: string;
/**
* Updated At
*/
updated_at?: string | null;
};
/**
* WorkflowRunUsageResponse
*/
@ -4553,6 +4675,10 @@ export type WorkflowRunUsageResponse = {
* Call Type
*/
call_type?: string | null;
/**
* Mode
*/
mode?: string | null;
/**
* Disposition
*/
@ -6245,6 +6371,194 @@ export type GetAmbientNoiseUploadUrlApiV1WorkflowAmbientNoiseUploadUrlPostRespon
export type GetAmbientNoiseUploadUrlApiV1WorkflowAmbientNoiseUploadUrlPostResponse = GetAmbientNoiseUploadUrlApiV1WorkflowAmbientNoiseUploadUrlPostResponses[keyof GetAmbientNoiseUploadUrlApiV1WorkflowAmbientNoiseUploadUrlPostResponses];
export type CreateTextChatSessionApiV1WorkflowWorkflowIdTextChatSessionsPostData = {
body: CreateTextChatSessionRequest;
headers?: {
/**
* Authorization
*/
authorization?: string | null;
/**
* X-Api-Key
*/
'X-API-Key'?: string | null;
};
path: {
/**
* Workflow Id
*/
workflow_id: number;
};
query?: never;
url: '/api/v1/workflow/{workflow_id}/text-chat/sessions';
};
export type CreateTextChatSessionApiV1WorkflowWorkflowIdTextChatSessionsPostErrors = {
/**
* Not found
*/
404: unknown;
/**
* Validation Error
*/
422: HttpValidationError;
};
export type CreateTextChatSessionApiV1WorkflowWorkflowIdTextChatSessionsPostError = CreateTextChatSessionApiV1WorkflowWorkflowIdTextChatSessionsPostErrors[keyof CreateTextChatSessionApiV1WorkflowWorkflowIdTextChatSessionsPostErrors];
export type CreateTextChatSessionApiV1WorkflowWorkflowIdTextChatSessionsPostResponses = {
/**
* Successful Response
*/
200: WorkflowRunTextSessionResponse;
};
export type CreateTextChatSessionApiV1WorkflowWorkflowIdTextChatSessionsPostResponse = CreateTextChatSessionApiV1WorkflowWorkflowIdTextChatSessionsPostResponses[keyof CreateTextChatSessionApiV1WorkflowWorkflowIdTextChatSessionsPostResponses];
export type GetTextChatSessionApiV1WorkflowWorkflowIdTextChatSessionsRunIdGetData = {
body?: never;
headers?: {
/**
* Authorization
*/
authorization?: string | null;
/**
* X-Api-Key
*/
'X-API-Key'?: string | null;
};
path: {
/**
* Workflow Id
*/
workflow_id: number;
/**
* Run Id
*/
run_id: number;
};
query?: never;
url: '/api/v1/workflow/{workflow_id}/text-chat/sessions/{run_id}';
};
export type GetTextChatSessionApiV1WorkflowWorkflowIdTextChatSessionsRunIdGetErrors = {
/**
* Not found
*/
404: unknown;
/**
* Validation Error
*/
422: HttpValidationError;
};
export type GetTextChatSessionApiV1WorkflowWorkflowIdTextChatSessionsRunIdGetError = GetTextChatSessionApiV1WorkflowWorkflowIdTextChatSessionsRunIdGetErrors[keyof GetTextChatSessionApiV1WorkflowWorkflowIdTextChatSessionsRunIdGetErrors];
export type GetTextChatSessionApiV1WorkflowWorkflowIdTextChatSessionsRunIdGetResponses = {
/**
* Successful Response
*/
200: WorkflowRunTextSessionResponse;
};
export type GetTextChatSessionApiV1WorkflowWorkflowIdTextChatSessionsRunIdGetResponse = GetTextChatSessionApiV1WorkflowWorkflowIdTextChatSessionsRunIdGetResponses[keyof GetTextChatSessionApiV1WorkflowWorkflowIdTextChatSessionsRunIdGetResponses];
export type AppendTextChatMessageApiV1WorkflowWorkflowIdTextChatSessionsRunIdMessagesPostData = {
body: AppendTextChatMessageRequest;
headers?: {
/**
* Authorization
*/
authorization?: string | null;
/**
* X-Api-Key
*/
'X-API-Key'?: string | null;
};
path: {
/**
* Workflow Id
*/
workflow_id: number;
/**
* Run Id
*/
run_id: number;
};
query?: never;
url: '/api/v1/workflow/{workflow_id}/text-chat/sessions/{run_id}/messages';
};
export type AppendTextChatMessageApiV1WorkflowWorkflowIdTextChatSessionsRunIdMessagesPostErrors = {
/**
* Not found
*/
404: unknown;
/**
* Validation Error
*/
422: HttpValidationError;
};
export type AppendTextChatMessageApiV1WorkflowWorkflowIdTextChatSessionsRunIdMessagesPostError = AppendTextChatMessageApiV1WorkflowWorkflowIdTextChatSessionsRunIdMessagesPostErrors[keyof AppendTextChatMessageApiV1WorkflowWorkflowIdTextChatSessionsRunIdMessagesPostErrors];
export type AppendTextChatMessageApiV1WorkflowWorkflowIdTextChatSessionsRunIdMessagesPostResponses = {
/**
* Successful Response
*/
200: WorkflowRunTextSessionResponse;
};
export type AppendTextChatMessageApiV1WorkflowWorkflowIdTextChatSessionsRunIdMessagesPostResponse = AppendTextChatMessageApiV1WorkflowWorkflowIdTextChatSessionsRunIdMessagesPostResponses[keyof AppendTextChatMessageApiV1WorkflowWorkflowIdTextChatSessionsRunIdMessagesPostResponses];
export type RewindTextChatSessionApiV1WorkflowWorkflowIdTextChatSessionsRunIdRewindPostData = {
body: RewindTextChatSessionRequest;
headers?: {
/**
* Authorization
*/
authorization?: string | null;
/**
* X-Api-Key
*/
'X-API-Key'?: string | null;
};
path: {
/**
* Workflow Id
*/
workflow_id: number;
/**
* Run Id
*/
run_id: number;
};
query?: never;
url: '/api/v1/workflow/{workflow_id}/text-chat/sessions/{run_id}/rewind';
};
export type RewindTextChatSessionApiV1WorkflowWorkflowIdTextChatSessionsRunIdRewindPostErrors = {
/**
* Not found
*/
404: unknown;
/**
* Validation Error
*/
422: HttpValidationError;
};
export type RewindTextChatSessionApiV1WorkflowWorkflowIdTextChatSessionsRunIdRewindPostError = RewindTextChatSessionApiV1WorkflowWorkflowIdTextChatSessionsRunIdRewindPostErrors[keyof RewindTextChatSessionApiV1WorkflowWorkflowIdTextChatSessionsRunIdRewindPostErrors];
export type RewindTextChatSessionApiV1WorkflowWorkflowIdTextChatSessionsRunIdRewindPostResponses = {
/**
* Successful Response
*/
200: WorkflowRunTextSessionResponse;
};
export type RewindTextChatSessionApiV1WorkflowWorkflowIdTextChatSessionsRunIdRewindPostResponse = RewindTextChatSessionApiV1WorkflowWorkflowIdTextChatSessionsRunIdRewindPostResponses[keyof RewindTextChatSessionApiV1WorkflowWorkflowIdTextChatSessionsRunIdRewindPostResponses];
export type GetDefaultConfigurationsApiV1UserConfigurationsDefaultsGetData = {
body?: never;
path?: never;

View file

@ -1,5 +1,6 @@
"use client";
import { usePathname } from "next/navigation";
import { useEffect } from "react";
declare global {
@ -22,7 +23,22 @@ const CHATWOOT_BASE_URL = process.env.NEXT_PUBLIC_CHATWOOT_URL;
const CHATWOOT_WEBSITE_TOKEN = process.env.NEXT_PUBLIC_CHATWOOT_TOKEN;
export default function ChatwootWidget() {
const pathname = usePathname();
useEffect(() => {
const isWorkflowPage = /^\/workflow\/[^/]+(?:\/.*)?$/.test(pathname);
if (isWorkflowPage) {
document.getElementById("cw-widget-holder")?.remove();
document.getElementById("cw-bubble-holder")?.remove();
document.getElementById("cw-widget-styles")?.remove();
document
.querySelector(`script[src="${CHATWOOT_BASE_URL}/packs/js/sdk.js"]`)
?.remove();
delete window.chatwootSettings;
return;
}
// Don't initialize if environment variables are not set
if (!CHATWOOT_BASE_URL || !CHATWOOT_WEBSITE_TOKEN) {
console.warn("Chatwoot not configured: Missing NEXT_PUBLIC_CHATWOOT_URL or NEXT_PUBLIC_CHATWOOT_TOKEN");
@ -72,7 +88,7 @@ export default function ChatwootWidget() {
};
document.body.appendChild(script);
}, []);
}, [pathname]);
return null;
}

View file

@ -9,8 +9,10 @@ export const BaseNode = forwardRef<
invalid?: boolean;
selected_through_edge?: boolean;
hovered_through_edge?: boolean;
runtimeActive?: boolean;
runtimePulseNonce?: number;
}
>(({ className, selected, invalid, selected_through_edge, hovered_through_edge, ...props }, ref) => (
>(({ children, className, selected, invalid, selected_through_edge, hovered_through_edge, runtimeActive, runtimePulseNonce, ...props }, ref) => (
<div
ref={ref}
className={cn(
@ -26,11 +28,22 @@ export const BaseNode = forwardRef<
// Hovered through edge takes precedence over selected through edge
hovered_through_edge ? "ring-2 ring-primary/60 shadow-[0_0_12px_rgba(96,165,250,0.3)]" : "",
!hovered_through_edge && selected_through_edge ? "ring-1 ring-primary/50 shadow-[0_0_8px_rgba(59,130,246,0.2)]" : "",
runtimeActive ? "ring-2 ring-sky-400/60 shadow-[0_0_0_1px_rgba(56,189,248,0.18),0_0_24px_rgba(14,165,233,0.18)]" : "",
!selected_through_edge && !hovered_through_edge && "hover:border-muted-foreground/50",
)}
tabIndex={0}
{...props}
/>
>
{runtimeActive ? (
<span
key={`runtime-pulse-${runtimePulseNonce ?? 0}`}
className="pointer-events-none absolute -inset-2 rounded-[18px] border-2 border-sky-400/55"
aria-hidden="true"
style={{ animation: "ping 900ms ease-out 2" }}
/>
) : null}
{children}
</div>
));
BaseNode.displayName = "BaseNode";

View file

@ -608,6 +608,8 @@ export const GenericNode = memo(({ data, selected, id, type }: GenericNodeProps)
invalid={data.invalid}
selected_through_edge={data.selected_through_edge}
hovered_through_edge={data.hovered_through_edge}
runtimeActive={data.runtime_active}
runtimePulseNonce={data.runtime_pulse_nonce}
title={data.name || fallbackTitle}
icon={<Icon />}
badgeLabel={badge.label}

View file

@ -10,6 +10,8 @@ interface NodeContentProps {
invalid?: boolean;
selected_through_edge?: boolean;
hovered_through_edge?: boolean;
runtimeActive?: boolean;
runtimePulseNonce?: number;
title: string;
icon: ReactNode;
badgeLabel?: string;
@ -31,6 +33,8 @@ export const NodeContent = ({
invalid,
selected_through_edge,
hovered_through_edge,
runtimeActive,
runtimePulseNonce,
title,
icon,
badgeLabel,
@ -54,6 +58,8 @@ export const NodeContent = ({
invalid={invalid}
selected_through_edge={selected_through_edge}
hovered_through_edge={hovered_through_edge}
runtimeActive={runtimeActive}
runtimePulseNonce={runtimePulseNonce}
className={`p-0 ${className}`}
onDoubleClick={onDoubleClick}
>

View file

@ -17,6 +17,8 @@ export type FlowNodeData = {
validationMessage?: string | null;
selected_through_edge?: boolean;
hovered_through_edge?: boolean;
runtime_active?: boolean;
runtime_pulse_nonce?: number;
allow_interrupt?: boolean;
extraction_enabled?: boolean;
extraction_prompt?: string;

View file

@ -0,0 +1,71 @@
"use client";
import { MessageSquare, Mic, MicOff } from "lucide-react";
import type { ReactNode } from "react";
import { cn } from "@/lib/utils";
import type { ConversationStatus } from "./types";
interface ConversationContainerProps {
title: string;
status: ConversationStatus;
children: ReactNode;
messageCount?: number;
}
const STATUS_CONFIG = {
ready: {
icon: MicOff,
label: "Ready",
className: "bg-muted text-muted-foreground",
},
live: {
icon: Mic,
label: "Live",
className: "bg-green-500/10 text-green-600 dark:text-green-400",
},
ended: {
icon: MicOff,
label: "Ended",
className: "bg-muted text-muted-foreground",
},
} satisfies Record<ConversationStatus, { icon: typeof Mic; label: string; className: string }>;
export function ConversationContainer({
title,
status,
children,
messageCount,
}: ConversationContainerProps) {
const statusConfig = STATUS_CONFIG[status];
const StatusIcon = statusConfig.icon;
return (
<div className="flex h-full min-h-0 w-full flex-col bg-background">
<div className="shrink-0 border-b border-border px-4 py-3">
<div className="flex items-center justify-between gap-3">
<div className="flex min-w-0 items-center gap-2">
<MessageSquare className="h-4 w-4 shrink-0 text-muted-foreground" />
<span className="truncate whitespace-nowrap text-sm font-medium">{title}</span>
</div>
<div className="flex shrink-0 items-center gap-2">
{messageCount !== undefined && messageCount > 0 ? (
<span className="text-xs text-muted-foreground">{messageCount} messages</span>
) : null}
<div
className={cn(
"flex shrink-0 items-center gap-1 rounded-full px-2 py-0.5 text-xs",
statusConfig.className,
)}
>
<StatusIcon className="h-3 w-3" />
<span>{statusConfig.label}</span>
</div>
</div>
</div>
</div>
{children}
</div>
);
}

View file

@ -0,0 +1,15 @@
"use client";
import { MessageSquare } from "lucide-react";
import type { ConversationEmptyStateData } from "./types";
export function ConversationEmptyState({ title, subtitle }: ConversationEmptyStateData) {
return (
<div className="flex h-full flex-col items-center justify-center text-sm text-muted-foreground">
<MessageSquare className="mb-4 h-10 w-10 opacity-30" />
<p className="font-medium">{title}</p>
<p className="mt-1 px-4 text-center text-xs">{subtitle}</p>
</div>
);
}

View file

@ -0,0 +1,61 @@
"use client";
import type { ReactNode } from "react";
import { MessageBubble } from "./MessageBubble";
import { NodeTransitionMarker } from "./NodeTransitionMarker";
import { NoticeCard } from "./NoticeCard";
import { ToolCallCard } from "./ToolCallCard";
import type { ConversationItem } from "./types";
interface ConversationItemViewProps {
item: ConversationItem;
actions?: ReactNode;
}
export function ConversationItemView({ item, actions }: ConversationItemViewProps) {
if (item.kind === "message") {
return (
<div className="group space-y-1">
<MessageBubble
role={item.role}
text={item.text}
final={item.final}
tone={item.tone}
reasoningDurationMs={item.reasoningDurationMs}
/>
{actions ? (
<div className="flex h-5 items-center justify-end gap-1 opacity-0 transition-opacity group-hover:opacity-100 group-focus-within:opacity-100">
{actions}
</div>
) : null}
</div>
);
}
if (item.kind === "tool-call") {
return (
<ToolCallCard
functionName={item.functionName}
status={item.status}
argumentsValue={item.arguments}
resultValue={item.result}
reasoningDurationMs={item.reasoningDurationMs}
/>
);
}
if (item.kind === "node-transition") {
return <NodeTransitionMarker nodeName={item.nodeName} />;
}
return (
<NoticeCard
tone={item.tone}
title={item.title}
text={item.text}
linkHref={item.linkHref}
linkLabel={item.linkLabel}
/>
);
}

View file

@ -0,0 +1,32 @@
"use client";
import type { ReactNode } from "react";
import { cn } from "@/lib/utils";
interface ConversationRailFrameProps {
children: ReactNode;
className?: string;
header?: ReactNode;
footer?: ReactNode;
}
export function ConversationRailFrame({
children,
className,
header,
footer,
}: ConversationRailFrameProps) {
return (
<div
className={cn(
"flex h-full min-h-0 flex-col overflow-hidden rounded-2xl border border-border bg-card shadow-sm",
className,
)}
>
{header ? <div className="shrink-0 border-b border-border px-4 py-3">{header}</div> : null}
<div className="min-h-0 flex-1 overflow-hidden">{children}</div>
{footer ? <div className="shrink-0 border-t border-border px-4 py-3">{footer}</div> : null}
</div>
);
}

View file

@ -0,0 +1,60 @@
"use client";
import type { ReactNode } from "react";
import { useEffect, useRef } from "react";
import { cn } from "@/lib/utils";
import { ConversationEmptyState } from "./ConversationEmptyState";
import { ConversationItemView } from "./ConversationItemView";
import type { ConversationEmptyStateData, ConversationItem } from "./types";
interface ConversationTimelineProps {
items: ConversationItem[];
autoScroll?: boolean;
scrollBehavior?: ScrollBehavior;
emptyState: ConversationEmptyStateData;
pendingIndicator?: ReactNode;
renderItemActions?: (item: ConversationItem) => ReactNode;
className?: string;
}
export function ConversationTimeline({
items,
autoScroll = false,
scrollBehavior = "auto",
emptyState,
pendingIndicator,
renderItemActions,
className,
}: ConversationTimelineProps) {
const scrollContainerRef = useRef<HTMLDivElement | null>(null);
const scrollEndRef = useRef<HTMLDivElement | null>(null);
useEffect(() => {
if (!autoScroll) {
return;
}
scrollEndRef.current?.scrollIntoView({ behavior: scrollBehavior, block: "end" });
}, [autoScroll, items, pendingIndicator, scrollBehavior]);
return (
<div ref={scrollContainerRef} className={cn("flex-1 overflow-y-auto", className)}>
{items.length === 0 && !pendingIndicator ? (
<ConversationEmptyState title={emptyState.title} subtitle={emptyState.subtitle} />
) : (
<div className="space-y-3 p-4">
{items.map((item) => (
<ConversationItemView
key={item.id}
item={item}
actions={renderItemActions?.(item)}
/>
))}
{pendingIndicator}
<div ref={scrollEndRef} />
</div>
)}
</div>
);
}

View file

@ -0,0 +1,61 @@
"use client";
import { Brain } from "lucide-react";
import { cn } from "@/lib/utils";
interface MessageBubbleProps {
role: "user" | "assistant";
text: string;
final?: boolean;
tone?: "default" | "muted";
reasoningDurationMs?: number;
}
export function MessageBubble({
role,
text,
final = true,
tone = "default",
reasoningDurationMs,
}: MessageBubbleProps) {
const isUser = role === "user";
const isMuted = tone === "muted";
return (
<div className={cn("flex", isUser ? "justify-end" : "justify-start")}>
<div className="flex max-w-[85%] flex-col gap-1">
{!isUser && reasoningDurationMs !== undefined ? (
<div className="flex items-center gap-1.5 px-1 text-xs text-muted-foreground">
<Brain className="h-3 w-3" />
<span className="font-medium">Reasoning Delay:</span>
<span>{Math.round(reasoningDurationMs)}ms</span>
</div>
) : null}
<div
className={cn(
"whitespace-pre-wrap break-words rounded-2xl px-4 py-3 text-sm leading-relaxed shadow-sm",
isUser
? "rounded-br-md bg-primary text-primary-foreground"
: isMuted
? "rounded-bl-md border border-dashed border-border bg-background text-muted-foreground"
: "rounded-bl-md border border-slate-200/80 bg-muted text-foreground",
!final && "opacity-70",
)}
>
<div>{text}</div>
{!final ? (
<div
className={cn(
"mt-1 text-[10px] italic",
isUser ? "text-primary-foreground/70" : "text-muted-foreground",
)}
>
speaking...
</div>
) : null}
</div>
</div>
</div>
);
}

View file

@ -0,0 +1,20 @@
"use client";
import { GitBranch } from "lucide-react";
interface NodeTransitionMarkerProps {
nodeName: string;
}
export function NodeTransitionMarker({ nodeName }: NodeTransitionMarkerProps) {
return (
<div className="flex items-center gap-2 py-2">
<div className="h-px flex-1 bg-border" />
<div className="inline-flex items-center gap-1.5 rounded-full border border-blue-500/20 bg-blue-500/10 px-3 py-1 text-xs">
<GitBranch className="h-3 w-3 text-blue-500" />
<span className="font-medium text-blue-700 dark:text-blue-400">{nodeName}</span>
</div>
<div className="h-px flex-1 bg-border" />
</div>
);
}

View file

@ -0,0 +1,73 @@
"use client";
import { AlertTriangle, ExternalLink, MicOff } from "lucide-react";
import { cn } from "@/lib/utils";
interface NoticeCardProps {
tone: "warning" | "error";
title: string;
text: string;
linkHref?: string;
linkLabel?: string;
}
export function NoticeCard({
tone,
title,
text,
linkHref,
linkLabel,
}: NoticeCardProps) {
const isWarning = tone === "warning";
const Icon = isWarning ? MicOff : AlertTriangle;
return (
<div
className={cn(
"flex items-start gap-2 rounded-lg border px-3 py-2",
isWarning
? "border-amber-500/20 bg-amber-500/10"
: "border-red-500/20 bg-red-500/10",
)}
>
<Icon
className={cn(
"mt-0.5 h-4 w-4 shrink-0",
isWarning ? "text-amber-500" : "text-red-500",
)}
/>
<div className="min-w-0 flex-1">
<div
className={cn(
"text-xs font-medium",
isWarning ? "text-amber-700 dark:text-amber-400" : "text-red-700 dark:text-red-400",
)}
>
{title}
</div>
<div
className={cn(
"mt-0.5 break-words text-sm",
isWarning ? "text-amber-600 dark:text-amber-300" : "text-red-600 dark:text-red-300",
)}
>
{text}
</div>
{linkHref && linkLabel ? (
<a
href={linkHref}
target="_blank"
rel="noopener noreferrer"
className={cn(
"mt-1 inline-flex items-center gap-1 text-xs hover:underline",
isWarning ? "text-amber-600 dark:text-amber-400" : "text-red-600 dark:text-red-400",
)}
>
{linkLabel} <ExternalLink className="h-3 w-3" />
</a>
) : null}
</div>
</div>
);
}

View file

@ -0,0 +1,73 @@
"use client";
import {
conversationItemsFromLiveFeedback,
conversationItemsFromRealtimeFeedbackEvents,
} from "./adapters/fromRealtimeFeedback";
import { ConversationContainer } from "./ConversationContainer";
import { ConversationTimeline } from "./ConversationTimeline";
import type {
ConversationStatus,
RealtimeFeedbackMessage,
WorkflowRunLogs,
} from "./types";
import { countConversationMessages } from "./utils";
interface LiveModeProps {
mode: "live";
messages: RealtimeFeedbackMessage[];
isCallActive: boolean;
isCallCompleted: boolean;
}
interface HistoricalModeProps {
mode: "historical";
logs: WorkflowRunLogs | null;
}
type RealtimeFeedbackProps = LiveModeProps | HistoricalModeProps;
export function RealtimeFeedback(props: RealtimeFeedbackProps) {
let items;
let status: ConversationStatus;
let title: string;
let emptyState: { title: string; subtitle: string };
let autoScroll = false;
if (props.mode === "historical") {
items = props.logs?.realtime_feedback_events
? conversationItemsFromRealtimeFeedbackEvents(props.logs.realtime_feedback_events)
: [];
status = "ended";
title = "Call Transcript";
emptyState = {
title: "No conversation recorded",
subtitle: "Real-time feedback events were not captured for this call",
};
} else {
items = conversationItemsFromLiveFeedback(props.messages);
status = props.isCallActive ? "live" : props.isCallCompleted ? "ended" : "ready";
title = "Live Transcript";
emptyState = {
title: "No messages yet",
subtitle: props.isCallActive
? "Start speaking to see the transcript"
: "Start the call to begin the conversation",
};
autoScroll = true;
}
return (
<ConversationContainer
title={title}
status={status}
messageCount={countConversationMessages(items) || undefined}
>
<ConversationTimeline
items={items}
autoScroll={autoScroll}
emptyState={emptyState}
/>
</ConversationContainer>
);
}

View file

@ -0,0 +1,116 @@
"use client";
import { Brain, ChevronRight, Wrench } from "lucide-react";
import { useState } from "react";
import { Badge } from "@/components/ui/badge";
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible";
import { cn } from "@/lib/utils";
import { formatConversationValue } from "./utils";
interface ToolCallCardProps {
functionName: string;
status: "running" | "completed";
argumentsValue?: unknown;
resultValue?: unknown;
reasoningDurationMs?: number;
}
export function ToolCallCard({
functionName,
status,
argumentsValue,
resultValue,
reasoningDurationMs,
}: ToolCallCardProps) {
const [open, setOpen] = useState(false);
const hasArguments = argumentsValue !== undefined;
const hasResult = resultValue !== undefined;
const hasDetails = hasArguments || hasResult;
return (
<div className="flex justify-center">
<div className="flex w-full max-w-[85%] flex-col gap-1">
{reasoningDurationMs !== undefined ? (
<div className="flex items-center justify-center gap-1.5 text-xs text-muted-foreground">
<Brain className="h-3 w-3" />
<span className="font-medium">Reasoning Delay:</span>
<span>{Math.round(reasoningDurationMs)}ms</span>
</div>
) : null}
<Collapsible
open={hasDetails ? open : false}
onOpenChange={hasDetails ? setOpen : undefined}
className="rounded-2xl border border-amber-500/20 bg-amber-500/10"
>
<div className="flex items-start gap-2 px-3.5 py-3 text-sm">
<Wrench className="mt-0.5 h-4 w-4 shrink-0 text-amber-500" />
<div className="min-w-0 flex-1">
<div className="flex flex-wrap items-center gap-2">
<span className="font-mono text-xs text-amber-700 dark:text-amber-400">
{functionName}()
</span>
<Badge
variant="outline"
className={cn(
"h-5 px-1.5 text-[10px] uppercase tracking-[0.14em]",
status === "running"
? "border-amber-400/60 text-amber-700 dark:text-amber-300"
: "border-emerald-500/30 text-emerald-700 dark:text-emerald-300",
)}
>
{status === "running" ? "Running" : "Completed"}
</Badge>
</div>
{hasDetails ? (
<div className="mt-2">
<CollapsibleTrigger asChild>
<button
type="button"
className="inline-flex items-center gap-1 text-xs text-muted-foreground hover:text-foreground"
>
<ChevronRight
className={cn(
"h-3.5 w-3.5 transition-transform",
open && "rotate-90",
)}
/>
Details
</button>
</CollapsibleTrigger>
</div>
) : null}
</div>
</div>
{hasDetails ? (
<CollapsibleContent className="border-t border-amber-500/20 px-3.5 py-3">
<div className="space-y-3">
{hasArguments ? (
<div className="space-y-1">
<p className="text-[11px] font-medium uppercase tracking-[0.14em] text-muted-foreground">
Arguments
</p>
<pre className="overflow-x-auto rounded-xl bg-background/70 p-3 text-xs leading-5 text-foreground">
{formatConversationValue(argumentsValue)}
</pre>
</div>
) : null}
{hasResult ? (
<div className="space-y-1">
<p className="text-[11px] font-medium uppercase tracking-[0.14em] text-muted-foreground">
Result
</p>
<pre className="overflow-x-auto rounded-xl bg-background/70 p-3 text-xs leading-5 text-foreground">
{formatConversationValue(resultValue)}
</pre>
</div>
) : null}
</div>
</CollapsibleContent>
) : null}
</Collapsible>
</div>
</div>
);
}

View file

@ -0,0 +1,283 @@
import type {
ConversationItem,
RealtimeFeedbackEvent,
RealtimeFeedbackMessage,
} from "../types";
function feedbackEventText(event: RealtimeFeedbackEvent) {
return (
event.payload.text ??
event.payload.error ??
(typeof event.payload.result === "string" ? event.payload.result : undefined) ??
event.payload.function_name ??
event.payload.node_name ??
""
);
}
function liveFeedbackItem(message: RealtimeFeedbackMessage, reasoningDurationMs?: number): ConversationItem | null {
if (message.type === "ttfb-metric") {
return null;
}
if (message.type === "user-transcription") {
return {
kind: "message",
id: message.id,
timestamp: message.timestamp,
role: "user",
text: message.text,
final: message.final,
};
}
if (message.type === "bot-text") {
return {
kind: "message",
id: message.id,
timestamp: message.timestamp,
role: "assistant",
text: message.text,
final: message.final,
reasoningDurationMs,
};
}
if (message.type === "function-call") {
return {
kind: "tool-call",
id: message.id,
timestamp: message.timestamp,
functionName: message.functionName ?? "tool",
toolCallId: message.toolCallId,
arguments: message.arguments,
result: message.result,
status: message.status ?? "completed",
reasoningDurationMs,
};
}
if (message.type === "node-transition") {
return {
kind: "node-transition",
id: message.id,
timestamp: message.timestamp,
nodeId: message.nodeId,
nodeName: message.nodeName ?? message.text,
previousNodeId: message.previousNodeId,
previousNodeName: message.previousNode,
allowInterrupt: message.allowInterrupt,
};
}
if (message.type === "interrupt-warning") {
return {
kind: "notice",
id: message.id,
timestamp: message.timestamp,
tone: "warning",
title: "Interruption Disabled",
text: message.text,
linkHref: "https://docs.dograh.com/configurations/interruption",
linkLabel: "Learn more",
};
}
if (message.type === "pipeline-error") {
return {
kind: "notice",
id: message.id,
timestamp: message.timestamp,
tone: "error",
title: message.fatal ? "Fatal Pipeline Error" : "Pipeline Error",
text: message.text,
fatal: message.fatal,
};
}
return null;
}
export function conversationItemsFromLiveFeedback(messages: RealtimeFeedbackMessage[]) {
const items: ConversationItem[] = [];
let pendingReasoningDurationMs: number | undefined;
messages.forEach((message) => {
if (message.type === "ttfb-metric") {
if (message.ttfbSeconds !== undefined) {
pendingReasoningDurationMs = message.ttfbSeconds * 1000;
}
return;
}
const item = liveFeedbackItem(message, pendingReasoningDurationMs);
if (!item) {
return;
}
items.push(item);
if (item.kind === "message" || item.kind === "tool-call") {
pendingReasoningDurationMs = undefined;
}
});
return items;
}
export function conversationItemsFromRealtimeFeedbackEvents(events: RealtimeFeedbackEvent[]) {
const items: ConversationItem[] = [];
const toolCallIndexById = new Map<string, number>();
let pendingReasoningDurationMs: number | undefined;
let currentBotItemIndex: number | null = null;
let currentBotTurn: number | null = null;
events.forEach((event, index) => {
if (event.type === "rtf-ttfb-metric") {
if (event.payload.ttfb_seconds !== undefined) {
pendingReasoningDurationMs = event.payload.ttfb_seconds * 1000;
}
return;
}
if (event.type === "rtf-user-transcription") {
currentBotItemIndex = null;
currentBotTurn = null;
items.push({
kind: "message",
id: `user-${event.turn}-${index}`,
timestamp: event.timestamp,
role: "user",
text: feedbackEventText(event),
final: event.payload.final,
});
return;
}
if (event.type === "rtf-bot-text") {
const text = feedbackEventText(event);
const lastItem = currentBotItemIndex !== null ? items[currentBotItemIndex] : null;
if (
currentBotItemIndex !== null &&
currentBotTurn === event.turn &&
lastItem?.kind === "message" &&
lastItem.role === "assistant"
) {
items[currentBotItemIndex] = {
...lastItem,
text: `${lastItem.text} ${text}`.trim(),
};
return;
}
items.push({
kind: "message",
id: `bot-${event.turn}-${index}`,
timestamp: event.timestamp,
role: "assistant",
text,
final: event.payload.final,
reasoningDurationMs: pendingReasoningDurationMs,
});
currentBotItemIndex = items.length - 1;
currentBotTurn = event.turn;
pendingReasoningDurationMs = undefined;
return;
}
currentBotItemIndex = null;
currentBotTurn = null;
if (event.type === "rtf-function-call-start") {
const toolCallId = event.payload.tool_call_id;
items.push({
kind: "tool-call",
id: toolCallId ?? `tool-${event.turn}-${index}`,
timestamp: event.timestamp,
functionName: event.payload.function_name ?? "tool",
toolCallId,
arguments: event.payload.arguments,
status: "running",
reasoningDurationMs: pendingReasoningDurationMs,
});
if (toolCallId) {
toolCallIndexById.set(toolCallId, items.length - 1);
}
pendingReasoningDurationMs = undefined;
return;
}
if (event.type === "rtf-function-call-end") {
const toolCallId = event.payload.tool_call_id;
const existingIndex = toolCallId ? toolCallIndexById.get(toolCallId) : undefined;
if (existingIndex !== undefined) {
const existingItem = items[existingIndex];
if (existingItem?.kind === "tool-call") {
items[existingIndex] = {
...existingItem,
status: "completed",
result: event.payload.result,
};
}
return;
}
items.push({
kind: "tool-call",
id: toolCallId ?? `tool-result-${event.turn}-${index}`,
timestamp: event.timestamp,
functionName: event.payload.function_name ?? "tool",
toolCallId,
result: event.payload.result,
status: "completed",
reasoningDurationMs: pendingReasoningDurationMs,
});
pendingReasoningDurationMs = undefined;
return;
}
if (event.type === "rtf-node-transition") {
items.push({
kind: "node-transition",
id: `node-${event.turn}-${index}`,
timestamp: event.timestamp,
nodeId: event.payload.node_id,
nodeName: event.payload.node_name ?? feedbackEventText(event) ?? "Node",
previousNodeId: event.payload.previous_node_id,
previousNodeName: event.payload.previous_node_name ?? event.payload.previous_node,
allowInterrupt: event.payload.allow_interrupt,
});
return;
}
if (event.type === "rtf-interrupt-warning") {
items.push({
kind: "notice",
id: `warning-${event.turn}-${index}`,
timestamp: event.timestamp,
tone: "warning",
title: "Interruption Disabled",
text: feedbackEventText(event),
linkHref: "https://docs.dograh.com/configurations/interruption",
linkLabel: "Learn more",
});
return;
}
if (event.type === "rtf-pipeline-error") {
items.push({
kind: "notice",
id: `error-${event.turn}-${index}`,
timestamp: event.timestamp,
tone: "error",
title: event.payload.fatal ? "Fatal Pipeline Error" : "Pipeline Error",
text: feedbackEventText(event),
fatal: event.payload.fatal,
});
}
});
return items;
}

View file

@ -0,0 +1,180 @@
import type { ConversationItem } from "../types";
interface TextChatMessageLike {
text?: string;
created_at?: string;
}
interface TextChatEventLike {
type?: unknown;
payload?: unknown;
created_at?: unknown;
}
interface TextChatTurnLike {
id: string;
status?: string;
created_at?: string;
user_message?: TextChatMessageLike | null;
assistant_message?: TextChatMessageLike | null;
events?: Array<Record<string, unknown>>;
}
function asRecord(value: unknown) {
return value && typeof value === "object" ? (value as Record<string, unknown>) : null;
}
function asString(value: unknown) {
return typeof value === "string" ? value : undefined;
}
function conversationItemsFromTextChatEvents(
events: Array<Record<string, unknown>>,
turnId: string,
fallbackTimestamp?: string,
) {
const items: ConversationItem[] = [];
const toolCallIndexById = new Map<string, number>();
events.forEach((rawEvent, index) => {
const event = rawEvent as TextChatEventLike;
const eventType = asString(event.type);
const payload = asRecord(event.payload);
if (!eventType || !payload) {
return;
}
const timestamp = asString(event.created_at) ?? fallbackTimestamp;
if (eventType === "node_transition") {
const nodeName = asString(payload.node_name) ?? "Node";
items.push({
kind: "node-transition",
id: `${turnId}-node-${index}`,
turnId,
timestamp,
nodeId: asString(payload.node_id),
nodeName,
previousNodeId: asString(payload.previous_node_id),
previousNodeName: asString(payload.previous_node_name),
allowInterrupt: typeof payload.allow_interrupt === "boolean" ? payload.allow_interrupt : undefined,
});
return;
}
if (eventType === "execution_error") {
items.push({
kind: "notice",
id: `${turnId}-error-${index}`,
turnId,
timestamp,
tone: "error",
title: "Execution Error",
text: asString(payload.message) ?? "Execution error",
fatal: true,
});
return;
}
if (eventType === "tool_call_started") {
const functionName = asString(payload.function_name) ?? "tool";
const toolCallId = asString(payload.tool_call_id);
items.push({
kind: "tool-call",
id: toolCallId ?? `${turnId}-tool-${index}`,
turnId,
timestamp,
functionName,
toolCallId,
status: "running",
arguments: payload.arguments,
});
if (toolCallId) {
toolCallIndexById.set(toolCallId, items.length - 1);
}
return;
}
if (eventType === "tool_call_result") {
const functionName = asString(payload.function_name) ?? "tool";
const toolCallId = asString(payload.tool_call_id);
const existingIndex = toolCallId ? toolCallIndexById.get(toolCallId) : undefined;
if (existingIndex !== undefined) {
const existingItem = items[existingIndex];
if (existingItem?.kind === "tool-call") {
items[existingIndex] = {
...existingItem,
status: "completed",
result: payload.result,
};
}
return;
}
items.push({
kind: "tool-call",
id: toolCallId ?? `${turnId}-tool-result-${index}`,
turnId,
timestamp,
functionName,
toolCallId,
status: "completed",
result: payload.result,
});
}
});
return items;
}
export function conversationItemsFromTextChatTurns(turns: TextChatTurnLike[]) {
const items: ConversationItem[] = [];
turns.forEach((turn) => {
if (turn.user_message?.text) {
items.push({
kind: "message",
id: `${turn.id}-user`,
turnId: turn.id,
timestamp: turn.user_message.created_at ?? turn.created_at,
role: "user",
text: turn.user_message.text,
});
}
items.push(
...conversationItemsFromTextChatEvents(
turn.events ?? [],
turn.id,
turn.created_at,
),
);
if (turn.assistant_message?.text) {
items.push({
kind: "message",
id: `${turn.id}-assistant`,
turnId: turn.id,
timestamp: turn.assistant_message.created_at ?? turn.created_at,
role: "assistant",
text: turn.assistant_message.text,
});
return;
}
if (turn.status === "failed") {
items.push({
kind: "message",
id: `${turn.id}-assistant-failed`,
turnId: turn.id,
timestamp: turn.created_at,
role: "assistant",
text: "Agent turn failed",
tone: "muted",
});
}
});
return items;
}

View file

@ -0,0 +1,5 @@
export * from "./ConversationContainer";
export * from "./ConversationRailFrame";
export * from "./ConversationTimeline";
export * from "./RealtimeFeedback";
export * from "./types";

View file

@ -0,0 +1,117 @@
export type ConversationStatus = "ready" | "live" | "ended";
export type RealtimeFeedbackMessageType =
| "user-transcription"
| "bot-text"
| "function-call"
| "node-transition"
| "ttfb-metric"
| "pipeline-error"
| "interrupt-warning";
export interface RealtimeFeedbackMessage {
id: string;
type: RealtimeFeedbackMessageType;
text: string;
final?: boolean;
timestamp: string;
functionName?: string;
toolCallId?: string;
arguments?: unknown;
result?: unknown;
status?: "running" | "completed";
nodeId?: string;
nodeName?: string;
previousNodeId?: string;
previousNode?: string;
allowInterrupt?: boolean;
ttfbSeconds?: number;
processor?: string;
model?: string;
fatal?: boolean;
}
export interface RealtimeFeedbackEvent {
type: string;
payload: {
text?: string;
final?: boolean;
user_id?: string;
timestamp?: string;
function_name?: string;
tool_call_id?: string;
arguments?: unknown;
result?: unknown;
node_id?: string;
node_name?: string;
previous_node_id?: string;
previous_node?: string;
previous_node_name?: string;
allow_interrupt?: boolean;
ttfb_seconds?: number;
processor?: string;
model?: string;
error?: string;
fatal?: boolean;
};
timestamp: string;
turn: number;
}
export interface WorkflowRunLogs {
realtime_feedback_events?: RealtimeFeedbackEvent[];
}
interface ConversationItemBase {
id: string;
timestamp?: string;
turnId?: string;
reasoningDurationMs?: number;
}
export interface ConversationMessageItem extends ConversationItemBase {
kind: "message";
role: "user" | "assistant";
text: string;
final?: boolean;
tone?: "default" | "muted";
}
export interface ConversationToolCallItem extends ConversationItemBase {
kind: "tool-call";
functionName: string;
toolCallId?: string;
status: "running" | "completed";
arguments?: unknown;
result?: unknown;
}
export interface ConversationNodeTransitionItem extends ConversationItemBase {
kind: "node-transition";
nodeId?: string;
nodeName: string;
previousNodeId?: string;
previousNodeName?: string;
allowInterrupt?: boolean;
}
export interface ConversationNoticeItem extends ConversationItemBase {
kind: "notice";
tone: "warning" | "error";
title: string;
text: string;
fatal?: boolean;
linkHref?: string;
linkLabel?: string;
}
export type ConversationItem =
| ConversationMessageItem
| ConversationToolCallItem
| ConversationNodeTransitionItem
| ConversationNoticeItem;
export interface ConversationEmptyStateData {
title: string;
subtitle: string;
}

View file

@ -0,0 +1,21 @@
import type { ConversationItem } from "./types";
export function formatConversationValue(value: unknown) {
if (value == null) {
return "None";
}
if (typeof value === "string") {
return value;
}
try {
return JSON.stringify(value, null, 2);
} catch {
return String(value);
}
}
export function countConversationMessages(items: ConversationItem[]) {
return items.filter(
(item) => item.kind === "message" && item.tone !== "muted",
).length;
}

View file

@ -9,6 +9,7 @@ export const WORKFLOW_RUN_MODES = {
CLOUDONIX: 'cloudonix',
WEBRTC: 'webrtc',
SMALL_WEBRTC: 'smallwebrtc',
TEXTCHAT: 'textchat',
ARI: 'ari',
TELNYX: 'telnyx',
PLIVO: 'plivo'

View file

@ -2,15 +2,19 @@
import { createContext, useContext, useEffect, useState } from 'react';
export type TooltipKey = 'web_call' | 'customize_workflow'; // Add more tooltip keys as needed
export type TooltipKey = 'web_call' | 'customize_workflow';
export type OnboardingActionKey = 'web_call_started';
interface OnboardingState {
seenTooltips: TooltipKey[];
completedActions: OnboardingActionKey[];
}
interface OnboardingContextType {
hasSeenTooltip: (key: TooltipKey) => boolean;
markTooltipSeen: (key: TooltipKey) => void;
hasCompletedAction: (key: OnboardingActionKey) => boolean;
markActionCompleted: (key: OnboardingActionKey) => void;
resetOnboarding: () => void;
}
@ -18,6 +22,7 @@ const ONBOARDING_STORAGE_KEY = 'dograh_onboarding_state';
const defaultState: OnboardingState = {
seenTooltips: [],
completedActions: [],
};
const OnboardingContext = createContext<OnboardingContextType | undefined>(undefined);
@ -59,6 +64,19 @@ export const OnboardingProvider = ({ children }: { children: React.ReactNode })
}));
};
const hasCompletedAction = (key: OnboardingActionKey): boolean => {
return onboardingState.completedActions.includes(key);
};
const markActionCompleted = (key: OnboardingActionKey) => {
setOnboardingState(prev => ({
...prev,
completedActions: prev.completedActions.includes(key)
? prev.completedActions
: [...prev.completedActions, key]
}));
};
const resetOnboarding = () => {
setOnboardingState(defaultState);
localStorage.removeItem(ONBOARDING_STORAGE_KEY);
@ -69,6 +87,8 @@ export const OnboardingProvider = ({ children }: { children: React.ReactNode })
value={{
hasSeenTooltip,
markTooltipSeen,
hasCompletedAction,
markActionCompleted,
resetOnboarding
}}
>

View file

@ -2,13 +2,9 @@
import { createContext, ReactNode, useCallback, useContext, useEffect, useRef, useState } from 'react';
import { client } from '@/client/client.gen';
import { getTelephonyConfigWarningsApiV1OrganizationsTelephonyConfigWarningsGet } from '@/client/sdk.gen';
import { useAuth } from '@/lib/auth';
interface TelephonyConfigWarningsResponse {
telnyx_missing_webhook_public_key_count: number;
}
interface TelephonyConfigWarningsContextType {
telnyxMissingWebhookPublicKeyCount: number;
refresh: () => Promise<void>;
@ -34,11 +30,8 @@ export function TelephonyConfigWarningsProvider({ children }: { children: ReactN
const doFetch = useCallback(async () => {
setLoading(true);
try {
const res = await client.get<TelephonyConfigWarningsResponse>({
url: '/api/v1/organizations/telephony-config-warnings',
});
const data = res.data as TelephonyConfigWarningsResponse | undefined;
setCount(data?.telnyx_missing_webhook_public_key_count ?? 0);
const res = await getTelephonyConfigWarningsApiV1OrganizationsTelephonyConfigWarningsGet();
setCount(res.data?.telnyx_missing_webhook_public_key_count ?? 0);
} catch {
setCount(0);
} finally {

View file

@ -14,7 +14,7 @@ interface Result {
const CACHE_KEY = "dograh-latest-release";
const CACHE_TTL_MS = 6 * 60 * 60 * 1000;
const SEMVER_RE = /^(?:[a-z][a-z0-9-]*-)?v?(\d+)\.(\d+)\.(\d+)$/i;
const SEMVER_RE = /^v?(\d+)\.(\d+)\.(\d+)$/;
function parseSemver(tag: string): [number, number, number] | null {
const m = tag.match(SEMVER_RE);
@ -56,11 +56,11 @@ export function useLatestReleaseVersion(
}
let cancelled = false;
fetch("https://api.github.com/repos/dograh-hq/dograh/releases/latest")
fetch("/api/config/latest-version")
.then((res) => (res.ok ? res.json() : null))
.then((data) => {
if (cancelled || !data?.tag_name) return;
const tag = data.tag_name as string;
if (cancelled || !data?.latest) return;
const tag = data.latest as string;
try {
localStorage.setItem(
CACHE_KEY,
@ -72,7 +72,7 @@ export function useLatestReleaseVersion(
setLatest(tag);
})
.catch(() => {
// silent — don't break the sidebar if GitHub is unreachable
// silent — don't break the sidebar if the lookup fails
});
return () => {
@ -80,19 +80,13 @@ export function useLatestReleaseVersion(
};
}, [enabled, currentVersion]);
const normalizedCurrent = currentVersion
? currentVersion.startsWith("v")
? currentVersion
: `v${currentVersion}`
: null;
const currentParsed = normalizedCurrent ? parseSemver(normalizedCurrent) : null;
const currentParsed = currentVersion ? parseSemver(currentVersion) : null;
const latestParsed = latest ? parseSemver(latest) : null;
const isBehind = !!(
normalizedCurrent &&
currentVersion &&
latest &&
isOlder(normalizedCurrent, latest)
isOlder(currentVersion, latest)
);
const isLatest = !!(

View file

@ -131,9 +131,7 @@ export async function getServerAccessToken(): Promise<string | null> {
}
} else if (authProvider === 'local') {
// Get token from cookies (created by middleware)
const oss_token = await getOSSToken();
logger.debug(`oss_token: ${oss_token}`);
return oss_token;
return await getOSSToken();
}
return null;