mirror of
https://github.com/dograh-hq/dograh.git
synced 2026-06-07 07:55:16 +02:00
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:
parent
67479e98fd
commit
d97d1d72cd
96 changed files with 7630 additions and 1684 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@
|
|||
|
||||
Revision ID: 19d2a4b6c8ef
|
||||
Revises: 0a1b2c3d4e5f
|
||||
|
||||
Create Date: 2026-05-19 00:00:00.000000
|
||||
|
||||
"""
|
||||
|
|
|
|||
|
|
@ -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 ###
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
124
api/db/workflow_run_text_session_client.py
Normal file
124
api/db/workflow_run_text_session_client.py
Normal 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
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
282
api/routes/workflow_text_chat.py
Normal file
282
api/routes/workflow_text_chat.py
Normal 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)
|
||||
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -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."""
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
163
api/services/pipecat/realtime_feedback_events.py
Normal file
163
api/services/pipecat/realtime_feedback_events.py
Normal 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 ""
|
||||
|
|
@ -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}")
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
144
api/services/workflow/text_chat_logs.py
Normal file
144
api/services/workflow/text_chat_logs.py
Normal 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)
|
||||
649
api/services/workflow/text_chat_runner.py
Normal file
649
api/services/workflow/text_chat_runner.py
Normal 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(),
|
||||
)
|
||||
411
api/services/workflow/text_chat_session_service.py
Normal file
411
api/services/workflow/text_chat_session_service.py
Normal 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",
|
||||
]
|
||||
|
|
@ -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):
|
||||
|
|
|
|||
53
api/tests/test_realtime_feedback_events.py
Normal file
53
api/tests/test_realtime_feedback_events.py
Normal 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"
|
||||
|
|
@ -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) ────────────────────────
|
||||
|
||||
|
|
|
|||
126
api/tests/test_text_chat_logs.py
Normal file
126
api/tests/test_text_chat_logs.py
Normal 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
|
||||
)
|
||||
91
api/tests/test_text_chat_session_service.py
Normal file
91
api/tests/test_text_chat_session_service.py
Normal 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)
|
||||
181
api/tests/test_workflow_run_cost.py
Normal file
181
api/tests/test_workflow_run_cost.py
Normal 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"]
|
||||
)
|
||||
1194
api/tests/test_workflow_text_chat.py
Normal file
1194
api/tests/test_workflow_text_chat.py
Normal file
File diff suppressed because it is too large
Load diff
File diff suppressed because one or more lines are too long
2
pipecat
2
pipecat
|
|
@ -1 +1 @@
|
|||
Subproject commit 6b4474c1b870eae5e42ef7c5eec1b7f37fcecc61
|
||||
Subproject commit d1e23ca521f5412a9dc09430ada730500e15a7ab
|
||||
|
|
@ -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
122
ui/package-lock.json
generated
|
|
@ -26,7 +26,7 @@
|
|||
"@radix-ui/react-tooltip": "^1.2.8",
|
||||
"@sentry/nextjs": "^9.28.1",
|
||||
"@stackframe/stack": "^2.8.80",
|
||||
"@xyflow/react": "^12.9.2",
|
||||
"@xyflow/react": "^12.10.2",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"date-fns": "^4.1.0",
|
||||
|
|
@ -11227,12 +11227,12 @@
|
|||
"peer": true
|
||||
},
|
||||
"node_modules/@xyflow/react": {
|
||||
"version": "12.9.2",
|
||||
"resolved": "https://registry.npmjs.org/@xyflow/react/-/react-12.9.2.tgz",
|
||||
"integrity": "sha512-Xr+LFcysHCCoc5KRHaw+FwbqbWYxp9tWtk1mshNcqy25OAPuaKzXSdqIMNOA82TIXF/gFKo0Wgpa6PU7wUUVqw==",
|
||||
"version": "12.10.2",
|
||||
"resolved": "https://registry.npmjs.org/@xyflow/react/-/react-12.10.2.tgz",
|
||||
"integrity": "sha512-CgIi6HwlcHXwlkTpr0fxLv/0sRVNZ8IdwKLzzeCscaYBwpvfcH1QFOCeaTCuEn1FQEs/B8CjnTSjhs8udgmBgQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@xyflow/system": "0.0.72",
|
||||
"@xyflow/system": "0.0.76",
|
||||
"classcat": "^5.0.3",
|
||||
"zustand": "^4.4.0"
|
||||
},
|
||||
|
|
@ -11270,9 +11270,9 @@
|
|||
}
|
||||
},
|
||||
"node_modules/@xyflow/system": {
|
||||
"version": "0.0.72",
|
||||
"resolved": "https://registry.npmjs.org/@xyflow/system/-/system-0.0.72.tgz",
|
||||
"integrity": "sha512-WBI5Aau0fXTXwxHPzceLNS6QdXggSWnGjDtj/gG669crApN8+SCmEtkBth1m7r6pStNo/5fI9McEi7Dk0ymCLA==",
|
||||
"version": "0.0.76",
|
||||
"resolved": "https://registry.npmjs.org/@xyflow/system/-/system-0.0.76.tgz",
|
||||
"integrity": "sha512-hvwvnRS1B3REwVDlWexsq7YQaPZeG3/mKo1jv38UmnpWmxihp14bW6VtEOuHEwJX2FvzFw8k77LyKSk/wiZVNA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/d3-drag": "^3.0.7",
|
||||
|
|
@ -11661,12 +11661,6 @@
|
|||
"tslib": "^2.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/asynckit": {
|
||||
"version": "0.4.0",
|
||||
"resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz",
|
||||
"integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/atomic-sleep": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/atomic-sleep/-/atomic-sleep-1.0.0.tgz",
|
||||
|
|
@ -11702,17 +11696,6 @@
|
|||
"node": ">=4"
|
||||
}
|
||||
},
|
||||
"node_modules/axios": {
|
||||
"version": "1.13.5",
|
||||
"resolved": "https://registry.npmjs.org/axios/-/axios-1.13.5.tgz",
|
||||
"integrity": "sha512-cz4ur7Vb0xS4/KUN0tPWe44eqxrIu31me+fbang3ijiNscE129POzipJJA6zniq2C/Z6sJCjMimjS8Lc/GAs8Q==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"follow-redirects": "^1.15.11",
|
||||
"form-data": "^4.0.5",
|
||||
"proxy-from-env": "^1.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/axobject-query": {
|
||||
"version": "4.1.0",
|
||||
"resolved": "https://registry.npmjs.org/axobject-query/-/axobject-query-4.1.0.tgz",
|
||||
|
|
@ -11957,6 +11940,7 @@
|
|||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz",
|
||||
"integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"es-errors": "^1.3.0",
|
||||
|
|
@ -12243,18 +12227,6 @@
|
|||
"integrity": "sha512-IfEDxwoWIjkeXL1eXcDiow4UbKjhLdq6/EuSVR9GMN7KVH3r9gQ83e73hsz1Nd1T3ijd5xv1wcWRYO+D6kCI2w==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/combined-stream": {
|
||||
"version": "1.0.8",
|
||||
"resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz",
|
||||
"integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"delayed-stream": "~1.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/commander": {
|
||||
"version": "14.0.3",
|
||||
"resolved": "https://registry.npmjs.org/commander/-/commander-14.0.3.tgz",
|
||||
|
|
@ -12797,15 +12769,6 @@
|
|||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/delayed-stream": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz",
|
||||
"integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=0.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/destr": {
|
||||
"version": "2.0.5",
|
||||
"resolved": "https://registry.npmjs.org/destr/-/destr-2.0.5.tgz",
|
||||
|
|
@ -12875,6 +12838,7 @@
|
|||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
|
||||
"integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"call-bind-apply-helpers": "^1.0.1",
|
||||
|
|
@ -13022,6 +12986,7 @@
|
|||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz",
|
||||
"integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
|
|
@ -13031,6 +12996,7 @@
|
|||
"version": "1.3.0",
|
||||
"resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz",
|
||||
"integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
|
|
@ -13075,6 +13041,7 @@
|
|||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz",
|
||||
"integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"es-errors": "^1.3.0"
|
||||
|
|
@ -13087,6 +13054,7 @@
|
|||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz",
|
||||
"integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"es-errors": "^1.3.0",
|
||||
|
|
@ -13908,26 +13876,6 @@
|
|||
"dev": true,
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/follow-redirects": {
|
||||
"version": "1.15.11",
|
||||
"resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.11.tgz",
|
||||
"integrity": "sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "individual",
|
||||
"url": "https://github.com/sponsors/RubenVerborgh"
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=4.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"debug": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/for-each": {
|
||||
"version": "0.3.5",
|
||||
"resolved": "https://registry.npmjs.org/for-each/-/for-each-0.3.5.tgz",
|
||||
|
|
@ -13944,22 +13892,6 @@
|
|||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/form-data": {
|
||||
"version": "4.0.5",
|
||||
"resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz",
|
||||
"integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"asynckit": "^0.4.0",
|
||||
"combined-stream": "^1.0.8",
|
||||
"es-set-tostringtag": "^2.1.0",
|
||||
"hasown": "^2.0.2",
|
||||
"mime-types": "^2.1.12"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 6"
|
||||
}
|
||||
},
|
||||
"node_modules/forwarded-parse": {
|
||||
"version": "2.1.2",
|
||||
"resolved": "https://registry.npmjs.org/forwarded-parse/-/forwarded-parse-2.1.2.tgz",
|
||||
|
|
@ -14048,6 +13980,7 @@
|
|||
"version": "1.3.0",
|
||||
"resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz",
|
||||
"integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"call-bind-apply-helpers": "^1.0.2",
|
||||
|
|
@ -14081,6 +14014,7 @@
|
|||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz",
|
||||
"integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"dunder-proto": "^1.0.1",
|
||||
|
|
@ -14192,6 +14126,7 @@
|
|||
"version": "1.2.0",
|
||||
"resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz",
|
||||
"integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
|
|
@ -14268,6 +14203,7 @@
|
|||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz",
|
||||
"integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
|
|
@ -14280,6 +14216,7 @@
|
|||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz",
|
||||
"integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"has-symbols": "^1.0.3"
|
||||
|
|
@ -15084,12 +15021,6 @@
|
|||
"license": "MIT",
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/json-schema": {
|
||||
"version": "0.4.0",
|
||||
"resolved": "https://registry.npmjs.org/json-schema/-/json-schema-0.4.0.tgz",
|
||||
"integrity": "sha512-es94M3nTIfsEPisRafak+HDLfHXnKBhV3vU5eqPcS3flIWqcxJWgXHXiey3YrpaNsanY5ei1VoYEbOzijuq9BA==",
|
||||
"license": "(AFL-2.1 OR BSD-3-Clause)"
|
||||
},
|
||||
"node_modules/json-schema-traverse": {
|
||||
"version": "0.4.1",
|
||||
"resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz",
|
||||
|
|
@ -15499,6 +15430,7 @@
|
|||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
|
||||
"integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
|
|
@ -15547,6 +15479,7 @@
|
|||
"resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz",
|
||||
"integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
|
|
@ -15556,6 +15489,7 @@
|
|||
"resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz",
|
||||
"integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"mime-db": "1.52.0"
|
||||
},
|
||||
|
|
@ -18131,18 +18065,6 @@
|
|||
"node": ">= 0.8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/type-fest": {
|
||||
"version": "4.41.0",
|
||||
"resolved": "https://registry.npmjs.org/type-fest/-/type-fest-4.41.0.tgz",
|
||||
"integrity": "sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA==",
|
||||
"license": "(MIT OR CC0-1.0)",
|
||||
"engines": {
|
||||
"node": ">=16"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/typed-array-buffer": {
|
||||
"version": "1.0.3",
|
||||
"resolved": "https://registry.npmjs.org/typed-array-buffer/-/typed-array-buffer-1.0.3.tgz",
|
||||
|
|
|
|||
|
|
@ -30,7 +30,7 @@
|
|||
"@radix-ui/react-tooltip": "^1.2.8",
|
||||
"@sentry/nextjs": "^9.28.1",
|
||||
"@stackframe/stack": "^2.8.80",
|
||||
"@xyflow/react": "^12.9.2",
|
||||
"@xyflow/react": "^12.10.2",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"date-fns": "^4.1.0",
|
||||
|
|
|
|||
77
ui/src/app/api/config/latest-version/route.ts
Normal file
77
ui/src/app/api/config/latest-version/route.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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}`)}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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[],
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
@ -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"
|
||||
}}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
@ -2,5 +2,4 @@ export * from './ApiKeyErrorDialog';
|
|||
export * from './AudioControls';
|
||||
export * from './ConnectionStatus';
|
||||
export * from './ContextDisplay';
|
||||
export * from './RealtimeFeedback';
|
||||
export * from './WorkflowConfigErrorDialog';
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
|
@ -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
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -9,8 +9,10 @@ export const BaseNode = forwardRef<
|
|||
invalid?: boolean;
|
||||
selected_through_edge?: boolean;
|
||||
hovered_through_edge?: boolean;
|
||||
runtimeActive?: boolean;
|
||||
runtimePulseNonce?: number;
|
||||
}
|
||||
>(({ className, selected, invalid, selected_through_edge, hovered_through_edge, ...props }, ref) => (
|
||||
>(({ children, className, selected, invalid, selected_through_edge, hovered_through_edge, runtimeActive, runtimePulseNonce, ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn(
|
||||
|
|
@ -26,11 +28,22 @@ export const BaseNode = forwardRef<
|
|||
// Hovered through edge takes precedence over selected through edge
|
||||
hovered_through_edge ? "ring-2 ring-primary/60 shadow-[0_0_12px_rgba(96,165,250,0.3)]" : "",
|
||||
!hovered_through_edge && selected_through_edge ? "ring-1 ring-primary/50 shadow-[0_0_8px_rgba(59,130,246,0.2)]" : "",
|
||||
runtimeActive ? "ring-2 ring-sky-400/60 shadow-[0_0_0_1px_rgba(56,189,248,0.18),0_0_24px_rgba(14,165,233,0.18)]" : "",
|
||||
!selected_through_edge && !hovered_through_edge && "hover:border-muted-foreground/50",
|
||||
)}
|
||||
tabIndex={0}
|
||||
{...props}
|
||||
/>
|
||||
>
|
||||
{runtimeActive ? (
|
||||
<span
|
||||
key={`runtime-pulse-${runtimePulseNonce ?? 0}`}
|
||||
className="pointer-events-none absolute -inset-2 rounded-[18px] border-2 border-sky-400/55"
|
||||
aria-hidden="true"
|
||||
style={{ animation: "ping 900ms ease-out 2" }}
|
||||
/>
|
||||
) : null}
|
||||
{children}
|
||||
</div>
|
||||
));
|
||||
|
||||
BaseNode.displayName = "BaseNode";
|
||||
|
|
|
|||
|
|
@ -608,6 +608,8 @@ export const GenericNode = memo(({ data, selected, id, type }: GenericNodeProps)
|
|||
invalid={data.invalid}
|
||||
selected_through_edge={data.selected_through_edge}
|
||||
hovered_through_edge={data.hovered_through_edge}
|
||||
runtimeActive={data.runtime_active}
|
||||
runtimePulseNonce={data.runtime_pulse_nonce}
|
||||
title={data.name || fallbackTitle}
|
||||
icon={<Icon />}
|
||||
badgeLabel={badge.label}
|
||||
|
|
|
|||
|
|
@ -10,6 +10,8 @@ interface NodeContentProps {
|
|||
invalid?: boolean;
|
||||
selected_through_edge?: boolean;
|
||||
hovered_through_edge?: boolean;
|
||||
runtimeActive?: boolean;
|
||||
runtimePulseNonce?: number;
|
||||
title: string;
|
||||
icon: ReactNode;
|
||||
badgeLabel?: string;
|
||||
|
|
@ -31,6 +33,8 @@ export const NodeContent = ({
|
|||
invalid,
|
||||
selected_through_edge,
|
||||
hovered_through_edge,
|
||||
runtimeActive,
|
||||
runtimePulseNonce,
|
||||
title,
|
||||
icon,
|
||||
badgeLabel,
|
||||
|
|
@ -54,6 +58,8 @@ export const NodeContent = ({
|
|||
invalid={invalid}
|
||||
selected_through_edge={selected_through_edge}
|
||||
hovered_through_edge={hovered_through_edge}
|
||||
runtimeActive={runtimeActive}
|
||||
runtimePulseNonce={runtimePulseNonce}
|
||||
className={`p-0 ${className}`}
|
||||
onDoubleClick={onDoubleClick}
|
||||
>
|
||||
|
|
|
|||
|
|
@ -17,6 +17,8 @@ export type FlowNodeData = {
|
|||
validationMessage?: string | null;
|
||||
selected_through_edge?: boolean;
|
||||
hovered_through_edge?: boolean;
|
||||
runtime_active?: boolean;
|
||||
runtime_pulse_nonce?: number;
|
||||
allow_interrupt?: boolean;
|
||||
extraction_enabled?: boolean;
|
||||
extraction_prompt?: string;
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
61
ui/src/components/workflow/conversation/MessageBubble.tsx
Normal file
61
ui/src/components/workflow/conversation/MessageBubble.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
73
ui/src/components/workflow/conversation/NoticeCard.tsx
Normal file
73
ui/src/components/workflow/conversation/NoticeCard.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
73
ui/src/components/workflow/conversation/RealtimeFeedback.tsx
Normal file
73
ui/src/components/workflow/conversation/RealtimeFeedback.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
116
ui/src/components/workflow/conversation/ToolCallCard.tsx
Normal file
116
ui/src/components/workflow/conversation/ToolCallCard.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
5
ui/src/components/workflow/conversation/index.ts
Normal file
5
ui/src/components/workflow/conversation/index.ts
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
export * from "./ConversationContainer";
|
||||
export * from "./ConversationRailFrame";
|
||||
export * from "./ConversationTimeline";
|
||||
export * from "./RealtimeFeedback";
|
||||
export * from "./types";
|
||||
117
ui/src/components/workflow/conversation/types.ts
Normal file
117
ui/src/components/workflow/conversation/types.ts
Normal 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;
|
||||
}
|
||||
21
ui/src/components/workflow/conversation/utils.ts
Normal file
21
ui/src/components/workflow/conversation/utils.ts
Normal 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;
|
||||
}
|
||||
|
|
@ -9,6 +9,7 @@ export const WORKFLOW_RUN_MODES = {
|
|||
CLOUDONIX: 'cloudonix',
|
||||
WEBRTC: 'webrtc',
|
||||
SMALL_WEBRTC: 'smallwebrtc',
|
||||
TEXTCHAT: 'textchat',
|
||||
ARI: 'ari',
|
||||
TELNYX: 'telnyx',
|
||||
PLIVO: 'plivo'
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}}
|
||||
>
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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 = !!(
|
||||
|
|
|
|||
|
|
@ -131,9 +131,7 @@ export async function getServerAccessToken(): Promise<string | null> {
|
|||
}
|
||||
} else if (authProvider === 'local') {
|
||||
// Get token from cookies (created by middleware)
|
||||
const oss_token = await getOSSToken();
|
||||
logger.debug(`oss_token: ${oss_token}`);
|
||||
return oss_token;
|
||||
return await getOSSToken();
|
||||
}
|
||||
|
||||
return null;
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue