mirror of
https://github.com/dograh-hq/dograh.git
synced 2026-06-22 08:38:13 +02:00
feat: add support for self hosted llm models
This commit is contained in:
parent
31e075d114
commit
ac0731a374
17 changed files with 179 additions and 48 deletions
1
.gitignore
vendored
1
.gitignore
vendored
|
|
@ -16,3 +16,4 @@ venv/
|
||||||
.playwright-mcp
|
.playwright-mcp
|
||||||
coturn/
|
coturn/
|
||||||
*.wav
|
*.wav
|
||||||
|
dograh_pcm_cache/
|
||||||
|
|
@ -64,7 +64,7 @@ class WorkflowRecordingClient(BaseDBClient):
|
||||||
storage_key=storage_key,
|
storage_key=storage_key,
|
||||||
storage_backend=storage_backend,
|
storage_backend=storage_backend,
|
||||||
created_by=created_by,
|
created_by=created_by,
|
||||||
metadata=metadata or {},
|
recording_metadata=metadata or {},
|
||||||
)
|
)
|
||||||
|
|
||||||
session.add(recording)
|
session.add(recording)
|
||||||
|
|
|
||||||
|
|
@ -40,6 +40,7 @@ class UserConfigurationValidator:
|
||||||
ServiceProviders.SPEECHMATICS.value: self._check_speechmatics_api_key,
|
ServiceProviders.SPEECHMATICS.value: self._check_speechmatics_api_key,
|
||||||
ServiceProviders.CAMB.value: self._check_camb_api_key,
|
ServiceProviders.CAMB.value: self._check_camb_api_key,
|
||||||
ServiceProviders.AWS_BEDROCK.value: self._check_aws_bedrock_api_key,
|
ServiceProviders.AWS_BEDROCK.value: self._check_aws_bedrock_api_key,
|
||||||
|
ServiceProviders.SELF_HOSTED.value: self._check_self_hosted_api_key,
|
||||||
}
|
}
|
||||||
|
|
||||||
async def validate(self, configuration: UserConfiguration) -> APIKeyStatusResponse:
|
async def validate(self, configuration: UserConfiguration) -> APIKeyStatusResponse:
|
||||||
|
|
@ -74,6 +75,20 @@ class UserConfigurationValidator:
|
||||||
|
|
||||||
provider = service_config.provider
|
provider = service_config.provider
|
||||||
|
|
||||||
|
# Self-hosted doesn't require an API key
|
||||||
|
if provider == ServiceProviders.SELF_HOSTED.value:
|
||||||
|
try:
|
||||||
|
if not self._check_self_hosted_api_key(provider, service_config):
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
"model": service_name,
|
||||||
|
"message": f"Invalid {provider} configuration",
|
||||||
|
}
|
||||||
|
]
|
||||||
|
except ValueError as e:
|
||||||
|
return [{"model": service_name, "message": str(e)}]
|
||||||
|
return []
|
||||||
|
|
||||||
# AWS Bedrock uses AWS credentials instead of api_key
|
# AWS Bedrock uses AWS credentials instead of api_key
|
||||||
if provider == ServiceProviders.AWS_BEDROCK.value:
|
if provider == ServiceProviders.AWS_BEDROCK.value:
|
||||||
try:
|
try:
|
||||||
|
|
@ -163,7 +178,12 @@ class UserConfigurationValidator:
|
||||||
|
|
||||||
def _check_camb_api_key(self, model: str, api_key: str) -> bool:
|
def _check_camb_api_key(self, model: str, api_key: str) -> bool:
|
||||||
return True
|
return True
|
||||||
|
|
||||||
|
def _check_self_hosted_api_key(self, model: str, service_config) -> bool:
|
||||||
|
if not getattr(service_config, "base_url", None):
|
||||||
|
raise ValueError("base_url is required for self-hosted LLM")
|
||||||
|
return True
|
||||||
|
|
||||||
def _check_aws_bedrock_api_key(self, model: str, service_config) -> bool:
|
def _check_aws_bedrock_api_key(self, model: str, service_config) -> bool:
|
||||||
if not service_config.aws_access_key or not service_config.aws_secret_key:
|
if not service_config.aws_access_key or not service_config.aws_secret_key:
|
||||||
raise ValueError("AWS access key and secret key are required for Bedrock")
|
raise ValueError("AWS access key and secret key are required for Bedrock")
|
||||||
|
|
|
||||||
|
|
@ -27,6 +27,7 @@ class ServiceProviders(str, Enum):
|
||||||
SPEECHMATICS = "speechmatics"
|
SPEECHMATICS = "speechmatics"
|
||||||
CAMB = "camb"
|
CAMB = "camb"
|
||||||
AWS_BEDROCK = "aws_bedrock"
|
AWS_BEDROCK = "aws_bedrock"
|
||||||
|
SELF_HOSTED = "self_hosted"
|
||||||
|
|
||||||
|
|
||||||
class BaseServiceConfiguration(BaseModel):
|
class BaseServiceConfiguration(BaseModel):
|
||||||
|
|
@ -40,6 +41,7 @@ class BaseServiceConfiguration(BaseModel):
|
||||||
ServiceProviders.AZURE,
|
ServiceProviders.AZURE,
|
||||||
ServiceProviders.DOGRAH,
|
ServiceProviders.DOGRAH,
|
||||||
ServiceProviders.AWS_BEDROCK,
|
ServiceProviders.AWS_BEDROCK,
|
||||||
|
ServiceProviders.SELF_HOSTED,
|
||||||
# ServiceProviders.SARVAM,
|
# ServiceProviders.SARVAM,
|
||||||
]
|
]
|
||||||
api_key: str | list[str]
|
api_key: str | list[str]
|
||||||
|
|
@ -249,6 +251,22 @@ class AWSBedrockLLMConfiguration(BaseLLMConfiguration):
|
||||||
api_key: str | list[str] | None = Field(default=None)
|
api_key: str | list[str] | None = Field(default=None)
|
||||||
|
|
||||||
|
|
||||||
|
SELF_HOSTED_LLM_MODELS = ["llama3", "mistral", "phi3", "qwen2", "gemma2", "deepseek-r1"]
|
||||||
|
|
||||||
|
|
||||||
|
@register_llm
|
||||||
|
class SelfHostedLLMConfiguration(BaseLLMConfiguration):
|
||||||
|
provider: Literal[ServiceProviders.SELF_HOSTED] = ServiceProviders.SELF_HOSTED
|
||||||
|
model: str = Field(
|
||||||
|
default="llama3", json_schema_extra={"examples": SELF_HOSTED_LLM_MODELS}
|
||||||
|
)
|
||||||
|
base_url: str = Field(
|
||||||
|
default="http://localhost:11434/v1",
|
||||||
|
description="OpenAI-compatible endpoint (Ollama, vLLM, etc.)",
|
||||||
|
)
|
||||||
|
api_key: str | list[str] | None = Field(default=None)
|
||||||
|
|
||||||
|
|
||||||
LLMConfig = Annotated[
|
LLMConfig = Annotated[
|
||||||
Union[
|
Union[
|
||||||
OpenAILLMService,
|
OpenAILLMService,
|
||||||
|
|
@ -258,6 +276,7 @@ LLMConfig = Annotated[
|
||||||
AzureLLMService,
|
AzureLLMService,
|
||||||
DograhLLMService,
|
DograhLLMService,
|
||||||
AWSBedrockLLMConfiguration,
|
AWSBedrockLLMConfiguration,
|
||||||
|
SelfHostedLLMConfiguration,
|
||||||
],
|
],
|
||||||
Field(discriminator="provider"),
|
Field(discriminator="provider"),
|
||||||
]
|
]
|
||||||
|
|
@ -334,6 +353,12 @@ class CartesiaTTSConfiguration(BaseTTSConfiguration):
|
||||||
)
|
)
|
||||||
voice: str = Field(default="3faa81ae-d3d8-4ab1-9e44-e50e46d33c30")
|
voice: str = Field(default="3faa81ae-d3d8-4ab1-9e44-e50e46d33c30")
|
||||||
speed: float = Field(default=1.0, ge=0.6, le=1.5, description="Speed of the voice")
|
speed: float = Field(default=1.0, ge=0.6, le=1.5, description="Speed of the voice")
|
||||||
|
volume: float = Field(
|
||||||
|
default=1.0,
|
||||||
|
ge=0.5,
|
||||||
|
le=2.0,
|
||||||
|
description="Volume multiplier for generated speech",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
SARVAM_TTS_MODELS = ["bulbul:v2", "bulbul:v3"]
|
SARVAM_TTS_MODELS = ["bulbul:v2", "bulbul:v3"]
|
||||||
|
|
|
||||||
|
|
@ -66,6 +66,7 @@ class RecordingRouterProcessor(FrameProcessor):
|
||||||
self._frame_buffer: list[tuple[LLMTextFrame, FrameDirection]] = []
|
self._frame_buffer: list[tuple[LLMTextFrame, FrameDirection]] = []
|
||||||
self._mode: Optional[str] = None # None = detecting, "tts", "recording"
|
self._mode: Optional[str] = None # None = detecting, "tts", "recording"
|
||||||
self._recording_id_buffer = ""
|
self._recording_id_buffer = ""
|
||||||
|
self._recording_playback_started = False
|
||||||
|
|
||||||
# ------------------------------------------------------------------
|
# ------------------------------------------------------------------
|
||||||
# Frame dispatch
|
# Frame dispatch
|
||||||
|
|
@ -99,9 +100,15 @@ class RecordingRouterProcessor(FrameProcessor):
|
||||||
await self.push_frame(frame, direction)
|
await self.push_frame(frame, direction)
|
||||||
return
|
return
|
||||||
|
|
||||||
# --- Recording mode: accumulate recording_id silently ---
|
# --- Recording mode: accumulate text and start playback ASAP ---
|
||||||
if self._mode == "recording":
|
if self._mode == "recording":
|
||||||
self._recording_id_buffer += frame.text
|
self._recording_id_buffer += frame.text
|
||||||
|
if not self._recording_playback_started:
|
||||||
|
buf = self._recording_id_buffer.lstrip()
|
||||||
|
if " " in buf:
|
||||||
|
recording_id = buf.split()[0]
|
||||||
|
self._recording_playback_started = True
|
||||||
|
await self._play_recording(recording_id)
|
||||||
return
|
return
|
||||||
|
|
||||||
# --- Detection mode: buffer until marker found ---
|
# --- Detection mode: buffer until marker found ---
|
||||||
|
|
@ -178,16 +185,21 @@ class RecordingRouterProcessor(FrameProcessor):
|
||||||
self, frame: LLMFullResponseEndFrame, direction: FrameDirection
|
self, frame: LLMFullResponseEndFrame, direction: FrameDirection
|
||||||
):
|
):
|
||||||
if self._mode == "recording":
|
if self._mode == "recording":
|
||||||
recording_id = self._recording_id_buffer.strip()
|
full_text = self._recording_id_buffer.strip()
|
||||||
if recording_id:
|
if full_text:
|
||||||
# Push accumulated text as TTSTextFrame for UI feedback via observer
|
recording_id = full_text.split()[0]
|
||||||
|
|
||||||
|
# Push full text (marker + id + transcript) for assistant context
|
||||||
await self.push_frame(
|
await self.push_frame(
|
||||||
TTSTextFrame(
|
TTSTextFrame(
|
||||||
text=RECORDING_MARKER + self._recording_id_buffer,
|
text=RECORDING_MARKER + self._recording_id_buffer,
|
||||||
aggregated_by="recording_router",
|
aggregated_by="recording_router",
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
await self._play_recording(recording_id)
|
|
||||||
|
# Fallback: if response ended before a space arrived (no transcript)
|
||||||
|
if not self._recording_playback_started:
|
||||||
|
await self._play_recording(recording_id)
|
||||||
else:
|
else:
|
||||||
logger.warning(
|
logger.warning(
|
||||||
"RecordingRouterProcessor: recording mode but empty recording_id"
|
"RecordingRouterProcessor: recording mode but empty recording_id"
|
||||||
|
|
@ -256,3 +268,4 @@ class RecordingRouterProcessor(FrameProcessor):
|
||||||
self._frame_buffer = []
|
self._frame_buffer = []
|
||||||
self._mode = None
|
self._mode = None
|
||||||
self._recording_id_buffer = ""
|
self._recording_id_buffer = ""
|
||||||
|
self._recording_playback_started = False
|
||||||
|
|
|
||||||
|
|
@ -8,7 +8,11 @@ from api.services.configuration.registry import ServiceProviders
|
||||||
from pipecat.services.aws.llm import AWSBedrockLLMService, AWSBedrockLLMSettings
|
from pipecat.services.aws.llm import AWSBedrockLLMService, AWSBedrockLLMSettings
|
||||||
from pipecat.services.azure.llm import AzureLLMService, AzureLLMSettings
|
from pipecat.services.azure.llm import AzureLLMService, AzureLLMSettings
|
||||||
from pipecat.services.cartesia.stt import CartesiaSTTService
|
from pipecat.services.cartesia.stt import CartesiaSTTService
|
||||||
from pipecat.services.cartesia.tts import CartesiaTTSService, CartesiaTTSSettings, GenerationConfig
|
from pipecat.services.cartesia.tts import (
|
||||||
|
CartesiaTTSService,
|
||||||
|
CartesiaTTSSettings,
|
||||||
|
GenerationConfig,
|
||||||
|
)
|
||||||
from pipecat.services.deepgram.flux.stt import (
|
from pipecat.services.deepgram.flux.stt import (
|
||||||
DeepgramFluxSTTService,
|
DeepgramFluxSTTService,
|
||||||
DeepgramFluxSTTSettings,
|
DeepgramFluxSTTSettings,
|
||||||
|
|
@ -212,13 +216,19 @@ def create_tts_service(user_config, audio_config: "AudioConfig"):
|
||||||
)
|
)
|
||||||
elif user_config.tts.provider == ServiceProviders.CARTESIA.value:
|
elif user_config.tts.provider == ServiceProviders.CARTESIA.value:
|
||||||
speed = getattr(user_config.tts, "speed", None)
|
speed = getattr(user_config.tts, "speed", None)
|
||||||
generation_config = GenerationConfig(speed=speed) if speed and speed != 1.0 else None
|
generation_config = (
|
||||||
|
GenerationConfig(speed=speed) if speed and speed != 1.0 else None
|
||||||
|
)
|
||||||
return CartesiaTTSService(
|
return CartesiaTTSService(
|
||||||
api_key=user_config.tts.api_key,
|
api_key=user_config.tts.api_key,
|
||||||
settings=CartesiaTTSSettings(
|
settings=CartesiaTTSSettings(
|
||||||
voice=user_config.tts.voice,
|
voice=user_config.tts.voice,
|
||||||
model=user_config.tts.model,
|
model=user_config.tts.model,
|
||||||
**({"generation_config": generation_config} if generation_config else {}),
|
**(
|
||||||
|
{"generation_config": generation_config}
|
||||||
|
if generation_config
|
||||||
|
else {}
|
||||||
|
),
|
||||||
),
|
),
|
||||||
text_filters=[xml_function_tag_filter],
|
text_filters=[xml_function_tag_filter],
|
||||||
silence_time_s=1.0,
|
silence_time_s=1.0,
|
||||||
|
|
@ -353,6 +363,12 @@ def create_llm_service_from_provider(
|
||||||
aws_region=aws_region,
|
aws_region=aws_region,
|
||||||
settings=AWSBedrockLLMSettings(model=model),
|
settings=AWSBedrockLLMSettings(model=model),
|
||||||
)
|
)
|
||||||
|
elif provider == ServiceProviders.SELF_HOSTED.value:
|
||||||
|
return OpenAILLMService(
|
||||||
|
base_url=base_url or "http://localhost:11434/v1",
|
||||||
|
api_key=api_key or "none",
|
||||||
|
settings=OpenAILLMSettings(model=model),
|
||||||
|
)
|
||||||
else:
|
else:
|
||||||
raise HTTPException(status_code=400, detail=f"Invalid LLM provider {provider}")
|
raise HTTPException(status_code=400, detail=f"Invalid LLM provider {provider}")
|
||||||
|
|
||||||
|
|
@ -368,6 +384,8 @@ def create_llm_service(user_config):
|
||||||
kwargs["base_url"] = user_config.llm.base_url
|
kwargs["base_url"] = user_config.llm.base_url
|
||||||
elif provider == ServiceProviders.AZURE.value:
|
elif provider == ServiceProviders.AZURE.value:
|
||||||
kwargs["endpoint"] = user_config.llm.endpoint
|
kwargs["endpoint"] = user_config.llm.endpoint
|
||||||
|
elif provider == ServiceProviders.SELF_HOSTED.value:
|
||||||
|
kwargs["base_url"] = user_config.llm.base_url
|
||||||
elif provider == ServiceProviders.AWS_BEDROCK.value:
|
elif provider == ServiceProviders.AWS_BEDROCK.value:
|
||||||
kwargs["aws_access_key"] = user_config.llm.aws_access_key
|
kwargs["aws_access_key"] = user_config.llm.aws_access_key
|
||||||
kwargs["aws_secret_key"] = user_config.llm.aws_secret_key
|
kwargs["aws_secret_key"] = user_config.llm.aws_secret_key
|
||||||
|
|
|
||||||
|
|
@ -437,9 +437,7 @@ class PipecatEngine:
|
||||||
|
|
||||||
async def _do_extraction():
|
async def _do_extraction():
|
||||||
try:
|
try:
|
||||||
logger.debug(
|
logger.debug(f"Starting variable extraction for node: {node.name}")
|
||||||
f"Starting variable extraction for node: {node.name}"
|
|
||||||
)
|
|
||||||
extracted_data = (
|
extracted_data = (
|
||||||
await self._variable_extraction_manager._perform_extraction(
|
await self._variable_extraction_manager._perform_extraction(
|
||||||
extraction_variables, parent_context, extraction_prompt
|
extraction_variables, parent_context, extraction_prompt
|
||||||
|
|
@ -454,7 +452,9 @@ class PipecatEngine:
|
||||||
f"Variable extraction completed for node: {node.name}. Extracted: {extracted_data}"
|
f"Variable extraction completed for node: {node.name}. Extracted: {extracted_data}"
|
||||||
)
|
)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Error during variable extraction for node {node.name}: {str(e)}")
|
logger.error(
|
||||||
|
f"Error during variable extraction for node {node.name}: {str(e)}"
|
||||||
|
)
|
||||||
|
|
||||||
if run_in_background:
|
if run_in_background:
|
||||||
logger.debug(
|
logger.debug(
|
||||||
|
|
@ -497,9 +497,7 @@ class PipecatEngine:
|
||||||
logger.error(
|
logger.error(
|
||||||
f"Pending extraction task '{task_name}' failed: {result}"
|
f"Pending extraction task '{task_name}' failed: {result}"
|
||||||
)
|
)
|
||||||
logger.debug(
|
logger.debug(f"All pending extraction tasks completed in {elapsed:.2f}s")
|
||||||
f"All pending extraction tasks completed in {elapsed:.2f}s"
|
|
||||||
)
|
|
||||||
except asyncio.TimeoutError:
|
except asyncio.TimeoutError:
|
||||||
incomplete = [
|
incomplete = [
|
||||||
t.get_name() for t in self._pending_extraction_tasks if not t.done()
|
t.get_name() for t in self._pending_extraction_tasks if not t.done()
|
||||||
|
|
|
||||||
|
|
@ -34,13 +34,13 @@ You have two modes for responding:
|
||||||
Example: ▸ Hello! How can I help you today?
|
Example: ▸ Hello! How can I help you today?
|
||||||
|
|
||||||
2. PRE-RECORDED AUDIO (●): Play a pre-recorded audio message.
|
2. PRE-RECORDED AUDIO (●): Play a pre-recorded audio message.
|
||||||
Format: `●` followed by a space and ONLY the recording_id. Nothing else.
|
Format: `●` followed by a space followed by recording_id followed by provided transcript. Nothing else.
|
||||||
Example: ● rec_greeting_01
|
Example: ● rec_greeting_01 [ Provided Transcript ]
|
||||||
|
|
||||||
RULES:
|
RULES:
|
||||||
- Your response MUST start with either `▸` or `●` as the very first character.
|
- Your response MUST start with either `▸` or `●` as the very first character.
|
||||||
- For `▸` (dynamic speech): Follow with a space and your full response text.
|
- For `▸` (dynamic speech): Follow with a space and your full response text.
|
||||||
- For `●` (pre-recorded audio): Follow with a space and ONLY the recording_id. No other text.
|
- For `●` (pre-recorded audio): Follow with a space and the recording_id and the provided transcript. No other text.
|
||||||
- Use `●` when a pre-recorded message matches the situation well.
|
- Use `●` when a pre-recorded message matches the situation well.
|
||||||
- Use `▸` when you need to generate a dynamic, contextual response.
|
- Use `▸` when you need to generate a dynamic, contextual response.
|
||||||
- NEVER mix modes in a single response. Choose one."""
|
- NEVER mix modes in a single response. Choose one."""
|
||||||
|
|
|
||||||
|
|
@ -28,7 +28,9 @@ from api.utils.template_renderer import render_template
|
||||||
from pipecat.processors.aggregators.llm_context import LLMContext
|
from pipecat.processors.aggregators.llm_context import LLMContext
|
||||||
|
|
||||||
|
|
||||||
async def _run_llm_inference(llm, messages: list[dict], system_prompt: str) -> str | None:
|
async def _run_llm_inference(
|
||||||
|
llm, messages: list[dict], system_prompt: str
|
||||||
|
) -> str | None:
|
||||||
"""Run a one-shot LLM inference using the pipecat service."""
|
"""Run a one-shot LLM inference using the pipecat service."""
|
||||||
context = LLMContext()
|
context = LLMContext()
|
||||||
context.set_messages(messages)
|
context.set_messages(messages)
|
||||||
|
|
@ -51,7 +53,10 @@ async def _generate_conversation_summary(
|
||||||
]
|
]
|
||||||
|
|
||||||
try:
|
try:
|
||||||
summary = await _run_llm_inference(llm, messages, CONVERSATION_SUMMARY_SYSTEM_PROMPT) or ""
|
summary = (
|
||||||
|
await _run_llm_inference(llm, messages, CONVERSATION_SUMMARY_SYSTEM_PROMPT)
|
||||||
|
or ""
|
||||||
|
)
|
||||||
|
|
||||||
span_name = f"conversation-summary-before-{node_name}"
|
span_name = f"conversation-summary-before-{node_name}"
|
||||||
add_qa_span_to_trace(parent_ctx, model, messages, summary, span_name)
|
add_qa_span_to_trace(parent_ctx, model, messages, summary, span_name)
|
||||||
|
|
|
||||||
|
|
@ -154,7 +154,12 @@ async def ensure_node_summaries(
|
||||||
try:
|
try:
|
||||||
context = LLMContext()
|
context = LLMContext()
|
||||||
context.set_messages(messages)
|
context.set_messages(messages)
|
||||||
summary_text = await llm.run_inference(context, system_instruction=NODE_SUMMARY_SYSTEM_PROMPT) or ""
|
summary_text = (
|
||||||
|
await llm.run_inference(
|
||||||
|
context, system_instruction=NODE_SUMMARY_SYSTEM_PROMPT
|
||||||
|
)
|
||||||
|
or ""
|
||||||
|
)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.warning(f"Failed to generate summary for node {node_id}: {e}")
|
logger.warning(f"Failed to generate summary for node {node_id}: {e}")
|
||||||
updated_summaries[node_id] = {"summary": ""}
|
updated_summaries[node_id] = {"summary": ""}
|
||||||
|
|
|
||||||
|
|
@ -9,7 +9,7 @@ Covers:
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from types import SimpleNamespace
|
from types import SimpleNamespace
|
||||||
from unittest.mock import AsyncMock, MagicMock, patch
|
from unittest.mock import MagicMock, patch
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
from pydantic import ValidationError
|
from pydantic import ValidationError
|
||||||
|
|
@ -17,13 +17,12 @@ from pydantic import ValidationError
|
||||||
from api.services.configuration.check_validity import UserConfigurationValidator
|
from api.services.configuration.check_validity import UserConfigurationValidator
|
||||||
from api.services.configuration.registry import (
|
from api.services.configuration.registry import (
|
||||||
CAMB_TTS_MODELS,
|
CAMB_TTS_MODELS,
|
||||||
CambTTSConfiguration,
|
|
||||||
REGISTRY,
|
REGISTRY,
|
||||||
|
CambTTSConfiguration,
|
||||||
ServiceProviders,
|
ServiceProviders,
|
||||||
ServiceType,
|
ServiceType,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# 1. CambTTSConfiguration model tests
|
# 1. CambTTSConfiguration model tests
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
|
@ -48,6 +48,12 @@ new api route in backend, and wish to use it in the UI, generate the client usin
|
||||||
npm run generate-client
|
npm run generate-client
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## Conventions
|
||||||
|
|
||||||
|
### File Uploads
|
||||||
|
|
||||||
|
Always use a hidden `<input type="file">` with a visible `<Button>` that triggers it via `fileInputRef.current?.click()`. Never use a visible `<Input type="file">` — the native file input styling is inconsistent and confusing. Show the selected filename next to or below the button.
|
||||||
|
|
||||||
## Development
|
## Development
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
|
|
||||||
|
|
@ -519,13 +519,17 @@ export default function RunsPage() {
|
||||||
variant="outline"
|
variant="outline"
|
||||||
size="icon"
|
size="icon"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
const filter = encodeURIComponent(
|
if (run.gathered_context?.trace_url) {
|
||||||
`metadata;stringObject;attributes;contains;conversation.id,metadata;stringObject;attributes;contains;${run.id}`,
|
window.open(String(run.gathered_context.trace_url), '_blank');
|
||||||
);
|
} else {
|
||||||
window.open(
|
const filter = encodeURIComponent(
|
||||||
`${process.env.NEXT_PUBLIC_LANGFUSE_ENDPOINT}/project/${process.env.NEXT_PUBLIC_LANGFUSE_PROJECT_ID}/traces?search=&filter=${filter}&dateRange=All+time`,
|
`metadata;stringObject;attributes;contains;conversation.id,metadata;stringObject;attributes;contains;${run.id}`,
|
||||||
'_blank',
|
);
|
||||||
);
|
window.open(
|
||||||
|
`${process.env.NEXT_PUBLIC_LANGFUSE_ENDPOINT}/project/${process.env.NEXT_PUBLIC_LANGFUSE_PROJECT_ID}/traces?search=&filter=${filter}&dateRange=All+time`,
|
||||||
|
'_blank',
|
||||||
|
);
|
||||||
|
}
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Image
|
<Image
|
||||||
|
|
|
||||||
|
|
@ -216,7 +216,7 @@ export const RecordingsDialog = ({
|
||||||
<Label className="text-xs text-muted-foreground">
|
<Label className="text-xs text-muted-foreground">
|
||||||
Audio File
|
Audio File
|
||||||
</Label>
|
</Label>
|
||||||
<Input
|
<input
|
||||||
ref={fileInputRef}
|
ref={fileInputRef}
|
||||||
type="file"
|
type="file"
|
||||||
accept="audio/*"
|
accept="audio/*"
|
||||||
|
|
@ -233,11 +233,24 @@ export const RecordingsDialog = ({
|
||||||
setError(null);
|
setError(null);
|
||||||
setSelectedFile(file);
|
setSelectedFile(file);
|
||||||
}}
|
}}
|
||||||
className="text-sm"
|
className="hidden"
|
||||||
/>
|
/>
|
||||||
<p className="text-xs text-muted-foreground mt-1">
|
<Button
|
||||||
Max 5MB
|
type="button"
|
||||||
</p>
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
className="w-full justify-start text-sm font-normal"
|
||||||
|
onClick={() => fileInputRef.current?.click()}
|
||||||
|
>
|
||||||
|
<Upload className="w-4 h-4 mr-2 shrink-0" />
|
||||||
|
{selectedFile ? (
|
||||||
|
<span className="truncate">
|
||||||
|
{selectedFile.name} ({(selectedFile.size / (1024 * 1024)).toFixed(1)}MB)
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<span className="text-muted-foreground">Choose audio file (max 5MB)</span>
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<Label className="text-xs text-muted-foreground">
|
<Label className="text-xs text-muted-foreground">
|
||||||
|
|
@ -289,8 +302,8 @@ export const RecordingsDialog = ({
|
||||||
>
|
>
|
||||||
<div className="flex-1 min-w-0">
|
<div className="flex-1 min-w-0">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<code className="text-xs bg-muted px-1.5 py-0.5 rounded font-mono">
|
<code className="text-xs bg-muted px-1.5 py-0.5 rounded font-mono truncate max-w-[300px]">
|
||||||
{rec.recording_id}
|
{(rec.metadata?.original_filename as string) || rec.recording_id}
|
||||||
</code>
|
</code>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-sm text-muted-foreground mt-1 break-all line-clamp-2">
|
<p className="text-sm text-muted-foreground mt-1 break-all line-clamp-2">
|
||||||
|
|
|
||||||
|
|
@ -18,14 +18,12 @@ export const layoutNodes = (
|
||||||
// Separate nodes by type
|
// Separate nodes by type
|
||||||
const triggerNodes = nodes.filter(n => n.type === NodeType.TRIGGER);
|
const triggerNodes = nodes.filter(n => n.type === NodeType.TRIGGER);
|
||||||
const webhookNodes = nodes.filter(n => n.type === NodeType.WEBHOOK);
|
const webhookNodes = nodes.filter(n => n.type === NodeType.WEBHOOK);
|
||||||
const globalNodes = nodes.filter(n => n.type === NodeType.GLOBAL_NODE || n.type === 'global');
|
const qaNodes = nodes.filter(n => n.type === NodeType.QA);
|
||||||
|
const globalNodes = nodes.filter(n => n.type === NodeType.GLOBAL_NODE);
|
||||||
const workflowNodes = nodes.filter(n =>
|
const workflowNodes = nodes.filter(n =>
|
||||||
n.type === NodeType.START_CALL ||
|
n.type === NodeType.START_CALL ||
|
||||||
n.type === NodeType.AGENT_NODE ||
|
n.type === NodeType.AGENT_NODE ||
|
||||||
n.type === NodeType.END_CALL ||
|
n.type === NodeType.END_CALL
|
||||||
n.type === 'startCall' ||
|
|
||||||
n.type === 'agentNode' ||
|
|
||||||
n.type === 'endCall'
|
|
||||||
);
|
);
|
||||||
|
|
||||||
// If no workflow nodes, just return original nodes
|
// If no workflow nodes, just return original nodes
|
||||||
|
|
@ -161,12 +159,26 @@ export const layoutNodes = (
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Position QA nodes below webhook nodes on the right side
|
||||||
|
const qaStartY = webhookNodes.length > 0
|
||||||
|
? workflowCenterY - (webhookNodes.length * NODE_HEIGHT + (webhookNodes.length - 1) * VERTICAL_SPACING) / 2
|
||||||
|
+ webhookNodes.length * (NODE_HEIGHT + VERTICAL_SPACING) + VERTICAL_SPACING
|
||||||
|
: workflowCenterY;
|
||||||
|
const positionedQaNodes = qaNodes.map((node, index) => ({
|
||||||
|
...node,
|
||||||
|
position: {
|
||||||
|
x: webhookNodesX,
|
||||||
|
y: qaStartY + index * (NODE_HEIGHT + VERTICAL_SPACING)
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
|
||||||
// Combine all positioned nodes
|
// Combine all positioned nodes
|
||||||
const allPositionedNodes = [
|
const allPositionedNodes = [
|
||||||
...positionedTriggerNodes,
|
...positionedTriggerNodes,
|
||||||
...positionedGlobalNodes,
|
...positionedGlobalNodes,
|
||||||
...positionedWorkflowNodes,
|
...positionedWorkflowNodes,
|
||||||
...positionedWebhookNodes
|
...positionedWebhookNodes,
|
||||||
|
...positionedQaNodes
|
||||||
];
|
];
|
||||||
|
|
||||||
// Create a map for quick lookup
|
// Create a map for quick lookup
|
||||||
|
|
|
||||||
|
|
@ -236,11 +236,21 @@ export default function ServiceConfiguration() {
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
selectedProviders[service] = userConfig?.[service]?.provider as string;
|
selectedProviders[service] = userConfig?.[service]?.provider as string;
|
||||||
|
// Fill in schema defaults for fields not present in userConfig
|
||||||
|
const properties = response.data[service]?.[selectedProviders[service]]?.properties as Record<string, SchemaProperty>;
|
||||||
|
if (properties) {
|
||||||
|
Object.entries(properties).forEach(([field, schema]) => {
|
||||||
|
const key = `${service}_${field}`;
|
||||||
|
if (field !== "provider" && field !== "api_key" && schema.default !== undefined && !(key in defaultValues)) {
|
||||||
|
defaultValues[key] = schema.default;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
const properties = response.data[service]?.[selectedProviders[service]]?.properties as Record<string, SchemaProperty>;
|
const properties = response.data[service]?.[selectedProviders[service]]?.properties as Record<string, SchemaProperty>;
|
||||||
if (properties) {
|
if (properties) {
|
||||||
Object.entries(properties).forEach(([field, schema]) => {
|
Object.entries(properties).forEach(([field, schema]) => {
|
||||||
if (field !== "provider" && schema.default) {
|
if (field !== "provider" && schema.default !== undefined) {
|
||||||
defaultValues[`${service}_${field}`] = schema.default;
|
defaultValues[`${service}_${field}`] = schema.default;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -15,6 +15,7 @@ export interface MentionItem {
|
||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
description: string;
|
description: string;
|
||||||
|
filename: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface MentionTextareaProps {
|
interface MentionTextareaProps {
|
||||||
|
|
@ -46,6 +47,7 @@ export function MentionTextarea({
|
||||||
id: r.recording_id,
|
id: r.recording_id,
|
||||||
name: r.transcript,
|
name: r.transcript,
|
||||||
description: r.transcript,
|
description: r.transcript,
|
||||||
|
filename: (r.metadata?.original_filename as string) || r.recording_id,
|
||||||
})),
|
})),
|
||||||
[recordings]
|
[recordings]
|
||||||
);
|
);
|
||||||
|
|
@ -195,7 +197,7 @@ export function MentionTextarea({
|
||||||
>
|
>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<code className="text-xs bg-muted px-1 py-0.5 rounded font-mono">
|
<code className="text-xs bg-muted px-1 py-0.5 rounded font-mono">
|
||||||
{item.id}
|
{item.filename}
|
||||||
</code>
|
</code>
|
||||||
<span className="font-medium truncate">{item.name}</span>
|
<span className="font-medium truncate">{item.name}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue